From e47a58bda196148c5ff90aa82d0651835da1fe94 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Sun, 11 Oct 2026 02:31:11 +0200 Subject: [PATCH 1/4] feat(launcher): run the bundled Gentle Shell with its exact Pi and add bundled upgrade A launcher running from /versions/ that current points at is in bundled mode: it runs only the Pi pinned by that version, never GENTLE_SHELL_PI, an adjacent fallback or PATH, and gives Pi, setup and subagents the bundled environment (our Node and npm first, npm settings in the prefix, PI_SKIP_VERSION_CHECK=1). gentle-shell update explains that Pi ships pinned and runs gentle-shell upgrade. gentle-shell upgrade installs the latest release beside the current one from its published lockfile, verifies it, switches current atomically and keeps two versions; --rollback switches back. A failure leaves current unchanged. Non-bundled installs are unchanged. --- README.md | 5 +- bin/gentle-shell.mjs | 124 +++++++++++-- docs/bundled-install.md | 26 ++- docs/readme-reference.md | 14 +- lib/gentle-shell-bundled.ts | 158 +++++++++++++++++ lib/gentle-shell-launcher.ts | 8 +- odd/tasks/bundled-gentle-shell.md | 9 +- runtime/gentle-shell-bundled.mjs | 159 +++++++++++++++++ runtime/gentle-shell-launcher.mjs | 8 +- scripts/build-runtime-modules.mjs | 1 + scripts/bundled-install.mjs | 85 +++++++++ scripts/main-channel.mjs | 3 +- scripts/verify-package-files.mjs | 2 + tests/bundled-install.test.ts | 114 +++++++++++- tests/gentle-shell-bundled.test.ts | 259 ++++++++++++++++++++++++++++ tests/gentle-shell-launcher.test.ts | 7 + 16 files changed, 964 insertions(+), 18 deletions(-) create mode 100644 lib/gentle-shell-bundled.ts create mode 100644 runtime/gentle-shell-bundled.mjs create mode 100644 tests/gentle-shell-bundled.test.ts diff --git a/README.md b/README.md index 96c397b1a..716a4ce1f 100644 --- a/README.md +++ b/README.md @@ -346,9 +346,12 @@ gentle-shell upgrade # Switch channel gentle-shell upgrade --channel main gentle-shell upgrade --channel release + +# Bundled install: back to the previous version +gentle-shell upgrade --rollback ``` -`gentle-shell upgrade` uses the package manager that owns your installation. On the main channel it rebuilds Gentle AI and Gentle Shell from the latest `main` commits (only what changed) and needs pnpm and Go (a Go older than the minimum is replaced, for the build only, by the pinned Go the installer left in its own folder); switching back to release restores the pinned Gentle AI binary. With pnpm 11, the updated Gentle Shell may run a newer Pi that pnpm puts next to it (always at least the minimum); your own Pi is never changed. `gentle-shell update` is a different command: it is Pi's own package update. More: **[upgrade reference](docs/readme-reference.md#upgrade-subcommand-and-channels)**. +`gentle-shell upgrade` uses the package manager that owns your installation. On the main channel it rebuilds Gentle AI and Gentle Shell from the latest `main` commits (only what changed) and needs pnpm and Go (a Go older than the minimum is replaced, for the build only, by the pinned Go the installer left in its own folder); switching back to release restores the pinned Gentle AI binary. With pnpm 11, the updated Gentle Shell may run a newer Pi that pnpm puts next to it (always at least the minimum); your own Pi is never changed. A bundled install (the web installer's own Node, pnpm and Pi) follows the release channel only: it installs the new version beside the current one, verifies it, then switches, keeping the previous version for `--rollback`. `gentle-shell update` is a different command: it is Pi's own package update. In a bundled install Pi ships pinned with Gentle Shell, so updating Pi itself runs `gentle-shell upgrade` instead. More: **[upgrade reference](docs/readme-reference.md#upgrade-subcommand-and-channels)**. ### Remove it diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index b4aef6482..2658c1b6e 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -10,11 +10,13 @@ import { closeSync, constants as fsConstants, existsSync, + lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, + readlinkSync, realpathSync, renameSync, rmSync, @@ -54,6 +56,14 @@ import { restoreJsonField, shellQuote, } from "../runtime/gentle-shell-launcher.mjs"; +import { + bundledChildEnv, + bundledPi, + bundledUpdateNotice, + bundledUpdatePlan, + parseBundledUpgradeArgs, + resolveBundledInstall, +} from "../runtime/gentle-shell-bundled.mjs"; import { parseResumeHandoff, planResumeHint, @@ -128,6 +138,36 @@ function signalExitCode(signal) { return 128 + (typeof number === "number" ? number : 0); } +// A bundled install (S8): this package inside /versions/ that +// `current` names (lib/gentle-shell-bundled.ts decides from these reads). +const bundledFs = { + realpath: (path) => { + try { + return realpathSync(path); + } catch { + return undefined; + } + }, + readText: (path) => { + try { + return lstatSync(path).isFile() ? readFileSync(path, "utf8") : undefined; + } catch { + return undefined; + } + }, + readLink: (path) => { + try { + return readlinkSync(path); + } catch { + return undefined; + } + }, +}; + +// Set in a bundled install: the environment every child (Pi, setup, the version +// probe) starts from instead of process.env. +let bundledEnv; + function ownPackageVersion() { const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")); return packageJson.version; @@ -551,6 +591,8 @@ function ensurePackageLocalGentleAi(binaryPath, pinnedVersion, stdio) { // gentle-ai's (or pi's own) preflight finds `pi` even when it is bundled or // given through GENTLE_SHELL_PI. function buildSetupEnv(home, runtime) { + // Bundled: our node, npm and the version's Pi already lead its PATH. + if (bundledEnv) return { ...bundledEnv, PI_CODING_AGENT_DIR: home.dir, GENTLE_PI_AGENT_HOME: home.dir }; return { ...process.env, PI_CODING_AGENT_DIR: home.dir, @@ -1265,6 +1307,39 @@ async function handleUpgradeCommand(commandArgs) { } } +// `gentle-shell upgrade` in a bundled install: the latest release installed +// beside the active version, verified, then `current` switched (two versions +// kept), or `--rollback` to the kept one. Returns the exit code; a failure leaves +// `current` untouched. +async function handleBundledUpgrade(bundled, commandArgs) { + const parsed = parseBundledUpgradeArgs(commandArgs); + if ("error" in parsed) fail(`gentle-shell upgrade: ${parsed.error}`, parsed.usage ? 2 : 1); + const { prefixLayout, rollbackBundled, upgradeBundled } = await import("../scripts/bundled-install.mjs"); + const layout = prefixLayout({ platform: process.platform, env: process.env, home: homedir(), root: bundled.root }); + const out = (line) => process.stdout.write(`${line}\n`); + try { + if (parsed.action === "rollback") { + const { from, to } = rollbackBundled(layout); + out(`Switched Gentle Shell from ${from} to ${to}.`); + return 0; + } + const { fetchDistribution } = await import("../scripts/bundled-wizard.mjs"); + const { latestRelease } = await import("../scripts/main-channel.mjs"); + await upgradeBundled({ + layout, + home: homedir(), + platform: process.platform, + arch: process.arch, + env: process.env, + adapters: { latest: () => latestRelease(globalThis.fetch), distribution: (version) => fetchDistribution({ version }) }, + out, + }); + return 0; + } catch (error) { + fail(`gentle-shell upgrade: ${error.message}${error.stderr ? `\n${error.stderr}` : ""}`, 1); + } +} + // `gentle-shell self-uninstall`: like upgrade, runs before any Pi runtime check, // so it also works when pi is missing. async function handleSelfUninstallCommand(commandArgs) { @@ -1297,8 +1372,10 @@ async function main() { handleHomeCommand(args.commandArgs); return; } + const bundled = resolveBundledInstall({ packageRoot, platform: process.platform, fs: bundledFs }); if (args.command === "upgrade") { - await handleUpgradeCommand(args.commandArgs); + if (bundled) process.exitCode = await handleBundledUpgrade(bundled, args.commandArgs); + else await handleUpgradeCommand(args.commandArgs); return; } if (args.command === "self-uninstall") { @@ -1310,13 +1387,24 @@ async function main() { let home = resolveHome({ args, env: process.env, homedir: homedir(), config }); if (home.mode === "path") home = { ...home, dir: resolvePath(home.dir) }; - const runtime = resolvePiRuntime({ - env: process.env, - resolveBundledCli, - findOnPath, - nodeExecPath: process.execPath, - }); - if (runtime === undefined) fail(missingPiMessage(), 1); + let runtime; + if (bundled) { + // S8: only the version's own Pi, never GENTLE_SHELL_PI, an adjacent fallback or PATH. + const pi = bundledPi(bundled, { platform: process.platform, fs: bundledFs }); + if (!pi.ok) fail(pi.message, 1); + runtime = { kind: "bundled", command: process.execPath, args: [pi.cli] }; + const { prefixLayout, setupEnvironment } = await import("../scripts/bundled-install.mjs"); + const layout = prefixLayout({ platform: process.platform, env: process.env, home: homedir(), root: bundled.root }); + bundledEnv = bundledChildEnv(setupEnvironment(layout, { env: process.env, id: bundled.id }), { node: process.execPath, cli: pi.cli }); + } else { + runtime = resolvePiRuntime({ + env: process.env, + resolveBundledCli, + findOnPath, + nodeExecPath: process.execPath, + }); + if (runtime === undefined) fail(missingPiMessage(), 1); + } const versionProbePlan = planSpawn({ command: runtime.command, args: [...runtime.args, "--version"], platform: process.platform }); const versionProbe = spawnSync(versionProbePlan.command, versionProbePlan.args, { @@ -1324,6 +1412,7 @@ async function main() { timeout: 15000, encoding: "utf8", shell: versionProbePlan.shell, + env: bundledEnv ?? process.env, }); if (versionProbe.error) fail(`Could not run the pi runtime at "${runtime.command}": ${versionProbe.error.message}`, 1); const versionCheck = checkPiVersion(versionProbe.stdout ?? ""); @@ -1334,6 +1423,21 @@ async function main() { process.exit(0); } + // S5: in a bundled install Pi updates only with Gentle Shell, so Pi's own + // update becomes `gentle-shell upgrade` (run when interactive, named + // otherwise); package updates still reach Pi. + let passthrough = args.passthrough; + if (bundled && args.piSubcommand === "update") { + const plan = bundledUpdatePlan(args.passthrough); + if (plan.self) { + const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true; + for (const line of bundledUpdateNotice({ piVersion: versionCheck.version, interactive })) process.stdout.write(`${line}\n`); + const code = interactive ? await handleBundledUpgrade(bundled, []) : 0; + if (code !== 0 || plan.piArgs === undefined) process.exit(code); + } + passthrough = plan.piArgs; + } + // Home-ownership signal for auto-provisioning (S9, homeIsForeign): must be // read before the isolated-home bootstrap below creates and seeds a // brand-new --home directory with its own settings.json — after that @@ -1494,9 +1598,9 @@ async function main() { takeOver, otherPackagePaths, looseExtensionEntries, - passthrough: [...managedHerdrExtensionArgs(home, args), ...args.passthrough], + passthrough: [...managedHerdrExtensionArgs(home, args), ...passthrough], piSubcommand: args.piSubcommand, - baseEnv: process.env, + baseEnv: bundledEnv ?? process.env, homedir: homedir(), // The spawn below sets no cwd, so pi runs in the launcher's own. cwd: process.cwd(), diff --git a/docs/bundled-install.md b/docs/bundled-install.md index dc99b846c..9b5e81c8c 100644 --- a/docs/bundled-install.md +++ b/docs/bundled-install.md @@ -2,7 +2,7 @@ Gentle Shell installs as one self-contained product. It carries its own pinned Node (with the npm in the same archive) and pnpm, and Go only when a build needs it. Pi is pinned with each Gentle Shell release. The user's own `node`, `npm`, `pnpm` and `go` are never run, read or changed. -The building blocks live in `scripts/bundled-install.mjs`. The web installer and `gentle-shell upgrade` share them. The web installer uses them for a new installation of the release channel ([bundled installation](install-wizard.md#bundled-installation)); `gentle-shell upgrade` does not yet. +The building blocks live in `scripts/bundled-install.mjs`. The web installer and `gentle-shell upgrade` share them. The web installer uses them for a new installation of the release channel ([bundled installation](install-wizard.md#bundled-installation)). The launcher and `gentle-shell upgrade` use them in a bundled install ([Launcher in a bundled install](#launcher-in-a-bundled-install)). ## Layout @@ -192,6 +192,30 @@ Dropped: inherited npm and pnpm settings, `NODE_OPTIONS`, `NODE_PATH`, and the w - **Registry.** The registry change goes through an injected adapter. - **No `pnpm setup`.** It is not used. +## Launcher in a bundled install + +`bin/gentle-shell.mjs` runs in bundled mode only when it runs from a bundled install. Its real package root must be `versions//node_modules/gentle-pi` of a version whose `.gentle-shell-version` marker reads ``, and `current` must name that version. `resolveBundledInstall` in `lib/gentle-shell-bundled.ts` decides this. Any other installation keeps the launcher's ordinary behavior, including a kept version that `current` does not name. + +- **Exact Pi (S8).** The launcher runs only the Pi that the version's `package.json` pins: `versions//node_modules/@earendil-works/pi-coding-agent`, inside the version folder, with that exact version and Pi's `dist/bundle/cli.js` bin. `GENTLE_SHELL_PI`, an adjacent fallback and `PATH` are never used. A missing or different Pi stops with a message that names the version and the pinned Pi, and says to run `gentle-shell upgrade` or the installer again. +- **Child environment.** Pi, `setup` and the version probe all start from `setupEnvironment`. That puts our Node and npm first on `PATH`, then the version's `node_modules/.bin`, and keeps npm's settings inside the prefix. `bundledChildEnv` then removes `GENTLE_SHELL_PI`, sets `GENTLE_PI_AGENTS_PI` to our node and the bundled `cli.js` so subagents never run a `pi` from `PATH`, and sets `PI_SKIP_VERSION_CHECK=1`. The subagent runner splits that variable on whitespace, so a path with a space leaves it unset. The runner then falls back to the running node and `cli.js`, which are the same pair. +- **Pi updates (S5).** `gentle-shell update`, with no package or with `--self`, `self`, `pi` or `--all`, says that Pi ships pinned with Gentle Shell. In a terminal it then runs `gentle-shell upgrade`; otherwise it names the command. The extension part of `--all` still goes to Pi as `update --extensions`. Package, `--models` and help forms go to Pi unchanged. +- **What cannot be intercepted.** A `pi update` that someone runs directly with the bundled `pi` (for example the agent's shell, which finds it through `node_modules/.bin`) is Pi's own command. In Pi 1.1.0, `PI_SKIP_VERSION_CHECK` hides only the startup "Update Available" notice (`utils/version-check.js`). For the update itself, `package-manager-cli.js` detects a pnpm install from the `.pnpm` path, and `config.js` (`getSelfUpdateCommand`) finds no global pnpm root that owns `versions/`. Pi then prints `cannot self-update this installation` and changes nothing. + +### `gentle-shell upgrade` in a bundled install + +`upgradeBundled` follows the release channel only. `--channel main` is refused until a later task. + +1. The latest release comes from the npm registry, as on the ordinary release channel (`latestRelease`). Its two assets come from `fetchDistribution` (identity encoding, size bounds), and `readDistribution` checks the exact versions, the workspace files and the lockfile sha256. A release without assets stops with its version, and nothing changes. +2. When that release is the active version, the command says it is already current and changes nothing. +3. Otherwise `ensureRuntime` runs (with Go on Windows), and `installVersion` installs the release in place beside the active version. +4. The new version's own `scripts/bundled-install.mjs` is loaded, only from that freshly installed folder, and only when its real path stays inside it. Its `prefixLayout`, `ensureRuntime` and `ensureLauncher` must all be functions; otherwise the upgrade stops. Its `ensureRuntime` installs that release's pinned Node and pnpm beside ours when the pins moved, and reuses them otherwise. +5. The new version's launcher must print exactly `gentle-shell ` and `pi `. It runs with the release's Node, in the pnpm environment, with no `GENTLE_*` or `PI_*` overrides. +6. Only then does `current` switch (`activateVersion`, one rename). The launcher is refreshed by the release's own `ensureLauncher`, and `pruneVersions(layout, 2)` keeps the new version and the previous one. + +Any failure before step 6 leaves `current` untouched and removes a version folder this command installed. `gentle-shell upgrade --rollback` switches `current` to the most recently active other version that is still installed, again with one rename. Running it twice returns to the first version. + +Known limits: a release whose lockfile needs a newer pnpm than ours cannot be installed by this upgrade, because the release's own runtime is installed only after its version folder exists. Older runtimes under `runtime/` are never removed. + ## Never touched - The user's Node, npm, pnpm, Go, `PNPM_HOME`, `~/.npmrc` and npm or pnpm global configuration. diff --git a/docs/readme-reference.md b/docs/readme-reference.md index 892ce2abe..6910f1887 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -296,6 +296,7 @@ gentle-shell [options] [-- pi-args...] gentle-shell home [link|isolated|] gentle-shell [home selectors] setup [--dry-run] gentle-shell upgrade [--channel release|main] +gentle-shell upgrade --rollback gentle-shell self-uninstall [--dry-run] [--yes] [--include-shared] ``` @@ -421,8 +422,19 @@ package. JavaScript entry, or a native `pnpm.exe`). A package manager that resolves only to an unrecognized shim counts as missing. macOS and Linux are unchanged. +- **Bundled install** (the web installer's own Node, pnpm and Pi): `upgrade` + follows the release channel only and refuses `--channel main`. It installs the + latest release beside the active version, checks that the new launcher reports + exactly that release's Gentle Shell and Pi, and only then switches `current`, + keeping two versions. An already current release changes nothing, and a failure + leaves `current` untouched. `--rollback` switches back to the previous kept + version. Details: [Bundled install](bundled-install.md#gentle-shell-upgrade-in-a-bundled-install). + `gentle-shell update` is different: it is Pi's own `update`, forwarded to the -resolved home. The browser installation wizard offers the same main channel; see +resolved home. In a bundled install Pi ships pinned with Gentle Shell, so +`update` without a package (or with `--self`, `self`, `pi` or `--all`) explains +that and runs `gentle-shell upgrade` (in a terminal) or names it; package +updates still reach Pi. The browser installation wizard offers the same main channel; see [Installation wizard](install-wizard.md#main-channel). ### `self-uninstall` subcommand diff --git a/lib/gentle-shell-bundled.ts b/lib/gentle-shell-bundled.ts new file mode 100644 index 000000000..25a31665a --- /dev/null +++ b/lib/gentle-shell-bundled.ts @@ -0,0 +1,158 @@ +import { posix, win32 } from "node:path"; + +// The launcher in a bundled install (S5, S8): pure decisions over an injected +// filesystem. bin/gentle-shell.mjs wires them to the real one. A bundled install +// is `/versions//` with this gentle-pi in its node_modules, marked by +// scripts/bundled-install.mjs, and `current` naming it; anything else keeps the +// launcher's ordinary behavior unchanged. + +export interface BundledFs { + // The canonical path, or undefined when it does not exist. + realpath: (path: string) => string | undefined; + // A regular file's text, or undefined. + readText: (path: string) => string | undefined; + // A symbolic link's target, or undefined. + readLink: (path: string) => string | undefined; +} + +export interface BundledInstall { + root: string; + id: string; + versionDir: string; +} + +type Env = Record; + +// The same rules scripts/bundled-install.mjs writes versions and markers with. +const VERSION_ID = /^[0-9A-Za-z][0-9A-Za-z.-]*$/; +const VERSION_MARKER = ".gentle-shell-version"; +const PI_PACKAGE = "@earendil-works/pi-coding-agent"; +const PI_BIN = "dist/bundle/cli.js"; + +const pathFor = (platform: string) => (platform === "win32" ? win32 : posix); + +function inside(path: typeof posix, parent: string, child: string): boolean { + const rest = path.relative(parent, child); + return rest !== "" && !rest.startsWith("..") && !path.isAbsolute(rest); +} + +function parseJson(text: string | undefined): any { + if (text === undefined) return undefined; + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +// The bundled install this package runs from: the nearest `versions/` +// ancestor of its real root whose marker names it, whose node_modules/gentle-pi +// is this package, and which `current` names (a relative symlink on POSIX, a +// pointer file on Windows). Undefined otherwise. +export function resolveBundledInstall(input: { packageRoot: string; platform: string; fs: BundledFs }): BundledInstall | undefined { + const path = pathFor(input.platform); + const real = input.fs.realpath(input.packageRoot) ?? input.packageRoot; + for (let dir = path.dirname(real); path.dirname(dir) !== dir; dir = path.dirname(dir)) { + if (path.basename(path.dirname(dir)) !== "versions") continue; + const id = path.basename(dir); + const root = path.dirname(path.dirname(dir)); + if (!VERSION_ID.test(id) || id.includes("..")) return undefined; + if (input.fs.readText(path.join(dir, VERSION_MARKER)) !== `${id}\n`) return undefined; + if (input.fs.realpath(path.join(dir, "node_modules", "gentle-pi")) !== real) return undefined; + const current = path.join(root, "current"); + const active = input.platform === "win32" ? input.fs.readText(current) === `${id}\n` : input.fs.readLink(current) === `versions/${id}`; + return active ? { root, id, versionDir: dir } : undefined; + } + return undefined; +} + +export type BundledPi = { ok: true; cli: string; version: string } | { ok: false; message: string }; + +// The exact Pi of the version (S8): the Pi the version's package.json pins, +// installed inside the version folder with Pi's own bin entry. Never another Pi. +export function bundledPi(install: BundledInstall, input: { platform: string; fs: BundledFs }): BundledPi { + const path = pathFor(input.platform); + const pinned = parseJson(input.fs.readText(path.join(install.versionDir, "package.json")))?.dependencies?.[PI_PACKAGE]; + const failure = (found?: string): BundledPi => ({ + ok: false, + message: [ + `gentle-shell: Gentle Shell ${install.id} needs its bundled Pi ${pinned ?? "(unknown pin)"} in ${install.versionDir}, ${found ? `but found Pi ${found}` : "but it is missing"}.`, + "Run 'gentle-shell upgrade' to install the latest release, or run the Gentle Shell installer again to repair this version.", + ].join("\n"), + }); + const dir = input.fs.realpath(path.join(install.versionDir, "node_modules", ...PI_PACKAGE.split("/"))); + if (typeof pinned !== "string" || dir === undefined || !inside(path, input.fs.realpath(install.versionDir) ?? install.versionDir, dir)) return failure(); + const metadata = parseJson(input.fs.readText(path.join(dir, "package.json"))); + if (metadata?.name !== PI_PACKAGE || metadata.bin?.pi !== PI_BIN) return failure(); + if (metadata.version !== pinned) return failure(String(metadata.version)); + const cli = path.join(dir, ...PI_BIN.split("/")); + if (input.fs.realpath(cli) !== cli || input.fs.readText(cli) === undefined) return failure(); + return { ok: true, cli, version: pinned }; +} + +// What every child of a bundled launch adds to the bundled environment: no Pi +// override, subagents on the same node and Pi (the subagent runner splits +// GENTLE_PI_AGENTS_PI on whitespace, so a spaced path is left to its default, +// the running node and cli.js), and no Pi update check, since Pi only updates +// with Gentle Shell (S5). +export function bundledChildEnv(base: Env, input: { node: string; cli: string }): Env { + const env: Env = { ...base, PI_SKIP_VERSION_CHECK: "1" }; + delete env.GENTLE_SHELL_PI; + if (/\s/.test(`${input.node}${input.cli}`)) delete env.GENTLE_PI_AGENTS_PI; + else env.GENTLE_PI_AGENTS_PI = `${input.node} ${input.cli}`; + return env; +} + +export interface BundledUpdatePlan { + // Pi's own update was asked for: redirect it to `gentle-shell upgrade`. + self: boolean; + // What Pi still runs, if anything: the extension part of `--all`, or the + // whole command when it never updates Pi itself. + piArgs?: string[]; +} + +// `gentle-shell update [target]` in a bundled install. Pi updates itself for a +// bare `update`, `--self`, `self`/`pi`, and `--all` (or `--self --extensions`); +// only that part is redirected. Packages, models and help still reach Pi. +export function bundledUpdatePlan(passthrough: string[]): BundledUpdatePlan { + const rest = passthrough.slice(1); + const passThrough = { self: false, piArgs: passthrough }; + if (rest.some((token) => token === "--models" || token === "--extension" || token.startsWith("--extension=") || token === "--help" || token === "-h")) { + return passThrough; + } + const positional = rest.filter((token) => !token.startsWith("-")); + const flags = rest.filter((token) => token.startsWith("-")); + const named = positional.length === 1 && (positional[0] === "self" || positional[0] === "pi"); + const extensions = flags.includes("--all") || flags.includes("--extensions"); + if ((positional.length > 0 && !named) || (named && extensions)) return passThrough; + const all = flags.includes("--all") || (flags.includes("--self") && flags.includes("--extensions")); + if (all) return { self: true, piArgs: ["update", "--extensions", ...flags.filter((flag) => !["--all", "--self", "--extensions"].includes(flag))] }; + return extensions ? passThrough : { self: true }; +} + +export function bundledUpdateNotice(input: { piVersion: string; interactive: boolean }): string[] { + return [ + `Pi ships pinned with Gentle Shell (Pi ${input.piVersion}): a newer Pi arrives with a Gentle Shell release.`, + input.interactive + ? "Running 'gentle-shell upgrade'..." + : "Run 'gentle-shell upgrade' to install the latest Gentle Shell release and its Pi.", + ]; +} + +export type BundledUpgradeAction = { action: "upgrade" } | { action: "rollback" } | { error: string; usage: boolean }; + +export const BUNDLED_UPGRADE_USAGE = "usage: gentle-shell upgrade [--channel release] | gentle-shell upgrade --rollback"; + +// `gentle-shell upgrade` arguments in a bundled install, which follows the +// release channel only (the main channel builds from source). +export function parseBundledUpgradeArgs(args: string[]): BundledUpgradeAction { + if (args.length === 0) return { action: "upgrade" }; + if (args.length === 1 && args[0] === "--rollback") return { action: "rollback" }; + const channel = args.length === 1 && args[0].startsWith("--channel=") ? args[0].slice("--channel=".length) + : args.length === 2 && args[0] === "--channel" ? args[1] : undefined; + if (channel === "release") return { action: "upgrade" }; + if (channel === "main") { + return { error: "this bundled installation follows the release channel only; the main channel needs a pnpm or npm installation of Gentle Shell", usage: false }; + } + return { error: BUNDLED_UPGRADE_USAGE, usage: true }; +} diff --git a/lib/gentle-shell-launcher.ts b/lib/gentle-shell-launcher.ts index e99e5659d..faa756b0e 100644 --- a/lib/gentle-shell-launcher.ts +++ b/lib/gentle-shell-launcher.ts @@ -1176,6 +1176,7 @@ export function helpText(): string { " gentle-shell home [link|isolated|]", " gentle-shell [home selectors] setup [--dry-run]", " gentle-shell upgrade [--channel release|main]", + " gentle-shell upgrade --rollback", " gentle-shell self-uninstall [--dry-run] [--yes] [--include-shared]", "", "Opens pi with the Gentle Shell package loaded, without touching your", @@ -1199,7 +1200,10 @@ export function helpText(): string { " Warns if configured MCP servers remain disabled; never re-enables them.", " upgrade Update Gentle Shell along its channel: the latest release, or the latest", " main commits of Gentle Shell and Gentle AI (built locally; needs Go and", - " pnpm). --channel switches the channel first.", + " pnpm). --channel switches the channel first. A bundled install (the web", + " installer's own Node, pnpm and Pi) follows the release channel only:", + " it installs the new version beside the current one, verifies it, then", + " switches; --rollback switches back to the previous kept version.", " self-uninstall Remove Gentle Shell: its own data, the isolated home and the gentle-pi", " package. Shows the plan first and asks before removing (--yes skips", " the question). The shared ~/.pi/gentle-ai configuration is kept unless", @@ -1211,6 +1215,8 @@ export function helpText(): string { " gentle-shell remove Run pi's own 'remove' against the resolved home.", " gentle-shell list Run pi's own 'list' against the resolved home.", " gentle-shell update [target] Run pi's own 'update' against the resolved home.", + " In a bundled install Pi ships pinned: 'update' without a package", + " runs 'gentle-shell upgrade' instead of updating Pi.", " gentle-shell config Run pi's own 'config' against the resolved home.", " gentle-shell auth Run pi's own 'auth' against the resolved home.", " 'gentle-shell uninstall ' is pi's own alias for 'remove'; to remove Gentle", diff --git a/odd/tasks/bundled-gentle-shell.md b/odd/tasks/bundled-gentle-shell.md index 2bced27f8..541b3d5fc 100644 --- a/odd/tasks/bundled-gentle-shell.md +++ b/odd/tasks/bundled-gentle-shell.md @@ -20,7 +20,7 @@ Branch: feat/bundled-gentle-shell (design approved; one work-unit commit and PR - T1a | S2,S3,S6,S7 | delegated writer (context backstop) | committed 55efdf527; verifier corrections (B1-B3, A1, A2, A4, A5, A9) implemented, awaiting parent review, CI gate update (31 to 35 native tests) and commit | Shared bundled-install module (used by the web installer and `gentle-shell upgrade`): claim the prefix (POSIX private dir; Windows the bootstrap's private-folder claim), install pinned Node and pnpm (and Go only when asked) into runtime/ reusing the verified downloads, pnpm store/cache/state/config inside the prefix, install a version into versions/-/ from a distribution manifest + frozen lockfile, atomic `current` switch keeping 2 versions, the one PATH entry and the bin/gentle-shell shim. Unit tests with injected download/run adapters; no release wiring yet. - T1b | S3 | delegated writer; committed b9b181a6e on feat/bundled-release-lockfile; diag runs 38092721527, 38092811888 and 38092917737: ubuntu and macOS pass, Windows blocked by the pinned Go download defect fixed in #2064 (rerun after main is merged); verifier advisories A1-A5 corrected, awaiting parent review and commit | Release CI: build and publish per release the distribution manifest (gentle-pi + exact Pi) and its pnpm-lock.yaml as release assets, verified by a frozen install in CI on the three OSes. - T1c | S1,S2,S3,S12 | delegated writer on feat/bundled-wizard; implemented test-first, local proof done, awaiting parent review, CI (37 native tests) and commit | Web installer uses the bundled-install module for new installations (release channel first). -- T2 | S8,S5 | pending | Launcher in bundled mode: exact Pi from `current`, no fallbacks; `pi update`/`gentle-shell update` redirect; `gentle-shell upgrade` = new version + verify + switch + keep 2. +- T2 | S8,S5 | delegated writer on feat/bundled-launcher; implemented test-first, local proof done, awaiting parent review and commit | Launcher in bundled mode: exact Pi from `current`, no fallbacks; `pi update`/`gentle-shell update` redirect; `gentle-shell upgrade` = new version + verify + switch + keep 2. - T3 | S4 | pending | Migration from global pnpm/npm installs with one confirmation. - T4 | S9 | pending | Isolation leaks (pi-pretty fff path, gentle-ai state, Engram npm). - T5 | S10 | pending | Delete the user-tool detection code and its tests; docs rewrite (install-wizard.md). @@ -47,3 +47,10 @@ Branch: feat/bundled-gentle-shell (design approved; one work-unit commit and PR - **Local proof.** Isolated HOME, assets built for 4.0.0 + Pi 1.0.0, the real wizard host over HTTP. Every step through bundled-path passed. Pi installed 4 packages with our npm into agent/npm, and no `~/.npm` was created. The launcher in a fresh zsh printed `gentle-shell 4.0.0 / pi 1.0.0 / home isolated`. The filtered npmrc kept `registry` and `${NPM_TOKEN}` and dropped `save-exact` and `prefix`. - **Proof failures, outside T1c.** shell-setup failed at Gentle AI's Engram step: `brew install engram` refused an untrusted tap in the isolated HOME (T4, S9). gentle-pi 4.0.0's postinstall and setup ran the user's `go version` from the PATH after ours. - **Gaps.** npm_config_* is only in setupEnvironment, because tests/build-distribution.test.ts requires no npm_config_prefix in the pnpm/launcher env. Runtime reuse still checks only `bin/node`, so a T1a-era runtime without npm would be reused. The launcher does not yet put our npm and Pi on PATH for later runs (T2). +- L13. T2 (writer, S8, S5, S6): + - **Bundled mode.** `lib/gentle-shell-bundled.ts` (built to runtime/) holds the pure rules. `resolveBundledInstall` turns bundled mode on only when the launcher's real package root is `versions//node_modules/gentle-pi`, the marker reads ``, and `current` names that id (a symlink on POSIX, a pointer file on Windows). Anything else, including a kept version that `current` does not name, keeps today's launcher. + - **Exact Pi (S8).** `bundledPi` accepts only the Pi pinned in the version's package.json, inside the version folder. GENTLE_SHELL_PI, the adjacent fallback and PATH are never used. A missing or different Pi fails and names the version, the pin, `gentle-shell upgrade` and the installer. Pi, setup and the version probe all start from setupEnvironment plus `bundledChildEnv`: no GENTLE_SHELL_PI, GENTLE_PI_AGENTS_PI set to our node and the bundled cli.js (left unset for a path with whitespace, since agents-runner splits on whitespace; its default is the same pair), and PI_SKIP_VERSION_CHECK=1. + - **S5.** `gentle-shell update` (bare, --self, self/pi, --all) prints the pinned-Pi notice. In a terminal it then runs the bundled upgrade; otherwise it names the command. `--all` still sends `update --extensions` to Pi. A direct `pi update` cannot be intercepted. Pi 1.1.0's sources show why it does nothing here: version-check.js honors PI_SKIP_VERSION_CHECK, and config.js getSelfUpdateCommand finds no global pnpm root that owns `.pnpm` under versions/. A real run of Pi 1.0.0's `pi update` from the bundled .bin printed "pi cannot self-update this installation", exited 1, and left the version folder unchanged. + - **Upgrade.** `upgradeBundled` and `rollbackBundled` in scripts/bundled-install.mjs. The latest release comes from npm (`latestRelease`, now exported from main-channel.mjs). Then fetchDistribution and readDistribution, ensureRuntime, and installVersion in place. Next, the new version's own scripts/bundled-install.mjs is loaded, only from that folder (real path inside it), and must export prefixLayout, ensureRuntime and ensureLauncher as functions, otherwise the upgrade fails closed. Its ensureRuntime installs the release's pins side by side. The new launcher's `--version` must print exactly both versions. Only then do activateVersion, the release's ensureLauncher and pruneVersions(2) run. An already current release changes nothing. A failure leaves `current` untouched and removes the version this call installed. `--channel main` is refused. `--rollback` activates the most recent other installed version. readDistribution's return shape was kept, since tests outside this task's surfaces pin it; that is why pin changes come from the new module rather than from generatedWith. + - **Local proof.** Isolated HOME, assets for 4.0.0 with Pi 1.0.0 and with Pi 1.1.0. The published 4.0.0 has no scripts/bundled-install.mjs: the upgrade to it failed closed, and its folder was removed. With this worktree overlaid in both versions, the following all held. `--version` printed `pi 1.0.0` while GENTLE_SHELL_PI pointed at a fake 9.9.9. The same assets reported "already current". The upgrade to 4.0.0-1.1.0 switched and kept both versions. `upgrade --rollback` switched back and forth. The real `gentle-shell upgrade` said release v4.0.0 does not publish the bundled distribution yet. + - **Gaps.** A release whose lockfile needs a newer pnpm than ours cannot be installed, because its runtime arrives only after its folder exists. Old runtimes are never pruned. The upgrade runs the new gentle-pi's postinstall with the previous release's Node. Pi's self-update probe runs the user's `pnpm root -g` when the agent's shell runs `pi update`; the proof saw it create ~/Library/pnpm/bin in the isolated HOME (S9). The bin integration tests skip on Windows (a POSIX `current` symlink); the pure resolver covers Windows paths. diff --git a/runtime/gentle-shell-bundled.mjs b/runtime/gentle-shell-bundled.mjs new file mode 100644 index 000000000..72e88c8c8 --- /dev/null +++ b/runtime/gentle-shell-bundled.mjs @@ -0,0 +1,159 @@ +// Generated by scripts/build-runtime-modules.mjs. Do not edit. +import { posix, win32 } from "node:path"; + +// The launcher in a bundled install (S5, S8): pure decisions over an injected +// filesystem. bin/gentle-shell.mjs wires them to the real one. A bundled install +// is `/versions//` with this gentle-pi in its node_modules, marked by +// scripts/bundled-install.mjs, and `current` naming it; anything else keeps the +// launcher's ordinary behavior unchanged. + + + + + + + + + + + + + + + + + + +// The same rules scripts/bundled-install.mjs writes versions and markers with. +const VERSION_ID = /^[0-9A-Za-z][0-9A-Za-z.-]*$/; +const VERSION_MARKER = ".gentle-shell-version"; +const PI_PACKAGE = "@earendil-works/pi-coding-agent"; +const PI_BIN = "dist/bundle/cli.js"; + +const pathFor = (platform ) => (platform === "win32" ? win32 : posix); + +function inside(path , parent , child ) { + const rest = path.relative(parent, child); + return rest !== "" && !rest.startsWith("..") && !path.isAbsolute(rest); +} + +function parseJson(text ) { + if (text === undefined) return undefined; + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +// The bundled install this package runs from: the nearest `versions/` +// ancestor of its real root whose marker names it, whose node_modules/gentle-pi +// is this package, and which `current` names (a relative symlink on POSIX, a +// pointer file on Windows). Undefined otherwise. +export function resolveBundledInstall(input ) { + const path = pathFor(input.platform); + const real = input.fs.realpath(input.packageRoot) ?? input.packageRoot; + for (let dir = path.dirname(real); path.dirname(dir) !== dir; dir = path.dirname(dir)) { + if (path.basename(path.dirname(dir)) !== "versions") continue; + const id = path.basename(dir); + const root = path.dirname(path.dirname(dir)); + if (!VERSION_ID.test(id) || id.includes("..")) return undefined; + if (input.fs.readText(path.join(dir, VERSION_MARKER)) !== `${id}\n`) return undefined; + if (input.fs.realpath(path.join(dir, "node_modules", "gentle-pi")) !== real) return undefined; + const current = path.join(root, "current"); + const active = input.platform === "win32" ? input.fs.readText(current) === `${id}\n` : input.fs.readLink(current) === `versions/${id}`; + return active ? { root, id, versionDir: dir } : undefined; + } + return undefined; +} + + + +// The exact Pi of the version (S8): the Pi the version's package.json pins, +// installed inside the version folder with Pi's own bin entry. Never another Pi. +export function bundledPi(install , input ) { + const path = pathFor(input.platform); + const pinned = parseJson(input.fs.readText(path.join(install.versionDir, "package.json")))?.dependencies?.[PI_PACKAGE]; + const failure = (found ) => ({ + ok: false, + message: [ + `gentle-shell: Gentle Shell ${install.id} needs its bundled Pi ${pinned ?? "(unknown pin)"} in ${install.versionDir}, ${found ? `but found Pi ${found}` : "but it is missing"}.`, + "Run 'gentle-shell upgrade' to install the latest release, or run the Gentle Shell installer again to repair this version.", + ].join("\n"), + }); + const dir = input.fs.realpath(path.join(install.versionDir, "node_modules", ...PI_PACKAGE.split("/"))); + if (typeof pinned !== "string" || dir === undefined || !inside(path, input.fs.realpath(install.versionDir) ?? install.versionDir, dir)) return failure(); + const metadata = parseJson(input.fs.readText(path.join(dir, "package.json"))); + if (metadata?.name !== PI_PACKAGE || metadata.bin?.pi !== PI_BIN) return failure(); + if (metadata.version !== pinned) return failure(String(metadata.version)); + const cli = path.join(dir, ...PI_BIN.split("/")); + if (input.fs.realpath(cli) !== cli || input.fs.readText(cli) === undefined) return failure(); + return { ok: true, cli, version: pinned }; +} + +// What every child of a bundled launch adds to the bundled environment: no Pi +// override, subagents on the same node and Pi (the subagent runner splits +// GENTLE_PI_AGENTS_PI on whitespace, so a spaced path is left to its default, +// the running node and cli.js), and no Pi update check, since Pi only updates +// with Gentle Shell (S5). +export function bundledChildEnv(base , input ) { + const env = { ...base, PI_SKIP_VERSION_CHECK: "1" }; + delete env.GENTLE_SHELL_PI; + if (/\s/.test(`${input.node}${input.cli}`)) delete env.GENTLE_PI_AGENTS_PI; + else env.GENTLE_PI_AGENTS_PI = `${input.node} ${input.cli}`; + return env; +} + + + + + + + + + +// `gentle-shell update [target]` in a bundled install. Pi updates itself for a +// bare `update`, `--self`, `self`/`pi`, and `--all` (or `--self --extensions`); +// only that part is redirected. Packages, models and help still reach Pi. +export function bundledUpdatePlan(passthrough ) { + const rest = passthrough.slice(1); + const passThrough = { self: false, piArgs: passthrough }; + if (rest.some((token) => token === "--models" || token === "--extension" || token.startsWith("--extension=") || token === "--help" || token === "-h")) { + return passThrough; + } + const positional = rest.filter((token) => !token.startsWith("-")); + const flags = rest.filter((token) => token.startsWith("-")); + const named = positional.length === 1 && (positional[0] === "self" || positional[0] === "pi"); + const extensions = flags.includes("--all") || flags.includes("--extensions"); + if ((positional.length > 0 && !named) || (named && extensions)) return passThrough; + const all = flags.includes("--all") || (flags.includes("--self") && flags.includes("--extensions")); + if (all) return { self: true, piArgs: ["update", "--extensions", ...flags.filter((flag) => !["--all", "--self", "--extensions"].includes(flag))] }; + return extensions ? passThrough : { self: true }; +} + +export function bundledUpdateNotice(input ) { + return [ + `Pi ships pinned with Gentle Shell (Pi ${input.piVersion}): a newer Pi arrives with a Gentle Shell release.`, + input.interactive + ? "Running 'gentle-shell upgrade'..." + : "Run 'gentle-shell upgrade' to install the latest Gentle Shell release and its Pi.", + ]; +} + + + +export const BUNDLED_UPGRADE_USAGE = "usage: gentle-shell upgrade [--channel release] | gentle-shell upgrade --rollback"; + +// `gentle-shell upgrade` arguments in a bundled install, which follows the +// release channel only (the main channel builds from source). +export function parseBundledUpgradeArgs(args ) { + if (args.length === 0) return { action: "upgrade" }; + if (args.length === 1 && args[0] === "--rollback") return { action: "rollback" }; + const channel = args.length === 1 && args[0].startsWith("--channel=") ? args[0].slice("--channel=".length) + : args.length === 2 && args[0] === "--channel" ? args[1] : undefined; + if (channel === "release") return { action: "upgrade" }; + if (channel === "main") { + return { error: "this bundled installation follows the release channel only; the main channel needs a pnpm or npm installation of Gentle Shell", usage: false }; + } + return { error: BUNDLED_UPGRADE_USAGE, usage: true }; +} diff --git a/runtime/gentle-shell-launcher.mjs b/runtime/gentle-shell-launcher.mjs index d4ddc7e68..0f694b526 100644 --- a/runtime/gentle-shell-launcher.mjs +++ b/runtime/gentle-shell-launcher.mjs @@ -1177,6 +1177,7 @@ export function helpText() { " gentle-shell home [link|isolated|]", " gentle-shell [home selectors] setup [--dry-run]", " gentle-shell upgrade [--channel release|main]", + " gentle-shell upgrade --rollback", " gentle-shell self-uninstall [--dry-run] [--yes] [--include-shared]", "", "Opens pi with the Gentle Shell package loaded, without touching your", @@ -1200,7 +1201,10 @@ export function helpText() { " Warns if configured MCP servers remain disabled; never re-enables them.", " upgrade Update Gentle Shell along its channel: the latest release, or the latest", " main commits of Gentle Shell and Gentle AI (built locally; needs Go and", - " pnpm). --channel switches the channel first.", + " pnpm). --channel switches the channel first. A bundled install (the web", + " installer's own Node, pnpm and Pi) follows the release channel only:", + " it installs the new version beside the current one, verifies it, then", + " switches; --rollback switches back to the previous kept version.", " self-uninstall Remove Gentle Shell: its own data, the isolated home and the gentle-pi", " package. Shows the plan first and asks before removing (--yes skips", " the question). The shared ~/.pi/gentle-ai configuration is kept unless", @@ -1212,6 +1216,8 @@ export function helpText() { " gentle-shell remove Run pi's own 'remove' against the resolved home.", " gentle-shell list Run pi's own 'list' against the resolved home.", " gentle-shell update [target] Run pi's own 'update' against the resolved home.", + " In a bundled install Pi ships pinned: 'update' without a package", + " runs 'gentle-shell upgrade' instead of updating Pi.", " gentle-shell config Run pi's own 'config' against the resolved home.", " gentle-shell auth Run pi's own 'auth' against the resolved home.", " 'gentle-shell uninstall ' is pi's own alias for 'remove'; to remove Gentle", diff --git a/scripts/build-runtime-modules.mjs b/scripts/build-runtime-modules.mjs index e46f29411..a5f520525 100644 --- a/scripts/build-runtime-modules.mjs +++ b/scripts/build-runtime-modules.mjs @@ -16,6 +16,7 @@ const sources = [ "telemetry-trigger", "child-package-injection", "gentle-shell-launcher", + "gentle-shell-bundled", "gentle-shell-resume-hint", "gentle-shell-uninstall", ]; diff --git a/scripts/bundled-install.mjs b/scripts/bundled-install.mjs index 66ad1afeb..6af3e3484 100644 --- a/scripts/bundled-install.mjs +++ b/scripts/bundled-install.mjs @@ -9,6 +9,7 @@ import { accessSync, chmodSync, closeSync, constants, lstatSync, mkdirSync, mkdt rmSync, symlinkSync, unlinkSync, writeFileSync, writeSync } from "node:fs"; import { posix, win32 } from "node:path"; import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; import { crc32, gunzipSync, inflateRawSync } from "node:zlib"; import { acquireGo, artifactFor, verifiedDownload } from "./installer-downloads.mjs"; import { ensureWindowsPrivateFolder, readWindowsPnpmArchive, verifyWindowsStorage } from "./installer-windows.mjs"; @@ -687,6 +688,90 @@ export function pruneVersions(layout, keep = 2) { return removed; } +const UNPUBLISHED = Object.freeze({ + missing: (version) => `release v${version} does not publish the bundled distribution yet; try again later`, + unavailable: (version) => `the bundled distribution of release v${version} could not be downloaded`, + rejected: (version) => `the bundled distribution of release v${version} did not verify`, +}); +const RELEASE_EXPORTS = Object.freeze(["prefixLayout", "ensureRuntime", "ensureLauncher"]); + +/** The module a freshly installed version ships (its own pins and launcher), + * only from a regular file whose real path stays inside that version folder. + */ +async function releaseModule(file, versionPath) { + if (!regular(file) || !realpathSync(file).startsWith(`${realpathSync(versionPath)}${pathFor(process.platform).sep}`)) return null; + return import(pathToFileURL(file).href); +} + +/** `gentle-shell upgrade` in a bundled install (S5, S6, S8): the latest + * release's distribution assets (adapters.latest, adapters.distribution) are + * installed beside the active version with our runtime, then the new version's + * own module (loaded only from that version folder) installs its pinned runtime + * when the pins moved; its launcher must print exactly the release's gentle-pi + * and Pi before `current` switches. Two versions are kept, and the launcher is + * refreshed for the new runtime. An already current release changes nothing; + * any failure leaves `current` untouched and removes a version this call + * installed. Returns { id, changed, previous?, removed? }. + */ +export async function upgradeBundled({ layout, home, platform = layout.platform, arch = process.arch, env = {}, adapters, out = () => {} }) { + const path = pathFor(process.platform); + const latest = await adapters.latest(); + const release = await adapters.distribution(latest); + if (release?.status !== "published") throw new Error((UNPUBLISHED[release?.status] ?? UNPUBLISHED.unavailable)(latest)); + const { manifest, lockfile } = release; + const { shell, pi, id } = versionsOf(manifest); + const active = activeVersion(layout); + if (active === id) { + out(`Gentle Shell ${shell} with Pi ${pi} is already current.`); + return { id, changed: false }; + } + const go = platform === "win32"; + const runtime = await (adapters.ensureRuntime ?? ensureRuntime)({ layout, platform, arch, go, env }); + const version = await installVersion({ layout, manifest, lockfile, platform, env, go: runtime.go, adapters: adapters.install ?? {} }); + let verified; + try { + const file = path.join(version.path, "node_modules", "gentle-pi", "scripts", "bundled-install.mjs"); + const module = await (adapters.load ?? releaseModule)(file, version.path); + if (!module || RELEASE_EXPORTS.some((name) => typeof module[name] !== "function")) { + throw new Error(`Gentle Shell ${id} has no usable scripts/bundled-install.mjs (${RELEASE_EXPORTS.join(", ")}); current stays at ${active}`); + } + const next = module.prefixLayout({ platform, env, home, root: layout.root }); + await module.ensureRuntime({ layout: next, platform, arch, go, env }); + // The environment our pnpm ran in, without any Pi or Gentle override that + // could point the launcher at another Pi or agent home. + const launchEnv = Object.fromEntries(Object.entries(pnpmEnvironment(next, { platform, env })).filter(([key]) => !/^(?:gentle_|pi_)/i.test(key))); + const entry = path.join(version.path, "node_modules", "gentle-pi", "bin", "gentle-shell.mjs"); + const result = await (adapters.run ?? runCommand)(next.node, [entry, "--version"], { cwd: layout.root, env: launchEnv, timeout: 120000 }); + const reported = String(result.stdout ?? "").trim(); + const [shellLine, piLine] = reported.split(/\r?\n/); + if (result.status !== 0 || shellLine !== `gentle-shell ${shell}` || piLine !== `pi ${pi}`) { + throw Object.assign(new Error(`Gentle Shell ${id} reported ${JSON.stringify(reported)}, not gentle-shell ${shell} with pi ${pi}; current stays at ${active}`), + { stderr: String(result.stderr ?? "").slice(-4000) }); + } + verified = { module, next }; + } catch (error) { + if (version.installed && activeVersion(layout) !== id) rmSync(version.path, { recursive: true, force: true }); + throw error; + } + activateVersion(layout, id); + verified.module.ensureLauncher(verified.next); + const removed = pruneVersions(layout, 2); + out(`Upgraded to Gentle Shell ${shell} with Pi ${pi} (from ${active ?? "no active version"}). Run 'gentle-shell upgrade --rollback' to switch back.`); + return { id, changed: true, previous: active, removed }; +} + +/** `gentle-shell upgrade --rollback`: `current` switches back to the most + * recently active other version still installed (activateVersion, one rename). + * Returns { from, to }. + */ +export function rollbackBundled(layout) { + const active = activeVersion(layout); + const previous = readHistory(layout).find((id) => id !== active && installedVersion(layout, id)); + if (!previous) throw new Error("No previous Gentle Shell version is kept to roll back to"); + activateVersion(layout, previous); + return { from: active, to: previous }; +} + /** The launcher text: our Node running the current gentle-pi's * bin/gentle-shell.mjs, passing every argument and the exit code. Without an * active version it says so and exits 1. The Windows one reads the pointer file diff --git a/scripts/main-channel.mjs b/scripts/main-channel.mjs index 46f61d0e8..a57bc43dc 100644 --- a/scripts/main-channel.mjs +++ b/scripts/main-channel.mjs @@ -249,7 +249,8 @@ function inside(parent, child) { return path !== "" && !path.startsWith("..") && !isAbsolute(path); } -async function latestRelease(fetch) { +/** The latest stable gentle-pi release on the npm registry. */ +export async function latestRelease(fetch) { let version = null; try { const response = await fetch("https://registry.npmjs.org/gentle-pi/latest", { headers: { Accept: "application/json" } }); diff --git a/scripts/verify-package-files.mjs b/scripts/verify-package-files.mjs index b8a9c9a10..94b5aaa41 100644 --- a/scripts/verify-package-files.mjs +++ b/scripts/verify-package-files.mjs @@ -85,6 +85,7 @@ export const requiredPaths = [ "lib/child-package-injection.ts", "lib/gentle-ai-binary.ts", "lib/gentle-shell-launcher.ts", + "lib/gentle-shell-bundled.ts", "lib/gentle-shell-resume-hint.ts", "lib/native-review-cli.ts", "lib/status-timing-diagnostics.ts", @@ -97,6 +98,7 @@ export const requiredPaths = [ "runtime/child-package-injection.mjs", "runtime/gentle-ai-binary.mjs", "runtime/gentle-shell-launcher.mjs", + "runtime/gentle-shell-bundled.mjs", "runtime/gentle-shell-resume-hint.mjs", "runtime/gentle-shell-uninstall.mjs", "runtime/native-review-cli.mjs", diff --git a/tests/bundled-install.test.ts b/tests/bundled-install.test.ts index 2178046b9..57247cf73 100644 --- a/tests/bundled-install.test.ts +++ b/tests/bundled-install.test.ts @@ -7,7 +7,8 @@ import { join, posix } from "node:path"; import test, { after, type TestContext } from "node:test"; import { crc32, gzipSync } from "node:zlib"; import { activateVersion, activeVersion, applyPathEntry, claimPrefix, distributionFiles, ensureLauncher, ensureRuntime, filterNpmrc, installVersion, nodeExecutable, - nodeRuntimeFiles, pathEntryPlan, pnpmEnvironment, prefixLayout, pruneVersions, removePathEntry, setupEnvironment, userNpmrc, writeNpmrcAuth } from "../scripts/bundled-install.mjs"; + nodeRuntimeFiles, pathEntryPlan, pnpmEnvironment, prefixLayout, pruneVersions, removePathEntry, rollbackBundled, setupEnvironment, upgradeBundled, userNpmrc, writeNpmrcAuth } + from "../scripts/bundled-install.mjs"; // Real directories, modes, symlinks and uids need a POSIX host. const posixHost = process.platform === "win32" ? "POSIX filesystem fixtures need a POSIX host" : false; @@ -772,3 +773,114 @@ test("setupEnvironment runs setup with our Node and npm, then the version's Pi, assert.equal(windows.Path, `C:\\L\\gentle-shell\\runtime\\node-${NODE};C:\\L\\gentle-shell\\versions\\4.1.0-1.0.2\\node_modules\\.bin;C:\\Windows`); assert.throws(() => setupEnvironment(layout, { platform: "linux", id: "../x", env: {} }), /Unsafe version id/); }); + +// T2 (S5, S6, S8): `gentle-shell upgrade` in a bundled install. +const LATEST = { shell: "4.2.0", pi: "1.0.2" }; +const releaseModuleFile = (layout: ReturnType, id: string) => join(layout.versions, id, "node_modules", "gentle-pi", "scripts", "bundled-install.mjs"); +function upgradeAdapters(layout: ReturnType, { release = LATEST, status = "published", reported = release, module = {} as Record } = {}) { + const calls: { ensureRuntime: unknown[]; verify: { command: string; args: string[]; env: Record }[]; loaded: string[]; launchers: unknown[] } = + { ensureRuntime: [], verify: [], loaded: [], launchers: [] }; + const releaseModule = { prefixLayout, ensureRuntime: async (options: unknown) => { calls.ensureRuntime.push(options); return { go: null }; }, + ensureLauncher: (next: unknown) => { calls.launchers.push(next); return { written: false }; }, ...module }; + const adapters = { + latest: async () => release.shell, + distribution: async (version: string) => (status === "published" ? { status, manifest: { ...release, shell: version }, lockfile: "lockfileVersion: '9.0'\n" } : { status }), + ensureRuntime: async (options: unknown) => { calls.ensureRuntime.push(options); return { go: null }; }, + install: { run: fakePnpm(release).run }, + load: async (file: string) => { calls.loaded.push(file); return releaseModule; }, + run: async (command: string, args: string[], options: { env: Record }) => { + calls.verify.push({ command, args, env: options.env }); + return { status: 0, stdout: `gentle-shell ${reported.shell}\npi ${reported.pi}\nhome isolated /x\n`, stderr: "" }; + }, + }; + return { adapters, calls }; +} +async function activeInstall() { + const { home: user, layout } = claimed(); + await installed(layout, [["4.0.0", "1.0.0"], ["4.1.0", "1.0.1"]]); + for (const id of ["4.0.0-1.0.0", "4.1.0-1.0.1"]) activateVersion(layout, id); + return { user, layout }; +} + +posixTest("upgradeBundled installs the latest release beside the active one, verifies it, switches current and keeps two", async () => { + const { user, layout } = await activeInstall(); + const { adapters, calls } = upgradeAdapters(layout); + const lines: string[] = []; + const result = await upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: { PATH: "/usr/bin", GENTLE_SHELL_PI: "/x/pi", PI_CODING_AGENT_DIR: "/x" }, + adapters, out: (line: string) => lines.push(line) }); + assert.deepEqual(result, { id: "4.2.0-1.0.2", changed: true, previous: "4.1.0-1.0.1", removed: ["4.0.0-1.0.0"] }); + assert.equal(activeVersion(layout), "4.2.0-1.0.2"); + assert.deepEqual(readdirSync(layout.versions).sort(), ["4.1.0-1.0.1", "4.2.0-1.0.2"]); + // Only the freshly installed version's own module is loaded. + assert.deepEqual(calls.loaded, [releaseModuleFile(layout, "4.2.0-1.0.2")]); + const [verify] = calls.verify; + assert.equal(verify.command, layout.node); + assert.deepEqual(verify.args, [join(layout.versions, "4.2.0-1.0.2", "node_modules", "gentle-pi", "bin", "gentle-shell.mjs"), "--version"]); + assert.equal(verify.env.GENTLE_SHELL_PI, undefined); + assert.equal(verify.env.PI_CODING_AGENT_DIR, undefined); + assert.match(lines.join("\n"), /Gentle Shell 4\.2\.0 with Pi 1\.0\.2.*4\.1\.0-1\.0\.1.*--rollback/s); +}); + +posixTest("upgradeBundled changes nothing when the latest release is already current", async () => { + const { user, layout } = await activeInstall(); + const { adapters, calls } = upgradeAdapters(layout, { release: { shell: "4.1.0", pi: "1.0.1" } }); + const lines: string[] = []; + const result = await upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters, out: (line: string) => lines.push(line) }); + assert.deepEqual(result, { id: "4.1.0-1.0.1", changed: false }); + assert.deepEqual([calls.ensureRuntime.length, calls.verify.length, calls.loaded.length], [0, 0, 0]); + assert.match(lines.join("\n"), /Gentle Shell 4\.1\.0 with Pi 1\.0\.1 is already current/); + assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]); +}); + +posixTest("upgradeBundled leaves current untouched when the release is unpublished or its verification fails", async () => { + const { user, layout } = await activeInstall(); + for (const status of ["missing", "unavailable", "rejected"]) { + await assert.rejects(upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters: upgradeAdapters(layout, { status }).adapters }), /v4\.2\.0/); + } + const wrong = upgradeAdapters(layout, { reported: { shell: "4.2.0", pi: "1.0.0" } }); + await assert.rejects(upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters: wrong.adapters }), /reported/); + assert.equal(activeVersion(layout), "4.1.0-1.0.1"); + // The version this upgrade installed and could not verify is removed again. + assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]); +}); + +posixTest("upgradeBundled fails closed when the new version's own module is missing or lacks an export it needs", async () => { + const { user, layout } = await activeInstall(); + for (const module of [{ ensureLauncher: undefined }, { prefixLayout: "not a function" }]) { + const { adapters, calls } = upgradeAdapters(layout, { module }); + await assert.rejects(upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters }), /4\.2\.0-1\.0\.2.*bundled-install\.mjs/s); + assert.equal(calls.verify.length, 0); + } + const absent = upgradeAdapters(layout); + await assert.rejects(upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters: { ...absent.adapters, load: undefined } }), /bundled-install\.mjs/); + assert.equal(activeVersion(layout), "4.1.0-1.0.1"); + assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]); +}); + +posixTest("upgradeBundled installs a new release's own pinned runtime and launcher when its pins moved", async () => { + const { user, layout } = await activeInstall(); + const next = { ...layout, node: join(layout.runtime, "node-26.0.0", "bin", "node") }; + const { adapters, calls } = upgradeAdapters(layout, { module: { prefixLayout: (options: { root: string; home: string }) => { + assert.deepEqual([options.root, options.home], [layout.root, user]); + return next; + } } }); + await upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters }); + // Ours first (the install runs on it), then the release's own, before verification runs on its Node. + assert.equal(calls.ensureRuntime.length, 2); + assert.equal((calls.ensureRuntime[1] as { layout: unknown }).layout, next); + assert.equal(calls.verify[0].command, next.node); + assert.deepEqual(calls.launchers, [next]); + assert.equal(activeVersion(layout), "4.2.0-1.0.2"); +}); + +posixTest("rollbackBundled switches current to the kept previous version, and back", async () => { + const { layout } = await activeInstall(); + assert.deepEqual(rollbackBundled(layout), { from: "4.1.0-1.0.1", to: "4.0.0-1.0.0" }); + assert.equal(activeVersion(layout), "4.0.0-1.0.0"); + assert.deepEqual(rollbackBundled(layout), { from: "4.0.0-1.0.0", to: "4.1.0-1.0.1" }); + const single = claimed().layout; + await installed(single, [["4.0.0", "1.0.0"]]); + activateVersion(single, "4.0.0-1.0.0"); + assert.throws(() => rollbackBundled(single), /No previous Gentle Shell version/); + assert.equal(activeVersion(single), "4.0.0-1.0.0"); +}); diff --git a/tests/gentle-shell-bundled.test.ts b/tests/gentle-shell-bundled.test.ts new file mode 100644 index 000000000..7ec26705f --- /dev/null +++ b/tests/gentle-shell-bundled.test.ts @@ -0,0 +1,259 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, posix, win32 } from "node:path"; +import { fileURLToPath } from "node:url"; +import test, { type TestContext } from "node:test"; +import { + bundledChildEnv, + bundledPi, + bundledUpdateNotice, + bundledUpdatePlan, + parseBundledUpgradeArgs, + resolveBundledInstall, + type BundledFs, + type BundledPi, +} from "../lib/gentle-shell-bundled.ts"; + +// A fake filesystem keyed by exact paths, so every platform's paths are plain +// strings on every runner: realpath maps a link to its target, and any other +// path holding a file to itself; readText returns a regular file's text, +// readLink a symlink's. +function fakeFs({ real = {}, files = {}, links = {} }: { real?: Record; files?: Record; links?: Record }): BundledFs { + const known = [...Object.keys(files), ...Object.keys(links), ...Object.values(real)]; + const exists = (path: string) => known.some((entry) => entry === path || entry.startsWith(`${path}/`) || entry.startsWith(`${path}\\`)); + return { + realpath: (path) => real[path] ?? (exists(path) ? path : undefined), + readText: (path) => files[path], + readLink: (path) => links[path], + }; +} + +const ID = "4.1.0-1.1.0"; +const POSIX_ROOT = "/home/ana/.gentle-shell"; +const POSIX_VERSION = posix.join(POSIX_ROOT, "versions", ID); +const POSIX_PACKAGE = posix.join(POSIX_VERSION, "node_modules", ".pnpm", "gentle-pi@4.1.0", "node_modules", "gentle-pi"); +const PI_PACKAGE = (version: string, path = posix) => path.join(version, "node_modules", "@earendil-works", "pi-coding-agent"); + +function posixInstall(overrides: { current?: string; marker?: string; link?: string } = {}) { + return fakeFs({ + real: { [posix.join(POSIX_VERSION, "node_modules", "gentle-pi")]: overrides.link ?? POSIX_PACKAGE }, + files: { [posix.join(POSIX_VERSION, ".gentle-shell-version")]: overrides.marker ?? `${ID}\n`, [posix.join(POSIX_PACKAGE, "package.json")]: "{}" }, + links: { [posix.join(POSIX_ROOT, "current")]: overrides.current ?? `versions/${ID}` }, + }); +} + +test("resolveBundledInstall finds the version folder current names from the real package root", () => { + assert.deepEqual(resolveBundledInstall({ packageRoot: POSIX_PACKAGE, platform: "linux", fs: posixInstall() }), + { root: POSIX_ROOT, id: ID, versionDir: POSIX_VERSION }); +}); + +test("resolveBundledInstall is off unless current names this marked version and its gentle-pi is this package", () => { + // Kept for rollback, but not the active one. + assert.equal(resolveBundledInstall({ packageRoot: POSIX_PACKAGE, platform: "linux", fs: posixInstall({ current: "versions/4.0.0-1.0.0" }) }), undefined); + // An unfinished install, or a folder without our marker. + assert.equal(resolveBundledInstall({ packageRoot: POSIX_PACKAGE, platform: "linux", fs: posixInstall({ marker: `installing ${ID} 42\n` }) }), undefined); + // The version's gentle-pi is another copy, not the running one. + assert.equal(resolveBundledInstall({ packageRoot: POSIX_PACKAGE, platform: "linux", fs: posixInstall({ link: "/elsewhere/gentle-pi" }) }), undefined); + // A global npm or pnpm install, or a source checkout. + for (const packageRoot of ["/usr/lib/node_modules/gentle-pi", "/home/ana/.local/share/pnpm/global/5/node_modules/gentle-pi", "/home/ana/src/gentle-shell"]) { + assert.equal(resolveBundledInstall({ packageRoot, platform: "linux", fs: posixInstall() }), undefined, packageRoot); + } + // An id the bundled install never writes. + const unsafe = posix.join(POSIX_ROOT, "versions", "-x", "node_modules", "gentle-pi"); + assert.equal(resolveBundledInstall({ packageRoot: unsafe, platform: "linux", fs: fakeFs({ + files: { [posix.join(POSIX_ROOT, "versions", "-x", ".gentle-shell-version")]: "-x\n" }, links: { [posix.join(POSIX_ROOT, "current")]: "versions/-x" } }) }), undefined); +}); + +test("resolveBundledInstall on Windows reads the current pointer file, never a link", () => { + const root = "C:\\Users\\Ana\\AppData\\Local\\gentle-shell"; + const version = win32.join(root, "versions", ID); + const packageRoot = win32.join(version, "node_modules", ".pnpm", "gentle-pi@4.1.0", "node_modules", "gentle-pi"); + const files = { [win32.join(version, ".gentle-shell-version")]: `${ID}\n`, [win32.join(root, "current")]: `${ID}\n` }; + const real = { [win32.join(version, "node_modules", "gentle-pi")]: packageRoot }; + assert.deepEqual(resolveBundledInstall({ packageRoot, platform: "win32", fs: fakeFs({ real, files }) }), { root, id: ID, versionDir: version }); + const linked = fakeFs({ real, files: { [win32.join(version, ".gentle-shell-version")]: `${ID}\n` }, links: { [win32.join(root, "current")]: `versions/${ID}` } }); + assert.equal(resolveBundledInstall({ packageRoot, platform: "win32", fs: linked }), undefined); +}); + +function piFiles(pinned: string, installed: string, path = posix, version = POSIX_VERSION) { + const pi = PI_PACKAGE(version, path); + return { + [path.join(version, "package.json")]: JSON.stringify({ dependencies: { "gentle-pi": "4.1.0", "@earendil-works/pi-coding-agent": pinned } }), + [path.join(pi, "package.json")]: JSON.stringify({ name: "@earendil-works/pi-coding-agent", version: installed, bin: { pi: "dist/bundle/cli.js" } }), + [path.join(pi, "dist", "bundle", "cli.js")]: "", + }; +} +const install = { root: POSIX_ROOT, id: ID, versionDir: POSIX_VERSION }; +const messageOf = (pi: BundledPi) => ("message" in pi ? pi.message : ""); + +test("bundledPi returns only the Pi the version pins, inside the version folder", () => { + assert.deepEqual(bundledPi(install, { platform: "linux", fs: fakeFs({ files: piFiles("1.1.0", "1.1.0") }) }), + { ok: true, version: "1.1.0", cli: posix.join(PI_PACKAGE(POSIX_VERSION), "dist", "bundle", "cli.js") }); + const windows = { root: "C:\\G", id: ID, versionDir: `C:\\G\\versions\\${ID}` }; + assert.deepEqual(bundledPi(windows, { platform: "win32", fs: fakeFs({ files: piFiles("1.1.0", "1.1.0", win32, windows.versionDir) }) }), + { ok: true, version: "1.1.0", cli: win32.join(PI_PACKAGE(windows.versionDir, win32), "dist", "bundle", "cli.js") }); +}); + +test("bundledPi fails with the version, the pinned Pi and the way to repair it", () => { + const repair = /Gentle Shell 4\.1\.0-1\.1\.0 .*Pi 1\.1\.0.*gentle-shell upgrade.*installer/s; + const mismatch = bundledPi(install, { platform: "linux", fs: fakeFs({ files: piFiles("1.1.0", "1.0.0") }) }); + assert.equal(mismatch.ok, false); + assert.match(messageOf(mismatch), repair); + assert.match(messageOf(mismatch), /found Pi 1\.0\.0/); + const files = piFiles("1.1.0", "1.1.0"); + delete files[posix.join(PI_PACKAGE(POSIX_VERSION), "dist", "bundle", "cli.js")]; + const missing = bundledPi(install, { platform: "linux", fs: fakeFs({ files }) }); + assert.match(messageOf(missing), repair); + // A Pi linked from outside the version folder is not the bundled one. + const outside = fakeFs({ real: { [PI_PACKAGE(POSIX_VERSION)]: "/usr/lib/node_modules/@earendil-works/pi-coding-agent" }, files: piFiles("1.1.0", "1.1.0") }); + assert.equal(bundledPi(install, { platform: "linux", fs: outside }).ok, false); +}); + +test("bundledChildEnv gives children the bundled Pi, no Pi override and no Pi update check", () => { + const env = bundledChildEnv({ PATH: "/our/node:/v/.bin:/usr/bin", GENTLE_SHELL_PI: "/usr/local/bin/pi", GENTLE_PI_AGENTS_PI: "pi" }, { node: "/g/node", cli: "/g/cli.js" }); + assert.equal(env.GENTLE_SHELL_PI, undefined); + assert.equal(env.GENTLE_PI_AGENTS_PI, "/g/node /g/cli.js"); + assert.equal(env.PI_SKIP_VERSION_CHECK, "1"); + assert.equal(env.PATH, "/our/node:/v/.bin:/usr/bin"); + // The subagent runner splits that variable on whitespace: a spaced path falls + // back to its own default, the running node and cli.js. + const spaced = bundledChildEnv({ GENTLE_PI_AGENTS_PI: "pi" }, { node: "C:\\Users\\Ana Li\\node.exe", cli: "C:\\cli.js" }); + assert.equal("GENTLE_PI_AGENTS_PI" in spaced, false); +}); + +test("bundledUpdatePlan redirects Pi's own update and keeps the extension updates", () => { + assert.deepEqual(bundledUpdatePlan(["update"]), { self: true }); + assert.deepEqual(bundledUpdatePlan(["update", "--self"]), { self: true }); + assert.deepEqual(bundledUpdatePlan(["update", "pi"]), { self: true }); + assert.deepEqual(bundledUpdatePlan(["update", "self"]), { self: true }); + assert.deepEqual(bundledUpdatePlan(["update", "--all"]), { self: true, piArgs: ["update", "--extensions"] }); + assert.deepEqual(bundledUpdatePlan(["update", "--self", "--extensions", "--approve"]), { self: true, piArgs: ["update", "--extensions", "--approve"] }); + for (const passthrough of [["update", "--extensions"], ["update", "npm:x"], ["update", "--models"], ["update", "--extension", "npm:x"], ["update", "--help"]]) { + assert.deepEqual(bundledUpdatePlan(passthrough), { self: false, piArgs: passthrough }, passthrough.join(" ")); + } +}); + +test("bundledUpdateNotice explains the pinned Pi and runs or names gentle-shell upgrade", () => { + const interactive = bundledUpdateNotice({ piVersion: "1.1.0", interactive: true }).join("\n"); + assert.match(interactive, /Pi ships pinned with Gentle Shell \(Pi 1\.1\.0\)/); + assert.match(interactive, /Running 'gentle-shell upgrade'/); + assert.match(bundledUpdateNotice({ piVersion: "1.1.0", interactive: false }).join("\n"), /Run 'gentle-shell upgrade'/); +}); + +test("parseBundledUpgradeArgs accepts the release channel and --rollback and refuses the main channel", () => { + assert.deepEqual(parseBundledUpgradeArgs([]), { action: "upgrade" }); + assert.deepEqual(parseBundledUpgradeArgs(["--channel", "release"]), { action: "upgrade" }); + assert.deepEqual(parseBundledUpgradeArgs(["--channel=release"]), { action: "upgrade" }); + assert.deepEqual(parseBundledUpgradeArgs(["--rollback"]), { action: "rollback" }); + for (const args of [["--channel", "main"], ["--channel=main"]]) { + const parsed = parseBundledUpgradeArgs(args); + assert.equal("error" in parsed && parsed.usage, false); + assert.match("error" in parsed ? parsed.error : "", /release channel only/); + } + for (const args of [["--rollback", "--channel", "release"], ["--channel"], ["--nope"]]) { + const parsed = parseBundledUpgradeArgs(args); + assert.equal("error" in parsed && parsed.usage, true, args.join(" ")); + } +}); + +// The real bin, run from a bundled prefix: a copy of bin/ and package.json +// placed where pnpm puts gentle-pi (runtime/ and scripts/ linked from this +// checkout), a fake exact Pi beside it, and `current` naming the version. +const repository = dirname(dirname(fileURLToPath(import.meta.url))); +const posixHost = process.platform === "win32" ? "the bundled prefix fixture uses a POSIX current symlink" : false; +const SHELL = JSON.parse(readFileSync(join(repository, "package.json"), "utf8")).version; +const FIXTURE_ID = `${SHELL}-1.1.0`; +const FAKE_PI = [ + "const args = process.argv.slice(2);", + "if (args.includes('--version')) { console.log('1.1.0'); process.exit(0); }", + "const pick = ['PATH', 'GENTLE_SHELL_PI', 'GENTLE_PI_AGENTS_PI', 'PI_SKIP_VERSION_CHECK', 'npm_config_prefix', 'PI_CODING_AGENT_DIR'];", + "console.log(JSON.stringify({ cli: process.argv[1], args, env: Object.fromEntries(pick.map((k) => [k, process.env[k]])) }));", +].join("\n"); + +function bundledPrefix(t: TestContext, { pi = "1.1.0" } = {}) { + const base = realpathSync(mkdtempSync(join(tmpdir(), "gentle-shell-bundled-"))); + t.after(() => rmSync(base, { recursive: true, force: true })); + const home = join(base, "home"); + const root = join(home, ".gentle-shell"); + const version = join(root, "versions", FIXTURE_ID); + const modules = join(version, "node_modules"); + const shell = join(modules, ".pnpm", `gentle-pi@${SHELL}`, "node_modules", "gentle-pi"); + mkdirSync(join(shell, "bin"), { recursive: true }); + writeFileSync(join(shell, "bin", "gentle-shell.mjs"), readFileSync(join(repository, "bin", "gentle-shell.mjs"))); + writeFileSync(join(shell, "package.json"), readFileSync(join(repository, "package.json"))); + for (const dir of ["runtime", "scripts"]) symlinkSync(join(repository, dir), join(shell, dir)); + symlinkSync(shell, join(modules, "gentle-pi")); + const piDir = join(modules, "@earendil-works", "pi-coding-agent"); + mkdirSync(join(piDir, "dist", "bundle"), { recursive: true }); + writeFileSync(join(piDir, "package.json"), JSON.stringify({ name: "@earendil-works/pi-coding-agent", version: pi, bin: { pi: "dist/bundle/cli.js" } })); + writeFileSync(join(piDir, "dist", "bundle", "cli.js"), FAKE_PI); + writeFileSync(join(version, "package.json"), JSON.stringify({ dependencies: { "gentle-pi": SHELL, "@earendil-works/pi-coding-agent": "1.1.0" } })); + writeFileSync(join(version, ".gentle-shell-version"), `${FIXTURE_ID}\n`); + symlinkSync(`versions/${FIXTURE_ID}`, join(root, "current")); + // A Pi on PATH and in GENTLE_SHELL_PI that must never run. + const userBin = join(base, "user-bin"); + mkdirSync(userBin); + writeFileSync(join(userBin, "pi"), "#!/bin/sh\necho 9.9.9\n", { mode: 0o755 }); + const env = { HOME: home, PATH: `${userBin}:/usr/bin:/bin`, GENTLE_SHELL_PI: join(userBin, "pi"), GENTLE_SHELL_NO_AUTO_SETUP: "1" }; + const entry = join(root, "current", "node_modules", "gentle-pi", "bin", "gentle-shell.mjs"); + const run = (args: string[]) => spawnSync(process.execPath, [entry, ...args], { env, encoding: "utf8", input: "" }); + return { root, version, piDir, run, entry: join(shell, "bin", "gentle-shell.mjs") }; +} + +test("the bundled launcher runs only its version's Pi, with the bundled environment", { skip: posixHost }, (t) => { + const prefix = bundledPrefix(t); + const version = prefix.run(["--version"]); + assert.equal(version.status, 0, version.stderr); + assert.match(version.stdout, new RegExp(`^gentle-shell ${SHELL.replaceAll(".", "\\.")}\npi 1\\.1\\.0\n`)); + const launched = prefix.run(["list"]); + assert.equal(launched.status, 0, launched.stderr); + const seen = JSON.parse(launched.stdout.trim().split("\n").at(-1) ?? "{}"); + assert.equal(seen.cli, join(realpathSync(prefix.piDir), "dist", "bundle", "cli.js")); + assert.deepEqual(seen.args, ["list"]); + assert.equal(seen.env.GENTLE_SHELL_PI, undefined); + assert.equal(seen.env.PI_SKIP_VERSION_CHECK, "1"); + assert.equal(seen.env.GENTLE_PI_AGENTS_PI, `${process.execPath} ${seen.cli}`); + assert.equal(seen.env.npm_config_prefix, join(prefix.root, "npm", "prefix")); + assert.deepEqual(seen.env.PATH.split(":").slice(1, 2), [join(prefix.root, "versions", FIXTURE_ID, "node_modules", ".bin")]); +}); + +test("the bundled launcher refuses a Pi that is not the pinned one", { skip: posixHost }, (t) => { + const result = bundledPrefix(t, { pi: "1.0.0" }).run(["--version"]); + assert.equal(result.status, 1); + assert.match(result.stderr, new RegExp(`Gentle Shell ${FIXTURE_ID.replaceAll(".", "\\.")} .*gentle-shell upgrade`, "s")); + assert.doesNotMatch(result.stdout, /9\.9\.9/); +}); + +test("gentle-shell update in a bundled install explains the pinned Pi instead of updating it", { skip: posixHost }, (t) => { + const result = bundledPrefix(t).run(["update"]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Pi ships pinned with Gentle Shell \(Pi 1\.1\.0\)/); + assert.match(result.stdout, /Run 'gentle-shell upgrade'/); + assert.doesNotMatch(result.stdout, /"args"/); +}); + +test("a kept version that current does not name keeps the ordinary launcher, Pi override and Pi update included", { skip: posixHost }, (t) => { + const prefix = bundledPrefix(t); + const other = join(prefix.root, "versions", "0.0.1-1.0.0"); + mkdirSync(other); + writeFileSync(join(other, ".gentle-shell-version"), "0.0.1-1.0.0\n"); + rmSync(join(prefix.root, "current")); + symlinkSync("versions/0.0.1-1.0.0", join(prefix.root, "current")); + const update = spawnSync(process.execPath, [prefix.entry, "update"], { env: { HOME: dirname(prefix.root), PATH: "/usr/bin:/bin", + GENTLE_SHELL_PI: join(dirname(dirname(prefix.root)), "user-bin", "pi"), GENTLE_SHELL_NO_AUTO_SETUP: "1" }, encoding: "utf8", input: "" }); + assert.equal(update.status, 0, update.stderr); + assert.equal(update.stdout, "9.9.9\n"); +}); + +test("gentle-shell upgrade in a bundled install refuses the main channel and rolls back to the kept version", { skip: posixHost }, (t) => { + const prefix = bundledPrefix(t); + const main = prefix.run(["upgrade", "--channel", "main"]); + assert.equal(main.status, 1); + assert.match(main.stderr, /release channel only/); + const none = prefix.run(["upgrade", "--rollback"]); + assert.equal(none.status, 1); + assert.match(none.stderr, /No previous Gentle Shell version/); + assert.equal(readlinkSync(join(prefix.root, "current")), `versions/${FIXTURE_ID}`); +}); diff --git a/tests/gentle-shell-launcher.test.ts b/tests/gentle-shell-launcher.test.ts index 735716a54..e2c7313aa 100644 --- a/tests/gentle-shell-launcher.test.ts +++ b/tests/gentle-shell-launcher.test.ts @@ -192,6 +192,13 @@ test("help text documents gentle-shell upgrade and its channels", () => { assert.ok(text.includes(" upgrade Update Gentle Shell along its channel: the latest release, or the latest")); }); +test("help text documents the bundled upgrade, its rollback and the pinned Pi update", () => { + const text = helpText(); + assert.ok(text.includes(" gentle-shell upgrade --rollback")); + assert.match(text, /bundled install.*release channel only.*--rollback switches back to the previous kept version/s); + assert.match(text, /In a bundled install Pi ships pinned: 'update' without a package\n\s+runs 'gentle-shell upgrade'/); +}); + test("parseLauncherArgs treats home as a plain passthrough token when it is not argv[0]", () => { const parsed = parseLauncherArgs(["--isolated", "home"]); assert.equal(parsed.command, undefined); From fcd94bfe0d53efcc304beb5b0a2109066af650cb Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Sun, 11 Oct 2026 11:29:47 +0200 Subject: [PATCH 2/4] fix(launcher): keep the user's environment for the agent's work in bundled mode Pi's bash tool copies process.env, so the bundled environment reached every command the agent ran in the user's projects. Pi and subagents now get the user's environment plus only Gentle Shell's variables; Pi installs packages with our npm through its npmCommand setting, written only into homes Gentle Shell owns. Also: update pi/self --extensions follow Pi's "all" parsing, update --all still updates extensions when the upgrade is unavailable, the pre-activation check runs the candidate in bundled mode, a launcher failure after activation switches current back, and rollback restores the target version's own runtime and launcher. --- bin/gentle-shell.mjs | 66 +++++++++++--- docs/bundled-install.md | 26 ++++-- docs/readme-reference.md | 11 ++- lib/gentle-shell-bundled.ts | 106 ++++++++++++++++++----- odd/tasks/bundled-gentle-shell.md | 23 ++++- runtime/gentle-shell-bundled.mjs | 106 ++++++++++++++++++----- scripts/bundled-install.mjs | 99 +++++++++++++++------ tests/bundled-install.test.ts | 79 ++++++++++++++--- tests/gentle-shell-bundled.test.ts | 133 ++++++++++++++++++++++++++--- 9 files changed, 531 insertions(+), 118 deletions(-) diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index 2658c1b6e..a3e692bad 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -57,12 +57,16 @@ import { shellQuote, } from "../runtime/gentle-shell-launcher.mjs"; import { + BUNDLED_VERIFY_ENV, bundledChildEnv, bundledPi, + bundledSetupEnv, + bundledUpdateNext, bundledUpdateNotice, bundledUpdatePlan, parseBundledUpgradeArgs, resolveBundledInstall, + withBundledNpmCommand, } from "../runtime/gentle-shell-bundled.mjs"; import { parseResumeHandoff, @@ -165,8 +169,10 @@ const bundledFs = { }; // Set in a bundled install: the environment every child (Pi, setup, the version -// probe) starts from instead of process.env. +// probe) starts from instead of process.env (the user's own plus Gentle Shell's +// variables), and the folders setup puts ahead of the user's PATH. let bundledEnv; +let bundledSetupDirs; function ownPackageVersion() { const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")); @@ -591,8 +597,10 @@ function ensurePackageLocalGentleAi(binaryPath, pinnedVersion, stdio) { // gentle-ai's (or pi's own) preflight finds `pi` even when it is bundled or // given through GENTLE_SHELL_PI. function buildSetupEnv(home, runtime) { - // Bundled: our node, npm and the version's Pi already lead its PATH. - if (bundledEnv) return { ...bundledEnv, PI_CODING_AGENT_DIR: home.dir, GENTLE_PI_AGENT_HOME: home.dir }; + // Bundled: our node, then the version's `pi`, ahead of the user's PATH. + if (bundledEnv) { + return bundledSetupEnv({ ...bundledEnv, PI_CODING_AGENT_DIR: home.dir, GENTLE_PI_AGENT_HOME: home.dir }, { dirs: bundledSetupDirs, platform: process.platform }); + } return { ...process.env, PI_CODING_AGENT_DIR: home.dir, @@ -1309,8 +1317,9 @@ async function handleUpgradeCommand(commandArgs) { // `gentle-shell upgrade` in a bundled install: the latest release installed // beside the active version, verified, then `current` switched (two versions -// kept), or `--rollback` to the kept one. Returns the exit code; a failure leaves -// `current` untouched. +// kept), or `--rollback` to the kept one. Returns the exit code (a failure is +// printed, not thrown, so `update --all` can go on); a failure leaves `current` +// as it was. async function handleBundledUpgrade(bundled, commandArgs) { const parsed = parseBundledUpgradeArgs(commandArgs); if ("error" in parsed) fail(`gentle-shell upgrade: ${parsed.error}`, parsed.usage ? 2 : 1); @@ -1319,7 +1328,7 @@ async function handleBundledUpgrade(bundled, commandArgs) { const out = (line) => process.stdout.write(`${line}\n`); try { if (parsed.action === "rollback") { - const { from, to } = rollbackBundled(layout); + const { from, to } = await rollbackBundled({ layout, home: homedir(), platform: process.platform, arch: process.arch, env: process.env }); out(`Switched Gentle Shell from ${from} to ${to}.`); return 0; } @@ -1336,7 +1345,27 @@ async function handleBundledUpgrade(bundled, commandArgs) { }); return 0; } catch (error) { - fail(`gentle-shell upgrade: ${error.message}${error.stderr ? `\n${error.stderr}` : ""}`, 1); + process.stderr.write(`gentle-shell upgrade: ${error.message}${error.stderr ? `\n${error.stderr}` : ""}\n`); + return 1; + } +} + +// The bundled npm as the `npmCommand` of a home Gentle Shell owns (the isolated +// home, or a --home it created or provisioned; never --link or a foreign home), +// so Pi installs packages with our npm while its tools keep the user's PATH. +// Only that key is written, and only when it differs. +async function ensureBundledNpmCommand(bundled, home, homeHadContentBeforeBootstrap) { + if (home.mode === "link") return; + const previous = provisionedEntry(readRawConfig(resolveConfigPath()), safeRealpath(home.dir)); + if (homeIsForeign(home, previous, homeHadContentBeforeBootstrap) || !isDirectory(home.dir)) return; + const { bundledNpmCommand, prefixLayout } = await import("../scripts/bundled-install.mjs"); + const layout = prefixLayout({ platform: process.platform, env: process.env, home: homedir(), root: bundled.root }); + const settingsPath = join(home.dir, "settings.json"); + const edit = withBundledNpmCommand(readJsonIfExists(settingsPath), bundledNpmCommand(layout)); + if (edit.status === "malformed") { + process.stderr.write(`gentle-shell: ${settingsPath} is not a JSON object, so Pi's package installs use the npm on your PATH\n`); + } else if (edit.status === "write") { + writeFileAtomically(settingsPath, edit.text); } } @@ -1372,7 +1401,9 @@ async function main() { handleHomeCommand(args.commandArgs); return; } - const bundled = resolveBundledInstall({ packageRoot, platform: process.platform, fs: bundledFs }); + // The verification input applies to `--version` only (scripts/bundled-install.mjs upgradeBundled). + const verifying = args.version && args.command === undefined ? process.env[BUNDLED_VERIFY_ENV] : undefined; + const bundled = resolveBundledInstall({ packageRoot, platform: process.platform, fs: bundledFs, verifying }); if (args.command === "upgrade") { if (bundled) process.exitCode = await handleBundledUpgrade(bundled, args.commandArgs); else await handleUpgradeCommand(args.commandArgs); @@ -1393,9 +1424,8 @@ async function main() { const pi = bundledPi(bundled, { platform: process.platform, fs: bundledFs }); if (!pi.ok) fail(pi.message, 1); runtime = { kind: "bundled", command: process.execPath, args: [pi.cli] }; - const { prefixLayout, setupEnvironment } = await import("../scripts/bundled-install.mjs"); - const layout = prefixLayout({ platform: process.platform, env: process.env, home: homedir(), root: bundled.root }); - bundledEnv = bundledChildEnv(setupEnvironment(layout, { env: process.env, id: bundled.id }), { node: process.execPath, cli: pi.cli }); + bundledEnv = bundledChildEnv(process.env, { node: process.execPath, cli: pi.cli }); + bundledSetupDirs = [dirname(process.execPath), join(bundled.versionDir, "node_modules", ".bin")]; } else { runtime = resolvePiRuntime({ env: process.env, @@ -1432,8 +1462,10 @@ async function main() { if (plan.self) { const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true; for (const line of bundledUpdateNotice({ piVersion: versionCheck.version, interactive })) process.stdout.write(`${line}\n`); - const code = interactive ? await handleBundledUpgrade(bundled, []) : 0; - if (code !== 0 || plan.piArgs === undefined) process.exit(code); + const code = interactive ? await handleBundledUpgrade(bundled, []) : undefined; + const next = bundledUpdateNext(plan, code); + if ("exitCode" in next) process.exit(next.exitCode); + if (next.note) process.stderr.write(`${next.note}\n`); } passthrough = plan.piArgs; } @@ -1461,6 +1493,14 @@ async function main() { process.stderr.write(`gentle-shell: using a separate home at ${home.dir}. Run 'gentle-shell --link' to reuse your pi sign-ins and chats.\n`); } + if (bundled) { + try { + await ensureBundledNpmCommand(bundled, home, homeHadContentBeforeBootstrap); + } catch (error) { + process.stderr.write(`gentle-shell: could not give Pi the bundled npm in ${join(home.dir, "settings.json")} (${error.message}); continuing\n`); + } + } + if (args.command === "setup") { await handleSetupCommand(args.commandArgs, home, runtime); return; diff --git a/docs/bundled-install.md b/docs/bundled-install.md index 9b5e81c8c..b591f16ae 100644 --- a/docs/bundled-install.md +++ b/docs/bundled-install.md @@ -154,7 +154,7 @@ Each kept line is copied verbatim, so `${VAR}` references stay for pnpm and npm ## Setup environment -`setupEnvironment(layout, { platform, env, id })` is the environment `gentle-shell setup` runs in. Its PATH, in order: +`setupEnvironment(layout, { platform, env, id })` is the environment the web installer runs `gentle-shell setup` in. The launcher itself then gives its children the user's environment ([Launcher in a bundled install](#launcher-in-a-bundled-install)). Its PATH, in order: 1. Our Node's folder, which holds npm on POSIX and `npm.cmd` on Windows. 2. `versions//node_modules/.bin`, which holds the version's `pi`. @@ -197,8 +197,19 @@ Dropped: inherited npm and pnpm settings, `NODE_OPTIONS`, `NODE_PATH`, and the w `bin/gentle-shell.mjs` runs in bundled mode only when it runs from a bundled install. Its real package root must be `versions//node_modules/gentle-pi` of a version whose `.gentle-shell-version` marker reads ``, and `current` must name that version. `resolveBundledInstall` in `lib/gentle-shell-bundled.ts` decides this. Any other installation keeps the launcher's ordinary behavior, including a kept version that `current` does not name. - **Exact Pi (S8).** The launcher runs only the Pi that the version's `package.json` pins: `versions//node_modules/@earendil-works/pi-coding-agent`, inside the version folder, with that exact version and Pi's `dist/bundle/cli.js` bin. `GENTLE_SHELL_PI`, an adjacent fallback and `PATH` are never used. A missing or different Pi stops with a message that names the version and the pinned Pi, and says to run `gentle-shell upgrade` or the installer again. -- **Child environment.** Pi, `setup` and the version probe all start from `setupEnvironment`. That puts our Node and npm first on `PATH`, then the version's `node_modules/.bin`, and keeps npm's settings inside the prefix. `bundledChildEnv` then removes `GENTLE_SHELL_PI`, sets `GENTLE_PI_AGENTS_PI` to our node and the bundled `cli.js` so subagents never run a `pi` from `PATH`, and sets `PI_SKIP_VERSION_CHECK=1`. The subagent runner splits that variable on whitespace, so a path with a space leaves it unset. The runner then falls back to the running node and `cli.js`, which are the same pair. -- **Pi updates (S5).** `gentle-shell update`, with no package or with `--self`, `self`, `pi` or `--all`, says that Pi ships pinned with Gentle Shell. In a terminal it then runs `gentle-shell upgrade`; otherwise it names the command. The extension part of `--all` still goes to Pi as `update --extensions`. Package, `--models` and help forms go to Pi unchanged. +- **Child environment (S9).** Pi's bash tool passes its own environment to every command the agent runs in the user's projects. Pi and its subagents therefore get the user's environment unchanged: `PATH`, `NODE_OPTIONS` and `npm_config_*` stay the user's, so `git`, `gh`, `node` and `npm` there are the user's own. `bundledChildEnv` adds only Gentle Shell's own variables: + - `GENTLE_SHELL_PI` is removed. + - `GENTLE_PI_AGENTS_PI` is set to our node and the bundled `cli.js`, so subagents never run a `pi` from `PATH`. The subagent runner splits that variable on whitespace, so a path with a space leaves it unset. The runner then falls back to the running node and `cli.js`, which are the same pair. + - `PI_SKIP_VERSION_CHECK=1`. + - The launcher still sets the agent home variables (`PI_CODING_AGENT_DIR`, `GENTLE_PI_AGENT_HOME`) as always. + + Pi itself needs no `PATH` entry, because it is started as our node with the bundled `cli.js`. +- **Pi's own npm.** Pi runs npm through its `npmCommand` setting (`getNpmCommand` in Pi's `core/package-manager.js`). For a home Gentle Shell owns, the launcher sets that key in its `settings.json` to `bundledNpmCommand(layout)`: our node, our `npm-cli.js`, `--userconfig=`, `--globalconfig`, `--cache` and `--prefix` inside `~/.gentle-shell/npm/`, and `--update-notifier=false`. "Owns" means the isolated home, or a `--home` it created or provisioned. It never sets the key for `--link`, a foreign home, or the user's `~/.pi`. + - Only that key is written, and only when it differs. A file that already holds the command keeps its bytes, and one that is not a JSON object is left alone with a warning. + - npm ranks command-line flags above `npm_config_*` variables, so the user's environment cannot redirect these four settings. An `npm_config_*` key that has no flag here (for example `npm_config_registry`) still reaches our npm, as the user's own network setting would. + - Pi passes its own `--prefix /npm` after these flags for package installs, and the later flag wins. +- **Setup.** `gentle-shell setup` runs Gentle AI, which finds `pi` on `PATH`. pnpm's `.bin/pi` shim runs the first `node` on `PATH`. So setup alone gets our node's folder, then the version's `node_modules/.bin`, ahead of the user's `PATH` (`bundledSetupEnv`). Everything else in its environment is the same as Pi's. +- **Pi updates (S5).** Some `gentle-shell update` forms make Pi update itself: no target, `--self`, `self` or `pi` as the target, `--all`, and `self` or `pi` with `--extensions`. Pi reads the last two as "all" (`package-manager-cli.js`). For these forms the launcher says that Pi ships pinned with Gentle Shell. In a terminal it then runs `gentle-shell upgrade`; otherwise it names the command. For an "all" form, Pi still gets `update --extensions`, even when the upgrade could not complete; the launcher reports that and the extensions still update. Package, `--models`, help, and forms Pi refuses (conflicting or unknown options) go to Pi unchanged. - **What cannot be intercepted.** A `pi update` that someone runs directly with the bundled `pi` (for example the agent's shell, which finds it through `node_modules/.bin`) is Pi's own command. In Pi 1.1.0, `PI_SKIP_VERSION_CHECK` hides only the startup "Update Available" notice (`utils/version-check.js`). For the update itself, `package-manager-cli.js` detects a pnpm install from the `.pnpm` path, and `config.js` (`getSelfUpdateCommand`) finds no global pnpm root that owns `versions/`. Pi then prints `cannot self-update this installation` and changes nothing. ### `gentle-shell upgrade` in a bundled install @@ -209,10 +220,13 @@ Dropped: inherited npm and pnpm settings, `NODE_OPTIONS`, `NODE_PATH`, and the w 2. When that release is the active version, the command says it is already current and changes nothing. 3. Otherwise `ensureRuntime` runs (with Go on Windows), and `installVersion` installs the release in place beside the active version. 4. The new version's own `scripts/bundled-install.mjs` is loaded, only from that freshly installed folder, and only when its real path stays inside it. Its `prefixLayout`, `ensureRuntime` and `ensureLauncher` must all be functions; otherwise the upgrade stops. Its `ensureRuntime` installs that release's pinned Node and pnpm beside ours when the pins moved, and reuses them otherwise. -5. The new version's launcher must print exactly `gentle-shell ` and `pi `. It runs with the release's Node, in the pnpm environment, with no `GENTLE_*` or `PI_*` overrides. -6. Only then does `current` switch (`activateVersion`, one rename). The launcher is refreshed by the release's own `ensureLauncher`, and `pruneVersions(layout, 2)` keeps the new version and the previous one. +5. The new version's launcher must print exactly `gentle-shell ` and `pi `. It runs with the release's Node, in the pnpm environment, with no `GENTLE_*` or `PI_*` overrides. It runs in bundled mode for that version, with its exact Pi: the only extra variable, `GENTLE_SHELL_VERIFY_VERSION=`, makes `resolveBundledInstall` treat that id as current. The launcher honors it for `--version` only, and never passes it to a child. A `pi` on `PATH` cannot pass this check. +6. Only then does `current` switch (`activateVersion`, one rename), and the release's own `ensureLauncher` writes the launcher for its Node. If the launcher cannot be written, `current` switches back and the command fails. +7. `pruneVersions(layout, 2)` keeps the new version and the previous one. If pruning fails, the upgrade still succeeds and prints a warning. -Any failure before step 6 leaves `current` untouched and removes a version folder this command installed. `gentle-shell upgrade --rollback` switches `current` to the most recently active other version that is still installed, again with one rename. Running it twice returns to the first version. +Any failure before step 7 leaves `current` as it was and removes a version folder this command installed. + +`gentle-shell upgrade --rollback` switches `current` to the most recently active other version that is still installed. It uses that version's own `scripts/bundled-install.mjs`, loaded and checked as in step 4. Its `ensureRuntime` makes sure its pinned runtime is present, and its `ensureLauncher` writes the launcher for its Node. Without a usable module, nothing changes. If the launcher cannot be written, `current` switches back. Running `--rollback` twice returns to the first version. Known limits: a release whose lockfile needs a newer pnpm than ours cannot be installed by this upgrade, because the release's own runtime is installed only after its version folder exists. Older runtimes under `runtime/` are never removed. diff --git a/docs/readme-reference.md b/docs/readme-reference.md index cb9f44fb5..7bd4230ad 100644 --- a/docs/readme-reference.md +++ b/docs/readme-reference.md @@ -469,9 +469,14 @@ package. `gentle-shell update` is different: it is Pi's own `update`, forwarded to the resolved home. In a bundled install Pi ships pinned with Gentle Shell, so -`update` without a package (or with `--self`, `self`, `pi` or `--all`) explains -that and runs `gentle-shell upgrade` (in a terminal) or names it; package -updates still reach Pi. The browser installation wizard offers the same main channel; see +every `update` form that updates Pi itself explains that and runs +`gentle-shell upgrade` (in a terminal) or names it. Those forms are no target, +`--self`, `self` or `pi`, `--all`, and `self` or `pi` with `--extensions`. The +extension part of an "all" form, and every package update, still reaches Pi. +In a bundled install Pi and the agent's commands keep your own environment; +Pi installs packages with Gentle Shell's npm through the `npmCommand` setting +of its own home. Details: [Bundled install](bundled-install.md#launcher-in-a-bundled-install). +The browser installation wizard offers the same main channel; see [Installation wizard](install-wizard.md#main-channel). ### `self-uninstall` subcommand diff --git a/lib/gentle-shell-bundled.ts b/lib/gentle-shell-bundled.ts index 25a31665a..38f313f77 100644 --- a/lib/gentle-shell-bundled.ts +++ b/lib/gentle-shell-bundled.ts @@ -29,6 +29,11 @@ const VERSION_MARKER = ".gentle-shell-version"; const PI_PACKAGE = "@earendil-works/pi-coding-agent"; const PI_BIN = "dist/bundle/cli.js"; +// `gentle-shell upgrade` sets this to the version it just installed while it +// runs that version's `--version`: the check then runs in bundled mode, with +// its exact Pi, before `current` names it. Never passed on to children. +export const BUNDLED_VERIFY_ENV = "GENTLE_SHELL_VERIFY_VERSION"; + const pathFor = (platform: string) => (platform === "win32" ? win32 : posix); function inside(path: typeof posix, parent: string, child: string): boolean { @@ -48,8 +53,9 @@ function parseJson(text: string | undefined): any { // The bundled install this package runs from: the nearest `versions/` // ancestor of its real root whose marker names it, whose node_modules/gentle-pi // is this package, and which `current` names (a relative symlink on POSIX, a -// pointer file on Windows). Undefined otherwise. -export function resolveBundledInstall(input: { packageRoot: string; platform: string; fs: BundledFs }): BundledInstall | undefined { +// pointer file on Windows) or `verifying` names (BUNDLED_VERIFY_ENV, for a +// `--version` check only). Undefined otherwise. +export function resolveBundledInstall(input: { packageRoot: string; platform: string; fs: BundledFs; verifying?: string }): BundledInstall | undefined { const path = pathFor(input.platform); const real = input.fs.realpath(input.packageRoot) ?? input.packageRoot; for (let dir = path.dirname(real); path.dirname(dir) !== dir; dir = path.dirname(dir)) { @@ -61,7 +67,7 @@ export function resolveBundledInstall(input: { packageRoot: string; platform: st if (input.fs.realpath(path.join(dir, "node_modules", "gentle-pi")) !== real) return undefined; const current = path.join(root, "current"); const active = input.platform === "win32" ? input.fs.readText(current) === `${id}\n` : input.fs.readLink(current) === `versions/${id}`; - return active ? { root, id, versionDir: dir } : undefined; + return active || input.verifying === id ? { root, id, versionDir: dir } : undefined; } return undefined; } @@ -90,19 +96,50 @@ export function bundledPi(install: BundledInstall, input: { platform: string; fs return { ok: true, cli, version: pinned }; } -// What every child of a bundled launch adds to the bundled environment: no Pi -// override, subagents on the same node and Pi (the subagent runner splits -// GENTLE_PI_AGENTS_PI on whitespace, so a spaced path is left to its default, -// the running node and cli.js), and no Pi update check, since Pi only updates -// with Gentle Shell (S5). +// The environment of a bundled Pi and its subagents: the user's own, unchanged +// (S9: Pi's tools run the user's commands in the user's projects, so its PATH, +// NODE_OPTIONS and npm settings stay the user's; Pi itself runs on our node, +// given explicitly, and its npm through the home's npmCommand setting), plus +// only no Pi override, subagents on the same node and Pi (the subagent runner +// splits GENTLE_PI_AGENTS_PI on whitespace, so a spaced path is left to its +// default, the running node and cli.js), and no Pi update check, since Pi only +// updates with Gentle Shell (S5). export function bundledChildEnv(base: Env, input: { node: string; cli: string }): Env { const env: Env = { ...base, PI_SKIP_VERSION_CHECK: "1" }; delete env.GENTLE_SHELL_PI; + delete env[BUNDLED_VERIFY_ENV]; if (/\s/.test(`${input.node}${input.cli}`)) delete env.GENTLE_PI_AGENTS_PI; else env.GENTLE_PI_AGENTS_PI = `${input.node} ${input.cli}`; return env; } +// `gentle-shell setup` in a bundled install: the child environment with `dirs` +// (our node's folder, then the version's node_modules/.bin) ahead of the user's +// PATH. Setup runs Gentle AI, which finds `pi` on PATH, and pnpm's `.bin/pi` +// runs the first `node` on PATH, so both must be ours there. +export function bundledSetupEnv(env: Env, input: { dirs: string[]; platform: string }): Env { + const key = Object.keys(env).find((name) => name.toUpperCase() === "PATH") ?? (input.platform === "win32" ? "Path" : "PATH"); + const rest = (env[key] ?? "").split(pathFor(input.platform).delimiter).filter((entry) => entry.length > 0); + return { ...env, [key]: [...input.dirs, ...rest].join(pathFor(input.platform).delimiter) }; +} + +export type BundledNpmCommandEdit = { status: "unchanged" } | { status: "malformed" } | { status: "write"; text: string }; + +// The agent home's settings.json with `npmCommand` set to the bundled npm +// (scripts/bundled-install.mjs bundledNpmCommand), every other key kept in +// order and the file's indentation and final newline kept. A file that already +// holds exactly that command is left as it is; one that is not a JSON object +// is never rewritten. +export function withBundledNpmCommand(settingsText: string | undefined, command: string[]): BundledNpmCommandEdit { + const settings = settingsText === undefined ? {} : parseJson(settingsText); + if (typeof settings !== "object" || settings === null || Array.isArray(settings)) return { status: "malformed" }; + const current = settings.npmCommand; + if (Array.isArray(current) && current.length === command.length && current.every((part, index) => part === command[index])) return { status: "unchanged" }; + const indent = settingsText?.match(/\{\r?\n([ \t]+)/)?.[1] ?? " "; + const serialized = JSON.stringify({ ...settings, npmCommand: command }, null, indent); + return { status: "write", text: settingsText === undefined || settingsText.endsWith("\n") ? `${serialized}\n` : serialized }; +} + export interface BundledUpdatePlan { // Pi's own update was asked for: redirect it to `gentle-shell upgrade`. self: boolean; @@ -111,23 +148,48 @@ export interface BundledUpdatePlan { piArgs?: string[]; } -// `gentle-shell update [target]` in a bundled install. Pi updates itself for a -// bare `update`, `--self`, `self`/`pi`, and `--all` (or `--self --extensions`); -// only that part is redirected. Packages, models and help still reach Pi. +// `gentle-shell update [target]` in a bundled install, read the way Pi's +// package-manager-cli.js reads it: the target is Pi itself for a bare +// `update`, `--self`, or a `self`/`pi` positional, and "all" for `--all`, +// `--self --extensions` or `self`/`pi` with `--extensions`. Only Pi's own part +// is redirected; "all" still sends `update --extensions` (with the other +// flags) to Pi. Packages, models, help, and forms Pi refuses (conflicting or +// unknown options) reach Pi unchanged. export function bundledUpdatePlan(passthrough: string[]): BundledUpdatePlan { - const rest = passthrough.slice(1); const passThrough = { self: false, piArgs: passthrough }; - if (rest.some((token) => token === "--models" || token === "--extension" || token.startsWith("--extension=") || token === "--help" || token === "-h")) { - return passThrough; + const flags: string[] = []; + const positional: string[] = []; + let self = false; + let extensions = false; + let all = false; + for (const token of passthrough.slice(1)) { + if (token === "--self") self = true; + else if (token === "--extensions") extensions = true; + else if (token === "--all") all = true; + else if (["--approve", "-a", "--no-approve", "-na", "--force"].includes(token)) flags.push(token); + else if (token.startsWith("-")) return passThrough; + else positional.push(token); + } + if (positional.length > 1 || (all && (self || extensions || positional.length > 0))) return passThrough; + if (positional.length === 1) { + if ((positional[0] !== "self" && positional[0] !== "pi") || self) return passThrough; + self = true; + } + if (all || (self && extensions)) return { self: true, piArgs: ["update", "--extensions", ...flags] }; + if (extensions) return passThrough; + return { self: true }; +} + +// What `gentle-shell update` does after the redirected upgrade (`upgradeCode` +// is undefined when it did not run): with nothing left for Pi it exits with the +// upgrade's code; otherwise Pi still runs, so `update --all` updates the +// extensions even when the upgrade could not complete. +export function bundledUpdateNext(plan: BundledUpdatePlan, upgradeCode: number | undefined): { exitCode: number } | { piArgs: string[]; note?: string } { + if (plan.piArgs === undefined) return { exitCode: upgradeCode ?? 0 }; + if (plan.self && upgradeCode !== undefined && upgradeCode !== 0) { + return { piArgs: plan.piArgs, note: "gentle-shell: the Gentle Shell upgrade did not complete; updating the extensions anyway." }; } - const positional = rest.filter((token) => !token.startsWith("-")); - const flags = rest.filter((token) => token.startsWith("-")); - const named = positional.length === 1 && (positional[0] === "self" || positional[0] === "pi"); - const extensions = flags.includes("--all") || flags.includes("--extensions"); - if ((positional.length > 0 && !named) || (named && extensions)) return passThrough; - const all = flags.includes("--all") || (flags.includes("--self") && flags.includes("--extensions")); - if (all) return { self: true, piArgs: ["update", "--extensions", ...flags.filter((flag) => !["--all", "--self", "--extensions"].includes(flag))] }; - return extensions ? passThrough : { self: true }; + return { piArgs: plan.piArgs }; } export function bundledUpdateNotice(input: { piVersion: string; interactive: boolean }): string[] { diff --git a/odd/tasks/bundled-gentle-shell.md b/odd/tasks/bundled-gentle-shell.md index 541b3d5fc..29db65d67 100644 --- a/odd/tasks/bundled-gentle-shell.md +++ b/odd/tasks/bundled-gentle-shell.md @@ -20,7 +20,7 @@ Branch: feat/bundled-gentle-shell (design approved; one work-unit commit and PR - T1a | S2,S3,S6,S7 | delegated writer (context backstop) | committed 55efdf527; verifier corrections (B1-B3, A1, A2, A4, A5, A9) implemented, awaiting parent review, CI gate update (31 to 35 native tests) and commit | Shared bundled-install module (used by the web installer and `gentle-shell upgrade`): claim the prefix (POSIX private dir; Windows the bootstrap's private-folder claim), install pinned Node and pnpm (and Go only when asked) into runtime/ reusing the verified downloads, pnpm store/cache/state/config inside the prefix, install a version into versions/-/ from a distribution manifest + frozen lockfile, atomic `current` switch keeping 2 versions, the one PATH entry and the bin/gentle-shell shim. Unit tests with injected download/run adapters; no release wiring yet. - T1b | S3 | delegated writer; committed b9b181a6e on feat/bundled-release-lockfile; diag runs 38092721527, 38092811888 and 38092917737: ubuntu and macOS pass, Windows blocked by the pinned Go download defect fixed in #2064 (rerun after main is merged); verifier advisories A1-A5 corrected, awaiting parent review and commit | Release CI: build and publish per release the distribution manifest (gentle-pi + exact Pi) and its pnpm-lock.yaml as release assets, verified by a frozen install in CI on the three OSes. - T1c | S1,S2,S3,S12 | delegated writer on feat/bundled-wizard; implemented test-first, local proof done, awaiting parent review, CI (37 native tests) and commit | Web installer uses the bundled-install module for new installations (release channel first). -- T2 | S8,S5 | delegated writer on feat/bundled-launcher; implemented test-first, local proof done, awaiting parent review and commit | Launcher in bundled mode: exact Pi from `current`, no fallbacks; `pi update`/`gentle-shell update` redirect; `gentle-shell upgrade` = new version + verify + switch + keep 2. +- T2 | S8,S5,S9 | delegated writer on feat/bundled-launcher; first pass committed by the parent (HEAD 6f213404d); verifier corrections A1-A6 implemented test-first, local proof done, awaiting parent review and commit | Launcher in bundled mode: exact Pi from `current`, no fallbacks; `pi update`/`gentle-shell update` redirect; `gentle-shell upgrade` = new version + verify + switch + keep 2. - T3 | S4 | pending | Migration from global pnpm/npm installs with one confirmation. - T4 | S9 | pending | Isolation leaks (pi-pretty fff path, gentle-ai state, Engram npm). - T5 | S10 | pending | Delete the user-tool detection code and its tests; docs rewrite (install-wizard.md). @@ -54,3 +54,24 @@ Branch: feat/bundled-gentle-shell (design approved; one work-unit commit and PR - **Upgrade.** `upgradeBundled` and `rollbackBundled` in scripts/bundled-install.mjs. The latest release comes from npm (`latestRelease`, now exported from main-channel.mjs). Then fetchDistribution and readDistribution, ensureRuntime, and installVersion in place. Next, the new version's own scripts/bundled-install.mjs is loaded, only from that folder (real path inside it), and must export prefixLayout, ensureRuntime and ensureLauncher as functions, otherwise the upgrade fails closed. Its ensureRuntime installs the release's pins side by side. The new launcher's `--version` must print exactly both versions. Only then do activateVersion, the release's ensureLauncher and pruneVersions(2) run. An already current release changes nothing. A failure leaves `current` untouched and removes the version this call installed. `--channel main` is refused. `--rollback` activates the most recent other installed version. readDistribution's return shape was kept, since tests outside this task's surfaces pin it; that is why pin changes come from the new module rather than from generatedWith. - **Local proof.** Isolated HOME, assets for 4.0.0 with Pi 1.0.0 and with Pi 1.1.0. The published 4.0.0 has no scripts/bundled-install.mjs: the upgrade to it failed closed, and its folder was removed. With this worktree overlaid in both versions, the following all held. `--version` printed `pi 1.0.0` while GENTLE_SHELL_PI pointed at a fake 9.9.9. The same assets reported "already current". The upgrade to 4.0.0-1.1.0 switched and kept both versions. `upgrade --rollback` switched back and forth. The real `gentle-shell upgrade` said release v4.0.0 does not publish the bundled distribution yet. - **Gaps.** A release whose lockfile needs a newer pnpm than ours cannot be installed, because its runtime arrives only after its folder exists. Old runtimes are never pruned. The upgrade runs the new gentle-pi's postinstall with the previous release's Node. Pi's self-update probe runs the user's `pnpm root -g` when the agent's shell runs `pi update`; the proof saw it create ~/Library/pnpm/bin in the isolated HOME (S9). The bin integration tests skip on Windows (a POSIX `current` symlink); the pure resolver covers Windows paths. +- L14. T2 corrections (writer), after the independent verifier (no blockers; A1-A6 fixed, A7-A10 left as advisories): + - **A1 (S9).** Pi's bash tool passes `process.env` to every command (Pi's utils/shell.js getShellEnv), so the bundled PATH, npm_config_* and dropped NODE_OPTIONS reached the user's projects. Pi and subagents now get the user's environment unchanged, plus only GENTLE_PI_AGENTS_PI, PI_SKIP_VERSION_CHECK=1 and the agent home variables; GENTLE_SHELL_PI and the verify variable are removed. Pi is started as our node plus cli.js. + - **A1, Pi's npm.** Pi's own npm runs through its `npmCommand` setting (core/package-manager.js getNpmCommand). For a home Gentle Shell owns (the isolated home, or a --home it created or provisioned; never --link, a foreign home or ~/.pi), the launcher writes only that key: `bundledNpmCommand` = our node, our npm-cli.js, --userconfig, --globalconfig, --cache and --prefix in the prefix, and --update-notifier=false. It writes nothing when the key already matches, and warns on a non-object file. + - **A1, observed.** npm 11.19.0 ranks these flags above npm_config_* (`config get cache` printed the flag's value with npm_config_cache set). Pi's later `--prefix /npm` wins for installs. + - **A1, setup.** Setup keeps only our node folder and the version's .bin ahead of the user's PATH (`bundledSetupEnv`), because pnpm's .bin/pi shim runs the first `node` on PATH. setupEnvironment remains the web installer's env for its shell-setup step. + - **A2.** `update self|pi --extensions` counts as "all", as in Pi (package-manager-cli.js). The update plan now mirrors Pi's parser, and forms Pi refuses pass through. + - **A3.** handleBundledUpgrade returns its exit code instead of exiting. `bundledUpdateNext` still sends `update --extensions` to Pi after a failed upgrade, with a note. + - **A4.** The pre-activation `--version` runs with GENTLE_SHELL_VERIFY_VERSION=. resolveBundledInstall treats that id as current, honored for `--version` only, so the check uses the exact Pi. A PATH pi printing the same version fails it. + - **A5.** When ensureLauncher fails after activation, `current` goes back (one rename) and the new version is removed. A pruning failure is a returned and printed warning. + - **A6.** rollbackBundled is async. It loads the target's own scripts/bundled-install.mjs with the same inside-the-folder and export checks (otherwise it fails closed), runs its ensureRuntime, and writes its launcher; a launcher failure puts `current` back. + - **Local proof.** Isolated HOME, assets 4.0.0 + Pi 1.0.0 and 1.1.0, this worktree overlaid on both versions. A probe extension ran inside the bundled Pi; its child shell saw the user's fake `node` first, the user's NODE_OPTIONS and npm_config_*, and no GENTLE_SHELL_PI. + - `gentle-shell install npm:is-number@7.0.0` succeeded with our npm, while the user's npmrc named an unreachable registry. The user's npm cache was not created, and the prefix npm cache was filled. + - The real upgrade check failed when the 1.1.0 Pi cli was missing and a PATH pi printed 1.1.0. It passed once the cli was restored. + - Rollback switched twice, and it failed closed for a target without its module. + - `update --all` under script(1) printed the failed upgrade (v4.0.0 has no assets) and still printed "Updated packages". + - **Advisories left.** + - A7: two concurrent upgrades or rollbacks are not locked against each other. + - A8: a malformed `current` makes upgrade and rollback throw instead of repairing it. + - A9: the latest version comes from npm before its GitHub assets exist; the upgrade then says so and changes nothing. + - A10: was not detailed in the handoff. + - Also: npm_config_* keys without a flag (for example the user's registry) still reach our npm; the proof's HOME gained ~/.pi/agent/pi-pretty/fff (S9, T4). diff --git a/runtime/gentle-shell-bundled.mjs b/runtime/gentle-shell-bundled.mjs index 72e88c8c8..f23b40146 100644 --- a/runtime/gentle-shell-bundled.mjs +++ b/runtime/gentle-shell-bundled.mjs @@ -30,6 +30,11 @@ const VERSION_MARKER = ".gentle-shell-version"; const PI_PACKAGE = "@earendil-works/pi-coding-agent"; const PI_BIN = "dist/bundle/cli.js"; +// `gentle-shell upgrade` sets this to the version it just installed while it +// runs that version's `--version`: the check then runs in bundled mode, with +// its exact Pi, before `current` names it. Never passed on to children. +export const BUNDLED_VERIFY_ENV = "GENTLE_SHELL_VERIFY_VERSION"; + const pathFor = (platform ) => (platform === "win32" ? win32 : posix); function inside(path , parent , child ) { @@ -49,8 +54,9 @@ function parseJson(text ) { // The bundled install this package runs from: the nearest `versions/` // ancestor of its real root whose marker names it, whose node_modules/gentle-pi // is this package, and which `current` names (a relative symlink on POSIX, a -// pointer file on Windows). Undefined otherwise. -export function resolveBundledInstall(input ) { +// pointer file on Windows) or `verifying` names (BUNDLED_VERIFY_ENV, for a +// `--version` check only). Undefined otherwise. +export function resolveBundledInstall(input ) { const path = pathFor(input.platform); const real = input.fs.realpath(input.packageRoot) ?? input.packageRoot; for (let dir = path.dirname(real); path.dirname(dir) !== dir; dir = path.dirname(dir)) { @@ -62,7 +68,7 @@ export function resolveBundledInstall(input if (input.fs.realpath(path.join(dir, "node_modules", "gentle-pi")) !== real) return undefined; const current = path.join(root, "current"); const active = input.platform === "win32" ? input.fs.readText(current) === `${id}\n` : input.fs.readLink(current) === `versions/${id}`; - return active ? { root, id, versionDir: dir } : undefined; + return active || input.verifying === id ? { root, id, versionDir: dir } : undefined; } return undefined; } @@ -91,19 +97,49 @@ export function bundledPi(install , input return { ok: true, cli, version: pinned }; } -// What every child of a bundled launch adds to the bundled environment: no Pi -// override, subagents on the same node and Pi (the subagent runner splits -// GENTLE_PI_AGENTS_PI on whitespace, so a spaced path is left to its default, -// the running node and cli.js), and no Pi update check, since Pi only updates -// with Gentle Shell (S5). +// The environment of a bundled Pi and its subagents: the user's own, unchanged +// (S9: Pi's tools run the user's commands in the user's projects, so its PATH, +// NODE_OPTIONS and npm settings stay the user's; Pi itself runs on our node, +// given explicitly, and its npm through the home's npmCommand setting), plus +// only no Pi override, subagents on the same node and Pi (the subagent runner +// splits GENTLE_PI_AGENTS_PI on whitespace, so a spaced path is left to its +// default, the running node and cli.js), and no Pi update check, since Pi only +// updates with Gentle Shell (S5). export function bundledChildEnv(base , input ) { const env = { ...base, PI_SKIP_VERSION_CHECK: "1" }; delete env.GENTLE_SHELL_PI; + delete env[BUNDLED_VERIFY_ENV]; if (/\s/.test(`${input.node}${input.cli}`)) delete env.GENTLE_PI_AGENTS_PI; else env.GENTLE_PI_AGENTS_PI = `${input.node} ${input.cli}`; return env; } +// `gentle-shell setup` in a bundled install: the child environment with `dirs` +// (our node's folder, then the version's node_modules/.bin) ahead of the user's +// PATH. Setup runs Gentle AI, which finds `pi` on PATH, and pnpm's `.bin/pi` +// runs the first `node` on PATH, so both must be ours there. +export function bundledSetupEnv(env , input ) { + const key = Object.keys(env).find((name) => name.toUpperCase() === "PATH") ?? (input.platform === "win32" ? "Path" : "PATH"); + const rest = (env[key] ?? "").split(pathFor(input.platform).delimiter).filter((entry) => entry.length > 0); + return { ...env, [key]: [...input.dirs, ...rest].join(pathFor(input.platform).delimiter) }; +} + + + +// The agent home's settings.json with `npmCommand` set to the bundled npm +// (scripts/bundled-install.mjs bundledNpmCommand), every other key kept in +// order and the file's indentation and final newline kept. A file that already +// holds exactly that command is left as it is; one that is not a JSON object +// is never rewritten. +export function withBundledNpmCommand(settingsText , command ) { + const settings = settingsText === undefined ? {} : parseJson(settingsText); + if (typeof settings !== "object" || settings === null || Array.isArray(settings)) return { status: "malformed" }; + const current = settings.npmCommand; + if (Array.isArray(current) && current.length === command.length && current.every((part, index) => part === command[index])) return { status: "unchanged" }; + const indent = settingsText?.match(/\{\r?\n([ \t]+)/)?.[1] ?? " "; + const serialized = JSON.stringify({ ...settings, npmCommand: command }, null, indent); + return { status: "write", text: settingsText === undefined || settingsText.endsWith("\n") ? `${serialized}\n` : serialized }; +} @@ -112,23 +148,49 @@ export function bundledChildEnv(base , input ) -// `gentle-shell update [target]` in a bundled install. Pi updates itself for a -// bare `update`, `--self`, `self`/`pi`, and `--all` (or `--self --extensions`); -// only that part is redirected. Packages, models and help still reach Pi. + +// `gentle-shell update [target]` in a bundled install, read the way Pi's +// package-manager-cli.js reads it: the target is Pi itself for a bare +// `update`, `--self`, or a `self`/`pi` positional, and "all" for `--all`, +// `--self --extensions` or `self`/`pi` with `--extensions`. Only Pi's own part +// is redirected; "all" still sends `update --extensions` (with the other +// flags) to Pi. Packages, models, help, and forms Pi refuses (conflicting or +// unknown options) reach Pi unchanged. export function bundledUpdatePlan(passthrough ) { - const rest = passthrough.slice(1); const passThrough = { self: false, piArgs: passthrough }; - if (rest.some((token) => token === "--models" || token === "--extension" || token.startsWith("--extension=") || token === "--help" || token === "-h")) { - return passThrough; + const flags = []; + const positional = []; + let self = false; + let extensions = false; + let all = false; + for (const token of passthrough.slice(1)) { + if (token === "--self") self = true; + else if (token === "--extensions") extensions = true; + else if (token === "--all") all = true; + else if (["--approve", "-a", "--no-approve", "-na", "--force"].includes(token)) flags.push(token); + else if (token.startsWith("-")) return passThrough; + else positional.push(token); + } + if (positional.length > 1 || (all && (self || extensions || positional.length > 0))) return passThrough; + if (positional.length === 1) { + if ((positional[0] !== "self" && positional[0] !== "pi") || self) return passThrough; + self = true; + } + if (all || (self && extensions)) return { self: true, piArgs: ["update", "--extensions", ...flags] }; + if (extensions) return passThrough; + return { self: true }; +} + +// What `gentle-shell update` does after the redirected upgrade (`upgradeCode` +// is undefined when it did not run): with nothing left for Pi it exits with the +// upgrade's code; otherwise Pi still runs, so `update --all` updates the +// extensions even when the upgrade could not complete. +export function bundledUpdateNext(plan , upgradeCode ) { + if (plan.piArgs === undefined) return { exitCode: upgradeCode ?? 0 }; + if (plan.self && upgradeCode !== undefined && upgradeCode !== 0) { + return { piArgs: plan.piArgs, note: "gentle-shell: the Gentle Shell upgrade did not complete; updating the extensions anyway." }; } - const positional = rest.filter((token) => !token.startsWith("-")); - const flags = rest.filter((token) => token.startsWith("-")); - const named = positional.length === 1 && (positional[0] === "self" || positional[0] === "pi"); - const extensions = flags.includes("--all") || flags.includes("--extensions"); - if ((positional.length > 0 && !named) || (named && extensions)) return passThrough; - const all = flags.includes("--all") || (flags.includes("--self") && flags.includes("--extensions")); - if (all) return { self: true, piArgs: ["update", "--extensions", ...flags.filter((flag) => !["--all", "--self", "--extensions"].includes(flag))] }; - return extensions ? passThrough : { self: true }; + return { piArgs: plan.piArgs }; } export function bundledUpdateNotice(input ) { diff --git a/scripts/bundled-install.mjs b/scripts/bundled-install.mjs index 4a4e0098e..c85acc133 100644 --- a/scripts/bundled-install.mjs +++ b/scripts/bundled-install.mjs @@ -712,24 +712,66 @@ const UNPUBLISHED = Object.freeze({ rejected: (version) => `the bundled distribution of release v${version} did not verify`, }); const RELEASE_EXPORTS = Object.freeze(["prefixLayout", "ensureRuntime", "ensureLauncher"]); +/** The variable that makes a version's launcher run in bundled mode for + * `--version` only, as if `current` named that version (lib/gentle-shell-bundled.ts). + */ +const VERIFY_ENV = "GENTLE_SHELL_VERIFY_VERSION"; -/** The module a freshly installed version ships (its own pins and launcher), - * only from a regular file whose real path stays inside that version folder. +/** The command Pi runs for npm in a bundled install (its `npmCommand` setting): + * our npm on our Node, reading only the prefix npmrc and global config, with its + * cache and global prefix inside the prefix. npm ranks command-line flags above + * npm_config_* variables, so the user's environment cannot redirect these. + */ +export function bundledNpmCommand(layout) { + return [layout.node, layout.npmCli, `--userconfig=${layout.npmrc}`, `--globalconfig=${layout.npmGlobalConfig}`, `--cache=${layout.npmCache}`, + `--prefix=${layout.npmPrefix}`, "--update-notifier=false"]; +} + +/** The module an installed version ships (its own pins and launcher), only from + * a regular file whose real path stays inside that version folder. */ async function releaseModule(file, versionPath) { if (!regular(file) || !realpathSync(file).startsWith(`${realpathSync(versionPath)}${pathFor(process.platform).sep}`)) return null; return import(pathToFileURL(file).href); } +/** An installed version's own scripts/bundled-install.mjs and its layout of this + * prefix; it must export prefixLayout, ensureRuntime and ensureLauncher as + * functions. `active` names the version `current` stays at otherwise. + */ +async function loadRelease({ layout, id, home, platform, env, adapters, active }) { + const path = pathFor(process.platform); + const versionPath = versionDirectory(layout, id); + const file = path.join(versionPath, "node_modules", "gentle-pi", "scripts", "bundled-install.mjs"); + const module = await (adapters.load ?? releaseModule)(file, versionPath); + if (!module || RELEASE_EXPORTS.some((name) => typeof module[name] !== "function")) { + throw new Error(`Gentle Shell ${id} has no usable scripts/bundled-install.mjs (${RELEASE_EXPORTS.join(", ")}); current stays at ${active}`); + } + return { module, next: module.prefixLayout({ platform, env, home, root: layout.root }) }; +} +/** Switches `current` to `id` and writes that version's launcher; when the + * launcher cannot be written, `current` goes back to `active` (one rename each). + */ +function activateWithLauncher(layout, id, active, { module, next }) { + activateVersion(layout, id); + try { + module.ensureLauncher(next); + } catch (error) { + if (active) activateVersion(layout, active); + else unlinkSync(layout.current); + throw new Error(`the launcher for Gentle Shell ${id} could not be written (${error.message}); current stays at ${active}`); + } +} /** `gentle-shell upgrade` in a bundled install (S5, S6, S8): the latest * release's distribution assets (adapters.latest, adapters.distribution) are * installed beside the active version with our runtime, then the new version's * own module (loaded only from that version folder) installs its pinned runtime - * when the pins moved; its launcher must print exactly the release's gentle-pi - * and Pi before `current` switches. Two versions are kept, and the launcher is - * refreshed for the new runtime. An already current release changes nothing; - * any failure leaves `current` untouched and removes a version this call - * installed. Returns { id, changed, previous?, removed? }. + * when the pins moved. Its launcher, run in bundled mode for that version, must + * print exactly the release's gentle-pi and Pi before `current` switches; the + * release's launcher is then written (a failure puts `current` back) and two + * versions are kept (a pruning failure is only a warning). An already current + * release changes nothing; any failure leaves `current` as it was and removes a + * version this call installed. Returns { id, changed, previous?, removed?, warning? }. */ export async function upgradeBundled({ layout, home, platform = layout.platform, arch = process.arch, env = {}, adapters, out = () => {} }) { const path = pathFor(process.platform); @@ -746,47 +788,50 @@ export async function upgradeBundled({ layout, home, platform = layout.platform, const go = platform === "win32"; const runtime = await (adapters.ensureRuntime ?? ensureRuntime)({ layout, platform, arch, go, env }); const version = await installVersion({ layout, manifest, lockfile, platform, env, go: runtime.go, adapters: adapters.install ?? {} }); - let verified; try { - const file = path.join(version.path, "node_modules", "gentle-pi", "scripts", "bundled-install.mjs"); - const module = await (adapters.load ?? releaseModule)(file, version.path); - if (!module || RELEASE_EXPORTS.some((name) => typeof module[name] !== "function")) { - throw new Error(`Gentle Shell ${id} has no usable scripts/bundled-install.mjs (${RELEASE_EXPORTS.join(", ")}); current stays at ${active}`); - } - const next = module.prefixLayout({ platform, env, home, root: layout.root }); - await module.ensureRuntime({ layout: next, platform, arch, go, env }); + const loaded = await loadRelease({ layout, id, home, platform, env, adapters, active }); + await loaded.module.ensureRuntime({ layout: loaded.next, platform, arch, go, env }); // The environment our pnpm ran in, without any Pi or Gentle override that - // could point the launcher at another Pi or agent home. - const launchEnv = Object.fromEntries(Object.entries(pnpmEnvironment(next, { platform, env })).filter(([key]) => !/^(?:gentle_|pi_)/i.test(key))); + // could point the launcher at another Pi or agent home, plus the one input + // that runs this version in bundled mode for its --version. + const launchEnv = { ...Object.fromEntries(Object.entries(pnpmEnvironment(loaded.next, { platform, env })).filter(([key]) => !/^(?:gentle_|pi_)/i.test(key))), + [VERIFY_ENV]: id }; const entry = path.join(version.path, "node_modules", "gentle-pi", "bin", "gentle-shell.mjs"); - const result = await (adapters.run ?? runCommand)(next.node, [entry, "--version"], { cwd: layout.root, env: launchEnv, timeout: 120000 }); + const result = await (adapters.run ?? runCommand)(loaded.next.node, [entry, "--version"], { cwd: layout.root, env: launchEnv, timeout: 120000 }); const reported = String(result.stdout ?? "").trim(); const [shellLine, piLine] = reported.split(/\r?\n/); if (result.status !== 0 || shellLine !== `gentle-shell ${shell}` || piLine !== `pi ${pi}`) { throw Object.assign(new Error(`Gentle Shell ${id} reported ${JSON.stringify(reported)}, not gentle-shell ${shell} with pi ${pi}; current stays at ${active}`), { stderr: String(result.stderr ?? "").slice(-4000) }); } - verified = { module, next }; + activateWithLauncher(layout, id, active, loaded); } catch (error) { if (version.installed && activeVersion(layout) !== id) rmSync(version.path, { recursive: true, force: true }); throw error; } - activateVersion(layout, id); - verified.module.ensureLauncher(verified.next); - const removed = pruneVersions(layout, 2); out(`Upgraded to Gentle Shell ${shell} with Pi ${pi} (from ${active ?? "no active version"}). Run 'gentle-shell upgrade --rollback' to switch back.`); - return { id, changed: true, previous: active, removed }; + try { + return { id, changed: true, previous: active, removed: (adapters.prune ?? pruneVersions)(layout, 2) }; + } catch (error) { + const warning = `older versions were not removed (${error.message})`; + out(`warning: ${warning}`); + return { id, changed: true, previous: active, removed: [], warning }; + } } /** `gentle-shell upgrade --rollback`: `current` switches back to the most - * recently active other version still installed (activateVersion, one rename). - * Returns { from, to }. + * recently active other version still installed, with that version's own + * launcher and runtime pins (its own scripts/bundled-install.mjs, loaded like + * upgradeBundled loads it). Without a usable module nothing changes; when its + * launcher cannot be written `current` goes back. Returns { from, to }. */ -export function rollbackBundled(layout) { +export async function rollbackBundled({ layout, home, platform = layout.platform, arch = process.arch, env = {}, adapters = {} }) { const active = activeVersion(layout); const previous = readHistory(layout).find((id) => id !== active && installedVersion(layout, id)); if (!previous) throw new Error("No previous Gentle Shell version is kept to roll back to"); - activateVersion(layout, previous); + const loaded = await loadRelease({ layout, id: previous, home, platform, env, adapters, active }); + await loaded.module.ensureRuntime({ layout: loaded.next, platform, arch, go: platform === "win32", env }); + activateWithLauncher(layout, previous, active, loaded); return { from: active, to: previous }; } diff --git a/tests/bundled-install.test.ts b/tests/bundled-install.test.ts index 991a64094..985733c89 100644 --- a/tests/bundled-install.test.ts +++ b/tests/bundled-install.test.ts @@ -7,7 +7,7 @@ import { join, posix } from "node:path"; import test, { after, type TestContext } from "node:test"; import { crc32, gzipSync } from "node:zlib"; import { activateVersion, activeVersion, applyPathEntry, claimPrefix, prefixRoot, distributionFiles, ensureLauncher, ensureRuntime, filterNpmrc, installVersion, nodeExecutable, - nodeRuntimeFiles, pathEntryPlan, pnpmEnvironment, prefixLayout, pruneVersions, removePathEntry, rollbackBundled, setupEnvironment, upgradeBundled, userNpmrc, writeNpmrcAuth } + nodeRuntimeFiles, pathEntryPlan, pnpmEnvironment, prefixLayout, pruneVersions, removePathEntry, rollbackBundled, setupEnvironment, bundledNpmCommand, upgradeBundled, userNpmrc, writeNpmrcAuth } from "../scripts/bundled-install.mjs"; // Real directories, modes, symlinks and uids need a POSIX host. @@ -841,6 +841,8 @@ posixTest("upgradeBundled installs the latest release beside the active one, ver assert.deepEqual(verify.args, [join(layout.versions, "4.2.0-1.0.2", "node_modules", "gentle-pi", "bin", "gentle-shell.mjs"), "--version"]); assert.equal(verify.env.GENTLE_SHELL_PI, undefined); assert.equal(verify.env.PI_CODING_AGENT_DIR, undefined); + // The check runs that version in bundled mode, as if current named it (only for --version). + assert.equal(verify.env.GENTLE_SHELL_VERIFY_VERSION, "4.2.0-1.0.2"); assert.match(lines.join("\n"), /Gentle Shell 4\.2\.0 with Pi 1\.0\.2.*4\.1\.0-1\.0\.1.*--rollback/s); }); @@ -896,14 +898,71 @@ posixTest("upgradeBundled installs a new release's own pinned runtime and launch assert.equal(activeVersion(layout), "4.2.0-1.0.2"); }); -posixTest("rollbackBundled switches current to the kept previous version, and back", async () => { - const { layout } = await activeInstall(); - assert.deepEqual(rollbackBundled(layout), { from: "4.1.0-1.0.1", to: "4.0.0-1.0.0" }); +posixTest("upgradeBundled puts current back when the new launcher cannot be written", async () => { + const { user, layout } = await activeInstall(); + const { adapters } = upgradeAdapters(layout, { module: { ensureLauncher: () => { throw new Error("Conflicting launcher: /x"); } } }); + await assert.rejects(upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters }), /launcher.*current stays at 4\.1\.0-1\.0\.1/s); + assert.equal(activeVersion(layout), "4.1.0-1.0.1"); + assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]); +}); + +posixTest("upgradeBundled reports success with a warning when only pruning fails", async () => { + const { user, layout } = await activeInstall(); + const { adapters } = upgradeAdapters(layout); + const lines: string[] = []; + const result = await upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters: { ...adapters, prune: () => { throw new Error("EBUSY"); } }, + out: (line: string) => lines.push(line) }); + assert.deepEqual(result, { id: "4.2.0-1.0.2", changed: true, previous: "4.1.0-1.0.1", removed: [], warning: "older versions were not removed (EBUSY)" }); + assert.equal(activeVersion(layout), "4.2.0-1.0.2"); + assert.match(lines.join("\n"), /Upgraded to Gentle Shell 4\.2\.0.*warning: older versions were not removed \(EBUSY\)/s); +}); + +function rollbackAdapters(module: Record = {}) { + const calls: { loaded: string[]; runtimes: unknown[]; launchers: unknown[] } = { loaded: [], runtimes: [], launchers: [] }; + const releaseModule = { prefixLayout, ensureRuntime: async (options: { layout: unknown }) => { calls.runtimes.push(options.layout); return { go: null }; }, + ensureLauncher: (next: unknown) => { calls.launchers.push(next); return { written: true }; }, ...module }; + return { calls, adapters: { load: async (file: string) => { calls.loaded.push(file); return releaseModule; } } }; +} + +posixTest("rollbackBundled switches current to the kept previous version with its own launcher, and back", async () => { + const { user, layout } = await activeInstall(); + const { calls, adapters } = rollbackAdapters(); + const options = { layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters }; + assert.deepEqual(await rollbackBundled(options), { from: "4.1.0-1.0.1", to: "4.0.0-1.0.0" }); assert.equal(activeVersion(layout), "4.0.0-1.0.0"); - assert.deepEqual(rollbackBundled(layout), { from: "4.0.0-1.0.0", to: "4.1.0-1.0.1" }); - const single = claimed().layout; - await installed(single, [["4.0.0", "1.0.0"]]); - activateVersion(single, "4.0.0-1.0.0"); - assert.throws(() => rollbackBundled(single), /No previous Gentle Shell version/); - assert.equal(activeVersion(single), "4.0.0-1.0.0"); + assert.deepEqual(calls.loaded, [releaseModuleFile(layout, "4.0.0-1.0.0")]); + assert.equal(calls.runtimes.length, 1); + assert.equal(calls.launchers.length, 1); + assert.deepEqual(await rollbackBundled(options), { from: "4.0.0-1.0.0", to: "4.1.0-1.0.1" }); + assert.equal(calls.loaded[1], releaseModuleFile(layout, "4.1.0-1.0.1")); + const single = claimed(); + await installed(single.layout, [["4.0.0", "1.0.0"]]); + activateVersion(single.layout, "4.0.0-1.0.0"); + await assert.rejects(rollbackBundled({ ...options, layout: single.layout, home: single.home }), /No previous Gentle Shell version/); + assert.equal(activeVersion(single.layout), "4.0.0-1.0.0"); +}); + +posixTest("rollbackBundled fails closed without the target's own module and puts current back when its launcher fails", async () => { + const { user, layout } = await activeInstall(); + for (const module of [{ ensureLauncher: undefined }, { ensureRuntime: 1 }]) { + const { calls, adapters } = rollbackAdapters(module); + await assert.rejects(rollbackBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters }), /4\.0\.0-1\.0\.0.*bundled-install\.mjs/s); + assert.equal(calls.launchers.length, 0); + assert.equal(activeVersion(layout), "4.1.0-1.0.1"); + } + // The real loader: these fixture versions ship no module at all. + await assert.rejects(rollbackBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {} }), /bundled-install\.mjs/); + const failing = rollbackAdapters({ ensureLauncher: () => { throw new Error("Conflicting launcher: /x"); } }); + await assert.rejects(rollbackBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters: failing.adapters }), /launcher.*current stays at 4\.1\.0-1\.0\.1/s); + assert.equal(activeVersion(layout), "4.1.0-1.0.1"); + assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]); +}); + +test("bundledNpmCommand runs our npm on our Node with only the prefix configuration", () => { + const layout = prefixLayout({ platform: "linux", env: {}, home: "/home/me" }); + const root = "/home/me/.gentle-shell"; + assert.deepEqual(bundledNpmCommand(layout), [`${root}/runtime/node-${NODE}/bin/node`, `${root}/runtime/node-${NODE}/lib/node_modules/npm/bin/npm-cli.js`, + `--userconfig=${root}/pnpm/config/npmrc`, `--globalconfig=${root}/npm/npmrc`, `--cache=${root}/npm/cache`, `--prefix=${root}/npm/prefix`, "--update-notifier=false"]); + const windows = bundledNpmCommand(prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\L" }, home: "C:\\U" })); + assert.deepEqual(windows.slice(0, 2), [`C:\\L\\gentle-shell\\runtime\\node-${NODE}\\node.exe`, `C:\\L\\gentle-shell\\runtime\\node-${NODE}\\node_modules\\npm\\bin\\npm-cli.js`]); }); diff --git a/tests/gentle-shell-bundled.test.ts b/tests/gentle-shell-bundled.test.ts index 7ec26705f..b9f9906f4 100644 --- a/tests/gentle-shell-bundled.test.ts +++ b/tests/gentle-shell-bundled.test.ts @@ -6,11 +6,15 @@ import { dirname, join, posix, win32 } from "node:path"; import { fileURLToPath } from "node:url"; import test, { type TestContext } from "node:test"; import { + BUNDLED_VERIFY_ENV, bundledChildEnv, bundledPi, + bundledSetupEnv, + bundledUpdateNext, bundledUpdateNotice, bundledUpdatePlan, parseBundledUpgradeArgs, + withBundledNpmCommand, resolveBundledInstall, type BundledFs, type BundledPi, @@ -66,6 +70,14 @@ test("resolveBundledInstall is off unless current names this marked version and files: { [posix.join(POSIX_ROOT, "versions", "-x", ".gentle-shell-version")]: "-x\n" }, links: { [posix.join(POSIX_ROOT, "current")]: "versions/-x" } }) }), undefined); }); +test("resolveBundledInstall treats only the version being verified as current, for that check", () => { + const kept = posixInstall({ current: "versions/4.0.0-1.0.0" }); + assert.deepEqual(resolveBundledInstall({ packageRoot: POSIX_PACKAGE, platform: "linux", fs: kept, verifying: ID }), { root: POSIX_ROOT, id: ID, versionDir: POSIX_VERSION }); + assert.equal(resolveBundledInstall({ packageRoot: POSIX_PACKAGE, platform: "linux", fs: kept, verifying: "4.0.0-1.0.0" }), undefined); + // The marker and the package location are still required. + assert.equal(resolveBundledInstall({ packageRoot: POSIX_PACKAGE, platform: "linux", fs: posixInstall({ current: "versions/x", marker: "x\n" }), verifying: ID }), undefined); +}); + test("resolveBundledInstall on Windows reads the current pointer file, never a link", () => { const root = "C:\\Users\\Ana\\AppData\\Local\\gentle-shell"; const version = win32.join(root, "versions", ID); @@ -111,18 +123,41 @@ test("bundledPi fails with the version, the pinned Pi and the way to repair it", assert.equal(bundledPi(install, { platform: "linux", fs: outside }).ok, false); }); -test("bundledChildEnv gives children the bundled Pi, no Pi override and no Pi update check", () => { - const env = bundledChildEnv({ PATH: "/our/node:/v/.bin:/usr/bin", GENTLE_SHELL_PI: "/usr/local/bin/pi", GENTLE_PI_AGENTS_PI: "pi" }, { node: "/g/node", cli: "/g/cli.js" }); - assert.equal(env.GENTLE_SHELL_PI, undefined); - assert.equal(env.GENTLE_PI_AGENTS_PI, "/g/node /g/cli.js"); - assert.equal(env.PI_SKIP_VERSION_CHECK, "1"); - assert.equal(env.PATH, "/our/node:/v/.bin:/usr/bin"); +test("bundledChildEnv keeps the user's environment and adds only the bundled Pi, no Pi override and no Pi update check", () => { + const user = { PATH: "/home/ana/.nvm/bin:/usr/bin", NODE_OPTIONS: "--max-old-space-size=4096", npm_config_registry: "https://npm.example", npm_config_prefix: "/home/ana/.npm-global", + GENTLE_SHELL_PI: "/usr/local/bin/pi", GENTLE_PI_AGENTS_PI: "pi", [BUNDLED_VERIFY_ENV]: ID }; + const env = bundledChildEnv(user, { node: "/g/node", cli: "/g/cli.js" }); + assert.deepEqual(env, { PATH: user.PATH, NODE_OPTIONS: user.NODE_OPTIONS, npm_config_registry: user.npm_config_registry, npm_config_prefix: user.npm_config_prefix, + GENTLE_PI_AGENTS_PI: "/g/node /g/cli.js", PI_SKIP_VERSION_CHECK: "1" }); // The subagent runner splits that variable on whitespace: a spaced path falls // back to its own default, the running node and cli.js. const spaced = bundledChildEnv({ GENTLE_PI_AGENTS_PI: "pi" }, { node: "C:\\Users\\Ana Li\\node.exe", cli: "C:\\cli.js" }); assert.equal("GENTLE_PI_AGENTS_PI" in spaced, false); }); +test("bundledSetupEnv puts only our node and the version's bin ahead of the user's PATH", () => { + const env = bundledSetupEnv({ PATH: "/usr/bin", NODE_OPTIONS: "--x", npm_config_registry: "r" }, { dirs: ["/g/node-bin", "/g/v/node_modules/.bin"], platform: "linux" }); + assert.deepEqual(env, { PATH: "/g/node-bin:/g/v/node_modules/.bin:/usr/bin", NODE_OPTIONS: "--x", npm_config_registry: "r" }); + const windows = bundledSetupEnv({ Path: "C:\\Windows" }, { dirs: ["C:\\G\\node"], platform: "win32" }); + assert.deepEqual(windows, { Path: "C:\\G\\node;C:\\Windows" }); +}); + +test("withBundledNpmCommand sets only npmCommand and leaves an already bundled settings file as it is", () => { + const command = ["/g/node", "/g/npm-cli.js", "--userconfig=/g/npmrc"]; + const text = '{\n "theme": "gentle",\n "packages": ["npm:x"]\n}\n'; + const added = withBundledNpmCommand(text, command); + assert.equal(added.status, "write"); + const written = added.status === "write" ? added.text : ""; + assert.deepEqual(JSON.parse(written), { theme: "gentle", packages: ["npm:x"], npmCommand: command }); + assert.match(written, /^\{\n {4}"theme"/); + assert.ok(written.endsWith("}\n")); + assert.deepEqual(withBundledNpmCommand(written, command), { status: "unchanged" }); + const replaced = withBundledNpmCommand('{"npmCommand":["npm"]}', command); + assert.deepEqual(JSON.parse(replaced.status === "write" ? replaced.text : "{}"), { npmCommand: command }); + assert.deepEqual(JSON.parse((withBundledNpmCommand(undefined, command) as { text: string }).text), { npmCommand: command }); + for (const malformed of ["{", "[]", "null"]) assert.deepEqual(withBundledNpmCommand(malformed, command), { status: "malformed" }); +}); + test("bundledUpdatePlan redirects Pi's own update and keeps the extension updates", () => { assert.deepEqual(bundledUpdatePlan(["update"]), { self: true }); assert.deepEqual(bundledUpdatePlan(["update", "--self"]), { self: true }); @@ -130,11 +165,29 @@ test("bundledUpdatePlan redirects Pi's own update and keeps the extension update assert.deepEqual(bundledUpdatePlan(["update", "self"]), { self: true }); assert.deepEqual(bundledUpdatePlan(["update", "--all"]), { self: true, piArgs: ["update", "--extensions"] }); assert.deepEqual(bundledUpdatePlan(["update", "--self", "--extensions", "--approve"]), { self: true, piArgs: ["update", "--extensions", "--approve"] }); - for (const passthrough of [["update", "--extensions"], ["update", "npm:x"], ["update", "--models"], ["update", "--extension", "npm:x"], ["update", "--help"]]) { + // Pi reads a self positional with --extensions as "all" too. + for (const target of ["pi", "self"]) { + assert.deepEqual(bundledUpdatePlan(["update", target, "--extensions"]), { self: true, piArgs: ["update", "--extensions"] }, target); + assert.deepEqual(bundledUpdatePlan(["update", "--extensions", target, "-a"]), { self: true, piArgs: ["update", "--extensions", "-a"] }, target); + } + // Pi itself refuses conflicting or unknown forms before updating anything. + for (const passthrough of [["update", "--extensions"], ["update", "npm:x"], ["update", "--models"], ["update", "--extension", "npm:x"], ["update", "--help"], + ["update", "--all", "--self"], ["update", "pi", "--all"], ["update", "pi", "npm:x"], ["update", "--bogus"]]) { assert.deepEqual(bundledUpdatePlan(passthrough), { self: false, piArgs: passthrough }, passthrough.join(" ")); } }); +test("bundledUpdateNext still updates extensions when the upgrade did not complete", () => { + assert.deepEqual(bundledUpdateNext({ self: false, piArgs: ["update", "npm:x"] }, undefined), { piArgs: ["update", "npm:x"] }); + assert.deepEqual(bundledUpdateNext({ self: true }, 0), { exitCode: 0 }); + assert.deepEqual(bundledUpdateNext({ self: true }, 1), { exitCode: 1 }); + assert.deepEqual(bundledUpdateNext({ self: true }, undefined), { exitCode: 0 }); + assert.deepEqual(bundledUpdateNext({ self: true, piArgs: ["update", "--extensions"] }, 0), { piArgs: ["update", "--extensions"] }); + const failed = bundledUpdateNext({ self: true, piArgs: ["update", "--extensions"] }, 1); + assert.deepEqual("piArgs" in failed ? failed.piArgs : [], ["update", "--extensions"]); + assert.match("note" in failed ? String(failed.note) : "", /upgrade did not complete.*extensions/); +}); + test("bundledUpdateNotice explains the pinned Pi and runs or names gentle-shell upgrade", () => { const interactive = bundledUpdateNotice({ piVersion: "1.1.0", interactive: true }).join("\n"); assert.match(interactive, /Pi ships pinned with Gentle Shell \(Pi 1\.1\.0\)/); @@ -168,7 +221,7 @@ const FIXTURE_ID = `${SHELL}-1.1.0`; const FAKE_PI = [ "const args = process.argv.slice(2);", "if (args.includes('--version')) { console.log('1.1.0'); process.exit(0); }", - "const pick = ['PATH', 'GENTLE_SHELL_PI', 'GENTLE_PI_AGENTS_PI', 'PI_SKIP_VERSION_CHECK', 'npm_config_prefix', 'PI_CODING_AGENT_DIR'];", + "const pick = ['PATH', 'GENTLE_SHELL_PI', 'GENTLE_PI_AGENTS_PI', 'PI_SKIP_VERSION_CHECK', 'npm_config_prefix', 'npm_config_registry', 'NODE_OPTIONS', 'PI_CODING_AGENT_DIR'];", "console.log(JSON.stringify({ cli: process.argv[1], args, env: Object.fromEntries(pick.map((k) => [k, process.env[k]])) }));", ].join("\n"); @@ -196,13 +249,14 @@ function bundledPrefix(t: TestContext, { pi = "1.1.0" } = {}) { const userBin = join(base, "user-bin"); mkdirSync(userBin); writeFileSync(join(userBin, "pi"), "#!/bin/sh\necho 9.9.9\n", { mode: 0o755 }); - const env = { HOME: home, PATH: `${userBin}:/usr/bin:/bin`, GENTLE_SHELL_PI: join(userBin, "pi"), GENTLE_SHELL_NO_AUTO_SETUP: "1" }; + const env = { HOME: home, PATH: `${userBin}:/usr/bin:/bin`, GENTLE_SHELL_PI: join(userBin, "pi"), GENTLE_SHELL_NO_AUTO_SETUP: "1", + NODE_OPTIONS: "--no-warnings", npm_config_registry: "https://npm.example" }; const entry = join(root, "current", "node_modules", "gentle-pi", "bin", "gentle-shell.mjs"); - const run = (args: string[]) => spawnSync(process.execPath, [entry, ...args], { env, encoding: "utf8", input: "" }); - return { root, version, piDir, run, entry: join(shell, "bin", "gentle-shell.mjs") }; + const run = (args: string[], extra: Record = {}) => spawnSync(process.execPath, [entry, ...args], { env: { ...env, ...extra }, encoding: "utf8", input: "" }); + return { root, home, userBin, version, piDir, run, env, entry: join(shell, "bin", "gentle-shell.mjs") }; } -test("the bundled launcher runs only its version's Pi, with the bundled environment", { skip: posixHost }, (t) => { +test("the bundled launcher runs only its version's Pi, with the user's environment and the bundled npm in its own home", { skip: posixHost }, (t) => { const prefix = bundledPrefix(t); const version = prefix.run(["--version"]); assert.equal(version.status, 0, version.stderr); @@ -215,8 +269,49 @@ test("the bundled launcher runs only its version's Pi, with the bundled environm assert.equal(seen.env.GENTLE_SHELL_PI, undefined); assert.equal(seen.env.PI_SKIP_VERSION_CHECK, "1"); assert.equal(seen.env.GENTLE_PI_AGENTS_PI, `${process.execPath} ${seen.cli}`); - assert.equal(seen.env.npm_config_prefix, join(prefix.root, "npm", "prefix")); - assert.deepEqual(seen.env.PATH.split(":").slice(1, 2), [join(prefix.root, "versions", FIXTURE_ID, "node_modules", ".bin")]); + // S9: what the agent runs in the user's projects sees the user's tools and settings. + assert.equal(seen.env.PATH, prefix.env.PATH); + assert.equal(seen.env.NODE_OPTIONS, "--no-warnings"); + assert.equal(seen.env.npm_config_registry, "https://npm.example"); + assert.equal(seen.env.npm_config_prefix, undefined); + // Pi's own npm is ours, configured only from the prefix, through the isolated home's settings. + const settings = JSON.parse(readFileSync(join(prefix.home, ".gentle-shell", "agent", "settings.json"), "utf8")); + assert.deepEqual(settings.npmCommand.slice(0, 2), [join(prefix.root, "runtime", "node-24.21.0", "bin", "node"), + join(prefix.root, "runtime", "node-24.21.0", "lib", "node_modules", "npm", "bin", "npm-cli.js")]); + assert.ok(settings.npmCommand.includes(`--userconfig=${join(prefix.root, "pnpm", "config", "npmrc")}`)); + assert.ok(settings.npmCommand.includes(`--prefix=${join(prefix.root, "npm", "prefix")}`)); + // --link is the user's own Pi home: never given our npm. + const linked = join(prefix.home, ".pi", "agent"); + mkdirSync(linked, { recursive: true }); + writeFileSync(join(linked, "settings.json"), "{}\n"); + assert.equal(prefix.run(["--link", "list"]).status, 0); + assert.equal(readFileSync(join(linked, "settings.json"), "utf8"), "{}\n"); +}); + +test("the version check before activation runs that version in bundled mode, never a PATH Pi", { skip: posixHost }, (t) => { + const prefix = bundledPrefix(t); + const other = join(prefix.root, "versions", "0.0.1-1.0.0"); + mkdirSync(other); + writeFileSync(join(other, ".gentle-shell-version"), "0.0.1-1.0.0\n"); + rmSync(join(prefix.root, "current")); + symlinkSync("versions/0.0.1-1.0.0", join(prefix.root, "current")); + const verify = (env: Record) => spawnSync(process.execPath, [prefix.entry, "--version"], { env: { HOME: prefix.home, PATH: `${prefix.userBin}:/usr/bin:/bin`, ...env }, + encoding: "utf8", input: "" }); + const good = verify({ [BUNDLED_VERIFY_ENV]: FIXTURE_ID }); + assert.equal(good.status, 0, good.stderr); + assert.match(good.stdout, /\npi 1\.1\.0\n/); + // The version's own Pi is broken, and the user's Pi prints the expected version. + writeFileSync(join(prefix.userBin, "pi"), "#!/bin/sh\necho 1.1.0\n", { mode: 0o755 }); + rmSync(join(prefix.piDir, "dist", "bundle", "cli.js")); + const broken = verify({ [BUNDLED_VERIFY_ENV]: FIXTURE_ID }); + assert.equal(broken.status, 1); + assert.match(broken.stderr, /bundled Pi 1\.1\.0/); + // Without the verification input, the same entry is an ordinary launcher that would pass. + assert.match(verify({}).stdout, /\npi 1\.1\.0\n/); + // Only --version honors it. + const list = spawnSync(process.execPath, [prefix.entry, "list"], { env: { HOME: prefix.home, PATH: `${prefix.userBin}:/usr/bin:/bin`, [BUNDLED_VERIFY_ENV]: FIXTURE_ID, + GENTLE_SHELL_NO_AUTO_SETUP: "1" }, encoding: "utf8", input: "" }); + assert.equal(list.stdout, "1.1.0\n"); }); test("the bundled launcher refuses a Pi that is not the pinned one", { skip: posixHost }, (t) => { @@ -234,6 +329,16 @@ test("gentle-shell update in a bundled install explains the pinned Pi instead of assert.doesNotMatch(result.stdout, /"args"/); }); +test("gentle-shell update pi --extensions in a bundled install redirects Pi's part and still updates the extensions with the bundled npm", { skip: posixHost }, (t) => { + const prefix = bundledPrefix(t); + const result = prefix.run(["update", "pi", "--extensions"]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Pi ships pinned with Gentle Shell \(Pi 1\.1\.0\)/); + const seen = JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "{}"); + assert.deepEqual(seen.args, ["update", "--extensions"]); + assert.ok(JSON.parse(readFileSync(join(prefix.home, ".gentle-shell", "agent", "settings.json"), "utf8")).npmCommand); +}); + test("a kept version that current does not name keeps the ordinary launcher, Pi override and Pi update included", { skip: posixHost }, (t) => { const prefix = bundledPrefix(t); const other = join(prefix.root, "versions", "0.0.1-1.0.0"); From cfd59f9f6328079c8c6bac9b54b1c652f1b9b464 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Sun, 11 Oct 2026 12:11:19 +0200 Subject: [PATCH 3/4] fix(launcher): let Pi recognize our npm and install git extensions in place Pi derives the package manager from the basenames in npmCommand, so [node, npm-cli.js, ...] read as "node" and git extensions lost --omit=dev --legacy-peer-deps. npmCommand now runs an extensionless CommonJS shim named npm inside the bundled runtime (with a commonjs package.json beside it), written by ensureRuntime and the launcher only when its content differs. The --prefix flag is dropped: on the command line it also set npm's local prefix, so a git extension's dependency install ran in the prefix instead of its clone. Settings holding our previous value are migrated, only that key. --- bin/gentle-shell.mjs | 4 +- docs/bundled-install.md | 18 +++++-- odd/tasks/bundled-gentle-shell.md | 9 ++++ scripts/bundled-install.mjs | 54 ++++++++++++++++++-- tests/bundled-install.test.ts | 82 +++++++++++++++++++++++++++--- tests/gentle-shell-bundled.test.ts | 41 +++++++++++++-- 6 files changed, 185 insertions(+), 23 deletions(-) diff --git a/bin/gentle-shell.mjs b/bin/gentle-shell.mjs index a3e692bad..a44b6c053 100755 --- a/bin/gentle-shell.mjs +++ b/bin/gentle-shell.mjs @@ -1358,8 +1358,10 @@ async function ensureBundledNpmCommand(bundled, home, homeHadContentBeforeBootst if (home.mode === "link") return; const previous = provisionedEntry(readRawConfig(resolveConfigPath()), safeRealpath(home.dir)); if (homeIsForeign(home, previous, homeHadContentBeforeBootstrap) || !isDirectory(home.dir)) return; - const { bundledNpmCommand, prefixLayout } = await import("../scripts/bundled-install.mjs"); + const { bundledNpmCommand, ensureNpmShim, prefixLayout } = await import("../scripts/bundled-install.mjs"); const layout = prefixLayout({ platform: process.platform, env: process.env, home: homedir(), root: bundled.root }); + // The entry npmCommand names, also for a runtime installed before it existed. + ensureNpmShim(layout); const settingsPath = join(home.dir, "settings.json"); const edit = withBundledNpmCommand(readJsonIfExists(settingsPath), bundledNpmCommand(layout)); if (edit.status === "malformed") { diff --git a/docs/bundled-install.md b/docs/bundled-install.md index b591f16ae..1ffcdac37 100644 --- a/docs/bundled-install.md +++ b/docs/bundled-install.md @@ -9,7 +9,7 @@ The building blocks live in `scripts/bundled-install.mjs`. The web installer and | Path (POSIX) | Contents | |---|---| | `~/.gentle-shell/agent/` | The isolated agent home. This module never writes it. | -| `~/.gentle-shell/runtime/node-24.21.0/` | Our Node from the verified archive: `bin/node`, the npm bundled with it under `lib/node_modules/npm`, and our own `bin/npm` and `bin/npx` wrappers. | +| `~/.gentle-shell/runtime/node-24.21.0/` | Our Node from the verified archive: `bin/node`, the npm bundled with it under `lib/node_modules/npm`, our own `bin/npm` and `bin/npx` wrappers, and `gentle-shell/npm`, the entry Pi's `npmCommand` runs. | | `~/.gentle-shell/npm/{prefix,cache}/`, `~/.gentle-shell/npm/npmrc` | npm's global prefix, cache and global config. The global config file is never written. | | `~/.gentle-shell/runtime/pnpm-11.1.1/package/` | Our pnpm, from the verified registry tarball. | | `~/.gentle-shell/runtime/go/1.25.14/go/` | Our Go, only when asked (`acquireGo` layout). | @@ -137,6 +137,13 @@ So `nodeRuntimeFiles` takes the npm bundled in the same verified Node archive (n `ensureRuntime` runs `npm-cli.js --version` with our Node from the staging folder. It must print the version in npm's own `package.json`. +`ensureNpmShim` then writes `runtime/node-/gentle-shell/npm`, a CommonJS file that loads the npm next to it, and a `package.json` beside it with `"type": "commonjs"`. That `package.json` keeps the entry CommonJS even under a user's `package.json` with `"type": "module"` above the prefix. `ensureRuntime` writes the entry for a new runtime and for a reused one. The launcher also writes it before it sets `npmCommand`, for a runtime installed before the entry existed. Each file is replaced with one rename, and only when its content differs. A link or anything but a regular file in its place stops it. A runtime from before npm was published (T1a) gets no entry, and the launcher then gives Pi no `npmCommand` and says so. + +Why the entry exists: + +- **The name.** Pi 1.1.0 takes the package manager's name from the basenames in `npmCommand` (`getPackageManagerName`), and only strips `.cmd` and `.exe`. With `npm-cli.js` it read `node`, and gave a git extension's dependencies a plain `npm install`. With an entry named exactly `npm`, it reads `npm` and uses `install --omit=dev --legacy-peer-deps`. +- **No `npm.cmd`.** On Windows, `npm.cmd` prefers an npm in the global prefix, which is the user's under the user's environment. The entry never goes through it. + ## User npm settings `userNpmrc` reads the user's npm configuration: `NPM_CONFIG_USERCONFIG` (any case) when it is an absolute path, otherwise `~/.npmrc`. It reads at most 1 MiB. @@ -204,10 +211,11 @@ Dropped: inherited npm and pnpm settings, `NODE_OPTIONS`, `NODE_PATH`, and the w - The launcher still sets the agent home variables (`PI_CODING_AGENT_DIR`, `GENTLE_PI_AGENT_HOME`) as always. Pi itself needs no `PATH` entry, because it is started as our node with the bundled `cli.js`. -- **Pi's own npm.** Pi runs npm through its `npmCommand` setting (`getNpmCommand` in Pi's `core/package-manager.js`). For a home Gentle Shell owns, the launcher sets that key in its `settings.json` to `bundledNpmCommand(layout)`: our node, our `npm-cli.js`, `--userconfig=`, `--globalconfig`, `--cache` and `--prefix` inside `~/.gentle-shell/npm/`, and `--update-notifier=false`. "Owns" means the isolated home, or a `--home` it created or provisioned. It never sets the key for `--link`, a foreign home, or the user's `~/.pi`. - - Only that key is written, and only when it differs. A file that already holds the command keeps its bytes, and one that is not a JSON object is left alone with a warning. - - npm ranks command-line flags above `npm_config_*` variables, so the user's environment cannot redirect these four settings. An `npm_config_*` key that has no flag here (for example `npm_config_registry`) still reaches our npm, as the user's own network setting would. - - Pi passes its own `--prefix /npm` after these flags for package installs, and the later flag wins. +- **Pi's own npm.** Pi runs npm through its `npmCommand` setting (`getNpmCommand` in Pi's `core/package-manager.js`). For a home Gentle Shell owns, the launcher sets that key in its `settings.json` to `bundledNpmCommand(layout)`: our node, our npm entry `runtime/node-/gentle-shell/npm` ([npm for Pi](#npm-for-pi)), `--userconfig=`, `--globalconfig` and `--cache` inside `~/.gentle-shell/npm/`, and `--update-notifier=false`. No other part's basename is `npm`, `pnpm` or `bun`, which Pi would reject as ambiguous. "Owns" means the isolated home, or a `--home` it created or provisioned. It never sets the key for `--link`, a foreign home, or the user's `~/.pi`. + - Only that key is written, and only when it differs. A file that already holds the command keeps its bytes, and one that is not a JSON object is left alone with a warning. An earlier value (`npm-cli.js` directly, or a `--prefix` flag) is replaced the same way. + - npm ranks command-line flags above `npm_config_*` variables, so the user's environment cannot redirect these three settings. An `npm_config_*` key that has no flag here (for example `npm_config_registry`) still reaches our npm, as the user's own network setting would. + - **No `--prefix`.** A `--prefix` flag on the command line also sets npm's local prefix (`loadLocalPrefix` in `@npmcli/config`). Pi installs a git extension's dependencies with `npm install` in the clone and passes no `--prefix` of its own, so that install would land in the prefix and fail. Pi's `npm:` installs and removals pass `--prefix /npm` themselves. + - The only global read is `npm root -g` (Pi's lookup of packages an older Pi installed globally). It then resolves to the user's `npm_config_prefix` or our node folder's `lib/node_modules`. It only prints a path and creates nothing. - **Setup.** `gentle-shell setup` runs Gentle AI, which finds `pi` on `PATH`. pnpm's `.bin/pi` shim runs the first `node` on `PATH`. So setup alone gets our node's folder, then the version's `node_modules/.bin`, ahead of the user's `PATH` (`bundledSetupEnv`). Everything else in its environment is the same as Pi's. - **Pi updates (S5).** Some `gentle-shell update` forms make Pi update itself: no target, `--self`, `self` or `pi` as the target, `--all`, and `self` or `pi` with `--extensions`. Pi reads the last two as "all" (`package-manager-cli.js`). For these forms the launcher says that Pi ships pinned with Gentle Shell. In a terminal it then runs `gentle-shell upgrade`; otherwise it names the command. For an "all" form, Pi still gets `update --extensions`, even when the upgrade could not complete; the launcher reports that and the extensions still update. Package, `--models`, help, and forms Pi refuses (conflicting or unknown options) go to Pi unchanged. - **What cannot be intercepted.** A `pi update` that someone runs directly with the bundled `pi` (for example the agent's shell, which finds it through `node_modules/.bin`) is Pi's own command. In Pi 1.1.0, `PI_SKIP_VERSION_CHECK` hides only the startup "Update Available" notice (`utils/version-check.js`). For the update itself, `package-manager-cli.js` detects a pnpm install from the `.pnpm` path, and `config.js` (`getSelfUpdateCommand`) finds no global pnpm root that owns `versions/`. Pi then prints `cannot self-update this installation` and changes nothing. diff --git a/odd/tasks/bundled-gentle-shell.md b/odd/tasks/bundled-gentle-shell.md index 29db65d67..9210f66b8 100644 --- a/odd/tasks/bundled-gentle-shell.md +++ b/odd/tasks/bundled-gentle-shell.md @@ -75,3 +75,12 @@ Branch: feat/bundled-gentle-shell (design approved; one work-unit commit and PR - A9: the latest version comes from npm before its GitHub assets exist; the upgrade then says so and changes nothing. - A10: was not detailed in the handoff. - Also: npm_config_* keys without a flag (for example the user's registry) still reach our npm; the proof's HOME gained ~/.pi/agent/pi-pretty/fff (S9, T4). +- L15. T2 N1 correction (writer, user-approved), plus one approved deviation: + - **N1.** Pi 1.1.0's getPackageManagerName (core/package-manager.js) names the package manager from the basenames in npmCommand, stripping only .cmd and .exe. With `[node, npm-cli.js, ...]` it read `node`, so getGitDependencyInstallArgs gave a plain `install`. `ensureNpmShim` now writes `runtime/node-/gentle-shell/npm`: a CommonJS file that loads the npm-cli.js next to it, with a sibling package.json `{"type":"commonjs"}` that holds against a user's ancestor `"type": "module"`. Each file is rewritten only when its content differs, and links or non-files are refused. ensureRuntime writes it for new and reused runtimes (only when npm exists, since a T1a-era runtime has none; tests/build-distribution.test.ts simulates one). The launcher writes it before setting npmCommand; without our npm it sets no npmCommand and warns. npmCommand is now `[node, /gentle-shell/npm, flags]`, and Pi's real code returns `npm` and `install --omit=dev --legacy-peer-deps` (the Windows shape is checked with win32.basename). + - **Approved deviation.** `--prefix` was dropped from npmCommand. A CLI prefix also sets npm's local prefix (@npmcli/config loadLocalPrefix), so Pi's git dependency install in the clone, which passes no --prefix of its own, landed in ~/.gentle-shell/npm/prefix. Observed with npm 11.19.0: exit 254, ENOENT for npm/prefix/package.json. The same defect existed before N1. The launcher replaces an older value (npm-cli.js, or `--prefix`) in owned homes, touching only that key. + - **Local proof (isolated HOME, assets 4.0.0 + Pi 1.1.0).** + - Starting from the pre-N1 state (no entry, settings.json holding the old value), one launch wrote the entry and replaced only npmCommand. `node --version` printed 11.19.0. + - Pi 1.1.0's own DefaultPackageManager, with its SettingsManager on that home, returned `npm` and `["install","--omit=dev","--legacy-peer-deps"]`. Its runNpmCommand (the call installGit makes after cloning) installed is-number in the clone, exit 0, without the devDependency. + - `npm root -g` through the command printed runtime/node-24.21.0/lib/node_modules and created no folder. `gentle-shell install npm:is-number@7.0.0` landed in agent/npm with the cache in the prefix; the user's npm_config_cache folder was never created. + - A hosted git extension was not installed: none known small enough. The clone step is Pi's own git and is unchanged. + - **Advisories left.** N3, N4 and N5 (not detailed in the handoff) are noted here for review. A leftover package-lock.json in npm/prefix came from the failing pre-fix run in the proof. diff --git a/scripts/bundled-install.mjs b/scripts/bundled-install.mjs index c85acc133..6a31d2744 100644 --- a/scripts/bundled-install.mjs +++ b/scripts/bundled-install.mjs @@ -68,6 +68,8 @@ export function prefixLayout({ platform, env = {}, home, root }) { nodeDir, node: windows ? path.join(nodeDir, "node.exe") : path.join(nodeDir, "bin", "node"), // The npm bundled in the same Node archive, for Pi's own package installs. npmCli: path.join(nodeDir, ...(windows ? [] : ["lib"]), "node_modules", "npm", "bin", "npm-cli.js"), + // The entry Pi's npmCommand runs that npm through (ensureNpmShim). + npmShim: path.join(nodeDir, "gentle-shell", "npm"), npmHome, npmPrefix: path.join(npmHome, "prefix"), npmCache: path.join(npmHome, "cache"), npmGlobalConfig: path.join(npmHome, "npmrc"), pnpmDir, pnpm: path.join(pnpmDir, "package", "bin", "pnpm.mjs"), // acquireGo publishes `//go`. @@ -367,6 +369,9 @@ export async function ensureRuntime({ layout, platform, arch, go = false, env = if (npm.status !== 0 || String(npm.stdout).trim() !== files.npm) throw new Error("Bundled npm verification failed"); }); if (node) acquired.push("node"); + // Also for a runtime published before the entry existed; one from before npm + // was published (T1a) has no npm to run, and the launcher then gives Pi none. + if (regular(layout.npmCli)) ensureNpmShim(layout); const pnpm = await publishRuntime(layout, "pnpm", layout.pnpmDir, layout.pnpm, { platform, arch, adapters }, async (stage, bytes, descriptor) => { // The tarball's executable bits are kept (bin/pnpm.mjs, dist/node-gyp-bin), for the owner only. const modes = new Map([...tarMembers(bytes, "pnpm archive rejected")].map((member) => [member.name, member.mode])); @@ -717,14 +722,53 @@ const RELEASE_EXPORTS = Object.freeze(["prefixLayout", "ensureRuntime", "ensureL */ const VERIFY_ENV = "GENTLE_SHELL_VERIFY_VERSION"; +const NPM_SHIM_MARK = "gentle-shell bundled npm entry (generated; do not edit)"; +/** Writes runtime/node-/gentle-shell/npm, a CommonJS file that runs the npm + * bundled with that Node, and a package.json beside it that keeps it CommonJS + * under any package.json with "type": "module" above the prefix. Pi names the + * package manager from the basenames in npmCommand (getPackageManagerName in + * Pi's core/package-manager.js), so this entry is named exactly `npm`; on + * Windows it also avoids npm.cmd, which prefers an npm in the global prefix. + * Each file is replaced with one rename, only when its content differs; a link + * or anything but a regular file in its place stops it. Returns { written }. + */ +export function ensureNpmShim(layout) { + const path = pathFor(layout.platform); + if (!regular(layout.npmCli)) throw new Error(`Bundled npm missing: ${layout.npmCli}`); + const folder = path.dirname(layout.npmShim); + const cli = path.relative(folder, layout.npmCli).split(path.sep).map((part) => JSON.stringify(part)).join(", "); + const files = { + "package.json": '{ "type": "commonjs" }\n', + [path.basename(layout.npmShim)]: [`// ${NPM_SHIM_MARK}`, `require(require("node:path").join(__dirname, ${cli}));`, ""].join("\n"), + }; + const info = stat(folder); + if (!info) mkdirSync(folder, { mode: 0o700 }); + else if (!info.isDirectory()) throw new Error(`Conflicting npm entry: ${folder}`); + let written = false; + for (const [name, text] of Object.entries(files)) { + const target = path.join(folder, name); + const existing = stat(target); + if (existing && !existing.isFile()) throw new Error(`Conflicting npm entry: ${target}`); + if (existing && readFileSync(target, "utf8") === text) continue; + replaceAtomically(target, (temp) => writeFileSync(temp, text, { flag: "wx", mode: 0o600 })); + written = true; + } + return { written }; +} + /** The command Pi runs for npm in a bundled install (its `npmCommand` setting): - * our npm on our Node, reading only the prefix npmrc and global config, with its - * cache and global prefix inside the prefix. npm ranks command-line flags above - * npm_config_* variables, so the user's environment cannot redirect these. + * our npm on our Node, through the entry named npm (ensureNpmShim), so Pi treats + * it as npm; it reads only the prefix npmrc and global config, with its cache + * inside the prefix. npm ranks command-line flags above npm_config_* variables, + * so the user's environment cannot redirect these. No --prefix: on the command + * line it also sets npm's local prefix, and Pi installs a git extension's + * dependencies with `npm install` in the clone, without a --prefix of its own + * (its npm: installs pass one). No other part's basename is npm, pnpm or bun, + * which Pi would reject as ambiguous. */ export function bundledNpmCommand(layout) { - return [layout.node, layout.npmCli, `--userconfig=${layout.npmrc}`, `--globalconfig=${layout.npmGlobalConfig}`, `--cache=${layout.npmCache}`, - `--prefix=${layout.npmPrefix}`, "--update-notifier=false"]; + return [layout.node, layout.npmShim, `--userconfig=${layout.npmrc}`, `--globalconfig=${layout.npmGlobalConfig}`, `--cache=${layout.npmCache}`, + "--update-notifier=false"]; } /** The module an installed version ships (its own pins and launcher), only from diff --git a/tests/bundled-install.test.ts b/tests/bundled-install.test.ts index 985733c89..75936743c 100644 --- a/tests/bundled-install.test.ts +++ b/tests/bundled-install.test.ts @@ -3,11 +3,11 @@ import { createHash } from "node:crypto"; import { chmodSync, existsSync, rmSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, symlinkSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; -import { join, posix } from "node:path"; +import { join, posix, win32 } from "node:path"; import test, { after, type TestContext } from "node:test"; import { crc32, gzipSync } from "node:zlib"; import { activateVersion, activeVersion, applyPathEntry, claimPrefix, prefixRoot, distributionFiles, ensureLauncher, ensureRuntime, filterNpmrc, installVersion, nodeExecutable, - nodeRuntimeFiles, pathEntryPlan, pnpmEnvironment, prefixLayout, pruneVersions, removePathEntry, rollbackBundled, setupEnvironment, bundledNpmCommand, upgradeBundled, userNpmrc, writeNpmrcAuth } + nodeRuntimeFiles, pathEntryPlan, pnpmEnvironment, prefixLayout, pruneVersions, removePathEntry, rollbackBundled, setupEnvironment, bundledNpmCommand, ensureNpmShim, upgradeBundled, userNpmrc, writeNpmrcAuth } from "../scripts/bundled-install.mjs"; // Real directories, modes, symlinks and uids need a POSIX host. @@ -263,7 +263,9 @@ posixTest("ensureRuntime installs the verified Node and pnpm into runtime/ and r assert.equal(lstatSync(join(layout.pnpmDir, "package", "dist", "node-gyp-bin", "node-gyp")).mode & 0o777, 0o700); assert.equal(lstatSync(join(layout.pnpmDir, "package", "package.json")).mode & 0o777, 0o600); // The Node executable and its npm are published, no links: npm and npx are our own wrappers. - assert.deepEqual(readdirSync(join(layout.runtime, `node-${NODE}`)).sort(), [".gentle-shell-runtime", "bin", "lib"]); + assert.deepEqual(readdirSync(join(layout.runtime, `node-${NODE}`)).sort(), [".gentle-shell-runtime", "bin", "gentle-shell", "lib"]); + // Pi's npmCommand entry, named npm (ensureNpmShim). + assert.match(readFileSync(layout.npmShim, "utf8"), /npm-cli\.js/); assert.deepEqual(readdirSync(join(layout.runtime, `node-${NODE}`, "bin")).sort(), ["node", "npm", "npx"]); assert.deepEqual(readdirSync(join(layout.runtime, `node-${NODE}`, "lib", "node_modules")), ["npm"]); assert.deepEqual(readdirSync(join(layout.runtime, `node-${NODE}`, "lib", "node_modules", "npm", "bin")).sort(), ["npm-cli.js", "npx-cli.js"]); @@ -958,11 +960,77 @@ posixTest("rollbackBundled fails closed without the target's own module and puts assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]); }); -test("bundledNpmCommand runs our npm on our Node with only the prefix configuration", () => { +test("bundledNpmCommand runs our npm through an entry named npm, with only the prefix configuration", () => { const layout = prefixLayout({ platform: "linux", env: {}, home: "/home/me" }); const root = "/home/me/.gentle-shell"; - assert.deepEqual(bundledNpmCommand(layout), [`${root}/runtime/node-${NODE}/bin/node`, `${root}/runtime/node-${NODE}/lib/node_modules/npm/bin/npm-cli.js`, - `--userconfig=${root}/pnpm/config/npmrc`, `--globalconfig=${root}/npm/npmrc`, `--cache=${root}/npm/cache`, `--prefix=${root}/npm/prefix`, "--update-notifier=false"]); + assert.deepEqual(bundledNpmCommand(layout), [`${root}/runtime/node-${NODE}/bin/node`, `${root}/runtime/node-${NODE}/gentle-shell/npm`, + `--userconfig=${root}/pnpm/config/npmrc`, `--globalconfig=${root}/npm/npmrc`, `--cache=${root}/npm/cache`, "--update-notifier=false"]); + // A --prefix flag would also set npm's local prefix: Pi's git-extension + // install (npm install in the clone, no --prefix of its own) would land there. + assert.equal(bundledNpmCommand(layout).some((part) => /^--prefix(?:=|$)/.test(part)), false); const windows = bundledNpmCommand(prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\L" }, home: "C:\\U" })); - assert.deepEqual(windows.slice(0, 2), [`C:\\L\\gentle-shell\\runtime\\node-${NODE}\\node.exe`, `C:\\L\\gentle-shell\\runtime\\node-${NODE}\\node_modules\\npm\\bin\\npm-cli.js`]); + assert.deepEqual(windows.slice(0, 2), [`C:\\L\\gentle-shell\\runtime\\node-${NODE}\\node.exe`, `C:\\L\\gentle-shell\\runtime\\node-${NODE}\\gentle-shell\\npm`]); +}); + +// N1: Pi 1.1.0 names the package manager from the basenames in npmCommand +// (DefaultPackageManager.getPackageManagerName) and picks its install flags from +// that name. Its own code, on this host's paths. +const PI_PACKAGE_MANAGER = new URL("../node_modules/@earendil-works/pi-coding-agent/dist/core/package-manager.js", import.meta.url); +async function piPackageManager(command: string[]) { + const { DefaultPackageManager } = await import(PI_PACKAGE_MANAGER.href); + return new DefaultPackageManager({ cwd: tmpdir(), agentDir: tmpdir(), settingsManager: { getNpmCommand: () => command } }); +} +// The same rule with Windows paths, on any runner: Pi calls node:path's +// basename, which is win32's on Windows, and strips only .cmd and .exe. +function windowsPackageManagerName(command: string[]) { + const name = (part: string) => win32.basename(part).replace(/\.(cmd|exe)$/i, ""); + const supported = new Set(["npm", "pnpm", "bun"]); + if (supported.has(name(command[0]))) return name(command[0]); + const wrapped = [...new Set(command.slice(1).map(name).filter((part) => supported.has(part)))]; + if (wrapped.length > 1) throw new Error(`Ambiguous npmCommand package managers: ${wrapped.join(", ")}`); + return wrapped[0] ?? name(command[0]); +} + +test("Pi reads the bundled npmCommand as npm and installs git extensions with npm's flags", async () => { + const host = process.platform === "win32" + ? prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\Users\\Ana Li\\AppData\\Local" }, home: "C:\\Users\\Ana Li" }) + : prefixLayout({ platform: process.platform, env: {}, home: "/home/npm" }); + const pi = await piPackageManager(bundledNpmCommand(host)); + assert.equal(pi.getPackageManagerName(), "npm"); + assert.deepEqual(pi.getGitDependencyInstallArgs(), ["install", "--omit=dev", "--legacy-peer-deps"]); + // The command before N1 read as `node`: plain `npm install` for git extensions. + const before = await piPackageManager([host.node, host.npmCli, ...bundledNpmCommand(host).slice(2)]); + assert.equal(before.getPackageManagerName(), "node"); + assert.deepEqual(before.getGitDependencyInstallArgs(), ["install"]); + // Windows-shaped paths, whatever the folder names (here "npm" and "pnpm" ones). + const windows = prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\pnpm\\npm" }, home: "C:\\Users\\bun" }); + assert.equal(windowsPackageManagerName(bundledNpmCommand(windows)), "npm"); + assert.equal(windowsPackageManagerName([windows.node, windows.npmCli]), "node"); +}); + +posixTest("ensureNpmShim writes one regular npm entry that runs our npm-cli.js, rewritten only when it differs", () => { + const { layout } = claimed(); + assert.throws(() => ensureNpmShim(layout), /npm/); + mkdirSync(posix.dirname(layout.npmCli), { recursive: true }); + // Prints its version and the arguments it got, like npm --version. + writeFileSync(layout.npmCli, "console.log(process.argv.slice(2).includes('--version') ? '11.19.0' : JSON.stringify(process.argv.slice(2)));\n"); + // A user's package.json with type module above the prefix must not make the entry an ES module. + writeFileSync(join(layout.root, "..", "package.json"), JSON.stringify({ type: "module" })); + assert.deepEqual(ensureNpmShim(layout), { written: true }); + assert.equal(posix.basename(layout.npmShim), "npm"); + assert.equal(lstatSync(layout.npmShim).isFile(), true); + const version = spawnSync(process.execPath, [layout.npmShim, "--version"], { encoding: "utf8" }); + assert.equal(version.status, 0, version.stderr); + assert.equal(version.stdout, "11.19.0\n"); + assert.equal(spawnSync(process.execPath, [layout.npmShim, "install", "--omit=dev"], { encoding: "utf8" }).stdout, '["install","--omit=dev"]\n'); + const text = readFileSync(layout.npmShim, "utf8"); + assert.deepEqual(ensureNpmShim(layout), { written: false }); + writeFileSync(layout.npmShim, "// stale\n"); + assert.deepEqual(ensureNpmShim(layout), { written: true }); + assert.equal(readFileSync(layout.npmShim, "utf8"), text); + // A link or a folder in its place is never written through or replaced. + rmSync(layout.npmShim); + symlinkSync(layout.npmCli, layout.npmShim); + assert.throws(() => ensureNpmShim(layout), /Conflicting npm entry/); + assert.equal(readlinkSync(layout.npmShim), layout.npmCli); }); diff --git a/tests/gentle-shell-bundled.test.ts b/tests/gentle-shell-bundled.test.ts index b9f9906f4..af308bbac 100644 --- a/tests/gentle-shell-bundled.test.ts +++ b/tests/gentle-shell-bundled.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, posix, win32 } from "node:path"; import { fileURLToPath } from "node:url"; @@ -245,6 +245,10 @@ function bundledPrefix(t: TestContext, { pi = "1.1.0" } = {}) { writeFileSync(join(version, "package.json"), JSON.stringify({ dependencies: { "gentle-pi": SHELL, "@earendil-works/pi-coding-agent": "1.1.0" } })); writeFileSync(join(version, ".gentle-shell-version"), `${FIXTURE_ID}\n`); symlinkSync(`versions/${FIXTURE_ID}`, join(root, "current")); + // The bundled npm the npmCommand entry loads. + const npmCli = join(root, "runtime", "node-24.21.0", "lib", "node_modules", "npm", "bin", "npm-cli.js"); + mkdirSync(dirname(npmCli), { recursive: true }); + writeFileSync(npmCli, "console.log('11.19.0');\n"); // A Pi on PATH and in GENTLE_SHELL_PI that must never run. const userBin = join(base, "user-bin"); mkdirSync(userBin); @@ -253,7 +257,7 @@ function bundledPrefix(t: TestContext, { pi = "1.1.0" } = {}) { NODE_OPTIONS: "--no-warnings", npm_config_registry: "https://npm.example" }; const entry = join(root, "current", "node_modules", "gentle-pi", "bin", "gentle-shell.mjs"); const run = (args: string[], extra: Record = {}) => spawnSync(process.execPath, [entry, ...args], { env: { ...env, ...extra }, encoding: "utf8", input: "" }); - return { root, home, userBin, version, piDir, run, env, entry: join(shell, "bin", "gentle-shell.mjs") }; + return { root, home, userBin, version, piDir, npmCli, run, env, entry: join(shell, "bin", "gentle-shell.mjs") }; } test("the bundled launcher runs only its version's Pi, with the user's environment and the bundled npm in its own home", { skip: posixHost }, (t) => { @@ -276,10 +280,12 @@ test("the bundled launcher runs only its version's Pi, with the user's environme assert.equal(seen.env.npm_config_prefix, undefined); // Pi's own npm is ours, configured only from the prefix, through the isolated home's settings. const settings = JSON.parse(readFileSync(join(prefix.home, ".gentle-shell", "agent", "settings.json"), "utf8")); - assert.deepEqual(settings.npmCommand.slice(0, 2), [join(prefix.root, "runtime", "node-24.21.0", "bin", "node"), - join(prefix.root, "runtime", "node-24.21.0", "lib", "node_modules", "npm", "bin", "npm-cli.js")]); + const shim = join(prefix.root, "runtime", "node-24.21.0", "gentle-shell", "npm"); + assert.deepEqual(settings.npmCommand.slice(0, 2), [join(prefix.root, "runtime", "node-24.21.0", "bin", "node"), shim]); + // The entry named npm exists before Pi is given it, and runs our npm. + assert.equal(spawnSync(process.execPath, [shim, "--version"], { encoding: "utf8" }).stdout, "11.19.0\n"); assert.ok(settings.npmCommand.includes(`--userconfig=${join(prefix.root, "pnpm", "config", "npmrc")}`)); - assert.ok(settings.npmCommand.includes(`--prefix=${join(prefix.root, "npm", "prefix")}`)); + assert.equal(settings.npmCommand.some((part: string) => part.startsWith("--prefix")), false); // --link is the user's own Pi home: never given our npm. const linked = join(prefix.home, ".pi", "agent"); mkdirSync(linked, { recursive: true }); @@ -288,6 +294,31 @@ test("the bundled launcher runs only its version's Pi, with the user's environme assert.equal(readFileSync(join(linked, "settings.json"), "utf8"), "{}\n"); }); +test("a home holding the npmCommand written before the npm entry existed gets only that key updated", { skip: posixHost }, (t) => { + const prefix = bundledPrefix(t); + const node = join(prefix.root, "runtime", "node-24.21.0"); + const flags = [`--userconfig=${join(prefix.root, "pnpm", "config", "npmrc")}`, `--globalconfig=${join(prefix.root, "npm", "npmrc")}`, + `--cache=${join(prefix.root, "npm", "cache")}`, "--update-notifier=false"]; + // The value written before N1: npm-cli.js directly, and a --prefix flag. + const previous = [join(node, "bin", "node"), prefix.npmCli, ...flags.slice(0, 3), `--prefix=${join(prefix.root, "npm", "prefix")}`, flags[3]]; + const agent = join(prefix.home, ".gentle-shell", "agent"); + mkdirSync(agent, { recursive: true }); + writeFileSync(join(agent, "settings.json"), `${JSON.stringify({ theme: "gentle", npmCommand: previous, packages: ["npm:x"] }, null, 2)}\n`); + assert.equal(prefix.run(["list"]).status, 0); + const settings = JSON.parse(readFileSync(join(agent, "settings.json"), "utf8")); + assert.deepEqual(settings, { theme: "gentle", npmCommand: [join(node, "bin", "node"), join(node, "gentle-shell", "npm"), ...flags], packages: ["npm:x"] }); +}); + +test("a runtime without our npm gives Pi no npmCommand and says so", { skip: posixHost }, (t) => { + const prefix = bundledPrefix(t); + rmSync(prefix.npmCli); + const result = prefix.run(["list"]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /could not give Pi the bundled npm.*Bundled npm missing/); + const settings = join(prefix.home, ".gentle-shell", "agent", "settings.json"); + assert.equal(existsSync(settings) && "npmCommand" in JSON.parse(readFileSync(settings, "utf8")), false); +}); + test("the version check before activation runs that version in bundled mode, never a PATH Pi", { skip: posixHost }, (t) => { const prefix = bundledPrefix(t); const other = join(prefix.root, "versions", "0.0.1-1.0.0"); From 2da662b3da3a8f92ebabceaad250efe5e909e081 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Sun, 11 Oct 2026 12:22:55 +0200 Subject: [PATCH 4/4] fix(installer): retry a locked Windows runtime publication rename On Windows the rename that publishes a verified runtime can lose a race with the node.exe just run from the staging folder or a real-time scanner still holding it (EPERM on the CI runner). Like the gentle-ai bundle publication, only EPERM, EBUSY and EACCES are retried, only on Windows, after 200, 400, 800 and 1600 ms; a lock that never clears surfaces the original error. --- scripts/bundled-install.mjs | 18 +++++++++++++++++- tests/bundled-install.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/scripts/bundled-install.mjs b/scripts/bundled-install.mjs index 6a31d2744..3da92e85f 100644 --- a/scripts/bundled-install.mjs +++ b/scripts/bundled-install.mjs @@ -319,6 +319,22 @@ export function runCommand(command, args, { cwd, env, timeout = 15000 }) { } const ownedRuntime = (directory, url, entry) => read(pathFor(process.platform).join(directory, RUNTIME_MARKER)) === `${url}\n` && regular(entry); +// Windows publication can lose a race with the node.exe just run from the +// staging folder or a real-time scanner still holding it (EPERM, EBUSY, +// EACCES). Only those codes, and only on Windows, are retried after each +// delay; a lock that never clears surfaces the original error. +const WINDOWS_RENAME_RETRY_DELAYS_MS = [200, 400, 800, 1600]; +export async function renameWithWindowsRetry(from, to, { platform = process.platform, rename = renameSync, delays = WINDOWS_RENAME_RETRY_DELAYS_MS } = {}) { + if (platform !== "win32") return rename(from, to); + for (let attempt = 0; ; attempt += 1) { + try { return rename(from, to); } + catch (error) { + if (attempt >= delays.length || !["EPERM", "EBUSY", "EACCES"].includes(error?.code)) throw error; + await new Promise((resolve) => setTimeout(resolve, delays[attempt])); + } + } +} + /** Publishes one verified runtime as `directory`: an existing one marked with the * same pinned URL is reused, an unmarked one is never replaced. `fill` writes and * checks the staging folder; one rename publishes it. Renaming is safe here, @@ -337,7 +353,7 @@ async function publishRuntime(layout, name, directory, entry, { platform, arch, await fill(stage, bytes, descriptor); writeFileSync(pathFor(process.platform).join(stage, RUNTIME_MARKER), `${descriptor.url}\n`, { flag: "wx", mode: 0o600 }); if (stat(directory)) throw new Error(`Conflicting runtime destination: ${directory}`); - renameSync(stage, directory); + await renameWithWindowsRetry(stage, directory); stage = null; return true; } finally { diff --git a/tests/bundled-install.test.ts b/tests/bundled-install.test.ts index 75936743c..ca8432b3c 100644 --- a/tests/bundled-install.test.ts +++ b/tests/bundled-install.test.ts @@ -1034,3 +1034,28 @@ posixTest("ensureNpmShim writes one regular npm entry that runs our npm-cli.js, assert.throws(() => ensureNpmShim(layout), /Conflicting npm entry/); assert.equal(readlinkSync(layout.npmShim), layout.npmCli); }); + +test("publishing a runtime retries only Windows lock errors on rename, then gives up with the original error", async () => { + const { renameWithWindowsRetry } = await import("../scripts/bundled-install.mjs"); + const lock = (code: string) => Object.assign(new Error(code), { code }); + const flaky = (failures: string[]) => { + const calls: string[] = []; + return { calls, rename: (from: string, to: string) => { calls.push(`${from}>${to}`); const code = failures.shift(); if (code) throw lock(code); } }; + }; + const noWait = { delays: [0, 0, 0, 0] }; + // A scanner or the just-run node.exe holding the staging folder clears after a moment. + const recovers = flaky(["EPERM", "EBUSY", "EACCES"]); + await renameWithWindowsRetry("s", "d", { platform: "win32", rename: recovers.rename, ...noWait }); + assert.equal(recovers.calls.length, 4); + // A lock that never clears surfaces the original error after the last delay. + const stuck = flaky(["EPERM", "EPERM", "EPERM", "EPERM", "EPERM"]); + await assert.rejects(renameWithWindowsRetry("s", "d", { platform: "win32", rename: stuck.rename, ...noWait }), (error: { code?: string }) => error.code === "EPERM"); + assert.equal(stuck.calls.length, 5); + // Any other error, or any other platform, fails at once. + const other = flaky(["EEXIST"]); + await assert.rejects(renameWithWindowsRetry("s", "d", { platform: "win32", rename: other.rename, ...noWait }), (error: { code?: string }) => error.code === "EEXIST"); + assert.equal(other.calls.length, 1); + const posixLock = flaky(["EPERM"]); + await assert.rejects(renameWithWindowsRetry("s", "d", { platform: "linux", rename: posixLock.rename, ...noWait }), (error: { code?: string }) => error.code === "EPERM"); + assert.equal(posixLock.calls.length, 1); +});