Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 30 additions & 2 deletions config/bash/init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion config/bash/path.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 39 additions & 0 deletions src/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 }];
}

/**
* `<state>/migrations.json` — what this side of the machine has run.
*
Expand Down
35 changes: 34 additions & 1 deletion src/mise-handover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down