diff --git a/packages/desktop/src/runtime-update.ts b/packages/desktop/src/runtime-update.ts new file mode 100644 index 00000000..a96644a6 --- /dev/null +++ b/packages/desktop/src/runtime-update.ts @@ -0,0 +1,108 @@ +/** + * Stage the latest published `kanban` runtime under userData so the + * installed shell can run a newer runtime than it was packaged with — + * without requiring a shell reinstall. + * + * Failures before the pointer write leave the existing pointer + * untouched. The bundled runtime under `app.asar.unpacked/cli/` + * remains the fallback. + */ + +import { existsSync } from "node:fs"; +import { cp, mkdir, rename, rm } from "node:fs/promises"; +import path from "node:path"; + +import pacote from "pacote"; +import semver from "semver"; + +import { + cleanupPartials, + cliEntryFor, + isBadVersion, + partialDir, + resolvePointerCliEntry, + versionDir, + versionFromCliEntry, + writePointer, +} from "./runtime-store.js"; + +const PACKAGE = "kanban"; + +export interface CheckOptions { + userData: string; + /** Version we'd launch right now (pointer or bundled). */ + currentVersion: string; + /** `app.asar.unpacked/node_modules/` — source for bundled `node-pty`. */ + nativeDepsSource: string; +} + +export type StageOutcome = + | { kind: "staged"; version: string } + | { kind: "up-to-date" } + | { kind: "already-staged" } + | { kind: "bad-version"; version: string }; + +export async function checkAndStageLatestRuntime( + opts: CheckOptions, +): Promise { + const manifest = await pacote.manifest(`${PACKAGE}@latest`); + const latest = manifest.version; + if (!semver.valid(latest)) { + throw new Error(`runtime-update: registry returned non-semver: ${latest}`); + } + + // `currentVersion` may come from a stale/invalid pointer — defend + // against semver.gt throwing on garbage input. + if ( + semver.valid(opts.currentVersion) && + !semver.gt(latest, opts.currentVersion) + ) { + return { kind: "up-to-date" }; + } + if (isBadVersion(opts.userData, latest)) { + return { kind: "bad-version", version: latest }; + } + // `already-staged` requires both a pointer at `latest` AND its + // `cliEntry` actually present on disk. Without the file-exists + // check this gate would silently lie when the version dir was + // wiped (corrupt userData, manual cleanup, partial uninstall), + // leaving the user's runtime in a state where loadOverride keeps + // returning null *and* the updater keeps short-circuiting on + // "already-staged" forever. + const stagedCli = resolvePointerCliEntry(opts.userData); + if (stagedCli && versionFromCliEntry(opts.userData, stagedCli) === latest) { + return { kind: "already-staged" }; + } + + cleanupPartials(opts.userData); + const stage = partialDir(opts.userData, latest); + await rm(stage, { recursive: true, force: true }); + await mkdir(path.dirname(stage), { recursive: true }); + await pacote.extract(`${PACKAGE}@${latest}`, stage); + + // `node-pty` is the sole external in `kanban`'s esbuild build + // (see scripts/build.mjs). pacote.extract doesn't install deps, + // so reuse the desktop's bundled prebuilt — already ABI-matched + // to this Electron, no `npm` required at runtime. + const ptySrc = path.join(opts.nativeDepsSource, "node-pty"); + if (!existsSync(ptySrc)) { + throw new Error(`runtime-update: bundled node-pty missing at ${ptySrc}`); + } + await cp(ptySrc, path.join(stage, "node_modules", "node-pty"), { + recursive: true, + dereference: true, + }); + + if (!existsSync(path.join(stage, "dist", "cli.js"))) { + throw new Error("runtime-update: extracted package missing dist/cli.js"); + } + + const finalDir = versionDir(opts.userData, latest); + await rm(finalDir, { recursive: true, force: true }); + await rename(stage, finalDir); + writePointer(opts.userData, { + version: latest, + cliEntry: cliEntryFor(opts.userData, latest), + }); + return { kind: "staged", version: latest }; +} diff --git a/packages/desktop/test/runtime-update.test.ts b/packages/desktop/test/runtime-update.test.ts new file mode 100644 index 00000000..e440148c --- /dev/null +++ b/packages/desktop/test/runtime-update.test.ts @@ -0,0 +1,206 @@ +/** + * Unit tests for `runtime-update.checkAndStageLatestRuntime`. pacote is + * mocked at the module boundary so these tests don't hit the registry. + */ + +import { + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + cliEntryFor, + markBadVersion, + readPointer, + versionDir, + writePointer, +} from "../src/runtime-store.js"; +import { checkAndStageLatestRuntime } from "../src/runtime-update.js"; + +const manifestMock = vi.fn(); +const extractMock = vi.fn(); + +vi.mock("pacote", () => ({ + default: { + manifest: (...args: unknown[]) => manifestMock(...args), + extract: (...args: unknown[]) => extractMock(...args), + }, +})); + +let userData: string; +let nativeDepsSource: string; + +beforeEach(() => { + userData = mkdtempSync(path.join(tmpdir(), "runtime-update-")); + nativeDepsSource = mkdtempSync(path.join(tmpdir(), "runtime-update-deps-")); + // Pretend node-pty is bundled — the updater copies it into each + // staged version. A bare directory is enough for `cp -r`. + mkdirSync(path.join(nativeDepsSource, "node-pty"), { recursive: true }); + writeFileSync( + path.join(nativeDepsSource, "node-pty", "package.json"), + JSON.stringify({ name: "node-pty", version: "1.0.0" }), + ); + + manifestMock.mockReset(); + extractMock.mockReset(); + + // Default extract: lay out a `dist/cli.js` so the post-extract + // sanity check passes. Tests override this for failure modes. + extractMock.mockImplementation(async (_spec: string, dest: string) => { + mkdirSync(path.join(dest, "dist"), { recursive: true }); + writeFileSync(path.join(dest, "dist", "cli.js"), "// runtime"); + }); +}); + +afterEach(() => { + rmSync(userData, { recursive: true, force: true }); + rmSync(nativeDepsSource, { recursive: true, force: true }); +}); + +describe("checkAndStageLatestRuntime: gates", () => { + it("returns up-to-date when latest <= currentVersion", async () => { + manifestMock.mockResolvedValueOnce({ version: "0.1.0" }); + + const outcome = await checkAndStageLatestRuntime({ + userData, + currentVersion: "0.1.0", + nativeDepsSource, + }); + + expect(outcome).toEqual({ kind: "up-to-date" }); + expect(extractMock).not.toHaveBeenCalled(); + }); + + it("returns already-staged when pointer.version === latest", async () => { + const cliEntry = cliEntryFor(userData, "0.5.0"); + mkdirSync(path.dirname(cliEntry), { recursive: true }); + writeFileSync(cliEntry, "// runtime"); + writePointer(userData, { version: "0.5.0", cliEntry }); + manifestMock.mockResolvedValueOnce({ version: "0.5.0" }); + + const outcome = await checkAndStageLatestRuntime({ + userData, + currentVersion: "0.4.0", + nativeDepsSource, + }); + + expect(outcome).toEqual({ kind: "already-staged" }); + expect(extractMock).not.toHaveBeenCalled(); + }); + + it("skips bad versions without extracting", async () => { + markBadVersion(userData, "1.0.0"); + manifestMock.mockResolvedValueOnce({ version: "1.0.0" }); + + const outcome = await checkAndStageLatestRuntime({ + userData, + currentVersion: "0.5.0", + nativeDepsSource, + }); + + expect(outcome).toEqual({ kind: "bad-version", version: "1.0.0" }); + expect(extractMock).not.toHaveBeenCalled(); + }); + + it("throws on a non-semver registry version", async () => { + manifestMock.mockResolvedValueOnce({ version: "garbage" }); + await expect( + checkAndStageLatestRuntime({ + userData, + currentVersion: "0.1.0", + nativeDepsSource, + }), + ).rejects.toThrow(/non-semver/); + }); + + it("treats a non-semver currentVersion as 'unknown' and proceeds", async () => { + // Defends against a corrupted pointer leaking a non-semver version + // into the gate; without the guard, semver.gt would throw. + manifestMock.mockResolvedValueOnce({ version: "1.0.0" }); + + const outcome = await checkAndStageLatestRuntime({ + userData, + currentVersion: "garbage", + nativeDepsSource, + }); + + expect(outcome.kind).toBe("staged"); + }); +}); + +describe("checkAndStageLatestRuntime: staging", () => { + it("stages, copies node-pty, and writes the pointer atomically", async () => { + manifestMock.mockResolvedValueOnce({ version: "1.0.0" }); + + const outcome = await checkAndStageLatestRuntime({ + userData, + currentVersion: "0.5.0", + nativeDepsSource, + }); + + expect(outcome).toEqual({ kind: "staged", version: "1.0.0" }); + expect(readPointer(userData)).toEqual({ + version: "1.0.0", + cliEntry: cliEntryFor(userData, "1.0.0"), + }); + + const finalDir = versionDir(userData, "1.0.0"); + expect( + readdirSync(path.join(finalDir, "node_modules")).includes("node-pty"), + ).toBe(true); + expect( + readdirSync(path.dirname(finalDir)).every((n) => !n.endsWith(".partial")), + ).toBe(true); + }); + + it("throws (and leaves pointer untouched) when bundled node-pty is missing", async () => { + rmSync(path.join(nativeDepsSource, "node-pty"), { recursive: true }); + manifestMock.mockResolvedValueOnce({ version: "1.0.0" }); + + await expect( + checkAndStageLatestRuntime({ + userData, + currentVersion: "0.5.0", + nativeDepsSource, + }), + ).rejects.toThrow(/bundled node-pty missing/); + expect(readPointer(userData)).toBeNull(); + }); + + it("throws when the extracted package has no dist/cli.js", async () => { + extractMock.mockImplementationOnce(async (_spec: string, dest: string) => { + mkdirSync(dest, { recursive: true }); + }); + manifestMock.mockResolvedValueOnce({ version: "1.0.0" }); + + await expect( + checkAndStageLatestRuntime({ + userData, + currentVersion: "0.5.0", + nativeDepsSource, + }), + ).rejects.toThrow(/missing dist\/cli\.js/); + expect(readPointer(userData)).toBeNull(); + }); + + it("recovers from a stale `.partial/` left by a prior interrupted run", async () => { + mkdirSync(`${versionDir(userData, "1.0.0")}.partial`, { recursive: true }); + manifestMock.mockResolvedValueOnce({ version: "1.0.0" }); + + const outcome = await checkAndStageLatestRuntime({ + userData, + currentVersion: "0.5.0", + nativeDepsSource, + }); + + expect(outcome.kind).toBe("staged"); + expect(readPointer(userData)?.version).toBe("1.0.0"); + }); +});