import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import * as fsPromises from "node:fs/promises"; import { request as httpRequest } from "node:http"; import { connect } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { exitCodeFor, openBrowser, openerFor, redirectPage, runnerEnvironment, upgradeEnvironment, wizardHandlers, writeRedirect } from "../bin/gentle-shell-install.mjs"; import { planPreflight, requirements } from "../scripts/installer-preflight.mjs"; import { PI_INSTALL_VERSION, blockedReasons, failedSteps } from "../scripts/installer-runner.mjs"; import { createInstallerServer, guidance } from "../scripts/installer-server.mjs"; import { distributionAsset, prefixLayout } from "../scripts/bundled-install.mjs"; import { bundledBlockedReasons, bundledFailedSteps, bundledPlan } from "../scripts/bundled-wizard.mjs"; type Response = { status: number; headers: Record; body: string }; type Plan = ReturnType; type Collected = { inventory: Record; plan: Plan }; type Log = (entry: Record) => void; type RunInstall = (request: { plan: Plan; consent: boolean }, log: Log) => Promise>; const BIN = "/home/u/.local/share/pnpm/bin"; // Every /api/* request carries the custom header, so cross-origin fetches need a preflight. const API = { "x-gentle-install": "1" }; const cleanInventory = { platform: "linux", arch: "x64", node: { available: true, version: "24.18.0", usable: true, persistent: false, npm: false }, pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false }, pi: { available: false }, shell: { available: false }, gentleAi: { available: false }, go: { available: false }, globalBin: { available: true, path: BIN, writable: true, onPath: false }, setup: false, }; function collected(changes: Record = {}): Collected { const inventory = { ...cleanInventory, ...changes }; return { inventory, plan: planPreflight(inventory) }; } const assetsDir = mkdtempSync(join(tmpdir(), "gentle-install-server-")); writeFileSync(join(assetsDir, "index.html"), "fixture\n"); writeFileSync(join(assetsDir, "wizard.js"), "// fixture\n"); test.after(() => rmSync(assetsDir, { recursive: true, force: true })); function send(port: number, { method = "GET", path = "/", headers = {} as Record, body = undefined as string | undefined, host = `127.0.0.1:${port}` as string | null } = {}): Promise { return new Promise((resolve, reject) => { const all: Record = { ...headers }; if (host !== null) all.host = host; if (body !== undefined) all["content-length"] = Buffer.byteLength(body); const req = httpRequest({ host: "127.0.0.1", port, method, path, headers: all, setHost: false, agent: false }, (res) => { const chunks: Buffer[] = []; res.on("data", (chunk) => chunks.push(chunk)); res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks).toString("utf8") })); }); req.on("error", reject); if (body !== undefined) req.write(body); req.end(); }); } async function start({ collect = (async () => collected()) as (channel: string) => Promise, runInstall = (async () => ({ outcome: "ready", completed: [] })) as RunInstall, limits = {}, clock = { value: 1_000_000 } } = {}) { let collects = 0; const channels: string[] = []; const runs: Array<{ request: { plan: Plan; consent: boolean } }> = []; const host = createInstallerServer({ assetsDir, collectPlan: async (channel: string) => { collects += 1; channels.push(channel); return collect(channel); }, runInstall: async (request: { plan: Plan; consent: boolean }, log: Log) => { runs.push({ request }); return runInstall(request, log); }, now: () => clock.value, limits, }); const { port, url, address } = await host.listen(); const origin = `http://127.0.0.1:${port}`; const code = new URL(url).searchParams.get("code") ?? ""; const login = async () => { const response = await send(port, { path: `/session?code=${code}` }); assert.equal(response.status, 200); const cookie = String(response.headers["set-cookie"]).split(";")[0]; return cookie; }; return { host, port, url, address, origin, code, login, runs, clock, collects: () => collects, channels }; } function post(port: number, path: string, cookie: string, body: unknown, headers: Record = {}) { return send(port, { method: "POST", path, body: typeof body === "string" ? body : JSON.stringify(body), headers: { cookie, origin: `http://127.0.0.1:${port}`, "x-gentle-install": "1", "content-type": "application/json", ...headers }, }); } async function plan(port: number, cookie: string) { const response = await send(port, { path: "/api/plan", headers: { cookie, ...API } }); assert.equal(response.status, 200); return JSON.parse(response.body); } function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } async function waitFor(check: () => Promise | boolean) { for (let i = 0; i < 200; i += 1) { if (await check()) return; await new Promise((done) => setTimeout(done, 5)); } throw new Error("condition not reached"); } test("binds only 127.0.0.1 on an ephemeral port and prints a one-time session URL", async () => { const { host, port, url, address } = await start(); try { assert.equal(address, "127.0.0.1"); assert.ok(port > 0); assert.match(url, new RegExp(`^http://127\\.0\\.0\\.1:${port}/session\\?code=[A-Za-z0-9_-]{43}$`)); const ipv6 = await new Promise((resolve) => { const socket = connect({ host: "::1", port }); socket.once("connect", () => { socket.destroy(); resolve("connected"); }); socket.once("error", () => resolve("refused")); }); assert.equal(ipv6, "refused"); } finally { await host.close("test"); } }); test("Host must be exactly 127.0.0.1: before any authorization", async () => { const { host, port, login } = await start(); try { const cookie = await login(); for (const value of [`localhost:${port}`, `evil:${port}`, "127.0.0.1", `127.0.0.1:${port + 1}`, `[::1]:${port}`, null]) { const response = await send(port, { path: "/api/plan", headers: { cookie, ...API }, host: value }); assert.equal(response.status, 421, String(value)); assert.doesNotMatch(response.body, /actions|planId/); } } finally { await host.close("test"); } }); test("session code is single-use, expires, and sets a strict HttpOnly cookie", async () => { const fresh = await start(); try { const response = await send(fresh.port, { path: `/session?code=${fresh.code}` }); // 200 with a same-origin refresh, not a 303: a redirect chain started from the // file: redirect page is cross-site, so the Strict cookie could be withheld on `/`. assert.equal(response.status, 200); assert.equal(response.headers.location, undefined); assert.equal(response.headers["content-type"], "text/html; charset=utf-8"); assert.equal(response.headers["content-security-policy"], "default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"); assert.ok(response.body.includes('')); assert.ok(!/ { const { host, port, login } = await start(); try { for (const path of ["/", "/wizard.js", "/api/plan", "/api/progress"]) { assert.equal((await send(port, { path })).status, 401, path); assert.equal((await send(port, { path, headers: { cookie: "gentle_install_session=forged" } })).status, 401, path); } const cookie = await login(); const page = await send(port, { path: "/", headers: { cookie } }); assert.equal(page.status, 200); assert.match(String(page.headers["content-type"]), /^text\/html/); assert.equal(page.body, readFileSync(join(assetsDir, "index.html"), "utf8")); const script = await send(port, { path: "/wizard.js", headers: { cookie } }); assert.equal(script.status, 200); assert.match(String(script.headers["content-type"]), /^text\/javascript/); assert.equal((await send(port, { path: "/wizard.css", headers: { cookie } })).status, 404); } finally { await host.close("test"); } }); test("POST needs the exact Origin and custom header; GET rejects a foreign Origin", async () => { const { host, port, login, runs } = await start(); try { const cookie = await login(); const { planId } = await plan(port, cookie); const body = { planId, consent: true }; assert.equal((await post(port, "/api/install", cookie, body, { origin: "http://evil.test" })).status, 403); assert.equal((await post(port, "/api/install", cookie, body, { origin: `http://localhost:${port}` })).status, 403); assert.equal((await post(port, "/api/install", cookie, body, { origin: "null" })).status, 403); const noOrigin = await send(port, { method: "POST", path: "/api/install", body: JSON.stringify(body), headers: { cookie, "x-gentle-install": "1", "content-type": "application/json" } }); assert.equal(noOrigin.status, 403); const noHeader = await send(port, { method: "POST", path: "/api/install", body: JSON.stringify(body), headers: { cookie, origin: `http://127.0.0.1:${port}`, "content-type": "application/json" } }); assert.equal(noHeader.status, 403); assert.equal((await post(port, "/api/install", cookie, body, { "x-gentle-install": "true" })).status, 403); assert.equal((await post(port, "/api/install", cookie, body, { "content-type": "text/plain" })).status, 415); assert.equal((await send(port, { path: "/api/plan", headers: { cookie, ...API, origin: "http://evil.test" } })).status, 403); assert.equal(runs.length, 0); } finally { await host.close("test"); } }); test("/api/plan is built server-side and explains profile and runtime persistence", async () => { const { host, port, login, collects } = await start(); try { const cookie = await login(); const view = await plan(port, cookie); assert.equal(collects(), 1); assert.match(view.planId, /^[A-Za-z0-9_-]{16,}$/); assert.deepEqual(view.actions.map((action: { id: string }) => action.id), collected().plan.actions.map((action) => action.id)); for (const action of view.actions) { assert.deepEqual(Object.keys(action).sort(), ["description", "id"]); assert.ok(action.description.length > 10); } assert.equal(view.profileChange.changesProfile, true); assert.equal(view.profileChange.binDir, BIN); assert.equal(view.profileChange.command, "pnpm setup"); assert.match(view.profileChange.description, /pnpm setup/); assert.deepEqual(view.persistence.tools, ["node", "npm", "pnpm"]); assert.equal(view.persistence.pnpmHome, "/home/u/.local/share/pnpm"); assert.match(view.persistence.description, /PNPM_HOME/); assert.deepEqual(view.blockers, []); // The plan endpoint accepts no client input. assert.equal((await post(port, "/api/plan", cookie, { plan: collected().plan })).status, 405); assert.equal((await send(port, { path: "/api/plan?plan=x", headers: { cookie, ...API } })).status, 400); } finally { await host.close("test"); } }); test("/api/plan lists only Node.js and npm for persistence when a newer pnpm 11 is already in $PNPM_HOME/bin", async () => { const { host, port, login, runs } = await start({ collect: async () => collected({ pnpm: { available: true, version: "11.5.0", usable: true, compatible: true, persistent: true, inGlobalBin: true }, }) }); try { const view = await plan(port, await login()); assert.deepEqual(view.blockers, []); assert.deepEqual(view.actions.map((action: { id: string }) => action.id).filter((id: string) => /^(persist-|configure-npm)/.test(id)), ["persist-node", "persist-npm", "configure-npm-prefix"]); assert.deepEqual(view.persistence.tools, ["node", "npm"]); assert.ok(view.persistence.description.startsWith("node, npm will be installed under $PNPM_HOME"), view.persistence.description); assert.deepEqual(runs, []); } finally { await host.close("test"); } }); test("/api/plan reports blockers with guidance and no profile change when already on PATH", async () => { const { host, port, login } = await start({ collect: async () => collected({ node: { available: true, version: "24.18.0", usable: true, persistent: true, npm: true }, pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: true }, globalBin: { available: true, path: BIN, writable: true, onPath: true }, pi: { available: null }, }) }); try { const view = await plan(port, await login()); assert.deepEqual(view.actions, []); assert.equal(view.blockers.length, 1); assert.equal(view.blockers[0].code, "unknown-tool"); assert.equal(view.blockers[0].tool, "pi"); assert.ok(view.blockers[0].guidance.length > 20); assert.equal(view.profileChange.changesProfile, false); assert.deepEqual(view.persistence.tools, []); } finally { await host.close("test"); } }); test("/api/plan names the found and required version of an older Gentle Shell and how to resolve it", async () => { const { host, port, login } = await start({ collect: async () => collected({ pi: { available: true, version: "1.2.4", usable: true }, shell: { available: true, version: "3.4.0", usable: false, global: true }, gentleAi: { available: null }, setup: { available: null }, }) }); try { const view = await plan(port, await login()); assert.deepEqual(view.blockers.map((blocker: { code: string; tool: string }) => [blocker.code, blocker.tool]), [["incompatible-tool", "shell"]]); assert.equal(view.blockers[0].guidance, "Gentle Shell 3.4.0 is installed globally with pnpm, but this installer needs 4.5.1 or newer. Nothing was replaced. " + "Update it with `pnpm add -g gentle-pi@4.5.1`, or remove it with `pnpm remove -g gentle-pi`, then select Check again."); assert.deepEqual(view.actions, []); } finally { await host.close("test"); } }); test("/api/plan installs the installer's Pi alongside an older Pi that neither pnpm nor npm manages, and says so before consent", async () => { const { host, port, login, runs } = await start({ collect: async () => collected({ pi: { available: true, version: "0.99.0", usable: true, external: true } }) }); try { const view = await plan(port, await login()); assert.deepEqual(view.blockers, []); const install = view.actions.find((action: { id: string }) => action.id === "install-pi"); assert.equal(install.description, `Install Pi ${PI_INSTALL_VERSION} globally with pnpm for Gentle Shell. The Pi 0.99.0 already on this computer ` + "was not installed with pnpm or npm, so it is left unchanged. Gentle Shell uses the installer's Pi; the `pi` command in a terminal may still run the older one."); assert.deepEqual(runs, []); } finally { await host.close("test"); } }); test("/api/plan shows an older Pi's update with its found and target versions and package manager, before consent", async () => { for (const owner of ["pnpm", "npm"]) { const { host, port, login, runs } = await start({ collect: async () => collected({ pi: { available: true, version: "0.87.1", usable: true, owner } }) }); try { const view = await plan(port, await login()); assert.deepEqual(view.blockers, []); const update = view.actions.find((action: { id: string }) => action.id === "update-pi"); assert.equal(update.description, `Update Pi 0.87.1 to ${PI_INSTALL_VERSION} with ${owner}, the package manager that installed it. ` + `Gentle Shell needs Pi ${requirements.pi} or newer.`); assert.ok(view.actions.findIndex((action: { id: string }) => action.id === "update-pi") < view.actions.findIndex((action: { id: string }) => action.id === "install-shell")); assert.deepEqual(runs, []); } finally { await host.close("test"); } } }); test("/api/plan says, before consent, that an older Node and an incompatible pnpm stay unchanged next to the pinned copies", async () => { const { host, port, login, runs } = await start({ collect: async () => collected({ node: { available: true, version: "24.21.0", usable: true, persistent: false, npm: false, found: "22.18.0" }, pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false, found: "10.27.0" }, }) }); try { const view = await plan(port, await login()); assert.deepEqual(view.blockers, []); assert.deepEqual(view.persistence.tools, ["node", "npm", "pnpm"]); const text = view.persistence.description; assert.ok(text.includes(`Node.js 22.18.0 on this computer is older than ${requirements.node}, so the installer uses its pinned Node.js 24.21.0 ` + "and installs it under $PNPM_HOME. Your Node.js 22.18.0 is left unchanged."), text); assert.ok(text.includes(`pnpm 10.27.0 on this computer is not a pnpm ${requirements.pnpm.split(".")[0]} release this installer can use, ` + `so the installer uses its pinned pnpm ${requirements.pnpm} and installs it under $PNPM_HOME. Your pnpm 10.27.0 is left unchanged.`), text); // No PATH precedence is promised: a version manager may put its own copy first again. assert.ok(text.includes("A version manager such as `mise activate` may still put your own versions first on PATH in new terminals."), text); assert.deepEqual(runs, []); } finally { await host.close("test"); } // Without a found version the description is unchanged. const plain = await start(); try { assert.doesNotMatch((await plan(plain.port, await plain.login())).persistence.description, /left unchanged|version manager/); } finally { await plain.host.close("test"); } }); test("/api/plan says before consent which Go is downloaded, only to build, with the Go found and the system unchanged", async () => { for (const [go, found] of [[{ available: true, version: "1.24.0", usable: true }, "Go 1.24.0 on this computer is older than 1.25.10"], [{ available: false }, "Go is missing on this computer"]] as const) { const { host, port, login } = await start({ collect: async () => collectedFor("main", { ...mainReady, go }) }); try { const view = await plan(port, await login()); assert.deepEqual(view.blockers, []); const acquire = view.actions.find((action: { id: string }) => action.id === "acquire-go"); assert.equal(acquire.description, `${found}, so the installer downloads Go 1.25.14 from go.dev, verifies its pinned SHA-256 checksum ` + "and uses it only to build Gentle AI. It is kept in the installer's own folder (~/.pi/gentle-ai/tools/go); your Go, PATH and shell profile are not changed."); assert.equal(view.actions.find((action: { id: string }) => action.id === "verify-go").description, "Check that the downloaded Go runs and reports the pinned version."); } finally { await host.close("test"); } } }); test("/api/plan says before consent that a pnpm in $PNPM_HOME/bin that could not be checked is left as it is", async () => { const note = "The pnpm in the pnpm global bin directory could not be checked, so it was left as it is, and the installer uses its own verified pnpm for this installation."; const pnpm = { available: true, version: requirements.pnpm, usable: true, compatible: true, persistent: true, inGlobalBin: true, unchecked: true }; const cases = [ [{ available: true, version: "24.18.0", usable: true, persistent: true, npm: true }, [], `No runtime needs to be installed under $PNPM_HOME. ${note}`], [{ available: true, version: "24.21.0", usable: true, persistent: false, npm: false }, ["node", "npm"], null], ] as const; for (const [node, tools, exact] of cases) { const { host, port, login, runs } = await start({ collect: async () => collected({ node, pnpm }) }); try { const view = await plan(port, await login()); assert.deepEqual(view.blockers, []); assert.equal(view.ready, false); assert.deepEqual(view.persistence.tools, tools); if (exact) assert.equal(view.persistence.description, exact); else assert.ok(view.persistence.description.endsWith(` ${note}`), view.persistence.description); assert.deepEqual(runs, []); } finally { await host.close("test"); } } // A checked pnpm there adds nothing. const checked = await start({ collect: async () => collected({ pnpm: { ...pnpm, unchecked: undefined } }) }); try { assert.doesNotMatch((await plan(checked.port, await checked.login())).persistence.description, /could not be checked/); } finally { await checked.host.close("test"); } }); test("/api/plan explains a pnpm in $PNPM_HOME/bin that the installer will not replace, with how to update it", async () => { const inGlobalBin = (pnpm: object) => ({ pnpm: { ...pnpm, inGlobalBin: true } }); const cases = [ [inGlobalBin({ available: true, version: "11.0.5", usable: true, compatible: false, persistent: true }), "incompatible-tool", `pnpm 11.0.5 is installed in the pnpm global bin directory, where this installer would put its own pnpm, but this installer needs pnpm ${requirements.pnpm} or newer. ` + `Nothing was replaced. Update it with \`pnpm self-update ${requirements.pnpm}\`, then select Check again.`], [inGlobalBin({ available: null }), "unknown-tool", "A pnpm is installed in the pnpm global bin directory, where this installer would put its own pnpm, but its version could not be checked. " + `Nothing was replaced. If \`pnpm --version\` reports a version older than ${requirements.pnpm}, update it with \`pnpm self-update ${requirements.pnpm}\`, then select Check again.`], ] as const; for (const [change, code, text] of cases) { const { host, port, login } = await start({ collect: async () => collected({ ...change, node: { available: true, version: "24.18.0", usable: true, persistent: true, npm: true } }) }); try { const view = await plan(port, await login()); assert.deepEqual(view.actions, []); const blocker = view.blockers.find((item: { tool: string }) => item.tool === "pnpm"); assert.equal(blocker.code, code); assert.equal(blocker.guidance, text); } finally { await host.close("test"); } } }); test("/api/plan explains a Gentle Shell that neither pnpm nor npm manages", async () => { const { host, port, login } = await start({ collect: async () => collected({ pi: { available: true, version: "1.2.4", usable: true }, shell: { available: null, outsidePnpm: true }, gentleAi: { available: null }, setup: { available: null }, }) }); try { const view = await plan(port, await login()); assert.deepEqual(view.blockers.map((blocker: { code: string; tool: string }) => [blocker.code, blocker.tool]), [["unknown-tool", "shell"]]); assert.equal(view.blockers[0].guidance, "Gentle Shell is already installed, but neither pnpm nor npm manages it (a linked source checkout, for example), " + "so this installer cannot update it. Nothing was replaced. Update it the way you installed it, then select Check again."); } finally { await host.close("test"); } }); test("/api/plan explains a Pi whose version cannot be read", async () => { const { host, port, login } = await start({ collect: async () => collected({ pi: { available: null, outsidePnpm: true } }) }); try { const view = await plan(port, await login()); assert.equal(view.blockers.find((item: { tool: string }) => item.tool === "pi").guidance, "Pi is already installed, but `pi --version` did not report a version this installer can check. Make sure `pi --version` works in a terminal, then select Check again."); } finally { await host.close("test"); } }); test("/api/plan says that pnpm may put a newer Pi next to Gentle Shell, at least the minimum", async () => { // A newer Pi installed with pnpm never blocks: it is kept as it is. const { host, port, login } = await start({ collect: async () => collected({ pi: { available: true, version: "1.1.0", usable: true } }) }); try { const view = await plan(port, await login()); assert.deepEqual(view.blockers, []); assert.equal(view.actions.find((action: { id: string }) => action.id === "install-shell").description, `Install Gentle Shell (gentle-pi) globally with pnpm. pnpm may put a newer Pi than ${PI_INSTALL_VERSION} next to it ` + `(at least Pi ${requirements.pi}), and Gentle Shell runs that Pi.`); } finally { await host.close("test"); } }); test("the outcome says which Pi Gentle Shell runs only when it is not the installer's Pi", async () => { const cases: [object, string][] = [ [{ outcome: "ready", completed: [], piVersion: "1.1.0" }, `${guidance.outcomes.ready} Gentle Shell runs Pi 1.1.0, which pnpm installed next to it.`], [{ outcome: "terminal-action-required", action: "open-new-terminal", completed: [], piVersion: "1.1.0" }, `${guidance.outcomes["terminal-action-required"]} Gentle Shell runs Pi 1.1.0, which pnpm installed next to it.`], [{ outcome: "ready", completed: [] }, guidance.outcomes.ready], [{ outcome: "ready", completed: [], piVersion: PI_INSTALL_VERSION }, guidance.outcomes.ready], [{ outcome: "ready", completed: [], piVersion: "1.1.0