[codex] Add packaged desktop auto-update (#1375)

* Add packaged desktop auto-update

* Handle counted beta nightly update versions

* Refresh desktop auto-update branch for main

* Serialize desktop updater operations

* Refresh auto-update branch for packaged paths
This commit is contained in:
PerishFire 2026-05-19 11:20:05 +08:00 committed by GitHub
parent 56988e406c
commit 4424f08be0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 2443 additions and 15 deletions

View file

@ -23,6 +23,7 @@ This file is the single source of truth for agents entering this repository. Rea
- `tools/dev` is the local development lifecycle control plane.
- `tools/pack` is the local packaged build/start/stop/logs control plane and mac beta release artifact preparation surface.
- `tools/pr` is the maintainer PR-duty control plane: a thin `gh` wrapper that encodes this repo's review-lane derivation, forbidden-surface flags, lane checklists, and validation-command suggestions.
- `tools/serve` is the local fixture-service control plane; first service is `tools-serve start updater` for deterministic updater metadata and artifacts.
- `e2e` owns user-level end-to-end smoke tests and Playwright UI automation; read `e2e/AGENTS.md` before editing its tests or commands.
## Inactive or placeholder directories
@ -47,7 +48,7 @@ This file is the single source of truth for agents entering this repository. Rea
## Root command boundary
- Keep root scripts reserved for true repo-level checks and tools control-plane entrypoints: `pnpm guard`, `pnpm typecheck`, `pnpm tools-dev`, `pnpm tools-pack`, and `pnpm tools-pr`.
- Keep root scripts reserved for true repo-level checks and tools control-plane entrypoints: `pnpm guard`, `pnpm typecheck`, `pnpm tools-dev`, `pnpm tools-pack`, `pnpm tools-pr`, and `pnpm tools-serve`.
- Do not add root aggregate `pnpm build` or `pnpm test` aliases. Build/test commands must stay package-scoped (`pnpm --filter <package> ...`) or tool-scoped (`pnpm tools-pack ...` / `pnpm tools-pr ...`).
- Do not add root e2e aliases; e2e package commands and ownership rules live in `e2e/AGENTS.md`.
@ -166,6 +167,7 @@ For a worked example of one full loop (red e2e spec → fix → green), see `e2e
```bash
pnpm install
pnpm tools-dev
pnpm tools-serve start updater
pnpm tools-dev start web
pnpm tools-dev run web --daemon-port 17456 --web-port 17573
pnpm tools-dev status --json
@ -199,6 +201,7 @@ pnpm --filter @open-design/desktop build
pnpm --filter @open-design/tools-dev build
pnpm --filter @open-design/tools-pack build
pnpm --filter @open-design/tools-pr build
pnpm --filter @open-design/tools-serve build
```
```bash

View file

@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { app } from "electron";
import { app, dialog, Menu, shell, type MenuItemConstructorOptions } from "electron";
import {
APP_KEYS,
@ -14,6 +14,7 @@ import {
type DesktopEvalInput,
type DesktopExportPdfInput,
type DesktopScreenshotInput,
type DesktopUpdateInput,
type RegisterDesktopAuthResult,
type SidecarStamp,
type WebStatusSnapshot,
@ -30,6 +31,7 @@ import { readProcessStamp } from "@open-design/platform";
import { createDesktopRuntime } from "./runtime.js";
import { attachDesktopProcessErrorFilter } from "./uncaught-exception.js";
import { createDesktopUpdater, type DesktopUpdater } from "./updater.js";
// Re-export pure URL-policy helpers so the packaged workspace's
// vitest can pin their behaviour without spinning up a full Electron
@ -72,6 +74,10 @@ export type DesktopMainOptions = {
* Node fetch can hit.
*/
discoverDaemonUrl?: () => Promise<string | null>;
update?: {
currentVersion?: string | null;
downloadRoot?: string | null;
};
};
function isDirectEntry(): boolean {
@ -118,6 +124,165 @@ function createWebDiscovery(runtime: SidecarRuntimeContext<SidecarStamp>): () =>
};
}
function buildUpdateMenuItems(updater: DesktopUpdater): MenuItemConstructorOptions[] {
const status = updater.snapshot();
const busy = status.state === "checking" || status.state === "downloading" || status.state === "installing";
return [
{
enabled: status.enabled && !busy,
label: status.state === "downloading" ? "Downloading Update..." : "Check for Updates...",
async click() {
const next = await updater.checkForUpdates();
await showUpdateResultDialog(updater, next);
},
},
{
enabled: status.enabled && status.state === "downloaded",
label: "Install Update...",
async click() {
const next = await updater.installUpdate();
if (next.state === "error") {
dialog.showErrorBox("Open Design update failed", next.error?.message ?? "Could not open the downloaded installer.");
}
},
},
];
}
async function showUpdateResultDialog(updater: DesktopUpdater, status = updater.snapshot()): Promise<void> {
if (!status.enabled) return;
if (status.state === "downloaded") {
const result = await dialog.showMessageBox({
buttons: ["Install Update", "Later"],
defaultId: 0,
message: "A new Open Design version has been downloaded.",
detail: "Open the installer to update Open Design. You may need to quit the app and replace the existing copy.",
type: "info",
});
if (result.response === 0) await updater.installUpdate();
return;
}
if (status.state === "not-available") {
await dialog.showMessageBox({
buttons: ["OK"],
message: "Open Design is up to date.",
type: "info",
});
return;
}
if (status.state === "unsupported") {
await dialog.showMessageBox({
buttons: ["OK"],
detail: status.error?.message,
message: "Updates are not available for this Open Design build.",
type: "info",
});
return;
}
if (status.state === "error") {
dialog.showErrorBox("Open Design update failed", status.error?.message ?? "Could not check for updates.");
}
}
function installDesktopMenu(updater: DesktopUpdater): () => void {
const rebuild = () => {
const updateItems = buildUpdateMenuItems(updater);
const template: MenuItemConstructorOptions[] = [
...(process.platform === "darwin"
? [
{
label: app.name,
submenu: [
{ role: "about" as const },
{ type: "separator" as const },
...updateItems,
{ type: "separator" as const },
{ role: "services" as const },
{ type: "separator" as const },
{ role: "hide" as const },
{ role: "hideOthers" as const },
{ role: "unhide" as const },
{ type: "separator" as const },
{ role: "quit" as const },
],
},
]
: [
{
label: "File",
submenu: [
...updateItems,
{ type: "separator" as const },
{ role: "quit" as const },
],
},
]),
{
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "selectAll" },
],
},
{
label: "View",
submenu: [
{ role: "reload" },
{ role: "forceReload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{ role: "togglefullscreen" },
],
},
{
label: "Window",
submenu: [
{ role: "minimize" },
{ role: "zoom" },
...(process.platform === "darwin"
? [{ type: "separator" as const }, { role: "front" as const }]
: [{ role: "close" as const }]),
],
},
{
label: "Help",
submenu: [
{
label: "Open Design",
click() {
void shell.openExternal("https://github.com/nexu-io/open-design");
},
},
],
},
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
};
rebuild();
return updater.subscribe(rebuild);
}
function scheduleStartupUpdateCheck(updater: DesktopUpdater): void {
if (!updater.shouldAutoCheck()) return;
setTimeout(() => {
void updater.checkForUpdates().then(async (status) => {
if (status.state === "downloaded") await showUpdateResultDialog(updater, status);
}).catch((error: unknown) => {
console.error("desktop update auto-check failed", error);
});
}, 5000).unref();
}
const REGISTER_DESKTOP_AUTH_RETRY_DELAYS_MS = [120, 240, 480, 960, 1500];
const REGISTER_DESKTOP_AUTH_TIMEOUT_MS = 800;
@ -220,6 +385,17 @@ export async function runDesktopMain(
// protection still works) and POSTs once more.
registerDesktopAuthWithDaemon: () => registerDesktopAuthWithDaemon(runtime, desktopAuthSecret),
});
const updater = createDesktopUpdater(
{
currentVersion: options.update?.currentVersion,
downloadRoot: options.update?.downloadRoot,
runtimeBase: runtime.base,
source: runtime.source,
},
{ openPath: (path) => shell.openPath(path) },
);
const disposeMenu = installDesktopMenu(updater);
scheduleStartupUpdateCheck(updater);
let ipcServer: JsonIpcServerHandle | null = null;
let shuttingDown = false;
@ -229,6 +405,7 @@ export async function runDesktopMain(
await options.beforeShutdown?.().catch((error: unknown) => {
console.error("desktop beforeShutdown failed", error);
});
disposeMenu();
await ipcServer?.close().catch(() => undefined);
await desktop.close().catch(() => undefined);
app.quit();
@ -252,7 +429,7 @@ export async function runDesktopMain(
const request = normalizeDesktopSidecarMessage(message);
switch (request.type) {
case SIDECAR_MESSAGES.STATUS:
return desktop.status();
return { ...desktop.status(), update: await updater.status() };
case SIDECAR_MESSAGES.EVAL:
return await desktop.eval(request.input as DesktopEvalInput);
case SIDECAR_MESSAGES.SCREENSHOT:
@ -263,6 +440,8 @@ export async function runDesktopMain(
return await desktop.click(request.input as DesktopClickInput);
case SIDECAR_MESSAGES.EXPORT_PDF:
return await desktop.exportPdf(request.input as DesktopExportPdfInput);
case SIDECAR_MESSAGES.UPDATE:
return await updater.handle((request.input as DesktopUpdateInput).action);
case SIDECAR_MESSAGES.SHUTDOWN:
setImmediate(() => {
shutdownAndExit();

View file

@ -0,0 +1,964 @@
import { createHash } from "node:crypto";
import { createReadStream, createWriteStream } from "node:fs";
import {
access,
lstat,
mkdir,
readdir,
readFile,
realpath,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
import { Readable, Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import {
DESKTOP_UPDATE_CHANNELS,
DESKTOP_UPDATE_MODES,
DESKTOP_UPDATE_STATES,
SIDECAR_SOURCES,
type DesktopUpdateAction,
type DesktopUpdateArtifactSnapshot,
type DesktopUpdateChannel,
type DesktopUpdateChecksumSnapshot,
type DesktopUpdateErrorSnapshot,
type DesktopUpdateMode,
type DesktopUpdateProgressSnapshot,
type DesktopUpdateStatusSnapshot,
type DesktopUpdateState,
type SidecarSource,
} from "@open-design/sidecar-proto";
export const DESKTOP_UPDATE_ENV = Object.freeze({
ARCH: "OD_UPDATE_ARCH",
AUTO_CHECK: "OD_UPDATE_AUTO_CHECK",
AUTO_DOWNLOAD: "OD_UPDATE_AUTO_DOWNLOAD",
AUTO_OPEN: "OD_UPDATE_AUTO_OPEN",
CHANNEL: "OD_UPDATE_CHANNEL",
CURRENT_VERSION: "OD_UPDATE_CURRENT_VERSION",
DOWNLOAD_ROOT: "OD_UPDATE_DOWNLOAD_ROOT",
ENABLED: "OD_UPDATE_ENABLED",
METADATA_URL: "OD_UPDATE_METADATA_URL",
MODE: "OD_UPDATE_MODE",
OPEN_DRY_RUN: "OD_UPDATE_OPEN_DRY_RUN",
PLATFORM: "OD_UPDATE_PLATFORM",
} as const);
const DEFAULT_RELEASE_ORIGIN = "https://releases.open-design.ai";
const OWNERSHIP_SENTINEL = ".open-design-updater-root.json";
const STATE_FILE = "state.json";
const UPDATE_ROOT_VERSION = 1;
export type DesktopUpdaterConfigInput = {
appVersion?: string | null;
arch?: string;
currentVersion?: string | null;
downloadRoot?: string | null;
env?: NodeJS.ProcessEnv;
mode?: DesktopUpdateMode;
platform?: string;
runtimeBase?: string | null;
source: SidecarSource;
};
export type DesktopUpdaterConfig = {
arch: string;
autoCheck: boolean;
autoDownload: boolean;
autoOpen: boolean;
channel: DesktopUpdateChannel;
currentVersion: string;
downloadRoot: string;
enabled: boolean;
metadataUrl: string;
mode: DesktopUpdateMode;
openDryRun: boolean;
platform: string;
source: SidecarSource;
};
export type DesktopUpdaterDeps = {
fetch?: typeof globalThis.fetch;
now?: () => Date;
openPath?: (path: string) => Promise<string>;
};
type UpdateCandidate = {
arch: string;
artifact: DesktopUpdateArtifactSnapshot;
checksum: DesktopUpdateChecksumSnapshot;
channel: DesktopUpdateChannel;
metadata: Record<string, unknown>;
platformKey: string;
version: string;
};
type PersistedUpdateState = {
artifact: DesktopUpdateArtifactSnapshot;
checksum: DesktopUpdateChecksumSnapshot;
channel: DesktopUpdateChannel;
downloadPath: string;
downloadedAt: string;
metadata: Record<string, unknown>;
platform: string;
platformKey: string;
verified: true;
version: 1;
updateVersion: string;
};
type OwnedRoot =
| { ok: true; manifestPath: string; realRoot: string }
| { error: DesktopUpdateErrorSnapshot; ok: false };
type ActionOptions = {
autoDownload?: boolean;
};
export type DesktopUpdater = {
checkForUpdates(options?: ActionOptions): Promise<DesktopUpdateStatusSnapshot>;
downloadUpdate(): Promise<DesktopUpdateStatusSnapshot>;
handle(action: DesktopUpdateAction): Promise<DesktopUpdateStatusSnapshot>;
installUpdate(): Promise<DesktopUpdateStatusSnapshot>;
shouldAutoCheck(): boolean;
snapshot(): DesktopUpdateStatusSnapshot;
status(): Promise<DesktopUpdateStatusSnapshot>;
subscribe(listener: () => void): () => void;
};
function isTruthyEnv(value: string | undefined): boolean | null {
if (value == null || value.length === 0) return null;
if (value === "1" || value === "true" || value === "yes") return true;
if (value === "0" || value === "false" || value === "no") return false;
throw new Error(`boolean env value must be one of 1/0/true/false/yes/no, got ${value}`);
}
function normalizeMode(value: string | undefined, fallback: DesktopUpdateMode): DesktopUpdateMode {
if (value == null || value.length === 0) return fallback;
if (value === DESKTOP_UPDATE_MODES.PACKAGE_LAUNCHER || value === DESKTOP_UPDATE_MODES.JS_INCREMENTAL) return value;
throw new Error(`unsupported desktop update mode: ${value}`);
}
function normalizeChannel(value: string | undefined, fallback: DesktopUpdateChannel): DesktopUpdateChannel {
if (value == null || value.length === 0) return fallback;
if (value === DESKTOP_UPDATE_CHANNELS.STABLE || value === DESKTOP_UPDATE_CHANNELS.BETA) return value;
throw new Error(`unsupported desktop update channel: ${value}`);
}
function defaultMetadataUrl(channel: DesktopUpdateChannel): string {
return `${DEFAULT_RELEASE_ORIGIN}/${channel}/latest/metadata.json`;
}
function normalizeDownloadRoot(value: string): string {
if (value.includes("\0")) throw new Error("update download root must not contain null bytes");
if (!isAbsolute(value)) throw new Error(`update download root must be absolute: ${value}`);
return resolve(value);
}
export function resolveDesktopUpdaterConfig(input: DesktopUpdaterConfigInput): DesktopUpdaterConfig {
const env = input.env ?? process.env;
const mode = normalizeMode(env[DESKTOP_UPDATE_ENV.MODE], input.mode ?? DESKTOP_UPDATE_MODES.PACKAGE_LAUNCHER);
const defaultEnabled = input.source === SIDECAR_SOURCES.PACKAGED;
const enabled = isTruthyEnv(env[DESKTOP_UPDATE_ENV.ENABLED]) ?? defaultEnabled;
const runtimeBase = input.runtimeBase == null ? process.cwd() : input.runtimeBase;
const downloadRoot = normalizeDownloadRoot(
env[DESKTOP_UPDATE_ENV.DOWNLOAD_ROOT] ??
input.downloadRoot ??
join(resolve(runtimeBase), "updates"),
);
const currentVersion =
env[DESKTOP_UPDATE_ENV.CURRENT_VERSION] ??
input.currentVersion ??
input.appVersion ??
"0.0.0";
const channel = normalizeChannel(env[DESKTOP_UPDATE_ENV.CHANNEL], defaultChannelForVersion(currentVersion));
return {
arch: env[DESKTOP_UPDATE_ENV.ARCH] ?? input.arch ?? process.arch,
autoCheck: isTruthyEnv(env[DESKTOP_UPDATE_ENV.AUTO_CHECK]) ?? enabled,
autoDownload: isTruthyEnv(env[DESKTOP_UPDATE_ENV.AUTO_DOWNLOAD]) ?? true,
autoOpen: isTruthyEnv(env[DESKTOP_UPDATE_ENV.AUTO_OPEN]) ?? false,
channel,
currentVersion,
downloadRoot,
enabled,
metadataUrl: env[DESKTOP_UPDATE_ENV.METADATA_URL] ?? defaultMetadataUrl(channel),
mode,
openDryRun: isTruthyEnv(env[DESKTOP_UPDATE_ENV.OPEN_DRY_RUN]) ?? false,
platform: env[DESKTOP_UPDATE_ENV.PLATFORM] ?? input.platform ?? process.platform,
source: input.source,
};
}
function capabilitiesFor(status: { mode: DesktopUpdateMode; platform: string; supported: boolean }) {
const packageLauncher = status.mode === DESKTOP_UPDATE_MODES.PACKAGE_LAUNCHER && status.platform === "darwin" && status.supported;
return {
canApplyInPlace: false,
canDownload: packageLauncher,
canOpenInstaller: packageLauncher,
requiresManualInstall: packageLauncher,
};
}
function createError(code: string, message: string, details?: unknown): DesktopUpdateErrorSnapshot {
return {
code,
...(details === undefined ? {} : { details }),
message,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value != null && !Array.isArray(value);
}
function stringField(record: Record<string, unknown>, key: string): string | null {
const value = record[key];
return typeof value === "string" && value.length > 0 ? value : null;
}
function numberField(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function objectField(record: Record<string, unknown>, key: string): Record<string, unknown> | null {
const value = record[key];
return isRecord(value) ? value : null;
}
function sanitizePathSegment(value: string): string {
return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "update";
}
function extensionForArtifact(name: string | undefined, type: string): string {
const ext = name == null ? "" : extname(name).toLowerCase();
if (ext === ".dmg" || ext === ".zip" || ext === ".exe" || ext === ".appimage") return ext;
if (type === "dmg") return ".dmg";
if (type === "zip") return ".zip";
if (type === "installer") return ".exe";
return ".bin";
}
function artifactFileName(candidate: UpdateCandidate): string {
const ext = extensionForArtifact(candidate.artifact.name, candidate.artifact.type ?? "artifact");
return [
"open-design",
sanitizePathSegment(candidate.version),
sanitizePathSegment(candidate.platformKey),
sanitizePathSegment(candidate.arch),
sanitizePathSegment(candidate.artifact.type ?? "artifact"),
].join("-") + ext;
}
function containsPath(root: string, path: string): boolean {
const rel = relative(root, path);
return rel === "" || (rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel));
}
async function writeJson(path: string, payload: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
await rename(tmp, path);
}
async function readJson<T>(path: string): Promise<T | null> {
try {
return JSON.parse(await readFile(path, "utf8")) as T;
} catch {
return null;
}
}
async function directoryIsEmpty(path: string): Promise<boolean> {
const entries = await readdir(path);
return entries.length === 0;
}
async function ensureOwnedUpdateRoot(config: DesktopUpdaterConfig): Promise<OwnedRoot> {
const root = normalizeDownloadRoot(config.downloadRoot);
try {
await mkdir(root, { recursive: true });
const rootEntry = await lstat(root);
if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink()) {
return {
ok: false,
error: createError("update-root-not-owned", `update root is not an owned directory: ${root}`),
};
}
const realRoot = await realpath(root);
const sentinelPath = join(realRoot, OWNERSHIP_SENTINEL);
const manifestPath = join(realRoot, STATE_FILE);
const sentinel = await readJson<{ namespace?: string; version?: number }>(sentinelPath);
if (sentinel != null) {
if (sentinel.version !== UPDATE_ROOT_VERSION) {
return {
ok: false,
error: createError("update-root-version-mismatch", `update root has unsupported ownership marker version at ${sentinelPath}`),
};
}
return { ok: true, manifestPath, realRoot };
}
if (!(await directoryIsEmpty(realRoot))) {
return {
ok: false,
error: createError(
"update-root-not-owned",
`update root is not empty and has no Open Design updater ownership marker: ${realRoot}`,
),
};
}
await writeJson(sentinelPath, {
createdAt: new Date().toISOString(),
owner: "open-design-updater",
source: config.source,
version: UPDATE_ROOT_VERSION,
});
return { ok: true, manifestPath, realRoot };
} catch (error) {
return {
ok: false,
error: createError("update-root-unavailable", error instanceof Error ? error.message : String(error)),
};
}
}
type ParsedComparableVersion = {
nums: [number, number, number];
pre: string[];
};
function numberPart(value: string | undefined): number {
return value != null && /^[0-9]+$/.test(value) ? Number(value) : 0;
}
function parseComparableVersion(value: string): ParsedComparableVersion {
const cleaned = value.trim().replace(/^v/i, "").split("+", 1)[0] ?? "";
const nightlyMatch = /^(\d+)\.(\d+)\.(\d+)\.nightly\.(\d+)$/i.exec(cleaned);
if (nightlyMatch?.[1] != null && nightlyMatch[2] != null && nightlyMatch[3] != null && nightlyMatch[4] != null) {
return {
nums: [Number(nightlyMatch[1]), Number(nightlyMatch[2]), Number(nightlyMatch[3])],
pre: ["nightly", nightlyMatch[4]],
};
}
const prereleaseSeparator = cleaned.indexOf("-");
const core = prereleaseSeparator === -1 ? cleaned : cleaned.slice(0, prereleaseSeparator);
const prerelease = prereleaseSeparator === -1 ? "" : cleaned.slice(prereleaseSeparator + 1);
const nums = core.split(".");
return {
nums: [numberPart(nums[0]), numberPart(nums[1]), numberPart(nums[2])],
pre: prerelease.length === 0 ? [] : prerelease.split("."),
};
}
function hasCountedPrerelease(version: string): boolean {
const parsed = parseComparableVersion(version);
const last = parsed.pre.at(-1);
return parsed.pre.length >= 2 && last != null && /^[0-9]+$/.test(last);
}
function defaultChannelForVersion(version: string): DesktopUpdateChannel {
return /(?:^|[-.])beta(?:[-.]|$)/i.test(version) || hasCountedPrerelease(version)
? DESKTOP_UPDATE_CHANNELS.BETA
: DESKTOP_UPDATE_CHANNELS.STABLE;
}
function compareIdentifier(a: string, b: string): number {
const aNum = /^[0-9]+$/.test(a) ? Number(a) : null;
const bNum = /^[0-9]+$/.test(b) ? Number(b) : null;
if (aNum != null && bNum != null) return Math.sign(aNum - bNum);
if (aNum != null) return -1;
if (bNum != null) return 1;
return a.localeCompare(b);
}
export function compareVersions(a: string, b: string): number {
const left = parseComparableVersion(a);
const right = parseComparableVersion(b);
for (let index = 0; index < 3; index += 1) {
const delta = (left.nums[index] ?? 0) - (right.nums[index] ?? 0);
if (delta !== 0) return Math.sign(delta);
}
if (left.pre.length === 0 && right.pre.length === 0) return 0;
if (left.pre.length === 0) return 1;
if (right.pre.length === 0) return -1;
const max = Math.max(left.pre.length, right.pre.length);
for (let index = 0; index < max; index += 1) {
const l = left.pre[index];
const r = right.pre[index];
if (l == null) return -1;
if (r == null) return 1;
const delta = compareIdentifier(l, r);
if (delta !== 0) return delta;
}
return 0;
}
function releaseVersion(metadata: Record<string, unknown>): string | null {
return (
stringField(metadata, "releaseVersion") ??
stringField(metadata, "betaVersion") ??
stringField(metadata, "nightlyVersion") ??
stringField(metadata, "stableVersion") ??
stringField(metadata, "baseVersion")
);
}
function selectedMacPlatformKey(platforms: Record<string, unknown>, arch: string): string {
return arch === "x64" ? "macIntel" : "mac";
}
function selectUpdateCandidate(
metadata: Record<string, unknown>,
config: DesktopUpdaterConfig,
): { candidate: UpdateCandidate; ok: true } | { error: DesktopUpdateErrorSnapshot; ok: false; state: DesktopUpdateState } {
if (config.mode === DESKTOP_UPDATE_MODES.JS_INCREMENTAL) {
return {
ok: false,
state: DESKTOP_UPDATE_STATES.UNSUPPORTED,
error: createError("update-mode-not-implemented", "js-incremental updates are not implemented yet"),
};
}
if (config.mode !== DESKTOP_UPDATE_MODES.PACKAGE_LAUNCHER) {
return {
ok: false,
state: DESKTOP_UPDATE_STATES.UNSUPPORTED,
error: createError("update-mode-unsupported", `unsupported update mode: ${config.mode}`),
};
}
if (config.platform !== "darwin") {
return {
ok: false,
state: DESKTOP_UPDATE_STATES.UNSUPPORTED,
error: createError("unsupported-platform", "package-launcher updates are currently mac-only"),
};
}
const platforms = objectField(metadata, "platforms");
if (platforms == null) {
return {
ok: false,
state: DESKTOP_UPDATE_STATES.ERROR,
error: createError("metadata-missing-platforms", "release metadata does not include platform artifacts"),
};
}
const platformKey = selectedMacPlatformKey(platforms, config.arch);
const platform = objectField(platforms, platformKey);
if (platform == null || platform.enabled !== true) {
return {
ok: false,
state: DESKTOP_UPDATE_STATES.ERROR,
error: createError("no-compatible-artifact", `release metadata does not include an enabled ${platformKey} artifact`),
};
}
const version = releaseVersion(metadata);
if (version == null) {
return {
ok: false,
state: DESKTOP_UPDATE_STATES.ERROR,
error: createError("metadata-missing-version", "release metadata does not include a release version"),
};
}
const artifacts = objectField(platform, "artifacts");
const dmg = artifacts == null ? null : objectField(artifacts, "dmg");
const url = dmg == null ? null : stringField(dmg, "url");
if (dmg == null || url == null) {
return {
ok: false,
state: DESKTOP_UPDATE_STATES.ERROR,
error: createError("no-compatible-artifact", `release metadata does not include a mac DMG artifact for ${platformKey}`),
};
}
const artifact: DesktopUpdateArtifactSnapshot = {
...(stringField(dmg, "name") == null ? {} : { name: stringField(dmg, "name") as string }),
platformKey,
...(numberField(dmg, "size") == null ? {} : { size: numberField(dmg, "size") }),
type: "dmg",
url,
};
const sha256 = stringField(dmg, "sha256") ?? stringField(dmg, "sha256Digest");
const sha512 = stringField(dmg, "sha512") ?? stringField(dmg, "sha512Digest");
const checksum: DesktopUpdateChecksumSnapshot =
sha512 != null
? { algorithm: "sha512", value: sha512 }
: {
algorithm: "sha256",
...(sha256 == null ? {} : { value: sha256 }),
...(stringField(dmg, "sha256Url") == null ? {} : { url: stringField(dmg, "sha256Url") as string }),
};
return {
ok: true,
candidate: {
arch: stringField(platform, "arch") ?? config.arch,
artifact,
checksum,
channel: config.channel,
metadata,
platformKey,
version,
},
};
}
async function fetchJson(fetchImpl: typeof globalThis.fetch, url: string): Promise<Record<string, unknown>> {
const response = await fetchImpl(url);
if (!response.ok) throw new Error(`metadata request returned HTTP ${response.status}`);
const body = await response.json();
if (!isRecord(body)) throw new Error("metadata response was not a JSON object");
return body;
}
function parseChecksumText(text: string, algorithm: "sha256" | "sha512"): string {
const length = algorithm === "sha256" ? 64 : 128;
const match = text.match(new RegExp(`\\b[0-9a-fA-F]{${length}}\\b`));
if (match == null) throw new Error(`checksum file does not include a ${algorithm} digest`);
return match[0].toLowerCase();
}
async function resolveChecksum(fetchImpl: typeof globalThis.fetch, checksum: DesktopUpdateChecksumSnapshot): Promise<DesktopUpdateChecksumSnapshot> {
if (checksum.value != null) return checksum;
if (checksum.url == null) throw new Error("artifact checksum is missing");
const response = await fetchImpl(checksum.url);
if (!response.ok) throw new Error(`checksum request returned HTTP ${response.status}`);
return {
...checksum,
value: parseChecksumText(await response.text(), checksum.algorithm),
};
}
async function hashFile(path: string, algorithm: "sha256" | "sha512"): Promise<string> {
const hash = createHash(algorithm);
await pipeline(createReadStream(path), hash);
return hash.digest("hex");
}
async function downloadToFile(
fetchImpl: typeof globalThis.fetch,
url: string,
path: string,
onProgress: (progress: DesktopUpdateProgressSnapshot) => void,
): Promise<void> {
const response = await fetchImpl(url);
if (!response.ok) throw new Error(`artifact request returned HTTP ${response.status}`);
if (response.body == null) throw new Error("artifact response did not include a body");
await mkdir(dirname(path), { recursive: true });
const totalRaw = response.headers.get("content-length");
const parsedTotalBytes = totalRaw == null ? undefined : Number(totalRaw);
const totalBytes = parsedTotalBytes != null && Number.isFinite(parsedTotalBytes) && parsedTotalBytes > 0
? parsedTotalBytes
: undefined;
let receivedBytes = 0;
const meter = new Transform({
transform(chunk: Buffer, _encoding, callback) {
receivedBytes += chunk.byteLength;
onProgress({ receivedBytes, ...(totalBytes == null ? {} : { totalBytes }) });
callback(null, chunk);
},
});
await pipeline(
Readable.fromWeb(response.body as never),
meter,
createWriteStream(path, { flags: "wx" }),
);
}
async function removeContainedEntry(root: string, path: string): Promise<boolean> {
const resolved = resolve(path);
if (!containsPath(root, resolved)) return false;
let entry;
try {
entry = await lstat(resolved);
} catch {
return false;
}
if (entry.isSymbolicLink()) return false;
if (entry.isDirectory()) {
const real = await realpath(resolved).catch(() => null);
if (real == null || !containsPath(root, real)) return false;
}
await rm(resolved, { force: true, recursive: true });
return true;
}
async function ensureOwnedSubdir(root: string, name: string): Promise<string> {
if (name.length === 0 || name.includes("\0") || /[\\/]/.test(name)) {
throw new Error(`update subdirectory must be a simple path segment: ${name}`);
}
const dir = join(root, name);
if (!containsPath(root, dir)) throw new Error(`update subdirectory escaped update root: ${dir}`);
await mkdir(dir, { recursive: true });
const entry = await lstat(dir);
if (!entry.isDirectory() || entry.isSymbolicLink()) {
throw new Error(`update subdirectory is not an owned directory: ${dir}`);
}
const realDir = await realpath(dir);
if (!containsPath(root, realDir)) throw new Error(`update subdirectory realpath escaped update root: ${realDir}`);
return realDir;
}
async function existingOwnedSubdir(root: string, name: string): Promise<string | null> {
const dir = join(root, name);
let entry;
try {
entry = await lstat(dir);
} catch {
return null;
}
if (!entry.isDirectory() || entry.isSymbolicLink()) return null;
const realDir = await realpath(dir).catch(() => null);
if (realDir == null || !containsPath(root, realDir)) return null;
return realDir;
}
async function cleanupOwnedUpdateRoot(root: string, keepPaths: readonly string[]): Promise<void> {
const keep = new Set(keepPaths.map((path) => resolve(path)));
for (const child of ["tmp", "artifacts"]) {
const dir = await existingOwnedSubdir(root, child);
if (dir == null) continue;
const entries = await readdir(dir).catch(() => []);
for (const entry of entries) {
const path = join(dir, entry);
if (keep.has(resolve(path))) continue;
await removeContainedEntry(root, path).catch(() => false);
}
}
}
function persistedState(value: unknown): PersistedUpdateState | null {
if (!isRecord(value)) return null;
if (value.version !== 1 || value.verified !== true) return null;
if (typeof value.updateVersion !== "string" || typeof value.downloadPath !== "string") return null;
if (typeof value.platform !== "string" || typeof value.platformKey !== "string") return null;
if (value.channel !== DESKTOP_UPDATE_CHANNELS.STABLE && value.channel !== DESKTOP_UPDATE_CHANNELS.BETA) return null;
if (!isRecord(value.metadata) || !isRecord(value.artifact) || !isRecord(value.checksum)) return null;
const artifact = value.artifact as DesktopUpdateArtifactSnapshot;
const checksum = value.checksum as DesktopUpdateChecksumSnapshot;
if (typeof artifact.url !== "string" || artifact.url.length === 0) return null;
if (checksum.algorithm !== "sha256" && checksum.algorithm !== "sha512") return null;
return value as PersistedUpdateState;
}
export function createDesktopUpdater(
configInput: DesktopUpdaterConfigInput,
deps: DesktopUpdaterDeps = {},
): DesktopUpdater {
const config = resolveDesktopUpdaterConfig(configInput);
const fetchImpl = deps.fetch ?? globalThis.fetch;
const now = deps.now ?? (() => new Date());
const openPath = deps.openPath ?? (async () => "openPath is not available");
const listeners = new Set<() => void>();
let candidate: UpdateCandidate | null = null;
let downloadPath: string | null = null;
let checksum: DesktopUpdateChecksumSnapshot | null = null;
let metadata: Record<string, unknown> | null = null;
let lastCheckedAt: string | undefined;
let installResult: DesktopUpdateStatusSnapshot["installResult"];
let progress: DesktopUpdateProgressSnapshot | undefined;
let state: DesktopUpdateState = DESKTOP_UPDATE_STATES.IDLE;
let error: DesktopUpdateErrorSnapshot | undefined;
let operation: Promise<unknown> = Promise.resolve();
function supported(): boolean {
return config.enabled && config.mode === DESKTOP_UPDATE_MODES.PACKAGE_LAUNCHER && config.platform === "darwin";
}
function emit(): void {
for (const listener of listeners) listener();
}
function setState(next: DesktopUpdateState, nextError?: DesktopUpdateErrorSnapshot): DesktopUpdateStatusSnapshot {
state = next;
error = nextError;
const status = snapshot();
emit();
return status;
}
function snapshot(): DesktopUpdateStatusSnapshot {
const statusSupported = supported();
return {
arch: config.arch,
...(candidate?.artifact == null ? {} : { artifact: candidate.artifact }),
...(candidate?.artifact.url == null ? {} : { artifactUrl: candidate.artifact.url }),
...(candidate?.version == null ? {} : { availableVersion: candidate.version }),
capabilities: capabilitiesFor({ mode: config.mode, platform: config.platform, supported: statusSupported }),
channel: config.channel,
...(checksum == null ? {} : { checksum }),
currentVersion: config.currentVersion,
...(downloadPath == null ? {} : { downloadPath }),
enabled: config.enabled,
...(error == null ? {} : { error }),
...(installResult == null ? {} : { installResult }),
...(lastCheckedAt == null ? {} : { lastCheckedAt }),
...(metadata == null ? {} : { metadata }),
mode: config.mode,
paths: { downloadRoot: config.downloadRoot },
platform: config.platform,
...(progress == null ? {} : { progress }),
state,
supported: statusSupported,
};
}
function unsupportedStatus(): DesktopUpdateStatusSnapshot | null {
if (!config.enabled) {
return setState(DESKTOP_UPDATE_STATES.IDLE);
}
if (config.mode === DESKTOP_UPDATE_MODES.JS_INCREMENTAL) {
return setState(
DESKTOP_UPDATE_STATES.UNSUPPORTED,
createError("update-mode-not-implemented", "js-incremental updates are not implemented yet"),
);
}
if (config.platform !== "darwin") {
return setState(
DESKTOP_UPDATE_STATES.UNSUPPORTED,
createError("unsupported-platform", "package-launcher updates are currently mac-only"),
);
}
return null;
}
async function restoreDownloadedState(): Promise<DesktopUpdateStatusSnapshot | null> {
const root = await ensureOwnedUpdateRoot(config);
if (!root.ok) return setState(DESKTOP_UPDATE_STATES.ERROR, root.error);
const saved = persistedState(await readJson(root.manifestPath));
if (saved == null) return null;
const resolvedDownload = resolve(saved.downloadPath);
if (!containsPath(root.realRoot, resolvedDownload)) {
return setState(DESKTOP_UPDATE_STATES.ERROR, createError("download-path-escaped", "saved update path is outside the update root"));
}
try {
await access(resolvedDownload);
const file = await stat(resolvedDownload);
if (!file.isFile()) return null;
} catch {
return null;
}
candidate = {
arch: config.arch,
artifact: saved.artifact,
checksum: saved.checksum,
channel: saved.channel,
metadata: saved.metadata,
platformKey: saved.platformKey,
version: saved.updateVersion,
};
checksum = saved.checksum;
metadata = saved.metadata;
downloadPath = resolvedDownload;
return setState(DESKTOP_UPDATE_STATES.DOWNLOADED);
}
async function checkForCandidate(options: ActionOptions = {}): Promise<DesktopUpdateStatusSnapshot> {
const unsupported = unsupportedStatus();
if (unsupported != null) return unsupported;
setState(DESKTOP_UPDATE_STATES.CHECKING);
try {
const body = await fetchJson(fetchImpl, config.metadataUrl);
lastCheckedAt = now().toISOString();
metadata = body;
const selected = selectUpdateCandidate(body, config);
if (!selected.ok) return setState(selected.state, selected.error);
if (compareVersions(selected.candidate.version, config.currentVersion) <= 0) {
candidate = null;
checksum = null;
downloadPath = null;
return setState(DESKTOP_UPDATE_STATES.NOT_AVAILABLE);
}
candidate = selected.candidate;
checksum = selected.candidate.checksum;
downloadPath = null;
const available = setState(DESKTOP_UPDATE_STATES.AVAILABLE);
if (options.autoDownload ?? config.autoDownload) return await downloadUpdate();
return available;
} catch (checkError) {
return setState(
DESKTOP_UPDATE_STATES.ERROR,
createError("metadata-unreachable", checkError instanceof Error ? checkError.message : String(checkError)),
);
}
}
async function downloadUpdate(): Promise<DesktopUpdateStatusSnapshot> {
const unsupported = unsupportedStatus();
if (unsupported != null) return unsupported;
if (candidate == null) {
const checked = await checkForCandidate({ autoDownload: false });
if (checked.state !== DESKTOP_UPDATE_STATES.AVAILABLE || candidate == null) return checked;
}
const root = await ensureOwnedUpdateRoot(config);
if (!root.ok) return setState(DESKTOP_UPDATE_STATES.ERROR, root.error);
setState(DESKTOP_UPDATE_STATES.DOWNLOADING);
const nextCandidate = candidate;
const outputName = artifactFileName(nextCandidate);
let tmpPath: string | null = null;
try {
const artifactsDir = await ensureOwnedSubdir(root.realRoot, "artifacts");
const tmpDir = await ensureOwnedSubdir(root.realRoot, "tmp");
const finalPath = join(artifactsDir, outputName);
tmpPath = join(tmpDir, `${outputName}.${process.pid}.${Date.now()}.download`);
if (!containsPath(root.realRoot, finalPath) || !containsPath(root.realRoot, tmpPath)) {
return setState(DESKTOP_UPDATE_STATES.ERROR, createError("download-path-escaped", "resolved update download path escaped update root"));
}
const resolvedChecksum = await resolveChecksum(fetchImpl, nextCandidate.checksum);
checksum = resolvedChecksum;
await rm(tmpPath, { force: true });
await downloadToFile(fetchImpl, nextCandidate.artifact.url, tmpPath, (nextProgress) => {
progress = nextProgress;
emit();
});
const digest = await hashFile(tmpPath, resolvedChecksum.algorithm);
if (resolvedChecksum.value == null || digest.toLowerCase() !== resolvedChecksum.value.toLowerCase()) {
await rm(tmpPath, { force: true });
return setState(
DESKTOP_UPDATE_STATES.ERROR,
createError("checksum-mismatch", "downloaded update checksum did not match release metadata", {
actual: digest,
expected: resolvedChecksum.value,
}),
);
}
await mkdir(dirname(finalPath), { recursive: true });
await rm(finalPath, { force: true });
await rename(tmpPath, finalPath);
downloadPath = finalPath;
progress = undefined;
const persisted: PersistedUpdateState = {
artifact: nextCandidate.artifact,
checksum: resolvedChecksum,
channel: nextCandidate.channel,
downloadPath: finalPath,
downloadedAt: now().toISOString(),
metadata: nextCandidate.metadata,
platform: config.platform,
platformKey: nextCandidate.platformKey,
updateVersion: nextCandidate.version,
verified: true,
version: 1,
};
await writeJson(root.manifestPath, persisted);
await cleanupOwnedUpdateRoot(root.realRoot, [finalPath, root.manifestPath]);
const downloaded = setState(DESKTOP_UPDATE_STATES.DOWNLOADED);
if (config.autoOpen) return await installUpdate();
return downloaded;
} catch (downloadError) {
if (tmpPath != null) await rm(tmpPath, { force: true }).catch(() => undefined);
return setState(
DESKTOP_UPDATE_STATES.ERROR,
createError("download-failed", downloadError instanceof Error ? downloadError.message : String(downloadError)),
);
}
}
async function installUpdate(): Promise<DesktopUpdateStatusSnapshot> {
const unsupported = unsupportedStatus();
if (unsupported != null) return unsupported;
if (downloadPath == null) {
const restored = await restoreDownloadedState();
if (restored == null || downloadPath == null) {
return setState(DESKTOP_UPDATE_STATES.ERROR, createError("update-not-downloaded", "no downloaded update package is available"));
}
}
const root = await ensureOwnedUpdateRoot(config);
if (!root.ok) return setState(DESKTOP_UPDATE_STATES.ERROR, root.error);
const resolvedDownload = resolve(downloadPath);
if (!containsPath(root.realRoot, resolvedDownload)) {
return setState(DESKTOP_UPDATE_STATES.ERROR, createError("download-path-escaped", "download path is outside the update root"));
}
setState(DESKTOP_UPDATE_STATES.INSTALLING);
const installChecksum = checksum;
if (installChecksum?.value == null) {
return setState(DESKTOP_UPDATE_STATES.ERROR, createError("checksum-missing", "downloaded update checksum is missing"));
}
let digest: string;
try {
digest = await hashFile(resolvedDownload, installChecksum.algorithm);
} catch (hashError) {
return setState(
DESKTOP_UPDATE_STATES.ERROR,
createError("download-unavailable", hashError instanceof Error ? hashError.message : String(hashError)),
);
}
if (digest.toLowerCase() !== installChecksum.value.toLowerCase()) {
return setState(
DESKTOP_UPDATE_STATES.ERROR,
createError("checksum-mismatch", "downloaded update checksum changed before install", {
actual: digest,
expected: installChecksum.value,
}),
);
}
try {
const openedAt = now().toISOString();
if (!config.openDryRun) {
const openError = await openPath(resolvedDownload);
if (openError.length > 0) {
return setState(DESKTOP_UPDATE_STATES.ERROR, createError("open-installer-failed", openError));
}
}
installResult = {
...(config.openDryRun ? { dryRun: true } : {}),
openedAt,
path: resolvedDownload,
};
return setState(DESKTOP_UPDATE_STATES.DOWNLOADED);
} catch (installError) {
return setState(
DESKTOP_UPDATE_STATES.ERROR,
createError("open-installer-failed", installError instanceof Error ? installError.message : String(installError)),
);
}
}
async function serialized(run: () => Promise<DesktopUpdateStatusSnapshot>): Promise<DesktopUpdateStatusSnapshot> {
const next = operation.catch(() => undefined).then(run);
operation = next.catch(() => undefined);
return await next;
}
return {
checkForUpdates: (options) => serialized(() => checkForCandidate(options)),
downloadUpdate: () => serialized(downloadUpdate),
handle(action) {
switch (action) {
case "status":
return this.status();
case "check":
return this.checkForUpdates();
case "download":
return this.downloadUpdate();
case "install":
return this.installUpdate();
}
},
installUpdate: () => serialized(installUpdate),
shouldAutoCheck: () => config.enabled && config.autoCheck,
snapshot,
async status() {
const unsupported = unsupportedStatus();
if (unsupported != null) return unsupported;
if (state === DESKTOP_UPDATE_STATES.IDLE) {
const restored = await restoreDownloadedState();
if (restored != null) return restored;
}
return snapshot();
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}

View file

@ -0,0 +1,477 @@
import { createHash } from "node:crypto";
import { existsSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
import { readFile, realpath, writeFile } from "node:fs/promises";
import { createServer, type Server } from "node:http";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
import {
DESKTOP_UPDATE_CHANNELS,
DESKTOP_UPDATE_STATES,
SIDECAR_SOURCES,
} from "@open-design/sidecar-proto";
import { compareVersions, createDesktopUpdater, DESKTOP_UPDATE_ENV, resolveDesktopUpdaterConfig } from "../../src/main/updater.js";
type FixtureServer = {
close: () => Promise<void>;
metadataUrl: string;
};
function prereleaseCounterParts(version: string): { baseVersion: string; number: number } | null {
const prerelease = /^(\d+\.\d+\.\d+)-.+\.(\d+)$/.exec(version);
if (prerelease?.[1] != null && prerelease[2] != null) {
return { baseVersion: prerelease[1], number: Number(prerelease[2]) };
}
const nightly = /^(\d+\.\d+\.\d+)\.nightly\.(\d+)$/i.exec(version);
if (nightly?.[1] != null && nightly[2] != null) {
return { baseVersion: nightly[1], number: Number(nightly[2]) };
}
return null;
}
async function createUpdaterFixture(options: {
artifactBody?: string;
channel?: "stable" | "beta";
version?: string;
} = {}): Promise<FixtureServer> {
const version = options.version ?? "1.0.1";
const channel = options.channel ?? "stable";
const artifactBody = options.artifactBody ?? "open design updater fixture";
const digest = createHash("sha256").update(artifactBody).digest("hex");
const server = createServer((request, response) => {
const url = request.url ?? "/";
if (url === "/metadata.json") {
response.setHeader("content-type", "application/json");
const betaVersion = prereleaseCounterParts(version);
response.end(JSON.stringify({
channel,
...(channel === "beta"
? {
baseVersion: betaVersion?.baseVersion,
betaNumber: betaVersion?.number,
betaVersion: version,
}
: {
baseVersion: version,
releaseVersion: version,
stableVersion: version,
}),
platforms: {
mac: {
arch: "arm64",
enabled: true,
artifacts: {
dmg: {
name: `open-design-${version}-mac-arm64.dmg`,
sha256Url: `http://${serverAddress(server)}/artifact.dmg.sha256`,
size: Buffer.byteLength(artifactBody),
url: `http://${serverAddress(server)}/artifact.dmg`,
},
},
},
},
version: 1,
}));
return;
}
if (url === "/artifact.dmg") {
response.setHeader("content-length", String(Buffer.byteLength(artifactBody)));
response.end(artifactBody);
return;
}
if (url === "/artifact.dmg.sha256") {
response.end(`${digest} artifact.dmg\n`);
return;
}
response.statusCode = 404;
response.end("not found");
});
await new Promise<void>((resolveListen, rejectListen) => {
server.once("error", rejectListen);
server.listen(0, "127.0.0.1", () => resolveListen());
});
const address = serverAddress(server);
return {
close: async () => {
await new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => (error == null ? resolveClose() : rejectClose(error)));
});
},
metadataUrl: `http://${address}/metadata.json`,
};
}
function serverAddress(server: Server): string {
const address = server.address();
if (address == null || typeof address === "string") throw new Error("fixture server is not listening on TCP");
return `127.0.0.1:${address.port}`;
}
function makeRoot(): string {
return mkdtempSync(join(tmpdir(), "od-updater-test-"));
}
function updaterEnv(metadataUrl: string): NodeJS.ProcessEnv {
return {
[DESKTOP_UPDATE_ENV.AUTO_DOWNLOAD]: "1",
[DESKTOP_UPDATE_ENV.CURRENT_VERSION]: "1.0.0",
[DESKTOP_UPDATE_ENV.ENABLED]: "1",
[DESKTOP_UPDATE_ENV.METADATA_URL]: metadataUrl,
[DESKTOP_UPDATE_ENV.OPEN_DRY_RUN]: "1",
[DESKTOP_UPDATE_ENV.PLATFORM]: "darwin",
};
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
async function waitForRequestCount(requests: readonly unknown[], count: number): Promise<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (requests.length >= count) return;
await new Promise<void>((resolveWait) => setImmediate(resolveWait));
}
throw new Error(`expected ${count} update requests, saw ${requests.length}`);
}
function metadataResponse(version: string): Response {
return new Response(JSON.stringify({
baseVersion: version,
channel: "stable",
platforms: {
mac: {
arch: "arm64",
enabled: true,
artifacts: {
dmg: {
name: `open-design-${version}-mac-arm64.dmg`,
sha256: "0".repeat(64),
size: 1,
url: `https://example.invalid/open-design-${version}-mac-arm64.dmg`,
},
},
},
},
releaseVersion: version,
stableVersion: version,
version: 1,
}));
}
describe("desktop updater", () => {
it("downloads, verifies, persists, and dry-runs opening a mac package", async () => {
const root = makeRoot();
const fixture = await createUpdaterFixture();
try {
const updater = createDesktopUpdater({
arch: "arm64",
downloadRoot: root,
env: updaterEnv(fixture.metadataUrl),
source: SIDECAR_SOURCES.TOOLS_PACK,
});
const checked = await updater.checkForUpdates();
expect(checked.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED);
expect(checked.channel).toBe(DESKTOP_UPDATE_CHANNELS.STABLE);
expect(checked.availableVersion).toBe("1.0.1");
expect(checked.checksum?.algorithm).toBe("sha256");
expect(checked.downloadPath).toEqual(expect.any(String));
expect(relative(await realpath(root), checked.downloadPath ?? "")).not.toMatch(/^\.\./);
expect(await readFile(checked.downloadPath ?? "", "utf8")).toBe("open design updater fixture");
const restored = await updater.status();
expect(restored.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED);
expect(restored.downloadPath).toBe(checked.downloadPath);
const installed = await updater.installUpdate();
expect(installed.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED);
expect(installed.installResult?.dryRun).toBe(true);
expect(installed.installResult?.path).toBe(checked.downloadPath);
} finally {
await fixture.close();
rmSync(root, { force: true, recursive: true });
}
});
it("reports not-available when metadata is not newer than the current app", async () => {
const root = makeRoot();
const fixture = await createUpdaterFixture({ version: "1.0.0" });
try {
const updater = createDesktopUpdater({
arch: "arm64",
downloadRoot: root,
env: updaterEnv(fixture.metadataUrl),
source: SIDECAR_SOURCES.TOOLS_PACK,
});
const checked = await updater.checkForUpdates();
expect(checked.state).toBe(DESKTOP_UPDATE_STATES.NOT_AVAILABLE);
expect(checked.downloadPath).toBeUndefined();
} finally {
await fixture.close();
rmSync(root, { force: true, recursive: true });
}
});
it("accepts beta metadata that exposes betaVersion instead of releaseVersion", async () => {
const root = makeRoot();
const fixture = await createUpdaterFixture({ channel: "beta", version: "1.0.1-beta.2" });
try {
const updater = createDesktopUpdater({
arch: "arm64",
downloadRoot: root,
env: {
...updaterEnv(fixture.metadataUrl),
[DESKTOP_UPDATE_ENV.CURRENT_VERSION]: "1.0.1-beta.1",
},
source: SIDECAR_SOURCES.TOOLS_PACK,
});
const checked = await updater.checkForUpdates();
expect(checked.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED);
expect(checked.channel).toBe(DESKTOP_UPDATE_CHANNELS.BETA);
expect(checked.availableVersion).toBe("1.0.1-beta.2");
} finally {
await fixture.close();
rmSync(root, { force: true, recursive: true });
}
});
it("treats a larger counted beta nightly prerelease as an update", async () => {
const root = makeRoot();
const fixture = await createUpdaterFixture({ channel: "beta", version: "1.0.1-beta-nightly.2" });
try {
const updater = createDesktopUpdater({
arch: "arm64",
downloadRoot: root,
env: {
...updaterEnv(fixture.metadataUrl),
[DESKTOP_UPDATE_ENV.CURRENT_VERSION]: "1.0.1-beta-nightly.1",
},
source: SIDECAR_SOURCES.TOOLS_PACK,
});
const checked = await updater.checkForUpdates();
expect(checked.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED);
expect(checked.channel).toBe(DESKTOP_UPDATE_CHANNELS.BETA);
expect(checked.availableVersion).toBe("1.0.1-beta-nightly.2");
} finally {
await fixture.close();
rmSync(root, { force: true, recursive: true });
}
});
it("re-verifies a downloaded package before opening it", async () => {
const root = makeRoot();
const fixture = await createUpdaterFixture();
try {
const updater = createDesktopUpdater({
arch: "arm64",
downloadRoot: root,
env: updaterEnv(fixture.metadataUrl),
source: SIDECAR_SOURCES.TOOLS_PACK,
});
const checked = await updater.checkForUpdates();
expect(checked.state).toBe(DESKTOP_UPDATE_STATES.DOWNLOADED);
await writeFile(checked.downloadPath ?? "", "tampered", "utf8");
const installed = await updater.installUpdate();
expect(installed.state).toBe(DESKTOP_UPDATE_STATES.ERROR);
expect(installed.error?.code).toBe("checksum-mismatch");
expect(installed.installResult).toBeUndefined();
} finally {
await fixture.close();
rmSync(root, { force: true, recursive: true });
}
});
it("serializes more than one queued update operation", async () => {
const root = makeRoot();
const requests: Array<{ resolve: (response: Response) => void }> = [];
const fetchImpl: typeof globalThis.fetch = async () => {
const request = deferred<Response>();
requests.push(request);
return await request.promise;
};
try {
const updater = createDesktopUpdater(
{
arch: "arm64",
downloadRoot: root,
env: {
...updaterEnv("https://example.invalid/metadata.json"),
[DESKTOP_UPDATE_ENV.AUTO_DOWNLOAD]: "0",
},
source: SIDECAR_SOURCES.TOOLS_PACK,
},
{ fetch: fetchImpl },
);
const first = updater.checkForUpdates({ autoDownload: false });
const second = updater.checkForUpdates({ autoDownload: false });
const third = updater.checkForUpdates({ autoDownload: false });
await waitForRequestCount(requests, 1);
expect(requests).toHaveLength(1);
requests[0]?.resolve(metadataResponse("1.0.1"));
await expect(first).resolves.toMatchObject({
availableVersion: "1.0.1",
state: DESKTOP_UPDATE_STATES.AVAILABLE,
});
await waitForRequestCount(requests, 2);
await new Promise<void>((resolveWait) => setImmediate(resolveWait));
expect(requests).toHaveLength(2);
requests[1]?.resolve(metadataResponse("1.0.2"));
await expect(second).resolves.toMatchObject({
availableVersion: "1.0.2",
state: DESKTOP_UPDATE_STATES.AVAILABLE,
});
await waitForRequestCount(requests, 3);
requests[2]?.resolve(metadataResponse("1.0.3"));
await expect(third).resolves.toMatchObject({
availableVersion: "1.0.3",
state: DESKTOP_UPDATE_STATES.AVAILABLE,
});
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("defaults counted beta nightly builds to the beta update channel", () => {
const root = makeRoot();
try {
const config = resolveDesktopUpdaterConfig({
currentVersion: "1.2.3-beta-nightly.4",
downloadRoot: root,
env: {
[DESKTOP_UPDATE_ENV.ENABLED]: "1",
},
source: SIDECAR_SOURCES.PACKAGED,
});
expect(config.channel).toBe(DESKTOP_UPDATE_CHANNELS.BETA);
expect(config.metadataUrl).toContain("/beta/latest/metadata.json");
} finally {
rmSync(root, { force: true, recursive: true });
}
});
it("does not offer an arm64-only mac package to x64 clients", async () => {
const root = makeRoot();
const fixture = await createUpdaterFixture();
try {
const updater = createDesktopUpdater({
arch: "x64",
downloadRoot: root,
env: updaterEnv(fixture.metadataUrl),
source: SIDECAR_SOURCES.TOOLS_PACK,
});
const checked = await updater.checkForUpdates();
expect(checked.state).toBe(DESKTOP_UPDATE_STATES.ERROR);
expect(checked.error?.code).toBe("no-compatible-artifact");
expect(checked.error?.message).toContain("macIntel");
} finally {
await fixture.close();
rmSync(root, { force: true, recursive: true });
}
});
it("refuses aggressive cleanup in a non-owned update root", async () => {
const root = makeRoot();
const fixture = await createUpdaterFixture();
const alienFile = join(root, "do-not-delete.txt");
try {
await writeFile(alienFile, "user file", "utf8");
const updater = createDesktopUpdater({
arch: "arm64",
downloadRoot: root,
env: updaterEnv(fixture.metadataUrl),
source: SIDECAR_SOURCES.TOOLS_PACK,
});
const checked = await updater.checkForUpdates();
expect(checked.state).toBe(DESKTOP_UPDATE_STATES.ERROR);
expect(checked.error?.code).toBe("update-root-not-owned");
expect(existsSync(alienFile)).toBe(true);
} finally {
await fixture.close();
rmSync(root, { force: true, recursive: true });
}
});
const symlinkIt = process.platform === "win32" ? it.skip : it;
symlinkIt("refuses to use a symlinked updater root", async () => {
const realRoot = makeRoot();
const linkParent = makeRoot();
const linkRoot = join(linkParent, "updates");
const fixture = await createUpdaterFixture();
try {
symlinkSync(realRoot, linkRoot, "dir");
const updater = createDesktopUpdater({
arch: "arm64",
downloadRoot: linkRoot,
env: updaterEnv(fixture.metadataUrl),
source: SIDECAR_SOURCES.TOOLS_PACK,
});
const checked = await updater.checkForUpdates();
expect(checked.state).toBe(DESKTOP_UPDATE_STATES.ERROR);
expect(checked.error?.code).toBe("update-root-not-owned");
expect(existsSync(join(realRoot, ".open-design-updater-root.json"))).toBe(false);
} finally {
await fixture.close();
rmSync(linkParent, { force: true, recursive: true });
rmSync(realRoot, { force: true, recursive: true });
}
});
symlinkIt("refuses to use symlinked updater subdirectories", async () => {
const root = makeRoot();
const outside = makeRoot();
const fixture = await createUpdaterFixture();
const outsideMarker = join(outside, "outside.txt");
try {
await writeFile(outsideMarker, "outside", "utf8");
const updater = createDesktopUpdater({
arch: "arm64",
downloadRoot: root,
env: updaterEnv(fixture.metadataUrl),
source: SIDECAR_SOURCES.TOOLS_PACK,
});
await updater.status();
symlinkSync(outside, join(root, "artifacts"), "dir");
const checked = await updater.checkForUpdates();
expect(checked.state).toBe(DESKTOP_UPDATE_STATES.ERROR);
expect(checked.error?.code).toBe("download-failed");
expect(existsSync(outsideMarker)).toBe(true);
} finally {
await fixture.close();
rmSync(root, { force: true, recursive: true });
rmSync(outside, { force: true, recursive: true });
}
});
it("compares stable and prerelease versions", () => {
expect(compareVersions("1.0.1", "1.0.0")).toBe(1);
expect(compareVersions("1.0.0", "1.0.0")).toBe(0);
expect(compareVersions("1.0.0-beta.2", "1.0.0-beta.1")).toBe(1);
expect(compareVersions("1.0.0-beta-nightly.2", "1.0.0-beta-nightly.1")).toBe(1);
expect(compareVersions("1.0.0-nightly.10", "1.0.0-nightly.2")).toBe(1);
expect(compareVersions("1.0.0.nightly.2", "1.0.0.nightly.1")).toBe(1);
expect(compareVersions("1.0.0", "1.0.0-beta.9")).toBe(1);
expect(compareVersions("1.0.0-beta.1", "1.0.0")).toBe(-1);
});
});

View file

@ -117,6 +117,10 @@ async function main(): Promise<void> {
async discoverDaemonUrl() {
return sidecars.daemon.url;
},
update: {
currentVersion: config.appVersion,
downloadRoot: paths.updateRoot,
},
});
}

View file

@ -104,6 +104,7 @@ export async function ensurePackagedNamespacePaths(
mkdir(paths.logsRoot, { recursive: true }),
mkdir(paths.desktopLogsRoot, { recursive: true }),
mkdir(paths.runtimeRoot, { recursive: true }),
mkdir(paths.updateRoot, { recursive: true }),
mkdir(paths.electronUserDataRoot, { recursive: true }),
mkdir(paths.electronSessionDataRoot, { recursive: true }),
]);

View file

@ -17,6 +17,7 @@ export type PackagedNamespacePaths = {
namespaceRoot: string;
resourceRoot: string;
runtimeRoot: string;
updateRoot: string;
webIdentityPath: string;
};
@ -39,6 +40,7 @@ export function resolvePackagedNamespacePaths(
namespaceRoot,
resourceRoot: config.resourceRoot,
runtimeRoot: join(namespaceRoot, "runtime"),
updateRoot: join(namespaceRoot, "updates"),
webIdentityPath: join(namespaceRoot, "runtime", "web-root.json"),
};
}

View file

@ -386,6 +386,7 @@ export async function startPackagedSidecars(
await mkdir(paths.logsRoot, { recursive: true });
await mkdir(paths.desktopLogsRoot, { recursive: true });
await mkdir(paths.runtimeRoot, { recursive: true });
await mkdir(paths.updateRoot, { recursive: true });
await mkdir(paths.electronUserDataRoot, { recursive: true });
await mkdir(paths.electronSessionDataRoot, { recursive: true });

View file

@ -14,7 +14,7 @@
* resolvedDir came from the trusted-picker flow.
*/
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { mkdir, realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@ -70,7 +70,7 @@ describe("validateExistingDirectory", () => {
it("accepts an existing absolute directory and returns the realpath", async () => {
const result = await validateExistingDirectory(tempRoot);
expect(result.ok).toBe(true);
if (result.ok) expect(result.resolved).toBe(tempRoot);
if (result.ok) expect(result.resolved).toBe(await realpath(tempRoot));
});
it("realpath-resolves symlinks so attackers cannot register one path and reach another", async () => {
@ -80,7 +80,7 @@ describe("validateExistingDirectory", () => {
symlinkSync(realDir, linkDir, "dir");
const result = await validateExistingDirectory(linkDir);
expect(result.ok).toBe(true);
if (result.ok) expect(result.resolved).toBe(realDir);
if (result.ok) expect(result.resolved).toBe(await realpath(realDir));
});
it("rejects macOS .app bundles even though they are technically directories", async () => {

View file

@ -37,6 +37,7 @@ function fakePaths(root: string): PackagedNamespacePaths {
namespaceRoot: root,
resourceRoot: join(root, "resources"),
runtimeRoot: join(root, "runtime"),
updateRoot: join(root, "updates"),
webIdentityPath: join(root, "runtime", "web-root.json"),
};
}

View file

@ -0,0 +1,31 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { resolvePackagedNamespacePaths } from "../src/paths.js";
import type { PackagedConfig } from "../src/config.js";
describe("resolvePackagedNamespacePaths", () => {
it("models update downloads as a namespace-scoped root beside data", () => {
const config: PackagedConfig = {
appVersion: "1.2.3",
daemonCliEntry: null,
daemonSidecarEntry: null,
namespace: "release",
namespaceBaseRoot: "/tmp/open-design-packaged/namespaces",
nodeCommand: null,
resourceRoot: "/tmp/open-design-packaged/resources",
telemetryRelayUrl: null,
posthogKey: null,
posthogHost: null,
webSidecarEntry: null,
webStandaloneRoot: null,
webOutputMode: "server",
};
const paths = resolvePackagedNamespacePaths(config);
expect(paths.namespaceRoot).toBe(join(config.namespaceBaseRoot, "release"));
expect(paths.dataRoot).toBe(join(paths.namespaceRoot, "data"));
expect(paths.updateRoot).toBe(join(paths.namespaceRoot, "updates"));
});
});

View file

@ -163,6 +163,7 @@ describe('buildPackagedDaemonSpawnEnv', () => {
namespaceRoot: '/tmp/od-pkg',
resourceRoot: '/tmp/od-pkg/resources',
runtimeRoot: '/tmp/od-pkg/runtime',
updateRoot: '/tmp/od-pkg/updates',
webIdentityPath: '/tmp/od-pkg/runtime/web-root.json',
};
}

View file

@ -1,8 +1,9 @@
// @vitest-environment node
import { execFile } from 'node:child_process';
import { execFile, spawn, type ChildProcessByStdio } from 'node:child_process';
import { access, mkdir, stat } from 'node:fs/promises';
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
import type { Readable } from 'node:stream';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
@ -81,6 +82,21 @@ type MacInspectResult = {
path: string;
};
status: DesktopStatus | null;
update?: {
availableVersion?: string;
channel?: string;
currentVersion?: string;
downloadPath?: string;
error?: {
code: string;
message: string;
};
installResult?: {
dryRun?: boolean;
path: string;
};
state: string;
};
};
type LogsResult = {
@ -88,6 +104,14 @@ type LogsResult = {
namespace: string;
};
type UpdaterFixtureProcess = {
close: () => Promise<void>;
info: {
metadataUrl: string;
version: string;
};
};
type HealthEvalValue = {
health: {
ok?: unknown;
@ -110,6 +134,8 @@ macDescribe('packaged mac runtime smoke', () => {
test('installs, starts, inspects, stops, and uninstalls the built mac artifact', async () => {
const report = await createPackagedSmokeReport('mac');
const updateEnv = captureUpdateEnv();
let updaterFixture: UpdaterFixtureProcess | null = null;
let passed = false;
try {
const install = await runToolsPackJson<MacInstallResult>('install');
@ -120,6 +146,13 @@ macDescribe('packaged mac runtime smoke', () => {
expectPathInside(install.dmgPath, join(outputNamespaceRoot, 'dmg'));
expectPathInside(install.installedAppPath, join(outputNamespaceRoot, 'install', 'Applications'));
updaterFixture = await startUpdaterFixtureProcess();
process.env.OD_UPDATE_ENABLED = '1';
process.env.OD_UPDATE_METADATA_URL = updaterFixture.info.metadataUrl;
process.env.OD_UPDATE_CURRENT_VERSION = '99.0.0-beta.0';
process.env.OD_UPDATE_OPEN_DRY_RUN = '1';
process.env.OD_UPDATE_AUTO_CHECK = '0';
const start = await runToolsPackJson<MacStartResult>('start');
started = true;
@ -147,6 +180,16 @@ macDescribe('packaged mac runtime smoke', () => {
expect(value.health.ok).toBe(true);
expect(value.health.version).toEqual(expect.any(String));
const updateStatus = await runToolsPackJson<MacInspectResult>('inspect', ['--update-action', 'check']);
expect(updateStatus.update?.state).toBe('downloaded');
expect(updateStatus.update?.channel).toBe('beta');
expect(updateStatus.update?.currentVersion).toBe('99.0.0-beta.0');
expect(updateStatus.update?.availableVersion).toBe(updaterFixture.info.version);
expectPathInside(updateStatus.update?.downloadPath ?? '', join(runtimeNamespaceRoot, 'updates'));
const updateInstall = await runToolsPackJson<MacInspectResult>('inspect', ['--update-action', 'install']);
expect(updateInstall.update?.state).toBe('downloaded');
expect(updateInstall.update?.installResult?.dryRun).toBe(true);
await mkdir(dirname(screenshotPath), { recursive: true });
const screenshot = await runToolsPackJson<MacInspectResult>('inspect', ['--path', screenshotPath]);
expect(screenshot.screenshot?.path).toBe(screenshotPath);
@ -192,6 +235,10 @@ macDescribe('packaged mac runtime smoke', () => {
});
passed = true;
} finally {
restoreUpdateEnv(updateEnv);
await updaterFixture?.close().catch((error: unknown) => {
console.error('failed to close updater fixture', error);
});
if (!passed) {
await printPackagedLogs().catch((error: unknown) => {
console.error('failed to read packaged mac logs after failure', error);
@ -1043,6 +1090,82 @@ async function runToolsPackJson<T>(action: string, extraArgs: string[] = []): Pr
}
}
const UPDATE_ENV_KEYS = [
'OD_UPDATE_AUTO_CHECK',
'OD_UPDATE_ENABLED',
'OD_UPDATE_METADATA_URL',
'OD_UPDATE_CURRENT_VERSION',
'OD_UPDATE_OPEN_DRY_RUN',
] as const;
function captureUpdateEnv(): Partial<Record<(typeof UPDATE_ENV_KEYS)[number], string>> {
return Object.fromEntries(
UPDATE_ENV_KEYS
.map((key) => [key, process.env[key]] as const)
.filter((entry): entry is readonly [(typeof UPDATE_ENV_KEYS)[number], string] => entry[1] != null),
);
}
function restoreUpdateEnv(previous: Partial<Record<(typeof UPDATE_ENV_KEYS)[number], string>>): void {
for (const key of UPDATE_ENV_KEYS) {
if (previous[key] == null) delete process.env[key];
else process.env[key] = previous[key];
}
}
async function startUpdaterFixtureProcess(): Promise<UpdaterFixtureProcess> {
const child = spawn(pnpmCommand, ['tools-serve', 'start', 'updater', '--json', '--channel', 'beta', '--version', '99.0.0-beta.1'], {
cwd: workspaceRoot,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
const info = await readUpdaterFixtureInfo(child);
return {
async close() {
if (child.exitCode != null) return;
child.kill('SIGTERM');
await new Promise<void>((resolve) => {
child.once('exit', () => resolve());
setTimeout(resolve, 2000).unref();
});
},
info,
};
}
async function readUpdaterFixtureInfo(child: ChildProcessByStdio<null, Readable, Readable>): Promise<UpdaterFixtureProcess['info']> {
let stdout = '';
let stderr = '';
return await new Promise<UpdaterFixtureProcess['info']>((resolveInfo, rejectInfo) => {
const timeout = setTimeout(() => {
rejectInfo(new Error(`tools-serve updater did not report metadata in time\nstdout:\n${stdout}\nstderr:\n${stderr}`));
}, 10_000);
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
const line = stdout.split('\n').find((entry) => entry.trim().startsWith('{'));
if (line == null) return;
clearTimeout(timeout);
try {
const parsed = JSON.parse(line) as UpdaterFixtureProcess['info'];
resolveInfo(parsed);
} catch (error) {
rejectInfo(error);
}
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.once('exit', (code, signal) => {
clearTimeout(timeout);
rejectInfo(new Error(`tools-serve updater exited before ready (code=${code}, signal=${signal ?? 'none'})\nstderr:\n${stderr}`));
});
child.once('error', (error) => {
clearTimeout(timeout);
rejectInfo(error);
});
});
}
type DesktopHarness = ReturnType<typeof createDesktopHarness>;
type DesktopSettingsSnapshot = {

View file

@ -14,6 +14,7 @@
"tools-dev": "pnpm exec tools-dev",
"tools-pack": "pnpm exec tools-pack",
"tools-pr": "pnpm exec tools-pr",
"tools-serve": "pnpm exec tools-serve",
"guard": "tsx ./scripts/guard.ts && node --import tsx --test scripts/style-policy.test.ts",
"i18n:check": "tsx ./scripts/i18n-check.ts",
"i18n:coverage": "tsx ./scripts/i18n-coverage-report.ts",
@ -27,6 +28,7 @@
"@open-design/tools-dev": "workspace:*",
"@open-design/tools-pack": "workspace:*",
"@open-design/tools-pr": "workspace:*",
"@open-design/tools-serve": "workspace:*",
"@types/node": "^20.17.10",
"tsx": "4.21.0",
"typescript": "^5.6.3"

View file

@ -76,8 +76,46 @@ export const SIDECAR_MESSAGES = Object.freeze({
SCREENSHOT: "screenshot",
SHUTDOWN: "shutdown",
STATUS: "status",
UPDATE: "update",
} as const);
export const DESKTOP_UPDATE_ACTIONS = Object.freeze({
CHECK: "check",
DOWNLOAD: "download",
INSTALL: "install",
STATUS: "status",
} as const);
export type DesktopUpdateAction = (typeof DESKTOP_UPDATE_ACTIONS)[keyof typeof DESKTOP_UPDATE_ACTIONS];
export const DESKTOP_UPDATE_MODES = Object.freeze({
JS_INCREMENTAL: "js-incremental",
PACKAGE_LAUNCHER: "package-launcher",
} as const);
export type DesktopUpdateMode = (typeof DESKTOP_UPDATE_MODES)[keyof typeof DESKTOP_UPDATE_MODES];
export const DESKTOP_UPDATE_CHANNELS = Object.freeze({
BETA: "beta",
STABLE: "stable",
} as const);
export type DesktopUpdateChannel = (typeof DESKTOP_UPDATE_CHANNELS)[keyof typeof DESKTOP_UPDATE_CHANNELS];
export const DESKTOP_UPDATE_STATES = Object.freeze({
AVAILABLE: "available",
CHECKING: "checking",
DOWNLOADED: "downloaded",
DOWNLOADING: "downloading",
ERROR: "error",
IDLE: "idle",
INSTALLING: "installing",
NOT_AVAILABLE: "not-available",
UNSUPPORTED: "unsupported",
} as const);
export type DesktopUpdateState = (typeof DESKTOP_UPDATE_STATES)[keyof typeof DESKTOP_UPDATE_STATES];
export const SIDECAR_ERROR_CODES = Object.freeze({
INVALID_MESSAGE: "SIDECAR_INVALID_MESSAGE",
UNKNOWN_MESSAGE: "SIDECAR_UNKNOWN_MESSAGE",
@ -129,6 +167,7 @@ export type DesktopStatusSnapshot = {
pid?: number | null;
state: DesktopRuntimeState;
title?: string | null;
update?: DesktopUpdateStatusSnapshot;
updatedAt?: string;
url?: string | null;
windowVisible?: boolean;
@ -186,6 +225,78 @@ export type DesktopExportPdfResult = {
path?: string;
};
export type DesktopUpdateCapabilitySet = {
canApplyInPlace: boolean;
canDownload: boolean;
canOpenInstaller: boolean;
requiresManualInstall: boolean;
};
export type DesktopUpdatePathSnapshot = {
downloadRoot?: string;
manifestPath?: string;
};
export type DesktopUpdateChecksumSnapshot = {
algorithm: "sha256" | "sha512";
url?: string;
value?: string;
};
export type DesktopUpdateArtifactSnapshot = {
name?: string;
platformKey?: string;
size?: number;
type?: string;
url: string;
};
export type DesktopUpdateProgressSnapshot = {
receivedBytes: number;
totalBytes?: number;
};
export type DesktopUpdateErrorSnapshot = {
code: string;
details?: unknown;
message: string;
};
export type DesktopUpdateInstallResult = {
dryRun?: boolean;
openedAt: string;
path: string;
};
export type DesktopUpdateStatusSnapshot = {
arch: string;
artifact?: DesktopUpdateArtifactSnapshot;
artifactUrl?: string;
availableVersion?: string;
capabilities: DesktopUpdateCapabilitySet;
channel: DesktopUpdateChannel;
checksum?: DesktopUpdateChecksumSnapshot;
currentVersion: string;
downloadPath?: string;
enabled: boolean;
error?: DesktopUpdateErrorSnapshot;
installResult?: DesktopUpdateInstallResult;
lastCheckedAt?: string;
metadata?: Record<string, unknown>;
mode: DesktopUpdateMode;
paths?: DesktopUpdatePathSnapshot;
platform: string;
progress?: DesktopUpdateProgressSnapshot;
state: DesktopUpdateState;
supported: boolean;
};
export type DesktopUpdateInput = {
action: DesktopUpdateAction;
};
export type DesktopUpdateResult = DesktopUpdateStatusSnapshot;
export type SidecarStatusMessage = { type: typeof SIDECAR_MESSAGES.STATUS };
export type SidecarShutdownMessage = { type: typeof SIDECAR_MESSAGES.SHUTDOWN };
export type DesktopEvalMessage = { input: DesktopEvalInput; type: typeof SIDECAR_MESSAGES.EVAL };
@ -193,6 +304,7 @@ export type DesktopScreenshotMessage = { input: DesktopScreenshotInput; type: ty
export type DesktopConsoleMessage = { type: typeof SIDECAR_MESSAGES.CONSOLE };
export type DesktopClickMessage = { input: DesktopClickInput; type: typeof SIDECAR_MESSAGES.CLICK };
export type DesktopExportPdfMessage = { input: DesktopExportPdfInput; type: typeof SIDECAR_MESSAGES.EXPORT_PDF };
export type DesktopUpdateMessage = { input: DesktopUpdateInput; type: typeof SIDECAR_MESSAGES.UPDATE };
// Sent by the desktop main process to the daemon over its sidecar IPC at
// startup, before the BrowserWindow is created. The base64 string is a
@ -228,7 +340,8 @@ export type DesktopSidecarMessage =
| DesktopScreenshotMessage
| DesktopConsoleMessage
| DesktopClickMessage
| DesktopExportPdfMessage;
| DesktopExportPdfMessage
| DesktopUpdateMessage;
export type ShutdownResult = {
accepted: true;
@ -260,6 +373,10 @@ export type OpenDesignSidecarContract = {
sources: typeof SIDECAR_SOURCES;
stampFields: typeof SIDECAR_STAMP_FIELDS;
stampFlags: typeof SIDECAR_STAMP_FLAGS;
updateActions: typeof DESKTOP_UPDATE_ACTIONS;
updateChannels: typeof DESKTOP_UPDATE_CHANNELS;
updateModes: typeof DESKTOP_UPDATE_MODES;
updateStates: typeof DESKTOP_UPDATE_STATES;
};
function assertObject(value: unknown, label: string): Record<string, unknown> {
@ -423,6 +540,19 @@ function normalizeDesktopExportPdfInput(input: unknown): DesktopExportPdfInput {
};
}
function isDesktopUpdateAction(value: unknown): value is DesktopUpdateAction {
return Object.values(DESKTOP_UPDATE_ACTIONS).includes(value as DesktopUpdateAction);
}
function normalizeDesktopUpdateInput(input: unknown): DesktopUpdateInput {
const value = assertObject(input, "desktop update input");
assertKnownKeys(value, ["action"], "desktop update input");
if (!isDesktopUpdateAction(value.action)) {
throw new Error(`unsupported desktop update action: ${String(value.action)}`);
}
return { action: value.action };
}
function normalizeMessageType(value: unknown, label: string): string {
if (typeof value !== "string" || value.length === 0) {
throw new SidecarContractError(SIDECAR_ERROR_CODES.INVALID_MESSAGE, `${label} type must be a non-empty string`);
@ -475,6 +605,9 @@ export function normalizeDesktopSidecarMessage(input: unknown): DesktopSidecarMe
case SIDECAR_MESSAGES.EXPORT_PDF:
assertKnownKeys(value, ["input", "type"], "desktop sidecar message");
return { input: normalizeDesktopExportPdfInput(value.input), type };
case SIDECAR_MESSAGES.UPDATE:
assertKnownKeys(value, ["input", "type"], "desktop sidecar message");
return { input: normalizeDesktopUpdateInput(value.input), type };
default:
throw new SidecarContractError(SIDECAR_ERROR_CODES.UNKNOWN_MESSAGE, `unknown desktop sidecar message: ${type}`);
}
@ -495,4 +628,8 @@ export const OPEN_DESIGN_SIDECAR_CONTRACT = Object.freeze({
sources: SIDECAR_SOURCES,
stampFields: SIDECAR_STAMP_FIELDS,
stampFlags: SIDECAR_STAMP_FLAGS,
updateActions: DESKTOP_UPDATE_ACTIONS,
updateChannels: DESKTOP_UPDATE_CHANNELS,
updateModes: DESKTOP_UPDATE_MODES,
updateStates: DESKTOP_UPDATE_STATES,
} as const satisfies OpenDesignSidecarContract);

View file

@ -2,6 +2,10 @@ import { describe, expect, it } from "vitest";
import {
APP_KEYS,
DESKTOP_UPDATE_ACTIONS,
DESKTOP_UPDATE_CHANNELS,
DESKTOP_UPDATE_MODES,
DESKTOP_UPDATE_STATES,
normalizeDaemonSidecarMessage,
normalizeDesktopSidecarMessage,
normalizeNamespace,
@ -36,6 +40,10 @@ describe("open-design sidecar contract", () => {
namespace: STAMP_NAMESPACE_FLAG,
source: STAMP_SOURCE_FLAG,
});
expect(OPEN_DESIGN_SIDECAR_CONTRACT.updateActions).toBe(DESKTOP_UPDATE_ACTIONS);
expect(OPEN_DESIGN_SIDECAR_CONTRACT.updateChannels).toBe(DESKTOP_UPDATE_CHANNELS);
expect(OPEN_DESIGN_SIDECAR_CONTRACT.updateModes).toBe(DESKTOP_UPDATE_MODES);
expect(OPEN_DESIGN_SIDECAR_CONTRACT.updateStates).toBe(DESKTOP_UPDATE_STATES);
});
it("accepts the explicit namespace contract", () => {
@ -161,4 +169,37 @@ describe("open-design sidecar contract", () => {
}),
).toThrow();
});
it("validates desktop update IPC message inputs", () => {
expect(
normalizeDesktopSidecarMessage({
input: { action: DESKTOP_UPDATE_ACTIONS.CHECK },
type: SIDECAR_MESSAGES.UPDATE,
}),
).toEqual({
input: { action: "check" },
type: "update",
});
expect(
normalizeDesktopSidecarMessage({
input: { action: DESKTOP_UPDATE_ACTIONS.INSTALL },
type: SIDECAR_MESSAGES.UPDATE,
}),
).toEqual({
input: { action: "install" },
type: "update",
});
expect(() =>
normalizeDesktopSidecarMessage({
input: { action: "apply" },
type: SIDECAR_MESSAGES.UPDATE,
}),
).toThrow(/unsupported desktop update action/);
expect(() =>
normalizeDesktopSidecarMessage({
input: { action: "status", path: "/tmp/update.dmg" },
type: SIDECAR_MESSAGES.UPDATE,
}),
).toThrow(/unsupported fields/);
});
});

View file

@ -17,6 +17,9 @@ importers:
'@open-design/tools-pr':
specifier: workspace:*
version: link:tools/pr
'@open-design/tools-serve':
specifier: workspace:*
version: link:tools/serve
'@types/node':
specifier: ^20.17.10
version: 20.19.39
@ -513,6 +516,28 @@ importers:
specifier: 6.0.3
version: 6.0.3
tools/serve:
dependencies:
cac:
specifier: 6.7.14
version: 6.7.14
devDependencies:
'@types/node':
specifier: 24.12.2
version: 24.12.2
esbuild:
specifier: 0.27.7
version: 0.27.7
tsx:
specifier: 4.21.0
version: 4.21.0
typescript:
specifier: 6.0.3
version: 6.0.3
vitest:
specifier: ^2.1.8
version: 2.1.9(@types/node@24.12.2)(jsdom@29.1.1)(lightningcss@1.32.0)
packages:
7zip-bin@5.2.0:

View file

@ -80,6 +80,8 @@ const residualAllowedExactPaths = new Set([
"tools/pack/esbuild.config.mjs",
"tools/pr/bin/tools-pr.mjs",
"tools/pr/esbuild.config.mjs",
"tools/serve/bin/tools-serve.mjs",
"tools/serve/esbuild.config.mjs",
"tools/pack/resources/mac/notarize.cjs",
// electron-builder hook path; CJS compatibility entry used by tools-pack desktop builds.
"tools/pack/resources/web-standalone-after-pack.cjs",
@ -393,6 +395,7 @@ const toolsRootAllowlist = new Map<string, "directory" | "file">([
["dev", "directory"],
["pack", "directory"],
["pr", "directory"],
["serve", "directory"],
]);
async function checkToolsLayout(): Promise<boolean> {
@ -406,7 +409,7 @@ async function checkToolsLayout(): Promise<boolean> {
const repositoryPath = `tools/${entry.name}${entry.isDirectory() ? "/" : ""}`;
if (expected == null) {
violations.push(`${repositoryPath} -> tools/ top-level entries are allowlisted; expected only AGENTS.md, dev/, pack/, and pr/`);
violations.push(`${repositoryPath} -> tools/ top-level entries are allowlisted; expected only AGENTS.md, dev/, pack/, pr/, and serve/`);
continue;
}

View file

@ -17,6 +17,7 @@ const buildTargets = [
"tools/dev",
"tools/pack",
"tools/pr",
"tools/serve",
];
const jsExtensions = new Set([".js", ".cjs", ".mjs"]);

View file

@ -10,6 +10,7 @@ Follow the root `AGENTS.md` first. This file only records module-level boundarie
- `pnpm tools-dev inspect desktop ...` inspects the desktop runtime through sidecar IPC.
- `tools/pack` provides `@open-design/tools-pack` and the `tools-pack` bin. The active slice is packaged artifact build/install/start/stop/logs/uninstall/cleanup/list/reset plus beta release artifact preparation for mac and Windows lanes, plus a Linux AppImage lane with optional containerized builds.
- `tools/pr` provides `@open-design/tools-pr` and the `tools-pr` bin. It is the maintainer PR-duty control plane: a thin `gh` wrapper that encodes this repo's review-lane derivation, forbidden-surface flags, per-lane checklists, and validation-command suggestions. It must not perform side effects (approve / request changes / merge / close / push); those stay in explicit `gh` calls the maintainer runs.
- `tools/serve` provides `@open-design/tools-serve` and the `tools-serve` bin. It owns local fixture services such as `tools-serve start updater`.
## Packaging scope
@ -34,6 +35,8 @@ pnpm --filter @open-design/tools-pack typecheck
pnpm --filter @open-design/tools-pack build
pnpm --filter @open-design/tools-pr typecheck
pnpm --filter @open-design/tools-pr build
pnpm --filter @open-design/tools-serve typecheck
pnpm --filter @open-design/tools-serve build
pnpm tools-dev status --json
pnpm tools-dev logs --json
pnpm tools-dev check
@ -54,4 +57,5 @@ pnpm tools-pr list
pnpm tools-pr list --bucket=merge-ready,approved-blocked
pnpm tools-pr view <num>
pnpm tools-pr view <num> --json
pnpm tools-serve start updater
```

View file

@ -16,6 +16,7 @@ import {
type DesktopEvalResult,
type DesktopScreenshotResult,
type DesktopStatusSnapshot,
type DesktopUpdateResult,
type WebStatusSnapshot,
} from "@open-design/sidecar-proto";
import { createSidecarLaunchEnv, requestJsonIpc } from "@open-design/sidecar";
@ -68,6 +69,7 @@ type CliOptions = ToolDevOptions & {
path?: string;
selector?: string;
timeout?: string;
updateAction?: string;
};
const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID;
@ -911,6 +913,18 @@ async function inspectDesktop(config: ToolDevConfig, target: string | undefined,
);
case "console":
return await requestJsonIpc<DesktopConsoleResult>(config.apps.desktop.ipcPath, { type: SIDECAR_MESSAGES.CONSOLE }, { timeoutMs });
case "update":
if (
options.updateAction != null &&
!["status", "check", "download", "install"].includes(options.updateAction)
) {
throw new Error("--update-action must be status, check, download, or install");
}
return await requestJsonIpc<DesktopUpdateResult>(
config.apps.desktop.ipcPath,
{ input: { action: options.updateAction ?? "status" }, type: SIDECAR_MESSAGES.UPDATE },
{ timeoutMs },
);
case "click":
if (options.selector == null) throw new Error("--selector is required for desktop click");
return await requestJsonIpc<DesktopClickResult>(
@ -1048,6 +1062,7 @@ addSharedOptions(
.option("--path <file>", "Output path for desktop screenshot")
.option("--selector <css>", "CSS selector for desktop click")
.option("--timeout <seconds>", "Desktop inspect timeout in seconds")
.option("--update-action <action>", "Desktop update action: status|check|download|install")
.action(async (appName: string, target: string | undefined, options: CliOptions) => {
output(await inspect(resolveToolDevConfig(options), appName, target, options), options);
});

View file

@ -50,7 +50,9 @@ namespace paths, and the packaged sidecar launcher passes daemon managed paths v
own default fallback for non-packaged launches, but packaged runtime must not rely on fallback inference from Electron
`userData`, app bundle names, or ports.
Runtime updater integration remains a later phase.
Packaged desktop can check the release metadata feed, download a verified mac DMG, and expose update actions through
desktop IPC. This first runtime updater phase still opens the downloaded installer for manual replacement instead of
applying an in-place update.
Electron-builder resources live under `tools/pack/resources/mac/`. The current logo is staged there as the mac icon/DMG
placeholder so future design-provided assets can replace the resource files without changing packaging code.

View file

@ -37,6 +37,7 @@ export type ToolPackCliOptions = {
signed?: boolean;
silent?: boolean;
to?: string;
updateAction?: string;
};
export type ToolPackRoots = {

View file

@ -67,7 +67,8 @@ function addSharedOptions(command: CacCommand) {
.option("--json", "print JSON")
.option("--namespace <name>", "runtime namespace")
.option("--expr <expression>", "desktop inspect eval expression")
.option("--path <path>", "desktop inspect screenshot path");
.option("--path <path>", "desktop inspect screenshot path")
.option("--update-action <action>", "desktop update action: status|check|download|install");
}
// Per-platform `--to` help text mirroring resolveToolPackBuildOutput in

View file

@ -12,6 +12,7 @@ import {
type DesktopEvalResult,
type DesktopScreenshotResult,
type DesktopStatusSnapshot,
type DesktopUpdateResult,
type SidecarStamp,
} from "@open-design/sidecar-proto";
import { createSidecarLaunchEnv, requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar";
@ -675,13 +676,20 @@ export async function readPackedMacLogs(config: ToolPackConfig) {
};
}
export async function inspectPackedMacApp(config: ToolPackConfig, options: { expr?: string; path?: string }): Promise<MacInspectResult> {
function resolveUpdateAction(value: string | undefined): "status" | "check" | "download" | "install" | null {
if (value == null) return null;
if (value === "status" || value === "check" || value === "download" || value === "install") return value;
throw new Error("--update-action must be status, check, download, or install");
}
export async function inspectPackedMacApp(config: ToolPackConfig, options: { expr?: string; path?: string; updateAction?: string }): Promise<MacInspectResult> {
const stamp = desktopStamp(config);
const status = await requestJsonIpc<DesktopStatusSnapshot>(
stamp.ipc,
{ type: SIDECAR_MESSAGES.STATUS },
{ timeoutMs: 2000 },
).catch(() => null);
const updateAction = resolveUpdateAction(options.updateAction);
return {
...(options.expr == null ? {} : {
@ -698,6 +706,13 @@ export async function inspectPackedMacApp(config: ToolPackConfig, options: { exp
{ timeoutMs: 10000 },
),
}),
...(updateAction == null ? {} : {
update: await requestJsonIpc<DesktopUpdateResult>(
stamp.ipc,
{ input: { action: updateAction }, type: SIDECAR_MESSAGES.UPDATE },
{ timeoutMs: 60000 },
),
}),
status,
};
}

View file

@ -1,4 +1,4 @@
import type { DesktopEvalResult, DesktopScreenshotResult, DesktopStatusSnapshot, SidecarStamp } from "@open-design/sidecar-proto";
import type { DesktopEvalResult, DesktopScreenshotResult, DesktopStatusSnapshot, DesktopUpdateResult, SidecarStamp } from "@open-design/sidecar-proto";
import type { CacheReport } from "../cache.js";
import type { ToolPackBuildOutput, ToolPackConfig } from "../config.js";
import type { INTERNAL_PACKAGES } from "./constants.js";
@ -81,6 +81,7 @@ export type MacInspectResult = {
eval?: DesktopEvalResult;
screenshot?: DesktopScreenshotResult;
status: DesktopStatusSnapshot | null;
update?: DesktopUpdateResult;
};
export type DesktopRootIdentityMarker = {

View file

@ -10,6 +10,7 @@ import {
type DesktopEvalResult,
type DesktopScreenshotResult,
type DesktopStatusSnapshot,
type DesktopUpdateResult,
type SidecarStamp,
} from "@open-design/sidecar-proto";
import { createSidecarLaunchEnv, requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar";
@ -406,9 +407,16 @@ export async function resetPackedWinNamespaces(config: ToolPackConfig): Promise<
return { namespaces, results };
}
export async function inspectPackedWinApp(config: ToolPackConfig, options: { expr?: string; path?: string }): Promise<WinInspectResult> {
function resolveUpdateAction(value: string | undefined): "status" | "check" | "download" | "install" | null {
if (value == null) return null;
if (value === "status" || value === "check" || value === "download" || value === "install") return value;
throw new Error("--update-action must be status, check, download, or install");
}
export async function inspectPackedWinApp(config: ToolPackConfig, options: { expr?: string; path?: string; updateAction?: string }): Promise<WinInspectResult> {
const stamp = desktopStamp(config);
const status = await requestJsonIpc<DesktopStatusSnapshot>(stamp.ipc, { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs: 2000 }).catch(() => null);
const updateAction = resolveUpdateAction(options.updateAction);
return {
...(options.expr == null ? {} : {
eval: await requestJsonIpc<DesktopEvalResult>(
@ -424,6 +432,13 @@ export async function inspectPackedWinApp(config: ToolPackConfig, options: { exp
{ timeoutMs: 10000 },
),
}),
...(updateAction == null ? {} : {
update: await requestJsonIpc<DesktopUpdateResult>(
stamp.ipc,
{ input: { action: updateAction }, type: SIDECAR_MESSAGES.UPDATE },
{ timeoutMs: 60000 },
),
}),
status,
};
}

View file

@ -1,4 +1,4 @@
import type { DesktopEvalResult, DesktopScreenshotResult, DesktopStatusSnapshot } from "@open-design/sidecar-proto";
import type { DesktopEvalResult, DesktopScreenshotResult, DesktopStatusSnapshot, DesktopUpdateResult } from "@open-design/sidecar-proto";
import type { CacheReport } from "../cache.js";
import type { ToolPackConfig } from "../config.js";
import type { INTERNAL_PACKAGES } from "./constants.js";
@ -314,4 +314,5 @@ export type WinInspectResult = {
eval?: DesktopEvalResult;
screenshot?: DesktopScreenshotResult;
status: DesktopStatusSnapshot | null;
update?: DesktopUpdateResult;
};

14
tools/serve/AGENTS.md Normal file
View file

@ -0,0 +1,14 @@
# tools/serve
Follow the root `AGENTS.md` and `tools/AGENTS.md` first. This tool owns small local-development service entrypoints.
## Owns
- `tools-serve` CLI.
- Local static updater fixtures for desktop update IPC and packaged-runtime debugging.
## Rules
- Keep services self-contained and local-first.
- Do not put product update runtime logic here; this tool serves deterministic fixtures only.
- New services should use explicit subcommands under `tools-serve start <service>`.

14
tools/serve/bin/tools-serve.mjs Executable file
View file

@ -0,0 +1,14 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const entryDir = dirname(fileURLToPath(import.meta.url));
const distEntry = resolve(entryDir, "../dist/index.mjs");
if (!existsSync(distEntry)) {
throw new Error(`tools-serve dist entry not found at ${distEntry}. Run "pnpm --filter @open-design/tools-serve build" first.`);
}
await import(pathToFileURL(distEntry).href);

View file

@ -0,0 +1,14 @@
import { build } from "esbuild";
await build({
banner: {
js: "#!/usr/bin/env node",
},
bundle: true,
entryPoints: ["./src/index.ts"],
format: "esm",
outfile: "./dist/index.mjs",
packages: "external",
platform: "node",
target: "node24",
});

28
tools/serve/package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "@open-design/tools-serve",
"version": "0.6.0",
"private": true,
"type": "module",
"bin": {
"tools-serve": "./bin/tools-serve.mjs"
},
"scripts": {
"build": "node ./esbuild.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly",
"dev": "tsx ./src/index.ts",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
},
"dependencies": {
"cac": "6.7.14"
},
"devDependencies": {
"@types/node": "24.12.2",
"esbuild": "0.27.7",
"tsx": "4.21.0",
"typescript": "6.0.3",
"vitest": "^2.1.8"
},
"engines": {
"node": "~24"
}
}

70
tools/serve/src/index.ts Normal file
View file

@ -0,0 +1,70 @@
import { cac } from "cac";
import { startUpdaterFixtureServer } from "./updater-fixture.js";
type CliOptions = {
channel?: "stable" | "beta";
host?: string;
json?: boolean;
port?: string;
version?: string;
};
function parsePort(value: string | undefined): number {
if (value == null || value.length === 0) return 0;
const port = Number(value);
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error("--port must be an integer between 0 and 65535");
}
return port;
}
function printJson(value: unknown): void {
process.stdout.write(`${JSON.stringify(value)}\n`);
}
async function start(service: string, options: CliOptions): Promise<void> {
if (service !== "updater") throw new Error(`unsupported tools-serve service: ${service}`);
const server = await startUpdaterFixtureServer({
channel: options.channel,
host: options.host,
port: parsePort(options.port),
version: options.version,
});
if (options.json === true) {
printJson(server.info);
} else {
process.stdout.write(`tools-serve updater: ${server.info.metadataUrl}\n`);
}
const shutdown = () => {
void server.close().finally(() => process.exit(0));
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
process.on("uncaughtException", (error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});
process.on("unhandledRejection", (error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
});
const cli = cac("tools-serve");
cli
.command("start <service>", "Start a local fixture service")
.option("--channel <channel>", "Updater channel: stable|beta", { default: "stable" })
.option("--host <host>", "Host to bind", { default: "127.0.0.1" })
.option("--json", "Print JSON")
.option("--port <port>", "Port to bind, 0 for dynamic", { default: "0" })
.option("--version <version>", "Fixture update version", { default: "99.0.0" })
.action((service: string, options: CliOptions) => {
void start(service, options);
});
cli.help();
cli.parse();

View file

@ -0,0 +1,159 @@
import { createHash } from "node:crypto";
import { createServer, type Server } from "node:http";
export type UpdaterFixtureOptions = {
artifactBody?: Buffer | string;
channel?: "stable" | "beta";
host?: string;
port?: number;
version?: string;
};
export type UpdaterFixtureInfo = {
artifactUrl: string;
channel: "stable" | "beta";
checksumUrl: string;
metadataUrl: string;
origin: string;
sha256: string;
version: string;
};
export type UpdaterFixtureServer = {
close(): Promise<void>;
info: UpdaterFixtureInfo;
};
function listen(server: Server, port: number, host: string): Promise<void> {
return new Promise<void>((resolveListen, rejectListen) => {
server.once("error", rejectListen);
server.listen(port, host, () => {
server.off("error", rejectListen);
resolveListen();
});
});
}
function close(server: Server): Promise<void> {
return new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => (error == null ? resolveClose() : rejectClose(error)));
});
}
function serverOrigin(server: Server): string {
const address = server.address();
if (address == null || typeof address === "string") throw new Error("updater fixture did not listen on TCP");
return `http://127.0.0.1:${address.port}`;
}
function prereleaseCounterParts(version: string): { baseVersion: string; number: number } | null {
const prerelease = /^(\d+\.\d+\.\d+)-.+\.(\d+)$/.exec(version);
if (prerelease?.[1] != null && prerelease[2] != null) {
return { baseVersion: prerelease[1], number: Number(prerelease[2]) };
}
const nightly = /^(\d+\.\d+\.\d+)\.nightly\.(\d+)$/i.exec(version);
if (nightly?.[1] != null && nightly[2] != null) {
return { baseVersion: nightly[1], number: Number(nightly[2]) };
}
return null;
}
function channelMetadata(channel: "stable" | "beta", version: string): Record<string, unknown> {
if (channel === "stable") {
return {
baseVersion: version,
releaseVersion: version,
stableVersion: version,
};
}
const countedVersion = prereleaseCounterParts(version);
if (countedVersion == null) {
throw new Error(`beta updater fixture version must match x.y.z-<label>.N; got ${version}`);
}
return {
baseVersion: countedVersion.baseVersion,
betaNumber: countedVersion.number,
betaVersion: version,
};
}
export async function startUpdaterFixtureServer(options: UpdaterFixtureOptions = {}): Promise<UpdaterFixtureServer> {
const channel = options.channel ?? "stable";
const host = options.host ?? "127.0.0.1";
const port = options.port ?? 0;
const version = options.version ?? "99.0.0";
const artifactName = `open-design-${version}-mac-arm64.dmg`;
const artifactBody = Buffer.isBuffer(options.artifactBody)
? options.artifactBody
: Buffer.from(options.artifactBody ?? `Open Design updater fixture ${version}\n`, "utf8");
const sha256 = createHash("sha256").update(artifactBody).digest("hex");
let info: UpdaterFixtureInfo | null = null;
const server = createServer((request, response) => {
if (info == null) {
response.statusCode = 503;
response.end("fixture not ready");
return;
}
const path = new URL(request.url ?? "/", info.origin).pathname;
if (path === `/${channel}/latest/metadata.json`) {
response.setHeader("content-type", "application/json; charset=utf-8");
response.end(JSON.stringify({
channel,
generatedAt: new Date().toISOString(),
...channelMetadata(channel, version),
platforms: {
mac: {
arch: "arm64",
artifacts: {
dmg: {
contentType: "application/x-apple-diskimage",
name: artifactName,
sha256Url: info.checksumUrl,
size: artifactBody.byteLength,
url: info.artifactUrl,
},
},
enabled: true,
feed: null,
signed: false,
},
},
version: 1,
}));
return;
}
if (path === `/${channel}/versions/${version}/${artifactName}`) {
response.setHeader("content-length", String(artifactBody.byteLength));
response.setHeader("content-type", "application/x-apple-diskimage");
response.end(artifactBody);
return;
}
if (path === `/${channel}/versions/${version}/${artifactName}.sha256`) {
response.setHeader("content-type", "text/plain; charset=utf-8");
response.end(`${sha256} ${artifactName}\n`);
return;
}
response.statusCode = 404;
response.end("not found");
});
await listen(server, port, host);
const origin = serverOrigin(server);
const artifactUrl = `${origin}/${channel}/versions/${version}/${artifactName}`;
info = {
artifactUrl,
channel,
checksumUrl: `${artifactUrl}.sha256`,
metadataUrl: `${origin}/${channel}/latest/metadata.json`,
origin,
sha256,
version,
};
return {
close: () => close(server),
info,
};
}

View file

@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { startUpdaterFixtureServer } from "../src/updater-fixture.js";
describe("updater fixture server", () => {
it("serves metadata, artifact bytes, and checksum for the updater flow", async () => {
const server = await startUpdaterFixtureServer({
artifactBody: "fixture artifact",
channel: "beta",
version: "2.0.0-beta-nightly.1",
});
try {
const metadataResponse = await fetch(server.info.metadataUrl);
expect(metadataResponse.ok).toBe(true);
const metadata = await metadataResponse.json() as {
baseVersion?: string;
betaNumber?: number;
betaVersion?: string;
channel?: string;
platforms?: { mac?: { artifacts?: { dmg?: { sha256Url?: string; url?: string } } } };
releaseVersion?: string;
};
expect(metadata.channel).toBe("beta");
expect(metadata.baseVersion).toBe("2.0.0");
expect(metadata.betaNumber).toBe(1);
expect(metadata.betaVersion).toBe("2.0.0-beta-nightly.1");
expect(metadata.releaseVersion).toBeUndefined();
expect(metadata.platforms?.mac?.artifacts?.dmg?.url).toBe(server.info.artifactUrl);
expect(metadata.platforms?.mac?.artifacts?.dmg?.sha256Url).toBe(server.info.checksumUrl);
const artifact = await fetch(server.info.artifactUrl);
expect(await artifact.text()).toBe("fixture artifact");
const checksum = await fetch(server.info.checksumUrl);
expect(await checksum.text()).toContain(server.info.sha256);
} finally {
await server.close();
}
});
});

21
tools/serve/tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"lib": ["ES2024"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"resolveJsonModule": true,
"rootDir": "src",
"skipLibCheck": true,
"strict": true,
"target": "ES2024",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}

View file

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"emitDeclarationOnly": false,
"noEmit": true,
"rootDir": "."
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
},
});