-
Notifications
You must be signed in to change notification settings - Fork 210
feat(desktop): stage latest runtime #466
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: cline/split-440-runtime-deps
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<StageOutcome> { | ||
| 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); | ||
|
Comment on lines
+77
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis is a comment left during a code review.
Path: packages/desktop/src/runtime-update.ts
Line: 77-81
Comment:
**`cleanupPartials` sweeps all in-flight partials globally**
`cleanupPartials` removes every `*.partial` directory under `versions/`, not just the one for the current `latest`. If two code paths (e.g., two renderer windows or a background updater racing with a foreground check) call `checkAndStageLatestRuntime` concurrently — potentially for different versions — one call's `cleanupPartials` will delete the other's in-flight partial mid-extract. The second caller would then hit the `rm(stage, { force: true })` step on a now-missing path (harmless) but the first caller's `pacote.extract` would be writing into a directory that was just deleted, producing a corrupt or empty stage. Scoping the sweep to only the current version's partial, or adding a simple lock/flag, would prevent this.
How can I resolve this? If you propose a fix, please make it concise. |
||
|
|
||
| // `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 }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 `<v>.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"); | ||
| }); | ||
|
Comment on lines
+193
to
+205
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The test creates Prompt To Fix With AIThis is a comment left during a code review.
Path: packages/desktop/test/runtime-update.test.ts
Line: 193-205
Comment:
**Stale-partial recovery test doesn't assert the partial is actually cleaned up**
The test creates `1.0.0.partial/`, stages, and asserts `outcome.kind === "staged"` and the pointer is written — but it doesn't verify the stale partial was removed. The main staging test already checks `readdirSync(path.dirname(finalDir)).every((n) => !n.endsWith(".partial"))`, and adding the same assertion here would confirm that `cleanupPartials` ran correctly and no partial remnants remain after recovery.
How can I resolve this? If you propose a fix, please make it concise. |
||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
already-stagedintegrity check only coversdist/cli.js, notnode_modules/node-ptyresolvePointerCliEntryreturns non-null only whendist/cli.jsis present on disk, but it doesn't verifynode_modules/node-ptystill exists. Ifnode-ptyis removed from a staged version — say by a partial uninstall or manualuserDatacleanup that removes just the native module — this guard returnsalready-staged, the runtime spawns, and the missing native module causes an immediate crash. Recovery requires going through a full startup failure beforemarkBadVersionis called. AddingexistsSync(path.join(versionDir(opts.userData, latest), "node_modules", "node-pty"))to thealready-stagedcondition would let the updater transparently re-stage instead.Prompt To Fix With AI