From bec9677bf43c56a333e35e4db94a854dccf22cea Mon Sep 17 00:00:00 2001 From: Filipe Forattini Date: Tue, 22 Sep 2026 13:51:14 -0300 Subject: [PATCH] fix: expose one active path per suite command --- README.md | 6 ++++++ config/bash/init.sh | 32 ++++++++++++++++++++++++++++++-- config/bash/path.sh | 5 ++++- src/migrations.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/mise-handover.test.ts | 35 ++++++++++++++++++++++++++++++++++- 5 files changed, 113 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ac6646f..56e6ab1 100644 --- a/README.md +++ b/README.md @@ -571,6 +571,12 @@ The bootstrap download is temporary ownership: after a successful first run proves mise has installed `red-dev`, the bootstrap removes its own copy. A cancelled setup keeps that copy so the command never disappears. +A shell without mise activation keeps the shim fallback; an activated shell uses +only mise's real tool directories, so the same command is not listed twice on +`PATH`. RedSkills runtime commands are the corresponding exception: their one +visible path is the stable launcher for the signed active package set, not the +npm acquisition tree behind it. + On the Ubuntu desktop, red-dev itself also has a tool-level postinstall: it runs the newly installed binary's `desktop reconcile`. That updates only the managed mise declaration, GNOME menu bar and shortcuts; it does not install diff --git a/config/bash/init.sh b/config/bash/init.sh index fc7d60f..e6b7ec5 100644 --- a/config/bash/init.sh +++ b/config/bash/init.sh @@ -66,9 +66,36 @@ _red_fix_path() { unset _red_posix_path } +# path.sh gives every shell mise's shims so commands also work without +# activation. Once activation succeeds, mise has inserted the real tool +# directories; keeping the shims as well makes every command appear +# twice in PATH. RedSkills is the other deliberate exception: its stable +# launchers follow the signed active set (including rollback), so the npm +# acquisition tree must not expose a second runtime beside them. +_red_drop_mise_fallbacks() { + local data="${MISE_DATA_DIR:-$HOME/.local/share/mise}" out="" entry + if [ "${RED_ENV:-}" = "windows" ] && [ -n "${_RED_CYGPATH:-}" ]; then + _red_data_posix=$("$_RED_CYGPATH" -u "$data" 2>/dev/null) + [ -n "$_red_data_posix" ] && data="$_red_data_posix" + unset _red_data_posix + fi + local IFS=':' + for entry in $PATH; do + [ -n "$entry" ] || continue + case "$entry" in + "$data/shims"|"$data/installs/red-skills/"*/node_modules/.bin) continue ;; + esac + out="${out:+$out:}$entry" + done + PATH="$out" +} + if command -v mise >/dev/null 2>&1; then - eval "$(mise activate bash)" - _red_fix_path + if eval "$(mise activate bash)"; then + _red_fix_path + _red_drop_mise_fallbacks + export PATH + fi fi if command -v zoxide >/dev/null 2>&1; then @@ -126,6 +153,7 @@ fi # directories glued into one that is neither. _red_fix_path unset -f _red_fix_path +unset -f _red_drop_mise_fallbacks unset _RED_CYGPATH export EDITOR="nvim" diff --git a/config/bash/path.sh b/config/bash/path.sh index 79a9159..8a1b260 100644 --- a/config/bash/path.sh +++ b/config/bash/path.sh @@ -75,7 +75,10 @@ _red_path_prepend "$HOME/.local/bin" # Shims have neither problem: each is a small exec into mise, so they # work with no shell integration at all. Prepended before ~/.local/bin # so that on a machine still carrying a binary an older release left -# there, the copy mise keeps current is the one that answers. +# there, the copy mise keeps current is the one that answers. init.sh +# removes this entry only after `mise activate` succeeds; interactive +# shells then expose the real tool directory once, while every other +# context retains this fallback. _red_path_prepend "${MISE_DATA_DIR:-$HOME/.local/share/mise}/shims" # The RedSkills runtimes, ahead of both. diff --git a/src/migrations.ts b/src/migrations.ts index fba5c84..3c647fc 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -31,6 +31,7 @@ import { providerFor, TOOLS } from "./manifest.ts"; import type { Platform } from "./platform.ts"; import { readPreferences, writePreferences } from "./preferences.ts"; import { userBinDir } from "./providers.ts"; +import { runtimeBinDir } from "./red-skills-companions.ts"; import { transcriptDir } from "./transcript.ts"; import { joinLines, splitLines, statements } from "./toml-lines.ts"; @@ -660,6 +661,17 @@ return {} log.plain(` original config backed up at ${backup}`); }, }, + { + id: "2026-09-22-single-red-skills-runtime-path", + describe: "retire the legacy rsp launcher after the signed package set takes ownership", + applies: (p) => p.os !== "windows" && staleRedSkillsRuntimeLaunchers().length > 0, + run: async () => { + for (const { name, path, replacement } of staleRedSkillsRuntimeLaunchers()) { + rmSync(path, { force: true }); + log.plain(` ${name}: removed ${path}; active package-set launcher is ${replacement}`); + } + }, + }, { id: "2026-09-22-suite-binary-handover", describe: "finish handing bootstrap binaries to their single mise owner", @@ -732,6 +744,33 @@ export function staleReleaseBinaries(p: Platform): { name: string; path: string return out; } +/** + * Runtime launchers left by the pre-package-set RedSkills installer. + * + * `rsp` used to be copied into ~/.local/bin as a 2 KiB discovery script. + * The signed package set now writes a stable launcher under red-dev's own + * runtime bin. Remove the old file only when both sides prove their identity: + * the replacement exists, and the legacy script contains its old package-cache + * discovery markers. A person's unrelated `rsp` is never ours to touch. + */ +export function staleRedSkillsRuntimeLaunchers( + home = process.env["HOME"] ?? "", +): { name: string; path: string; replacement: string }[] { + if (!home) return []; + const path = join(home, ".local", "bin", "rsp"); + const replacement = join(runtimeBinDir(home), "rsp"); + if (!existsSync(path) || !existsSync(replacement)) return []; + + let source: string; + try { + source = readFileSync(path, "utf8"); + } catch { + return []; + } + if (!source.includes("RED_SKILLS_DEV_PLUGIN_ROOT") || !source.includes("rsp.bundle.min.mjs")) return []; + return [{ name: "rsp", path, replacement }]; +} + /** * `/migrations.json` — what this side of the machine has run. * diff --git a/src/mise-handover.test.ts b/src/mise-handover.test.ts index 32c233d..48a731c 100644 --- a/src/mise-handover.test.ts +++ b/src/mise-handover.test.ts @@ -24,7 +24,7 @@ import { join } from "node:path"; import { providerFor, TOOLS } from "./manifest.ts"; import type { Platform } from "./platform.ts"; import { miseEntries, miseToolNames, miseToolSpecs } from "./mise-config.ts"; -import { staleReleaseBinaries } from "./migrations.ts"; +import { staleRedSkillsRuntimeLaunchers, staleReleaseBinaries } from "./migrations.ts"; import { runtimeBinDir } from "./red-skills-companions.ts"; import { locateTool } from "./verify-install.ts"; @@ -117,8 +117,34 @@ describe("the stale binary an older release left behind", () => { }); }); +describe("the legacy RedSkills runtime launcher", () => { + test("is retired only after the package set owns a replacement", () => { + const home = temp(); + const legacy = join(home, ".local", "bin", "rsp"); + const replacement = join(runtimeBinDir(home), "rsp"); + mkdirSync(join(home, ".local", "bin"), { recursive: true }); + mkdirSync(runtimeBinDir(home), { recursive: true }); + writeFileSync(legacy, "#!/bin/sh\n# RED_SKILLS_DEV_PLUGIN_ROOT\nnode rsp.bundle.min.mjs\n"); + writeFileSync(replacement, "#!/bin/sh\nexit 0\n"); + + expect(staleRedSkillsRuntimeLaunchers(home)).toEqual([{ name: "rsp", path: legacy, replacement }]); + }); + + test("leaves an unrelated command with the same name alone", () => { + const home = temp(); + const legacy = join(home, ".local", "bin", "rsp"); + mkdirSync(join(home, ".local", "bin"), { recursive: true }); + mkdirSync(runtimeBinDir(home), { recursive: true }); + writeFileSync(legacy, "#!/bin/sh\necho mine\n"); + writeFileSync(join(runtimeBinDir(home), "rsp"), "#!/bin/sh\nexit 0\n"); + + expect(staleRedSkillsRuntimeLaunchers(home)).toEqual([]); + }); +}); + describe("mise's shims", () => { const path = readFileSync("config/bash/path.sh", "utf8"); + const init = readFileSync("config/bash/init.sh", "utf8"); test("are on PATH, so a tool works outside an activated shell", () => { // `mise activate` covers interactive bash and nothing else. A @@ -128,6 +154,13 @@ describe("mise's shims", () => { expect(path).toContain("MISE_DATA_DIR"); }); + test("leave an activated shell after mise exposes the real tool directories", () => { + expect(init).toContain("_red_drop_mise_fallbacks()"); + expect(init).toContain('if eval "$(mise activate bash)"; then'); + expect(init).toContain("_red_drop_mise_fallbacks"); + expect(init).toContain('"$data/installs/red-skills/"*/node_modules/.bin'); + }); + test("win over a binary an older release left in ~/.local/bin", () => { // _red_path_prepend puts each entry in front, so the later line is // the earlier PATH entry. The shims have to be prepended after