Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions packages/desktop/src/runtime-update.ts
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" };
}
Comment on lines +65 to +75
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 already-staged integrity check only covers dist/cli.js, not node_modules/node-pty

resolvePointerCliEntry returns non-null only when dist/cli.js is present on disk, but it doesn't verify node_modules/node-pty still exists. If node-pty is removed from a staged version — say by a partial uninstall or manual userData cleanup that removes just the native module — this guard returns already-staged, the runtime spawns, and the missing native module causes an immediate crash. Recovery requires going through a full startup failure before markBadVersion is called. Adding existsSync(path.join(versionDir(opts.userData, latest), "node_modules", "node-pty")) to the already-staged condition would let the updater transparently re-stage instead.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/desktop/src/runtime-update.ts
Line: 65-75

Comment:
**`already-staged` integrity check only covers `dist/cli.js`, not `node_modules/node-pty`**

`resolvePointerCliEntry` returns non-null only when `dist/cli.js` is present on disk, but it doesn't verify `node_modules/node-pty` still exists. If `node-pty` is removed from a staged version — say by a partial uninstall or manual `userData` cleanup that removes just the native module — this guard returns `already-staged`, the runtime spawns, and the missing native module causes an immediate crash. Recovery requires going through a full startup failure before `markBadVersion` is called. Adding `existsSync(path.join(versionDir(opts.userData, latest), "node_modules", "node-pty"))` to the `already-staged` condition would let the updater transparently re-stage instead.

How can I resolve this? If you propose a fix, please make it concise.


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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Prompt To Fix With AI
This 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 };
}
206 changes: 206 additions & 0 deletions packages/desktop/test/runtime-update.test.ts
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Prompt To Fix With AI
This 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.

});