diff --git a/README.md b/README.md index 0026ae0..ac6646f 100644 --- a/README.md +++ b/README.md @@ -567,6 +567,9 @@ come from npm. The generated short alias is the installed identity; red-dev never also writes the backend-qualified spec into the global config. Existing machines are migrated only after the aliased replacement is present, with the original config backed up before the redundant declaration is retired. +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. 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 diff --git a/boot.ps1 b/boot.ps1 index 62d4bc2..cc21872 100644 --- a/boot.ps1 +++ b/boot.ps1 @@ -175,3 +175,22 @@ $ProgressPreference = $PreviousProgressPreference # falls back to a line menu in a narrow one, and prints help when there is # no terminal at all. & $Bin +$Status = $LASTEXITCODE + +# The downloaded executable is only the bootstrap. Once the successful first +# run has installed the managed mise identity, the child is closed and Windows +# releases the file lock, so this copy can be retired without scheduling a +# second cleanup process. A cancelled setup keeps the bootstrap intact. +if ($Status -eq 0) { + $Mise = Get-Command mise -ErrorAction SilentlyContinue + if ($Mise) { + $ManagedRoot = (& $Mise.Source where red-dev 2>$null | Select-Object -First 1) + $Managed = if ($ManagedRoot) { Join-Path $ManagedRoot 'red-dev.exe' } else { $null } + if ($Managed -and (Test-Path $Managed) -and ($Managed -ne $Bin)) { + Remove-Item $Bin -Force -ErrorAction SilentlyContinue + if (-not (Test-Path $Bin)) { Say "mise owns red-dev now; retired bootstrap $Bin" } + } + } +} + +exit $Status diff --git a/boot.sh b/boot.sh index 1c075d1..1eecfd0 100644 --- a/boot.sh +++ b/boot.sh @@ -219,7 +219,26 @@ export RED_DEV_BOOTSTRAP=1 # /dev/tty is the controlling terminal regardless of what stdin was # redirected to. When there is none — CI, a container, a cron job — the # fallback is the current behaviour, which is what should happen there. +set +e if [ -r /dev/tty ]; then - exec "$BIN" < /dev/tty + "$BIN" < /dev/tty + STATUS=$? +else + "$BIN" + STATUS=$? fi -exec "$BIN" +set -e + +# The downloaded binary is a bootstrap, not a second permanent owner. Once a +# successful first run has installed red-dev through the managed mise alias, +# retire this copy so PATH, updates and disk all have one answer. If setup was +# cancelled before mise acquired it, keep the bootstrap working. +if [ "$STATUS" -eq 0 ] && command -v mise >/dev/null 2>&1; then + MISE_RED_DEV=$(mise where red-dev 2>/dev/null || true) + if [ -n "$MISE_RED_DEV" ] && [ -x "$MISE_RED_DEV/red-dev" ] && [ "$MISE_RED_DEV/red-dev" != "$BIN" ]; then + rm -f "$BIN" + say "mise owns red-dev now; retired bootstrap $BIN" + fi +fi + +exit "$STATUS" diff --git a/src/migrations.ts b/src/migrations.ts index 41c1768..fba5c84 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -660,6 +660,38 @@ return {} log.plain(` original config backed up at ${backup}`); }, }, + { + id: "2026-09-22-suite-binary-handover", + describe: "finish handing bootstrap binaries to their single mise owner", + applies: (p) => staleReleaseBinaries(p).length > 0, + run: async (p) => { + const stale = staleReleaseBinaries(p); + const mise = Bun.which("mise"); + if (!mise) throw new Error("mise is not installed yet — bootstrap binaries left intact"); + + // Prove every replacement before removing any old copy. `mise where` + // asks for the alias's install tree directly; `which` can answer with + // the very ~/.local/bin file this migration is trying to retire. + for (const { name } of stale) { + const where = Bun.spawnSync([mise, "where", name], { stdout: "pipe", stderr: "ignore" }); + if (where.exitCode !== 0 || where.stdout.toString().trim() === "") { + throw new Error(`${name} has no mise replacement yet — bootstrap binaries left intact`); + } + } + + for (const { name, path } of stale) { + // Linux permits unlinking the image this process is executing; the + // inode stays alive until exit and the next command resolves through + // mise. Windows locks a running .exe, so boot.ps1 removes its bootstrap + // copy after this process returns and the migration retries once. + if (p.os === "windows" && samePath(path, process.execPath)) { + throw new Error(`${path} is still running — boot.ps1 will retire it after exit`); + } + rmSync(path, { force: true }); + log.plain(` ${name}: removed ${path}; mise is the only owner`); + } + }, + }, ]; /** diff --git a/src/mise-handover.test.ts b/src/mise-handover.test.ts index cc06255..32c233d 100644 --- a/src/mise-handover.test.ts +++ b/src/mise-handover.test.ts @@ -103,6 +103,18 @@ describe("the stale binary an older release left behind", () => { expect(staleReleaseBinaries(ubuntu).map((s) => s.name)).toEqual(["red"]); }); }); + + test("includes red-dev's bootstrap copy so a later run can finish the handover", () => { + const home = temp(); + mkdirSync(join(home, ".local", "bin"), { recursive: true }); + writeFileSync(join(home, ".local", "bin", "red-dev"), "#!/bin/sh\nexit 0\n"); + withEnv({ HOME: home }, () => { + expect(staleReleaseBinaries(ubuntu)).toContainEqual({ + name: "red-dev", + path: join(home, ".local", "bin", "red-dev"), + }); + }); + }); }); describe("mise's shims", () => { diff --git a/src/sudo-preflight.test.ts b/src/sudo-preflight.test.ts index aabc2a6..0b673d6 100644 --- a/src/sudo-preflight.test.ts +++ b/src/sudo-preflight.test.ts @@ -84,9 +84,15 @@ describe("the human install entry points", () => { // `curl | sh` leaves stdin pointing at the exhausted script pipe. The // downloaded binary must inherit the controlling terminal or neither // its sudo prompt nor its fullscreen interface can read a key. - expect(boot).toContain('exec "$BIN" < /dev/tty'); + expect(boot).toContain('"$BIN" < /dev/tty'); + // The bootstrap must regain control after the child closes so it can + // retire its copy once mise owns red-dev. `exec` would preserve the TTY + // but make the duplicate permanent again. + expect(boot).not.toContain('exec "$BIN" < /dev/tty'); + expect(boot).toContain("mise where red-dev"); + expect(boot).toContain('rm -f "$BIN"'); expect(boot.indexOf("export RED_DEV_BOOTSTRAP=1")).toBeLessThan( - boot.indexOf('exec "$BIN" < /dev/tty'), + boot.indexOf('"$BIN" < /dev/tty'), ); // The bootstrap enters the menu rather than `cmdInstall`, so it needs