first-commit
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
# tools/AGENTS.md
|
||||
|
||||
Follow the root `AGENTS.md` first. This file only records module-level boundaries for `tools/`.
|
||||
|
||||
## Active tools
|
||||
|
||||
- `tools/dev` provides `@open-design/tools-dev` and the `tools-dev` bin. It is the only currently active local development lifecycle control plane.
|
||||
- `pnpm tools-dev` manages daemon -> web -> desktop.
|
||||
- `pnpm tools-dev run web` runs foreground daemon + web for the Playwright webServer flow.
|
||||
- `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.
|
||||
|
||||
## Packaging scope
|
||||
|
||||
- Keep `tools-pack` focused on packaging/runtime control and release artifact preparation. Runtime updater product integration remains a later phase.
|
||||
- Pack-specific Electron builder resources belong under `tools/pack/resources/`; do not reference app/docs/download assets directly from pack logic.
|
||||
- Namespace controls packaged data/log/runtime/cache paths. Ports are transient transport details and must not participate in path decisions.
|
||||
- The package/build boundary of root `pnpm build` is intentionally unchanged in this round and should be handled by the future `tools-pack` task.
|
||||
|
||||
## Orchestration boundary
|
||||
|
||||
- Orchestration layers must consume primitives from `@open-design/sidecar-proto`, `@open-design/sidecar`, and `@open-design/platform`.
|
||||
- Do not hand-build `--od-stamp-*` args, process-scan regexes, runtime tokens, process roles, or duplicate namespace/source args in `tools/dev`, future `tools/pack`, or packaged launchers.
|
||||
- Port flags are authoritative inputs: `--daemon-port` and `--web-port`. Internal env vars are `OD_PORT` and `OD_WEB_PORT`; do not introduce `NEXT_PORT`.
|
||||
|
||||
## Common tools commands
|
||||
|
||||
```bash
|
||||
pnpm --filter @open-design/tools-dev typecheck
|
||||
pnpm --filter @open-design/tools-dev build
|
||||
pnpm --filter @open-design/tools-pack typecheck
|
||||
pnpm --filter @open-design/tools-pack build
|
||||
pnpm tools-dev status --json
|
||||
pnpm tools-dev logs --json
|
||||
pnpm tools-dev check
|
||||
pnpm tools-pack mac build --to all
|
||||
pnpm tools-pack mac install
|
||||
pnpm tools-pack mac cleanup
|
||||
pnpm tools-pack win build --to nsis
|
||||
pnpm tools-pack win install
|
||||
pnpm tools-pack win inspect --expr "document.title"
|
||||
pnpm tools-pack win cleanup
|
||||
pnpm tools-pack linux build --to appimage
|
||||
pnpm tools-pack linux install
|
||||
pnpm tools-pack linux build --containerized
|
||||
```
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/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');
|
||||
const requiredDistEntries = [distEntry];
|
||||
const missingDistEntries = requiredDistEntries.filter((entry) => !existsSync(entry));
|
||||
|
||||
if (missingDistEntries.length > 0) {
|
||||
throw new Error(
|
||||
`tools-dev dist entries not found: ${missingDistEntries.join(', ')}. Run "pnpm --filter @open-design/tools-dev build" first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await import(pathToFileURL(distEntry).href);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { build } from "esbuild";
|
||||
|
||||
await build({
|
||||
banner: {
|
||||
js: "#!/usr/bin/env node",
|
||||
},
|
||||
bundle: true,
|
||||
entryNames: "[name]",
|
||||
entryPoints: ["./src/index.ts"],
|
||||
format: "esm",
|
||||
outdir: "./dist",
|
||||
outExtension: {
|
||||
".js": ".mjs",
|
||||
},
|
||||
packages: "external",
|
||||
platform: "node",
|
||||
target: "node24",
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@open-design/tools-dev",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"tools-dev": "./bin/tools-dev.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node ./esbuild.config.mjs",
|
||||
"dev": "tsx ./src/index.ts",
|
||||
"test": "node --import tsx --test src/*.test.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@open-design/platform": "workspace:0.3.0",
|
||||
"@open-design/sidecar": "workspace:0.3.0",
|
||||
"@open-design/sidecar-proto": "workspace:0.3.0",
|
||||
"cac": "6.7.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.12.2",
|
||||
"esbuild": "0.27.7",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "~24"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
APP_KEYS,
|
||||
OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
SIDECAR_ENV,
|
||||
SIDECAR_SOURCES,
|
||||
} from "@open-design/sidecar-proto";
|
||||
import {
|
||||
resolveAppIpcPath,
|
||||
resolveAppRuntimePath,
|
||||
resolveLogFilePath,
|
||||
resolveNamespace,
|
||||
resolveNamespaceRoot,
|
||||
resolveSidecarBase,
|
||||
resolveSourceRuntimeRoot,
|
||||
} from "@open-design/sidecar";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ENTRY_DIR_NAME = path.basename(__dirname);
|
||||
|
||||
export const WORKSPACE_ROOT = path.resolve(__dirname, ENTRY_DIR_NAME === "dist" ? "../../.." : "../../..");
|
||||
|
||||
export const ALL_APPS = [APP_KEYS.DAEMON, APP_KEYS.WEB, APP_KEYS.DESKTOP] as const;
|
||||
export const DEFAULT_START_APPS = [APP_KEYS.DAEMON, APP_KEYS.WEB, APP_KEYS.DESKTOP] as const;
|
||||
export const DEFAULT_RUN_APPS = [APP_KEYS.DAEMON, APP_KEYS.WEB] as const;
|
||||
export const DEFAULT_STOP_APPS = [APP_KEYS.DESKTOP, APP_KEYS.WEB, APP_KEYS.DAEMON] as const;
|
||||
|
||||
export type ToolDevAppName = (typeof ALL_APPS)[number];
|
||||
|
||||
export type ToolDevOptions = {
|
||||
daemonPort?: number | string | null;
|
||||
json?: boolean;
|
||||
namespace?: string;
|
||||
prod?: boolean;
|
||||
toolsDevRoot?: string;
|
||||
webPort?: number | string | null;
|
||||
};
|
||||
|
||||
export type ToolDevAppConfig = {
|
||||
app: ToolDevAppName;
|
||||
ipcPath: string;
|
||||
latestLogPath: string;
|
||||
logDir: string;
|
||||
};
|
||||
|
||||
export type ToolDevConfig = {
|
||||
apps: {
|
||||
daemon: ToolDevAppConfig & {
|
||||
sidecarEntryPath: string;
|
||||
};
|
||||
desktop: ToolDevAppConfig & {
|
||||
electronBinaryPath: string;
|
||||
mainEntryPath: string;
|
||||
packageJsonPath: string;
|
||||
};
|
||||
web: ToolDevAppConfig & {
|
||||
nextDistDir: string;
|
||||
nextTsconfigPath: string;
|
||||
sidecarEntryPath: string;
|
||||
};
|
||||
};
|
||||
namespace: string;
|
||||
namespaceRoot: string;
|
||||
toolsDevRoot: string;
|
||||
tsxCliPath: string;
|
||||
workspaceRoot: string;
|
||||
};
|
||||
|
||||
function resolveTsxCliPath(): string {
|
||||
const require = createRequire(import.meta.url);
|
||||
return require.resolve("tsx/cli");
|
||||
}
|
||||
|
||||
function resolveElectronBinaryPath(workspaceRoot: string): string {
|
||||
const packageJsonPath = path.join(workspaceRoot, "apps/desktop/package.json");
|
||||
const require = createRequire(packageJsonPath);
|
||||
const electron = require("electron") as unknown;
|
||||
if (typeof electron === "string" && electron.length > 0) return electron;
|
||||
return require.resolve("electron/cli.js");
|
||||
}
|
||||
|
||||
function resolveAppConfig(options: {
|
||||
app: ToolDevAppName;
|
||||
namespace: string;
|
||||
namespaceRoot: string;
|
||||
toolsDevRoot: string;
|
||||
}): ToolDevAppConfig {
|
||||
return {
|
||||
app: options.app,
|
||||
ipcPath: resolveAppIpcPath({
|
||||
app: options.app,
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
namespace: options.namespace,
|
||||
}),
|
||||
latestLogPath: resolveLogFilePath({ runtimeRoot: options.namespaceRoot, app: options.app, contract: OPEN_DESIGN_SIDECAR_CONTRACT }),
|
||||
logDir: path.dirname(resolveLogFilePath({ runtimeRoot: options.namespaceRoot, app: options.app, contract: OPEN_DESIGN_SIDECAR_CONTRACT })),
|
||||
};
|
||||
}
|
||||
|
||||
export function isToolDevAppName(value: string): value is ToolDevAppName {
|
||||
return ALL_APPS.includes(value as ToolDevAppName);
|
||||
}
|
||||
|
||||
function unsupportedAppError(value: string): Error {
|
||||
return new Error(`unsupported tools-dev app: ${value} (expected one of: ${ALL_APPS.join(", ")})`);
|
||||
}
|
||||
|
||||
export function resolveTargetApps(appName: string | undefined, defaults: readonly ToolDevAppName[]): ToolDevAppName[] {
|
||||
if (appName == null) return [...defaults];
|
||||
if (!isToolDevAppName(appName)) throw unsupportedAppError(appName);
|
||||
return [appName];
|
||||
}
|
||||
|
||||
export function resolveStartApps(appName: string | undefined): ToolDevAppName[] {
|
||||
if (appName == null) return [...DEFAULT_START_APPS];
|
||||
if (!isToolDevAppName(appName)) throw unsupportedAppError(appName);
|
||||
if (appName === APP_KEYS.WEB) return [APP_KEYS.DAEMON, APP_KEYS.WEB];
|
||||
if (appName === APP_KEYS.DESKTOP) return [APP_KEYS.DAEMON, APP_KEYS.WEB, APP_KEYS.DESKTOP];
|
||||
return [APP_KEYS.DAEMON];
|
||||
}
|
||||
|
||||
export function resolveRunApps(appName: string | undefined): ToolDevAppName[] {
|
||||
if (appName == null) return [...DEFAULT_RUN_APPS];
|
||||
return resolveStartApps(appName);
|
||||
}
|
||||
|
||||
export function resolveStopApps(appName: string | undefined): ToolDevAppName[] {
|
||||
if (appName == null) return [...DEFAULT_STOP_APPS];
|
||||
if (!isToolDevAppName(appName)) throw unsupportedAppError(appName);
|
||||
if (appName === APP_KEYS.WEB) return [APP_KEYS.WEB, APP_KEYS.DAEMON];
|
||||
if (appName === APP_KEYS.DESKTOP) return [APP_KEYS.DESKTOP];
|
||||
return [APP_KEYS.DAEMON];
|
||||
}
|
||||
|
||||
export function parsePortOption(value: number | string | null | undefined, optionName: string): number | null {
|
||||
if (value == null || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
||||
throw new Error(`${optionName} must be an integer between 1 and 65535`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function resolveToolDevConfig(options: ToolDevOptions = {}): ToolDevConfig {
|
||||
const namespace = resolveNamespace({ namespace: options.namespace, env: process.env, contract: OPEN_DESIGN_SIDECAR_CONTRACT });
|
||||
const toolsDevRoot = resolveSidecarBase({
|
||||
base: options.toolsDevRoot ?? process.env[SIDECAR_ENV.BASE] ?? resolveSourceRuntimeRoot({
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
projectRoot: WORKSPACE_ROOT,
|
||||
source: SIDECAR_SOURCES.TOOLS_DEV,
|
||||
}),
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
env: process.env,
|
||||
projectRoot: WORKSPACE_ROOT,
|
||||
source: SIDECAR_SOURCES.TOOLS_DEV,
|
||||
});
|
||||
const namespaceRoot = resolveNamespaceRoot({ base: toolsDevRoot, namespace, contract: OPEN_DESIGN_SIDECAR_CONTRACT });
|
||||
const daemon = resolveAppConfig({ app: APP_KEYS.DAEMON, namespace, namespaceRoot, toolsDevRoot });
|
||||
const desktop = resolveAppConfig({ app: APP_KEYS.DESKTOP, namespace, namespaceRoot, toolsDevRoot });
|
||||
const web = resolveAppConfig({ app: APP_KEYS.WEB, namespace, namespaceRoot, toolsDevRoot });
|
||||
const desktopPackageJsonPath = path.join(WORKSPACE_ROOT, "apps/desktop/package.json");
|
||||
let cachedElectronBinaryPath: string | undefined;
|
||||
|
||||
return {
|
||||
apps: {
|
||||
daemon: {
|
||||
...daemon,
|
||||
sidecarEntryPath: path.join(WORKSPACE_ROOT, "apps/daemon/sidecar/index.ts"),
|
||||
},
|
||||
desktop: {
|
||||
...desktop,
|
||||
get electronBinaryPath() {
|
||||
if (cachedElectronBinaryPath == null) cachedElectronBinaryPath = resolveElectronBinaryPath(WORKSPACE_ROOT);
|
||||
return cachedElectronBinaryPath;
|
||||
},
|
||||
mainEntryPath: path.join(WORKSPACE_ROOT, "apps/desktop/dist/main/index.js"),
|
||||
packageJsonPath: desktopPackageJsonPath,
|
||||
},
|
||||
web: {
|
||||
...web,
|
||||
nextDistDir: resolveAppRuntimePath({ app: APP_KEYS.WEB, namespaceRoot, fileName: "next", contract: OPEN_DESIGN_SIDECAR_CONTRACT }),
|
||||
nextTsconfigPath: resolveAppRuntimePath({ app: APP_KEYS.WEB, namespaceRoot, fileName: "tsconfig.json", contract: OPEN_DESIGN_SIDECAR_CONTRACT }),
|
||||
sidecarEntryPath: path.join(WORKSPACE_ROOT, "apps/web/sidecar/index.ts"),
|
||||
},
|
||||
},
|
||||
namespace,
|
||||
namespaceRoot,
|
||||
toolsDevRoot,
|
||||
tsxCliPath: resolveTsxCliPath(),
|
||||
workspaceRoot: WORKSPACE_ROOT,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
appendStartupLogDiagnostics,
|
||||
createStartupLogDiagnostics,
|
||||
detectLogDiagnostics,
|
||||
} from "./diagnostics.js";
|
||||
|
||||
describe("tools-dev diagnostics", () => {
|
||||
it("detects native addon ABI mismatches", () => {
|
||||
const diagnostics = detectLogDiagnostics([
|
||||
"Error: The module '/repo/node_modules/better-sqlite3/build/Release/better_sqlite3.node'",
|
||||
"was compiled against a different Node.js version using",
|
||||
"NODE_MODULE_VERSION 127. This version of Node.js requires",
|
||||
"NODE_MODULE_VERSION 137. Please try re-compiling or re-installing",
|
||||
]);
|
||||
|
||||
assert.equal(diagnostics.length, 1);
|
||||
assert.match(diagnostics[0].message, /native Node addon ABI mismatch/);
|
||||
assert.match(diagnostics[0].recommendation, /rebuild better-sqlite3 --pending/);
|
||||
assert.match(diagnostics[0].recommendation, /pnpm install/);
|
||||
});
|
||||
|
||||
it("does not report diagnostics for unrelated logs", () => {
|
||||
assert.deepEqual(detectLogDiagnostics(["daemon booting", "ready"]), []);
|
||||
});
|
||||
|
||||
it("appends log tails and recommendations to startup timeout errors", () => {
|
||||
const error = appendStartupLogDiagnostics(
|
||||
new Error("daemon did not expose status in time"),
|
||||
"daemon",
|
||||
createStartupLogDiagnostics("/tmp/daemon.log", [
|
||||
"better_sqlite3.node was compiled against a different Node.js version using",
|
||||
"NODE_MODULE_VERSION 127. This version of Node.js requires NODE_MODULE_VERSION 137.",
|
||||
]),
|
||||
);
|
||||
|
||||
assert.match(error.message, /daemon did not expose status in time/);
|
||||
assert.match(error.message, /daemon log tail \(\/tmp\/daemon\.log\)/);
|
||||
assert.match(error.message, /better_sqlite3\.node/);
|
||||
assert.match(error.message, /pnpm --filter @open-design\/daemon rebuild better-sqlite3 --pending/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
export type LogDiagnostic = {
|
||||
message: string;
|
||||
recommendation: string;
|
||||
};
|
||||
|
||||
export type StartupLogDiagnostics = {
|
||||
diagnostics: LogDiagnostic[];
|
||||
logPath: string;
|
||||
lines: string[];
|
||||
};
|
||||
|
||||
const NATIVE_ADDON_ABI_MISMATCH_PATTERN = /was compiled against a different Node\.js version[\s\S]*?NODE_MODULE_VERSION\s+\d+[\s\S]*?requires\s+NODE_MODULE_VERSION\s+\d+/i;
|
||||
const NODE_MODULE_VERSION_PATTERN = /NODE_MODULE_VERSION\s+\d+[\s\S]*?NODE_MODULE_VERSION\s+\d+/i;
|
||||
|
||||
export function detectLogDiagnostics(lines: readonly string[]): LogDiagnostic[] {
|
||||
const logText = lines.join("\n");
|
||||
if (!NATIVE_ADDON_ABI_MISMATCH_PATTERN.test(logText) && !NODE_MODULE_VERSION_PATTERN.test(logText)) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
message: "Detected a native Node addon ABI mismatch in the daemon log.",
|
||||
recommendation: [
|
||||
"Rebuild native daemon dependencies for the active Node version:",
|
||||
" pnpm --filter @open-design/daemon rebuild better-sqlite3 --pending",
|
||||
"or refresh the workspace install:",
|
||||
" pnpm install",
|
||||
].join("\n"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function formatLogDiagnostics(diagnostics: readonly LogDiagnostic[]): string | null {
|
||||
if (diagnostics.length === 0) return null;
|
||||
return diagnostics
|
||||
.map((diagnostic) => `${diagnostic.message}\n${diagnostic.recommendation}`)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function createStartupLogDiagnostics(logPath: string, lines: readonly string[]): StartupLogDiagnostics {
|
||||
return {
|
||||
diagnostics: detectLogDiagnostics(lines),
|
||||
lines: [...lines],
|
||||
logPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendStartupLogDiagnostics(error: unknown, appName: string, details: StartupLogDiagnostics): Error {
|
||||
const baseMessage = error instanceof Error ? error.message : String(error);
|
||||
const sections = [baseMessage, `${appName} log tail (${details.logPath}):`];
|
||||
sections.push(details.lines.length > 0 ? details.lines.join("\n") : "(no log lines)");
|
||||
|
||||
const formattedDiagnostics = formatLogDiagnostics(details.diagnostics);
|
||||
if (formattedDiagnostics != null) sections.push(formattedDiagnostics);
|
||||
|
||||
return new Error(sections.join("\n\n"), error instanceof Error ? { cause: error } : undefined);
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { lstat, mkdir, open, rm, symlink, writeFile, type FileHandle } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { cac } from "cac";
|
||||
|
||||
import {
|
||||
APP_KEYS,
|
||||
OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
SIDECAR_ENV,
|
||||
SIDECAR_MESSAGES,
|
||||
SIDECAR_SOURCES,
|
||||
type DaemonStatusSnapshot,
|
||||
type DesktopClickResult,
|
||||
type DesktopConsoleResult,
|
||||
type DesktopEvalResult,
|
||||
type DesktopScreenshotResult,
|
||||
type DesktopStatusSnapshot,
|
||||
type WebStatusSnapshot,
|
||||
} from "@open-design/sidecar-proto";
|
||||
import { createSidecarLaunchEnv, requestJsonIpc } from "@open-design/sidecar";
|
||||
import {
|
||||
collectProcessTreePids,
|
||||
createPackageManagerInvocation,
|
||||
createProcessStampArgs,
|
||||
listProcessSnapshots,
|
||||
matchesStampedProcess,
|
||||
readLogTail,
|
||||
spawnBackgroundProcess,
|
||||
stopProcesses,
|
||||
type StopProcessesResult,
|
||||
} from "@open-design/platform";
|
||||
|
||||
import {
|
||||
ALL_APPS,
|
||||
DEFAULT_START_APPS,
|
||||
DEFAULT_STOP_APPS,
|
||||
parsePortOption,
|
||||
resolveRunApps,
|
||||
resolveStartApps,
|
||||
resolveStopApps,
|
||||
resolveTargetApps,
|
||||
resolveToolDevConfig,
|
||||
type ToolDevAppName,
|
||||
type ToolDevConfig,
|
||||
type ToolDevOptions,
|
||||
} from "./config.js";
|
||||
import {
|
||||
appendStartupLogDiagnostics,
|
||||
createStartupLogDiagnostics,
|
||||
detectLogDiagnostics,
|
||||
formatLogDiagnostics,
|
||||
type LogDiagnostic,
|
||||
} from "./diagnostics.js";
|
||||
import {
|
||||
inspectDaemonRuntime,
|
||||
inspectDesktopRuntime,
|
||||
inspectWebRuntime,
|
||||
waitForDaemonRuntime,
|
||||
waitForDesktopRuntime,
|
||||
waitForWebRuntime,
|
||||
} from "./sidecar-client.js";
|
||||
|
||||
type CliOptions = ToolDevOptions & {
|
||||
expr?: string;
|
||||
parentPid?: number;
|
||||
path?: string;
|
||||
selector?: string;
|
||||
timeout?: string;
|
||||
};
|
||||
|
||||
const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID;
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function exitWithError(error: unknown): never {
|
||||
process.stderr.write(`${formatError(error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.on("uncaughtException", exitWithError);
|
||||
process.on("unhandledRejection", exitWithError);
|
||||
|
||||
function printJson(payload: unknown): void {
|
||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function output(payload: unknown, options: CliOptions = {}): void {
|
||||
if (typeof payload === "string" && options.json !== true) {
|
||||
process.stdout.write(`${payload}\n`);
|
||||
return;
|
||||
}
|
||||
printJson(payload);
|
||||
}
|
||||
|
||||
function normalizeDisplayUrl(url: string): string {
|
||||
return url.endsWith("/") ? url : `${url}/`;
|
||||
}
|
||||
|
||||
function colorizeLink(url: string): string {
|
||||
if (process.env.NO_COLOR != null || process.stdout.isTTY !== true) return url;
|
||||
const reset = "\x1b[0m";
|
||||
const cyan = "\x1b[36m";
|
||||
const underline = "\x1b[4m";
|
||||
return `${cyan}${underline}${url}${reset}`;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
|
||||
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 | null {
|
||||
const value = record[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function numberArrayField(record: Record<string, unknown> | null, key: string): number[] {
|
||||
const value = record?.[key];
|
||||
return Array.isArray(value) ? value.filter((entry): entry is number => typeof entry === "number" && Number.isFinite(entry)) : [];
|
||||
}
|
||||
|
||||
function formatProcessList(pids: readonly number[]): string | null {
|
||||
if (pids.length === 0) return null;
|
||||
const visible = pids.slice(0, 5).join(", ");
|
||||
return pids.length > 5 ? `${visible}, +${pids.length - 5} more` : visible;
|
||||
}
|
||||
|
||||
function formatStatusSummary(status: unknown): string {
|
||||
const record = asRecord(status);
|
||||
if (record == null) return "status unavailable";
|
||||
|
||||
const parts = [stringField(record, "state") ?? "unknown"];
|
||||
const url = stringField(record, "url");
|
||||
const pid = numberField(record, "pid");
|
||||
const title = stringField(record, "title");
|
||||
const windowVisible = record.windowVisible;
|
||||
if (url != null) parts.push(url);
|
||||
if (pid != null) parts.push(`pid ${pid}`);
|
||||
if (title != null) parts.push(`title ${JSON.stringify(title)}`);
|
||||
if (typeof windowVisible === "boolean") parts.push(`window ${windowVisible ? "visible" : "hidden"}`);
|
||||
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function printStatusEntries(apps: Record<string, unknown>): void {
|
||||
for (const [appName, appStatus] of Object.entries(apps)) {
|
||||
process.stdout.write(`- ${appName}: ${formatStatusSummary(appStatus)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function printStartSection(result: Partial<Record<ToolDevAppName, unknown>>, heading: string): void {
|
||||
process.stdout.write(`${heading}\n`);
|
||||
const entries = Object.entries(result);
|
||||
if (entries.length === 0) {
|
||||
process.stdout.write("(no apps)\n");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [appName, rawEntry] of entries) {
|
||||
const entry = asRecord(rawEntry);
|
||||
const created = entry?.created;
|
||||
const action = created === true ? "started" : created === false ? "already running" : "ready";
|
||||
process.stdout.write(`- ${appName}: ${action} · ${formatStatusSummary(entry?.status)}\n`);
|
||||
const logPath = entry == null ? null : stringField(entry, "logPath");
|
||||
if (logPath != null) process.stdout.write(` log: ${logPath}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function printStartResult(result: Partial<Record<ToolDevAppName, unknown>>, options: CliOptions, heading = "tools-dev start"): void {
|
||||
if (options.json === true) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
printStartSection(result, heading);
|
||||
}
|
||||
|
||||
function printStopSection(result: Partial<Record<ToolDevAppName, unknown>>, heading: string): void {
|
||||
process.stdout.write(`${heading}\n`);
|
||||
const entries = Object.entries(result);
|
||||
if (entries.length === 0) {
|
||||
process.stdout.write("(no apps)\n");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [appName, rawEntry] of entries) {
|
||||
const entry = asRecord(rawEntry);
|
||||
const stop = asRecord(entry?.stop);
|
||||
const stoppedPids = formatProcessList(numberArrayField(stop, "stoppedPids"));
|
||||
const remainingPids = formatProcessList(numberArrayField(stop, "remainingPids"));
|
||||
const parts = [entry == null ? "unknown" : stringField(entry, "status") ?? "unknown"];
|
||||
const via = entry == null ? null : stringField(entry, "via");
|
||||
if (via != null) parts.push(`via ${via}`);
|
||||
if (stoppedPids != null) parts.push(`stopped pids ${stoppedPids}`);
|
||||
if (remainingPids != null) parts.push(`remaining pids ${remainingPids}`);
|
||||
process.stdout.write(`- ${appName}: ${parts.join(" · ")}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function printStopResult(result: Partial<Record<ToolDevAppName, unknown>>, options: CliOptions, heading = "tools-dev stop"): void {
|
||||
if (options.json === true) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
printStopSection(result, heading);
|
||||
}
|
||||
|
||||
function printRestartResult(result: unknown, options: CliOptions): void {
|
||||
if (options.json === true) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const record = asRecord(result);
|
||||
process.stdout.write("tools-dev restart\n");
|
||||
printStopSection((asRecord(record?.stop) ?? {}) as Partial<Record<ToolDevAppName, unknown>>, "Stop");
|
||||
printStartSection((asRecord(record?.start) ?? {}) as Partial<Record<ToolDevAppName, unknown>>, "Start");
|
||||
}
|
||||
|
||||
function printStatusResult(result: unknown, options: CliOptions, appName: string | undefined): void {
|
||||
if (options.json === true) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const record = asRecord(result);
|
||||
const apps = asRecord(record?.apps);
|
||||
if (apps != null) {
|
||||
const namespace = stringField(record ?? {}, "namespace");
|
||||
const statusLabel = stringField(record ?? {}, "status");
|
||||
const details = [namespace == null ? null : `namespace ${namespace}`, statusLabel].filter((entry): entry is string => entry != null);
|
||||
process.stdout.write(`tools-dev status${details.length > 0 ? ` (${details.join(" · ")})` : ""}\n`);
|
||||
printStatusEntries(apps);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write("tools-dev status\n");
|
||||
process.stdout.write(`- ${appName ?? ALL_APPS.join("/")}: ${formatStatusSummary(result)}\n`);
|
||||
}
|
||||
|
||||
function printRunForegroundResult(started: Partial<Record<ToolDevAppName, unknown>>, options: CliOptions): void {
|
||||
if (options.json === true) {
|
||||
printJson({ mode: "foreground", started });
|
||||
return;
|
||||
}
|
||||
|
||||
const webStatus = asRecord(asRecord(started.web)?.status);
|
||||
const daemonStatus = asRecord(asRecord(started.daemon)?.status);
|
||||
const webUrl = stringField(webStatus ?? {}, "url");
|
||||
const daemonUrl = stringField(daemonStatus ?? {}, "url");
|
||||
|
||||
if (webUrl != null || daemonUrl != null) {
|
||||
process.stdout.write("\n Open Design dev server ready\n\n");
|
||||
if (webUrl != null) process.stdout.write(` ➜ Web: ${colorizeLink(normalizeDisplayUrl(webUrl))}\n`);
|
||||
if (daemonUrl != null) process.stdout.write(` ➜ Daemon: ${colorizeLink(normalizeDisplayUrl(daemonUrl))}\n`);
|
||||
process.stdout.write("\n Press Ctrl+C to stop\n\n");
|
||||
return;
|
||||
}
|
||||
|
||||
printStartSection(started, "tools-dev run");
|
||||
process.stdout.write("Foreground loop is active. Press Ctrl+C to stop.\n");
|
||||
}
|
||||
|
||||
function runtimeLookup(config: ToolDevConfig) {
|
||||
return { base: config.toolsDevRoot, namespace: config.namespace };
|
||||
}
|
||||
|
||||
function appConfig(config: ToolDevConfig, appName: ToolDevAppName) {
|
||||
return config.apps[appName];
|
||||
}
|
||||
|
||||
function urlPort(url: string): string {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.port) return parsed.port;
|
||||
return parsed.protocol === "https:" ? "443" : "80";
|
||||
}
|
||||
|
||||
function statusMatchesForcedPort(url: string | null | undefined, forcedPort: number | null): boolean {
|
||||
return forcedPort == null || (url != null && urlPort(url) === String(forcedPort));
|
||||
}
|
||||
|
||||
function prependNodePath(entries: string[], current = process.env.NODE_PATH): string {
|
||||
const existing = current == null || current.length === 0 ? [] : current.split(path.delimiter);
|
||||
return [...entries, ...existing].join(path.delimiter);
|
||||
}
|
||||
|
||||
async function openAppLog(config: ToolDevConfig, appName: ToolDevAppName): Promise<FileHandle> {
|
||||
const logPath = appConfig(config, appName).latestLogPath;
|
||||
await mkdir(path.dirname(logPath), { recursive: true });
|
||||
return await open(logPath, "a");
|
||||
}
|
||||
|
||||
async function runLoggedCommand(request: {
|
||||
args: string[];
|
||||
command: string;
|
||||
cwd: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
logFd: number;
|
||||
windowsVerbatimArguments?: boolean;
|
||||
}): Promise<void> {
|
||||
const child = spawn(request.command, request.args, {
|
||||
cwd: request.cwd,
|
||||
env: request.env,
|
||||
stdio: ["ignore", request.logFd, request.logFd],
|
||||
windowsHide: process.platform === "win32",
|
||||
windowsVerbatimArguments: request.windowsVerbatimArguments,
|
||||
});
|
||||
|
||||
await new Promise<void>((resolveRun, rejectRun) => {
|
||||
child.once("error", rejectRun);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolveRun();
|
||||
return;
|
||||
}
|
||||
rejectRun(new Error(`command failed: ${request.command} ${request.args.join(" ")} (${signal ?? code})`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createAppStamp(config: ToolDevConfig, appName: ToolDevAppName) {
|
||||
const currentAppConfig = appConfig(config, appName);
|
||||
const stamp = {
|
||||
app: appName,
|
||||
ipc: currentAppConfig.ipcPath,
|
||||
mode: "dev" as const,
|
||||
namespace: config.namespace,
|
||||
source: SIDECAR_SOURCES.TOOLS_DEV,
|
||||
};
|
||||
|
||||
return {
|
||||
args: createProcessStampArgs(stamp, OPEN_DESIGN_SIDECAR_CONTRACT),
|
||||
env: createSidecarLaunchEnv({
|
||||
base: config.toolsDevRoot,
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
stamp,
|
||||
}),
|
||||
stamp,
|
||||
};
|
||||
}
|
||||
|
||||
async function findAppProcessTree(config: ToolDevConfig, appName: ToolDevAppName) {
|
||||
const processes = await listProcessSnapshots();
|
||||
const rootPids = processes
|
||||
.filter((processInfo) =>
|
||||
matchesStampedProcess(processInfo, {
|
||||
app: appName,
|
||||
mode: "dev",
|
||||
namespace: config.namespace,
|
||||
source: SIDECAR_SOURCES.TOOLS_DEV,
|
||||
}, OPEN_DESIGN_SIDECAR_CONTRACT),
|
||||
)
|
||||
.map((processInfo) => processInfo.pid);
|
||||
const pids = collectProcessTreePids(processes, rootPids);
|
||||
|
||||
return { pids, rootPids };
|
||||
}
|
||||
|
||||
async function waitForAppProcessExit(config: ToolDevConfig, appName: ToolDevAppName, timeoutMs = 5000): Promise<number[]> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const current = await findAppProcessTree(config, appName);
|
||||
if (current.pids.length === 0) return [];
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 120));
|
||||
}
|
||||
return (await findAppProcessTree(config, appName)).pids;
|
||||
}
|
||||
|
||||
async function assertNoStaleActiveProcess(config: ToolDevConfig, appName: ToolDevAppName): Promise<void> {
|
||||
const active = await findAppProcessTree(config, appName);
|
||||
if (active.pids.length > 0) {
|
||||
throw new Error(`${appName} has active stamped processes but no reachable IPC status; run tools-dev stop ${appName} first`);
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnSidecarRuntime(request: {
|
||||
appName: typeof APP_KEYS.DAEMON | typeof APP_KEYS.WEB;
|
||||
config: ToolDevConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logHandle: FileHandle;
|
||||
}): Promise<{ pid: number }> {
|
||||
const { args: stampArgs, env } = createAppStamp(request.config, request.appName);
|
||||
const sidecarConfig = request.config.apps[request.appName];
|
||||
const spawned = await spawnBackgroundProcess({
|
||||
args: [request.config.tsxCliPath, sidecarConfig.sidecarEntryPath, ...stampArgs],
|
||||
command: process.execPath,
|
||||
cwd: request.config.workspaceRoot,
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
...request.env,
|
||||
},
|
||||
logFd: request.logHandle.fd,
|
||||
});
|
||||
return { pid: spawned.pid };
|
||||
}
|
||||
|
||||
async function spawnDaemonRuntime(config: ToolDevConfig, options: CliOptions): Promise<{ pid: number }> {
|
||||
const daemonPort = parsePortOption(options.daemonPort, "--daemon-port");
|
||||
const webPort = parsePortOption(options.webPort, "--web-port");
|
||||
const logHandle = await openAppLog(config, APP_KEYS.DAEMON);
|
||||
|
||||
try {
|
||||
await logHandle.write(`\n[tools-dev] launching daemon at ${new Date().toISOString()}\n`);
|
||||
if (webPort != null) await logHandle.write(`[tools-dev] trusting web origin port ${webPort}\n`);
|
||||
return await spawnSidecarRuntime({
|
||||
appName: APP_KEYS.DAEMON,
|
||||
config,
|
||||
env: {
|
||||
[SIDECAR_ENV.DAEMON_PORT]: String(daemonPort ?? 0),
|
||||
...(webPort == null ? {} : { [SIDECAR_ENV.WEB_PORT]: String(webPort) }),
|
||||
...(options.parentPid == null ? {} : { [TOOLS_DEV_PARENT_PID_ENV]: String(options.parentPid) }),
|
||||
},
|
||||
logHandle,
|
||||
});
|
||||
} finally {
|
||||
await logHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnWebRuntime(config: ToolDevConfig, options: CliOptions): Promise<{ pid: number }> {
|
||||
const daemonStatus = await waitForDaemonRuntime(runtimeLookup(config));
|
||||
if (daemonStatus.url == null) throw new Error("daemon must be running before web starts");
|
||||
|
||||
const webPort = parsePortOption(options.webPort, "--web-port");
|
||||
const daemonPort = urlPort(daemonStatus.url);
|
||||
const logHandle = await openAppLog(config, APP_KEYS.WEB);
|
||||
|
||||
try {
|
||||
await ensureWebDevNodeModules(config);
|
||||
await writeWebDevTsconfig(config);
|
||||
await logHandle.write(`\n[tools-dev] launching web at ${new Date().toISOString()}\n`);
|
||||
await logHandle.write(`[tools-dev] proxying web API requests to daemon port ${daemonPort}\n`);
|
||||
return await spawnSidecarRuntime({
|
||||
appName: APP_KEYS.WEB,
|
||||
config,
|
||||
env: {
|
||||
NODE_PATH: prependNodePath([
|
||||
path.join(config.workspaceRoot, "apps/web/node_modules"),
|
||||
path.join(config.workspaceRoot, "node_modules"),
|
||||
]),
|
||||
[SIDECAR_ENV.DAEMON_PORT]: daemonPort,
|
||||
[SIDECAR_ENV.WEB_DIST_DIR]: config.apps.web.nextDistDir,
|
||||
[SIDECAR_ENV.WEB_TSCONFIG_PATH]: config.apps.web.nextTsconfigPath,
|
||||
[SIDECAR_ENV.WEB_PORT]: String(webPort ?? 0),
|
||||
PORT: String(webPort ?? 0),
|
||||
...(options.parentPid == null ? {} : { [TOOLS_DEV_PARENT_PID_ENV]: String(options.parentPid) }),
|
||||
...(options.prod === true
|
||||
? { NODE_ENV: "production", OD_WEB_OUTPUT_MODE: "server", OD_WEB_PROD: "1" }
|
||||
: {}),
|
||||
},
|
||||
logHandle,
|
||||
});
|
||||
} finally {
|
||||
await logHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function buildDesktop(config: ToolDevConfig, logHandle: FileHandle): Promise<void> {
|
||||
await logHandle.write(`\n[tools-dev] building @open-design/desktop at ${new Date().toISOString()}\n`);
|
||||
const invocation = createPackageManagerInvocation(["--filter", "@open-design/desktop", "build"], process.env);
|
||||
await runLoggedCommand({
|
||||
args: invocation.args,
|
||||
command: invocation.command,
|
||||
cwd: config.workspaceRoot,
|
||||
env: process.env,
|
||||
logFd: logHandle.fd,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureWebDevNodeModules(config: ToolDevConfig): Promise<void> {
|
||||
const webRuntimeRoot = path.dirname(config.apps.web.nextDistDir);
|
||||
const runtimeNodeModules = path.join(webRuntimeRoot, "node_modules");
|
||||
const webNodeModules = path.join(config.workspaceRoot, "apps/web/node_modules");
|
||||
|
||||
await mkdir(webRuntimeRoot, { recursive: true });
|
||||
const current = await lstat(runtimeNodeModules).catch(() => null);
|
||||
if (current?.isSymbolicLink()) return;
|
||||
if (current != null) await rm(runtimeNodeModules, { force: true, recursive: true });
|
||||
await symlink(webNodeModules, runtimeNodeModules, "junction");
|
||||
}
|
||||
|
||||
async function writeWebDevTsconfig(config: ToolDevConfig): Promise<void> {
|
||||
const webRoot = path.join(config.workspaceRoot, "apps/web");
|
||||
const tsconfigPath = config.apps.web.nextTsconfigPath;
|
||||
const tsconfigDir = path.dirname(tsconfigPath);
|
||||
const sourceTsconfig = path.join(webRoot, "tsconfig.json");
|
||||
const relativeSourceTsconfig = (path.relative(tsconfigDir, sourceTsconfig) || "./tsconfig.json").replaceAll("\\", "/");
|
||||
|
||||
await mkdir(tsconfigDir, { recursive: true });
|
||||
await writeFile(
|
||||
tsconfigPath,
|
||||
`${JSON.stringify({
|
||||
extends: relativeSourceTsconfig,
|
||||
compilerOptions: {
|
||||
plugins: [{ name: "next" }],
|
||||
},
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function spawnDesktopRuntime(config: ToolDevConfig, options: CliOptions): Promise<{ pid: number }> {
|
||||
const { args: stampArgs, env } = createAppStamp(config, APP_KEYS.DESKTOP);
|
||||
const logHandle = await openAppLog(config, APP_KEYS.DESKTOP);
|
||||
|
||||
try {
|
||||
await buildDesktop(config, logHandle);
|
||||
await logHandle.write(`[tools-dev] launching desktop at ${new Date().toISOString()}\n`);
|
||||
const spawnEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
...env,
|
||||
...(options.parentPid == null ? {} : { [TOOLS_DEV_PARENT_PID_ENV]: String(options.parentPid) }),
|
||||
};
|
||||
// ELECTRON_RUN_AS_NODE=1 makes Electron boot as plain Node and skip
|
||||
// main-process API injection (app, BrowserWindow, protocol all become
|
||||
// undefined). Strip it from the spawn env so desktop always boots in
|
||||
// real Electron mode even when the parent shell is an Electron-based
|
||||
// IDE that sets this variable for sidecar reuse.
|
||||
//
|
||||
// Iterate keys with a case-insensitive comparison rather than
|
||||
// `delete spawnEnv.ELECTRON_RUN_AS_NODE`: spreading process.env into
|
||||
// a plain object loses Node's Windows case-insensitive proxy, so any
|
||||
// alternate-cased variant (e.g. `electron_run_as_node`) would still
|
||||
// be passed to the child and Win32 CreateProcess would treat it as
|
||||
// the same variable, undoing the fix.
|
||||
//
|
||||
// Scope is tools-dev only. The packaged runtime intentionally sets
|
||||
// ELECTRON_RUN_AS_NODE on its own daemon/web sidecars (see
|
||||
// apps/packaged/src/sidecars.ts) to reuse the bundled Node binary;
|
||||
// that flow is independent and untouched here.
|
||||
for (const key of Object.keys(spawnEnv)) {
|
||||
if (key.toUpperCase() === "ELECTRON_RUN_AS_NODE") {
|
||||
delete spawnEnv[key];
|
||||
}
|
||||
}
|
||||
const spawned = await spawnBackgroundProcess({
|
||||
args: [config.apps.desktop.mainEntryPath, ...stampArgs],
|
||||
command: config.apps.desktop.electronBinaryPath,
|
||||
cwd: config.workspaceRoot,
|
||||
detached: true,
|
||||
env: spawnEnv,
|
||||
logFd: logHandle.fd,
|
||||
});
|
||||
return { pid: spawned.pid };
|
||||
} finally {
|
||||
await logHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function startDaemon(config: ToolDevConfig, options: CliOptions) {
|
||||
const daemonPort = parsePortOption(options.daemonPort, "--daemon-port");
|
||||
const existing = await inspectDaemonRuntime(runtimeLookup(config));
|
||||
if (existing?.url != null && statusMatchesForcedPort(existing.url, daemonPort)) {
|
||||
return { app: APP_KEYS.DAEMON, created: false, logPath: config.apps.daemon.latestLogPath, status: existing };
|
||||
}
|
||||
if (existing?.url != null) {
|
||||
throw new Error(`${APP_KEYS.DAEMON} is already running in namespace ${config.namespace} at ${existing.url}; stop it or choose another namespace`);
|
||||
}
|
||||
await assertNoStaleActiveProcess(config, APP_KEYS.DAEMON);
|
||||
|
||||
const spawned = await spawnDaemonRuntime(config, options);
|
||||
try {
|
||||
const status = await waitForDaemonRuntime(runtimeLookup(config));
|
||||
return {
|
||||
app: APP_KEYS.DAEMON,
|
||||
created: true,
|
||||
logPath: config.apps.daemon.latestLogPath,
|
||||
pid: spawned.pid,
|
||||
status,
|
||||
};
|
||||
} catch (error) {
|
||||
const logPath = config.apps.daemon.latestLogPath;
|
||||
const lines = await readLogTail(logPath, 80).catch(() => []);
|
||||
await stopApp(config, APP_KEYS.DAEMON).catch(() => undefined);
|
||||
throw appendStartupLogDiagnostics(error, APP_KEYS.DAEMON, createStartupLogDiagnostics(logPath, lines));
|
||||
}
|
||||
}
|
||||
|
||||
async function startWeb(config: ToolDevConfig, options: CliOptions) {
|
||||
const webPort = parsePortOption(options.webPort, "--web-port");
|
||||
const existing = await inspectWebRuntime(runtimeLookup(config));
|
||||
if (existing?.url != null && statusMatchesForcedPort(existing.url, webPort)) {
|
||||
return { app: APP_KEYS.WEB, created: false, logPath: config.apps.web.latestLogPath, status: existing };
|
||||
}
|
||||
if (existing?.url != null) {
|
||||
throw new Error(`${APP_KEYS.WEB} is already running in namespace ${config.namespace} at ${existing.url}; stop it or choose another namespace`);
|
||||
}
|
||||
await assertNoStaleActiveProcess(config, APP_KEYS.WEB);
|
||||
|
||||
const spawned = await spawnWebRuntime(config, options);
|
||||
try {
|
||||
const status = await waitForWebRuntime(runtimeLookup(config));
|
||||
return {
|
||||
app: APP_KEYS.WEB,
|
||||
created: true,
|
||||
logPath: config.apps.web.latestLogPath,
|
||||
pid: spawned.pid,
|
||||
status,
|
||||
};
|
||||
} catch (error) {
|
||||
await stopApp(config, APP_KEYS.WEB).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function startDesktop(config: ToolDevConfig, options: CliOptions) {
|
||||
const existing = await inspectDesktopRuntime(runtimeLookup(config));
|
||||
if (existing != null) {
|
||||
return { app: APP_KEYS.DESKTOP, created: false, logPath: config.apps.desktop.latestLogPath, status: existing };
|
||||
}
|
||||
await assertNoStaleActiveProcess(config, APP_KEYS.DESKTOP);
|
||||
|
||||
const spawned = await spawnDesktopRuntime(config, options);
|
||||
try {
|
||||
const status = await waitForDesktopRuntime(runtimeLookup(config));
|
||||
return {
|
||||
app: APP_KEYS.DESKTOP,
|
||||
created: true,
|
||||
logPath: config.apps.desktop.latestLogPath,
|
||||
pid: spawned.pid,
|
||||
status,
|
||||
};
|
||||
} catch (error) {
|
||||
await stopApp(config, APP_KEYS.DESKTOP).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function startApp(config: ToolDevConfig, appName: ToolDevAppName, options: CliOptions) {
|
||||
switch (appName) {
|
||||
case APP_KEYS.DAEMON:
|
||||
return await startDaemon(config, options);
|
||||
case APP_KEYS.WEB:
|
||||
return await startWeb(config, options);
|
||||
case APP_KEYS.DESKTOP:
|
||||
return await startDesktop(config, options);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestAppShutdown(config: ToolDevConfig, appName: ToolDevAppName): Promise<boolean> {
|
||||
try {
|
||||
await requestJsonIpc(appConfig(config, appName).ipcPath, { type: SIDECAR_MESSAGES.SHUTDOWN }, { timeoutMs: 1500 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function stoppedByGracefulResult(matchedPids: number[]): StopProcessesResult {
|
||||
return {
|
||||
alreadyStopped: matchedPids.length === 0,
|
||||
forcedPids: [],
|
||||
matchedPids,
|
||||
remainingPids: [],
|
||||
stoppedPids: matchedPids,
|
||||
};
|
||||
}
|
||||
|
||||
async function stopApp(config: ToolDevConfig, appName: ToolDevAppName) {
|
||||
const before = await findAppProcessTree(config, appName);
|
||||
const gracefulRequested = await requestAppShutdown(config, appName);
|
||||
const remainingAfterGraceful = gracefulRequested
|
||||
? await waitForAppProcessExit(config, appName)
|
||||
: before.pids;
|
||||
|
||||
if (remainingAfterGraceful.length === 0) {
|
||||
return {
|
||||
app: appName,
|
||||
status: before.pids.length === 0 ? "not-running" : "stopped",
|
||||
stop: stoppedByGracefulResult(before.pids),
|
||||
via: gracefulRequested ? "ipc" : "process-scan",
|
||||
};
|
||||
}
|
||||
|
||||
const stop = await stopProcesses(remainingAfterGraceful);
|
||||
return {
|
||||
app: appName,
|
||||
status: stop.remainingPids.length === 0 ? "stopped" : "partial",
|
||||
stop,
|
||||
via: gracefulRequested ? "ipc+fallback" : "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectAppStatus(config: ToolDevConfig, appName: ToolDevAppName) {
|
||||
if (appName === APP_KEYS.DAEMON) {
|
||||
const status = await inspectDaemonRuntime(runtimeLookup(config));
|
||||
if (status != null) return status;
|
||||
const active = await findAppProcessTree(config, appName);
|
||||
return { pid: active.rootPids[0] ?? null, state: active.pids.length > 0 ? "starting" : "idle", url: null } satisfies DaemonStatusSnapshot;
|
||||
}
|
||||
if (appName === APP_KEYS.WEB) {
|
||||
const status = await inspectWebRuntime(runtimeLookup(config));
|
||||
if (status != null) return status;
|
||||
const active = await findAppProcessTree(config, appName);
|
||||
return { pid: active.rootPids[0] ?? null, state: active.pids.length > 0 ? "starting" : "idle", url: null } satisfies WebStatusSnapshot;
|
||||
}
|
||||
|
||||
const status = await inspectDesktopRuntime(runtimeLookup(config));
|
||||
if (status != null) return status;
|
||||
const active = await findAppProcessTree(config, appName);
|
||||
return { pid: active.rootPids[0] ?? null, state: active.pids.length > 0 ? "unknown" : "idle", url: null };
|
||||
}
|
||||
|
||||
function summarizeStatus(apps: Record<ToolDevAppName, any>): string {
|
||||
const states = Object.values(apps).map((entry) => entry?.state);
|
||||
if (states.every((state) => state === "idle")) return "not-running";
|
||||
if (states.every((state) => state === "running")) return "running";
|
||||
return "partial";
|
||||
}
|
||||
|
||||
async function status(config: ToolDevConfig, appName: string | undefined) {
|
||||
const targets = resolveTargetApps(appName, DEFAULT_START_APPS);
|
||||
if (targets.length === 1) return await inspectAppStatus(config, targets[0]);
|
||||
|
||||
const apps = Object.fromEntries(
|
||||
await Promise.all(targets.map(async (target) => [target, await inspectAppStatus(config, target)] as const)),
|
||||
) as Record<ToolDevAppName, unknown>;
|
||||
return { apps, namespace: config.namespace, status: summarizeStatus(apps) };
|
||||
}
|
||||
|
||||
async function restartTargets(config: ToolDevConfig, appName: string | undefined, options: CliOptions) {
|
||||
const stopTargets = resolveStopApps(appName);
|
||||
const startTargets = resolveStartApps(appName);
|
||||
return {
|
||||
stop: await runSequential(stopTargets, (target) => stopApp(config, target)),
|
||||
start: await runSequential(startTargets, (target) => startApp(config, target, options)),
|
||||
};
|
||||
}
|
||||
|
||||
async function readLogs(config: ToolDevConfig, appName: ToolDevAppName) {
|
||||
const logPath = appConfig(config, appName).latestLogPath;
|
||||
return { app: appName, lines: await readLogTail(logPath, 200), logPath };
|
||||
}
|
||||
|
||||
function createLogDiagnostics(logs: Record<string, LogResult>): Record<string, LogDiagnostic[]> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(logs).map(([appName, log]) => [appName, detectLogDiagnostics(log.lines)] as const),
|
||||
);
|
||||
}
|
||||
|
||||
type LogResult = Awaited<ReturnType<typeof readLogs>>;
|
||||
|
||||
function isLogResult(value: LogResult | Record<string, LogResult>): value is LogResult {
|
||||
return Array.isArray((value as LogResult).lines);
|
||||
}
|
||||
|
||||
function printLogs(result: LogResult | Record<string, LogResult>, options: CliOptions) {
|
||||
if (options.json === true) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries: Array<[string, LogResult]> = isLogResult(result) ? [[result.app, result]] : Object.entries(result);
|
||||
for (const [appName, entry] of entries) {
|
||||
process.stdout.write(`[${appName}] ${entry.logPath}\n`);
|
||||
process.stdout.write(entry.lines.length > 0 ? `${entry.lines.join("\n")}\n` : "(no log lines)\n");
|
||||
}
|
||||
}
|
||||
|
||||
function printCheckResult(result: unknown, options: CliOptions): void {
|
||||
if (options.json === true) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
const record = asRecord(result);
|
||||
const namespace = record == null ? null : stringField(record, "namespace");
|
||||
process.stdout.write(`tools-dev check${namespace == null ? "" : ` (namespace ${namespace})`}\n`);
|
||||
|
||||
const apps = asRecord(record?.apps);
|
||||
if (apps != null) {
|
||||
process.stdout.write("Status\n");
|
||||
printStatusEntries(apps);
|
||||
}
|
||||
|
||||
const logs = asRecord(record?.logs);
|
||||
if (logs != null) {
|
||||
process.stdout.write("\nLogs\n");
|
||||
printLogs(logs as Record<string, LogResult>, options);
|
||||
}
|
||||
|
||||
const diagnostics = asRecord(record?.diagnostics);
|
||||
if (diagnostics != null) {
|
||||
const entries = Object.entries(diagnostics)
|
||||
.map(([appName, value]) => [appName, Array.isArray(value) ? formatLogDiagnostics(value as LogDiagnostic[]) : null] as const)
|
||||
.filter((entry): entry is readonly [string, string] => entry[1] != null);
|
||||
if (entries.length > 0) {
|
||||
process.stdout.write("\nDiagnostics\n");
|
||||
for (const [appName, message] of entries) {
|
||||
process.stdout.write(`[${appName}] ${message}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseTimeoutMs(value: string | undefined): number | undefined {
|
||||
if (value == null) return undefined;
|
||||
const seconds = Number(value);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) throw new Error("--timeout must be a positive number of seconds");
|
||||
return seconds * 1000;
|
||||
}
|
||||
|
||||
async function inspectDesktop(config: ToolDevConfig, target: string | undefined, options: CliOptions) {
|
||||
const operation = target ?? "status";
|
||||
const timeoutMs = parseTimeoutMs(options.timeout) ?? 30000;
|
||||
|
||||
switch (operation) {
|
||||
case "status":
|
||||
return (await inspectDesktopRuntime(runtimeLookup(config), 1000)) ?? ({ state: "idle" } satisfies DesktopStatusSnapshot);
|
||||
case "eval":
|
||||
if (options.expr == null) throw new Error("--expr is required for desktop eval");
|
||||
return await requestJsonIpc<DesktopEvalResult>(
|
||||
config.apps.desktop.ipcPath,
|
||||
{ input: { expression: options.expr }, type: SIDECAR_MESSAGES.EVAL },
|
||||
{ timeoutMs },
|
||||
);
|
||||
case "screenshot":
|
||||
if (options.path == null) throw new Error("--path is required for desktop screenshot");
|
||||
return await requestJsonIpc<DesktopScreenshotResult>(
|
||||
config.apps.desktop.ipcPath,
|
||||
{ input: { path: options.path }, type: SIDECAR_MESSAGES.SCREENSHOT },
|
||||
{ timeoutMs },
|
||||
);
|
||||
case "console":
|
||||
return await requestJsonIpc<DesktopConsoleResult>(config.apps.desktop.ipcPath, { type: SIDECAR_MESSAGES.CONSOLE }, { timeoutMs });
|
||||
case "click":
|
||||
if (options.selector == null) throw new Error("--selector is required for desktop click");
|
||||
return await requestJsonIpc<DesktopClickResult>(
|
||||
config.apps.desktop.ipcPath,
|
||||
{ input: { selector: options.selector }, type: SIDECAR_MESSAGES.CLICK },
|
||||
{ timeoutMs },
|
||||
);
|
||||
default:
|
||||
throw new Error(`unsupported desktop inspect target: ${operation}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function inspect(config: ToolDevConfig, appName: string, target: string | undefined, options: CliOptions) {
|
||||
if (appName === APP_KEYS.DAEMON) {
|
||||
if (target != null && target !== "status") throw new Error(`unsupported daemon inspect target: ${target}`);
|
||||
return (await inspectDaemonRuntime(runtimeLookup(config), 1000)) ?? ({ state: "idle", url: null } satisfies DaemonStatusSnapshot);
|
||||
}
|
||||
if (appName === APP_KEYS.WEB) {
|
||||
if (target != null && target !== "status") throw new Error(`unsupported web inspect target: ${target}`);
|
||||
return (await inspectWebRuntime(runtimeLookup(config), 1000)) ?? ({ state: "idle", url: null } satisfies WebStatusSnapshot);
|
||||
}
|
||||
if (appName !== APP_KEYS.DESKTOP) throw new Error(`unsupported tools-dev app: ${appName}`);
|
||||
return await inspectDesktop(config, target, options);
|
||||
}
|
||||
|
||||
async function runSequential<T>(targets: readonly ToolDevAppName[], operation: (target: ToolDevAppName) => Promise<T>) {
|
||||
const result: Partial<Record<ToolDevAppName, T>> = {};
|
||||
for (const target of targets) result[target] = await operation(target);
|
||||
return result;
|
||||
}
|
||||
|
||||
function stopOrderFor(targets: readonly ToolDevAppName[]): ToolDevAppName[] {
|
||||
const selected = new Set(targets);
|
||||
return DEFAULT_STOP_APPS.filter((target) => selected.has(target));
|
||||
}
|
||||
|
||||
async function runForeground(config: ToolDevConfig, appName: string | undefined, options: CliOptions) {
|
||||
const targets = resolveRunApps(appName);
|
||||
const foregroundOptions = { ...options, parentPid: process.pid };
|
||||
const started = await runSequential(targets, (target) => startApp(config, target, foregroundOptions));
|
||||
printRunForegroundResult(started, options);
|
||||
|
||||
let shuttingDown = false;
|
||||
const keepAlive = setInterval(() => undefined, 60_000);
|
||||
await new Promise<void>((resolveDone) => {
|
||||
const shutdown = () => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
clearInterval(keepAlive);
|
||||
process.stderr.write("\nStopping Open Design dev server...\n");
|
||||
void runSequential(stopOrderFor(targets), (target) => stopApp(config, target)).finally(() => {
|
||||
for (const sig of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.off(sig, shutdown);
|
||||
}
|
||||
process.exitCode = 0;
|
||||
resolveDone();
|
||||
});
|
||||
};
|
||||
for (const sig of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(sig, shutdown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const cli = cac("tools-dev");
|
||||
|
||||
function addSharedOptions(command: ReturnType<typeof cli.command>) {
|
||||
return command
|
||||
.option("--namespace <name>", "runtime namespace (default: default)")
|
||||
.option("--tools-dev-root <path>", "tools-dev runtime root")
|
||||
.option("--json", "print JSON");
|
||||
}
|
||||
|
||||
function addPortOptions(command: ReturnType<typeof cli.command>) {
|
||||
return command
|
||||
.option("--daemon-port <port>", "force daemon port; conflict quick-fails")
|
||||
.option("--web-port <port>", "force web port; conflict quick-fails")
|
||||
.option("--prod", "use production build (requires pnpm build first)");
|
||||
}
|
||||
|
||||
addPortOptions(addSharedOptions(cli.command("start [app]", "Start daemon, web, desktop, or all when app is omitted"))).action(
|
||||
async (appName: string | undefined, options: CliOptions) => {
|
||||
const config = resolveToolDevConfig(options);
|
||||
const targets = resolveStartApps(appName);
|
||||
const result = await runSequential(targets, (target) => startApp(config, target, options));
|
||||
printStartResult(result, options);
|
||||
},
|
||||
);
|
||||
|
||||
addPortOptions(addSharedOptions(cli.command("run [app]", "Start apps and keep this command alive until interrupted"))).action(
|
||||
async (appName: string | undefined, options: CliOptions) => {
|
||||
await runForeground(resolveToolDevConfig(options), appName, options);
|
||||
},
|
||||
);
|
||||
|
||||
addSharedOptions(cli.command("status [app]", "Show app status for daemon, web, desktop, or all")).action(
|
||||
async (appName: string | undefined, options: CliOptions) => {
|
||||
printStatusResult(await status(resolveToolDevConfig(options), appName), options, appName);
|
||||
},
|
||||
);
|
||||
|
||||
addSharedOptions(cli.command("stop [app]", "Stop daemon, web, desktop, or all when app is omitted")).action(
|
||||
async (appName: string | undefined, options: CliOptions) => {
|
||||
const config = resolveToolDevConfig(options);
|
||||
const targets = resolveStopApps(appName);
|
||||
const result = await runSequential(targets, (target) => stopApp(config, target));
|
||||
printStopResult(result, options);
|
||||
},
|
||||
);
|
||||
|
||||
addPortOptions(addSharedOptions(cli.command("restart [app]", "Restart daemon, web, desktop, or all when app is omitted"))).action(
|
||||
async (appName: string | undefined, options: CliOptions) => {
|
||||
printRestartResult(await restartTargets(resolveToolDevConfig(options), appName, options), options);
|
||||
},
|
||||
);
|
||||
|
||||
addSharedOptions(cli.command("logs [app]", "Show log tail for daemon, web, desktop, or all")).action(
|
||||
async (appName: string | undefined, options: CliOptions) => {
|
||||
const config = resolveToolDevConfig(options);
|
||||
const targets = resolveTargetApps(appName, DEFAULT_START_APPS);
|
||||
const result = targets.length === 1
|
||||
? await readLogs(config, targets[0])
|
||||
: Object.fromEntries(await Promise.all(targets.map(async (target) => [target, await readLogs(config, target)] as const)));
|
||||
printLogs(result, options);
|
||||
},
|
||||
);
|
||||
|
||||
addSharedOptions(
|
||||
cli.command("inspect <app> [target]", "Inspect daemon/web status or desktop status/eval/screenshot/console/click"),
|
||||
)
|
||||
.option("--expr <js>", "JavaScript expression for desktop eval")
|
||||
.option("--path <file>", "Output path for desktop screenshot")
|
||||
.option("--selector <css>", "CSS selector for desktop click")
|
||||
.option("--timeout <seconds>", "Desktop inspect timeout in seconds")
|
||||
.action(async (appName: string, target: string | undefined, options: CliOptions) => {
|
||||
output(await inspect(resolveToolDevConfig(options), appName, target, options), options);
|
||||
});
|
||||
|
||||
addSharedOptions(cli.command("check [app]", "Print status and recent logs for quick diagnostics")).action(
|
||||
async (appName: string | undefined, options: CliOptions) => {
|
||||
const config = resolveToolDevConfig(options);
|
||||
const targets = resolveTargetApps(appName, DEFAULT_START_APPS);
|
||||
const apps = Object.fromEntries(
|
||||
await Promise.all(targets.map(async (target) => [target, await inspectAppStatus(config, target)] as const)),
|
||||
);
|
||||
const logs = Object.fromEntries(
|
||||
await Promise.all(targets.map(async (target) => [target, await readLogs(config, target)] as const)),
|
||||
);
|
||||
printCheckResult({ apps, diagnostics: createLogDiagnostics(logs), logs, namespace: config.namespace }, options);
|
||||
},
|
||||
);
|
||||
|
||||
cli.help();
|
||||
|
||||
const rawCliArgs = process.argv.slice(2);
|
||||
const cliArgs = rawCliArgs[0] === "--" ? rawCliArgs.slice(1) : rawCliArgs;
|
||||
process.argv.splice(2, process.argv.length - 2, ...cliArgs);
|
||||
|
||||
if (cliArgs.length === 0 || (cliArgs[0]?.startsWith("-") && cliArgs[0] !== "--help" && cliArgs[0] !== "-h")) {
|
||||
process.argv.splice(2, 0, "start");
|
||||
}
|
||||
|
||||
cli.parse();
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
APP_KEYS,
|
||||
OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
SIDECAR_MESSAGES,
|
||||
type DaemonStatusSnapshot,
|
||||
type DesktopStatusSnapshot,
|
||||
type WebStatusSnapshot,
|
||||
} from "@open-design/sidecar-proto";
|
||||
import { requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar";
|
||||
|
||||
export type AppRuntimeLookup = {
|
||||
base: string;
|
||||
namespace: string;
|
||||
};
|
||||
|
||||
export function resolveDaemonIpcPath(runtime: AppRuntimeLookup): string {
|
||||
return resolveAppIpcPath({ app: APP_KEYS.DAEMON, contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: runtime.namespace });
|
||||
}
|
||||
|
||||
export function resolveWebIpcPath(runtime: AppRuntimeLookup): string {
|
||||
return resolveAppIpcPath({ app: APP_KEYS.WEB, contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: runtime.namespace });
|
||||
}
|
||||
|
||||
export function resolveDesktopIpcPath(runtime: AppRuntimeLookup): string {
|
||||
return resolveAppIpcPath({ app: APP_KEYS.DESKTOP, contract: OPEN_DESIGN_SIDECAR_CONTRACT, namespace: runtime.namespace });
|
||||
}
|
||||
|
||||
export async function inspectDaemonRuntime(runtime: AppRuntimeLookup, timeoutMs = 800): Promise<DaemonStatusSnapshot | null> {
|
||||
try {
|
||||
return await requestJsonIpc<DaemonStatusSnapshot>(resolveDaemonIpcPath(runtime), { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForDaemonRuntime(runtime: AppRuntimeLookup, timeoutMs = 35000): Promise<DaemonStatusSnapshot> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const snapshot = await inspectDaemonRuntime(runtime, 800);
|
||||
if (snapshot?.url != null) return snapshot;
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 150));
|
||||
}
|
||||
throw new Error("daemon did not expose status in time");
|
||||
}
|
||||
|
||||
export async function inspectWebRuntime(runtime: AppRuntimeLookup, timeoutMs = 800): Promise<WebStatusSnapshot | null> {
|
||||
try {
|
||||
return await requestJsonIpc<WebStatusSnapshot>(resolveWebIpcPath(runtime), { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForWebRuntime(runtime: AppRuntimeLookup, timeoutMs = 35000): Promise<WebStatusSnapshot> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const snapshot = await inspectWebRuntime(runtime, 800);
|
||||
if (snapshot?.url != null) return snapshot;
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 150));
|
||||
}
|
||||
throw new Error("web did not expose status in time");
|
||||
}
|
||||
|
||||
export async function inspectDesktopRuntime(runtime: AppRuntimeLookup, timeoutMs = 800): Promise<DesktopStatusSnapshot | null> {
|
||||
try {
|
||||
return await requestJsonIpc<DesktopStatusSnapshot>(resolveDesktopIpcPath(runtime), { type: SIDECAR_MESSAGES.STATUS }, { timeoutMs });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForDesktopRuntime(runtime: AppRuntimeLookup, timeoutMs = 15000): Promise<DesktopStatusSnapshot> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const snapshot = await inspectDesktopRuntime(runtime, 800);
|
||||
if (snapshot != null) return snapshot;
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 150));
|
||||
}
|
||||
throw new Error("desktop did not expose status in time");
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["ES2024", "DOM"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"target": "ES2024",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "esbuild.config.mjs"]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# tools/pack
|
||||
|
||||
Follow the root `AGENTS.md` and `tools/AGENTS.md` first. This tool owns the repo-external packaged build/start/stop/logs command surface.
|
||||
|
||||
## Owns
|
||||
|
||||
- Local packaging orchestration for packaged Open Design artifacts.
|
||||
- mac build/install/start/stop/logs/uninstall/cleanup smoke commands.
|
||||
- Windows NSIS build/install/start/stop/logs/uninstall/cleanup/list/reset smoke commands.
|
||||
- Windows registry observation/cleanup must go through `reg.exe` and stay scoped to entries matching the namespace install/uninstaller paths.
|
||||
- Windows lifecycle logs must expose NSIS automation logs/markers/timings in addition to app runtime logs.
|
||||
- Linux AppImage build/install/start/stop/logs/uninstall/cleanup smoke commands.
|
||||
- Linux containerized builds via `electronuserland/builder` Docker image for distro-agnostic glibc compat.
|
||||
- Consuming sidecar/process/path primitives from `@open-design/sidecar-proto`, `@open-design/sidecar`, and `@open-design/platform`.
|
||||
|
||||
## Does not own
|
||||
|
||||
- Product business logic.
|
||||
- Sidecar protocol definitions.
|
||||
- A second process identity model.
|
||||
- Product/business update runtime integration.
|
||||
|
||||
## Rules
|
||||
|
||||
- Do not hand-build `--od-stamp-*` args; use `createProcessStampArgs` with `OPEN_DESIGN_SIDECAR_CONTRACT`.
|
||||
- Do not use port numbers in data/log/runtime/cache path decisions. Namespace decides paths; ports are only transient transports.
|
||||
- Release artifacts keep canonical app identity (`Open Design.app` on mac, `Open Design.exe` inside the Windows installer); local tools-pack installs may use namespace-scoped install paths only as a developer multi-instance validation convention.
|
||||
- Do not let namespace-named `.app` installs change data/log/runtime/cache path conventions.
|
||||
- Use `--portable` for public/release artifacts so packaged config does not bake local tools-pack runtime roots from the build machine.
|
||||
- Pack resource files used by electron-builder belong under `tools/pack/resources/`; do not point pack logic at Downloads, web public assets, docs assets, or other app-owned resource paths.
|
||||
@@ -0,0 +1,125 @@
|
||||
# tools/pack
|
||||
|
||||
Local packaging control plane for Open Design.
|
||||
|
||||
The active slice is mac-first local packaging and smoke lifecycle control:
|
||||
|
||||
- `tools-pack mac build --to all`
|
||||
- `tools-pack mac build --to app|dmg|zip`
|
||||
- `tools-pack mac build --to all --signed`
|
||||
- `tools-pack mac build --to all --portable` for release artifacts that must not bake local tools-pack runtime paths
|
||||
- `tools-pack mac install`
|
||||
- `tools-pack mac start`
|
||||
- `tools-pack mac stop`
|
||||
- `tools-pack mac logs`
|
||||
- `tools-pack mac uninstall`
|
||||
- `tools-pack mac cleanup`
|
||||
|
||||
Build artifacts are namespace-scoped under `.tmp/tools-pack/out/mac/namespaces/<namespace>/`.
|
||||
Release artifacts keep the canonical `Open Design.app` bundle shape; local `tools-pack install` copies it as
|
||||
`Open Design.<namespace>.app` so developer namespaces can coexist without affecting runtime data/log/cache paths.
|
||||
|
||||
Packaged runtime state is namespace-scoped under `.tmp/tools-pack/runtime/mac/namespaces/<namespace>/`:
|
||||
|
||||
- `data/` is the daemon-managed data root passed to the daemon through the packaged sidecar launch environment.
|
||||
- `logs/` contains packaged process logs for `desktop`, `web`, and `daemon`.
|
||||
- `runtime/` is the sidecar runtime base used by the packaged desktop/web/daemon process group.
|
||||
- `cache/` is reserved for namespace-local packaged cache state.
|
||||
- `user-data/` is the Electron/Chromium `userData` root, with `user-data/session/` used for `sessionData`.
|
||||
|
||||
Finder/manual launches cannot carry argv stamps on the root desktop process. To keep process fallback safe,
|
||||
`apps/packaged` writes `runtime/desktop-root.json` with the desktop stamp, PID, executable path, app path, and log path.
|
||||
`tools-pack mac stop` trusts that marker only when namespace/stamp/PID/command validation passes; otherwise it reports the
|
||||
unmanaged/not-owned reason instead of killing unknown processes.
|
||||
|
||||
### `tools-pack mac stop` validation
|
||||
|
||||
- If the marker is absent, stop reports `not-running`.
|
||||
- If the marker PID is gone, stop reports `not-running` and clears the stale marker.
|
||||
- If the marker PID was reused by an unrelated process, stop reports `unmanaged`.
|
||||
- If the marker namespace, stamp, runtime root, or command does not match the current namespace, stop reports `unmanaged`.
|
||||
|
||||
This keeps `stop` from killing processes outside the current namespace.
|
||||
|
||||
Packaged desktop also writes main-process lifecycle logs to `logs/desktop/latest.log` so Finder/manual launches are
|
||||
diagnosable. This log is intentionally scoped to packaged desktop startup/shutdown/process errors and does not capture
|
||||
web/renderer console output.
|
||||
|
||||
The packaged daemon path contract is explicit: `tools-pack` writes namespace/base config, `apps/packaged` resolves
|
||||
namespace paths, and the packaged sidecar launcher passes daemon managed paths via launch env. The daemon may keep its
|
||||
own default fallback for non-packaged launches, but packaged runtime must not rely on fallback inference from Electron
|
||||
`userData`, app bundle names, or ports.
|
||||
|
||||
The current release slice is mac beta publication. Runtime updater integration and Windows packaging remain later phases.
|
||||
|
||||
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.
|
||||
|
||||
Local developer artifacts bake the tools-pack namespace runtime root so `tools-pack mac start/stop/logs/cleanup` can manage
|
||||
them from the repo. Release artifacts use `--portable` so the installed app resolves namespace data/log/runtime/user-data
|
||||
from the user's Electron `userData` root instead of the build machine's `.tmp` path.
|
||||
|
||||
## Linux
|
||||
|
||||
Local lifecycle commands:
|
||||
|
||||
- `tools-pack linux build --to all` (default; produces AppImage)
|
||||
- `tools-pack linux build --to appimage` (explicit AppImage)
|
||||
- `tools-pack linux build --to dir` (unpacked output for fast iteration)
|
||||
- `tools-pack linux build --containerized` (run electron-builder inside `electronuserland/builder:base` Docker for distro-agnostic glibc compat — requires Docker)
|
||||
- `tools-pack linux build --to all --portable` (release artifacts that must not bake local tools-pack runtime paths)
|
||||
- `tools-pack linux install`
|
||||
- `tools-pack linux start`
|
||||
- `tools-pack linux stop`
|
||||
- `tools-pack linux logs`
|
||||
- `tools-pack linux uninstall`
|
||||
- `tools-pack linux cleanup`
|
||||
|
||||
Build artifacts are namespace-scoped under `.tmp/tools-pack/out/linux/namespaces/<namespace>/`. Packaged runtime state is namespace-scoped under `.tmp/tools-pack/runtime/linux/namespaces/<namespace>/{data,logs,runtime,cache,user-data}/`. Containerized build cache lives under `.tmp/tools-pack/.docker-cache/{electron,electron-builder}/`.
|
||||
|
||||
Local installs use XDG paths:
|
||||
|
||||
- AppImage: `~/.local/bin/Open-Design.<namespace>.AppImage`
|
||||
- Menu entry: `~/.local/share/applications/open-design-<namespace>.desktop`
|
||||
- Icon: `~/.local/share/icons/hicolor/512x512/apps/open-design-<namespace>.png`
|
||||
|
||||
The `<namespace>` suffix is unconditional so multiple developer namespaces can coexist on the same desktop. The `.desktop` file registers the `od://` scheme via `MimeType=x-scheme-handler/od;` and pre-sets `OD_NAMESPACE` on the `Exec=` line so menu launches identify the correct namespace.
|
||||
|
||||
### AppImage launch mode (FUSE caveat)
|
||||
|
||||
`tools-pack linux start` always spawns the AppImage with `--appimage-extract-and-run`. Smoke testing on Ubuntu 24.04 and Arch Linux showed that direct FUSE-mounted AppImage launches make Node module loads (Express, better-sqlite3, etc.) slow enough that the daemon sidecar consistently failed to clear `apps/packaged`'s 35-second startup timeout. Extract-and-run unpacks the AppImage into `/tmp/appimage_extracted_<hex>/` and exec's the inner Electron from there, bypassing FUSE and getting daemon boot in under 5 seconds — roughly an order-of-magnitude improvement.
|
||||
|
||||
**Implication for end-users:** if launching the installed AppImage manually (not via `tools-pack linux start`), pass `--appimage-extract-and-run` yourself, or rely on a desktop launcher / `appimage-launcher` daemon that handles extract-and-run automatically.
|
||||
|
||||
### Optional system tools
|
||||
|
||||
`tools-pack linux install` and `tools-pack linux uninstall` invoke `update-desktop-database` and `gtk-update-icon-cache` as best-effort post-hooks. Either tool being absent (`iconCache: "missing"` in the output) is harmless — the icon and menu entry still work, the cache just isn't refreshed. Install via your distro:
|
||||
|
||||
- Arch / CachyOS: `sudo pacman -S desktop-file-utils gtk-update-icon-cache`
|
||||
- Debian / Ubuntu: `sudo apt install desktop-file-utils gtk-update-icon-cache`
|
||||
- Fedora: `sudo dnf install desktop-file-utils gtk-update-icon-cache`
|
||||
|
||||
`libfuse2` is needed for FUSE-mounted AppImage launch (the default mode when running an AppImage directly without `--appimage-extract-and-run`). `tools-pack linux start` always uses extract-and-run and bypasses FUSE entirely, so it does not need `libfuse2`. Most modern distros ship `libfuse2` by default; older Ubuntu LTS hosts may need `sudo apt install libfuse2t64` (or `libfuse2` on pre-24.04).
|
||||
|
||||
### Sandbox / chrome-sandbox
|
||||
|
||||
Electron 41 on Linux requires `kernel.unprivileged_userns_clone=1` (default on Arch, Ubuntu 24+, Debian 12+) or AppImage's `--no-sandbox` fallback. Most modern distros need no extra setup.
|
||||
|
||||
### Distro-agnostic guarantee
|
||||
|
||||
AppImages built natively on a rolling distro (e.g., Arch / CachyOS) link against recent glibc and may not run on stable distros (Ubuntu 22.04, Debian 12). Use `--containerized` to build against the wide-compat `electronuserland/builder:base` baseline (Ubuntu 18.04 / glibc 2.27).
|
||||
|
||||
### Format choice: why AppImage first
|
||||
|
||||
Linux desktop apps in this space split across formats: VS Code ships `.deb` + `.rpm` + Snap; Discord ships AppImage + `.deb`; Slack ships `.deb` + `.rpm`; Cursor and Obsidian ship AppImage. We start with AppImage because it is universal (one artifact runs on any glibc-compatible distro), needs no repo plumbing, and integrates cleanly with the namespace-scoped install layout. `.deb` / `.rpm` / Snap / Flatpak can land incrementally if user demand surfaces.
|
||||
|
||||
### Out of scope (later phases)
|
||||
|
||||
- AppImage signing (`--signed`) — deferred pending a GPG key infrastructure decision and a user-facing verification flow design (no ETA).
|
||||
- AppImage auto-update feed (`latest-linux.yml`) — the linux electron-builder config has no `publish` block wired, so a generated feed would point users at a feed that never updates. Tracked alongside signing.
|
||||
- Additional package formats: `.deb`, `.rpm`, Snap, Flatpak.
|
||||
- Linux entry in `ci.yml` (release lanes only build linux; PR validation does not yet).
|
||||
|
||||
`--to dmg` is manual-install DMG output only. Any builder-generated updater metadata such as `latest-mac.yml` or
|
||||
`.blockmap` files is treated as scratch and cleaned from the builder directory; release-beta generates the authoritative
|
||||
`latest-mac.yml` feed during release asset preparation, pointing at the update ZIP.
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/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-pack dist entry not found at ${distEntry}. Run "pnpm --filter @open-design/tools-pack build" first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await import(pathToFileURL(distEntry).href);
|
||||
@@ -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",
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@open-design/tools-pack",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"tools-pack": "./bin/tools-pack.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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@open-design/platform": "workspace:0.3.0",
|
||||
"@open-design/sidecar": "workspace:0.3.0",
|
||||
"@open-design/sidecar-proto": "workspace:0.3.0",
|
||||
"@electron/notarize": "3.1.0",
|
||||
"cac": "6.7.14",
|
||||
"electron-builder": "26.8.1"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,12 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Open Design (@@NAMESPACE@@)
|
||||
GenericName=Open Design
|
||||
Comment=Open Design packaged build (@@NAMESPACE@@)
|
||||
Exec=env OD_PACKAGED_NAMESPACE=@@NAMESPACE@@ @@EXEC_PATH@@ --appimage-extract-and-run %U
|
||||
Icon=@@ICON_PATH@@
|
||||
Categories=Development;Utility;
|
||||
StartupWMClass=Open Design
|
||||
StartupNotify=true
|
||||
Terminal=false
|
||||
MimeType=x-scheme-handler/od;
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
@@ -0,0 +1,35 @@
|
||||
const path = require("node:path");
|
||||
|
||||
module.exports = async function notarize(context) {
|
||||
if (context.electronPlatformName !== "darwin") {
|
||||
return;
|
||||
}
|
||||
|
||||
const appleId = process.env.APPLE_ID;
|
||||
const appleIdPassword = process.env.APPLE_APP_SPECIFIC_PASSWORD;
|
||||
const teamId = process.env.APPLE_TEAM_ID;
|
||||
const missing = [
|
||||
["APPLE_ID", appleId],
|
||||
["APPLE_APP_SPECIFIC_PASSWORD", appleIdPassword],
|
||||
["APPLE_TEAM_ID", teamId],
|
||||
]
|
||||
.filter(([, value]) => !value)
|
||||
.map(([name]) => name);
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`[tools-pack notarize] missing required Apple notarization env: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const productFilename = context.packager.appInfo.productFilename;
|
||||
const appPath = path.join(context.appOutDir, `${productFilename}.app`);
|
||||
const { notarize } = await import("@electron/notarize");
|
||||
|
||||
await notarize({
|
||||
appPath,
|
||||
appleId,
|
||||
appleIdPassword,
|
||||
teamId,
|
||||
});
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,146 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
SIDECAR_DEFAULTS,
|
||||
} from "@open-design/sidecar-proto";
|
||||
import { resolveNamespace } from "@open-design/sidecar";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ENTRY_DIR_NAME = path.basename(__dirname);
|
||||
|
||||
export const WORKSPACE_ROOT = resolve(__dirname, ENTRY_DIR_NAME === "dist" ? "../../.." : "../../..");
|
||||
|
||||
export type ToolPackPlatform = "mac" | "win" | "linux";
|
||||
export type ToolPackBuildOutput = "all" | "app" | "appimage" | "dir" | "dmg" | "nsis" | "zip";
|
||||
|
||||
export type ToolPackCliOptions = {
|
||||
containerized?: boolean;
|
||||
dir?: string;
|
||||
expr?: string;
|
||||
json?: boolean;
|
||||
namespace?: string;
|
||||
path?: string;
|
||||
portable?: boolean;
|
||||
removeData?: boolean;
|
||||
removeLogs?: boolean;
|
||||
removeProductUserData?: boolean;
|
||||
removeSidecars?: boolean;
|
||||
signed?: boolean;
|
||||
silent?: boolean;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export type ToolPackRoots = {
|
||||
output: {
|
||||
appBuilderRoot: string;
|
||||
namespaceRoot: string;
|
||||
platformRoot: string;
|
||||
root: string;
|
||||
};
|
||||
runtime: {
|
||||
namespaceBaseRoot: string;
|
||||
namespaceRoot: string;
|
||||
};
|
||||
toolPackRoot: string;
|
||||
};
|
||||
|
||||
export type ToolPackConfig = {
|
||||
containerized: boolean;
|
||||
electronBuilderCliPath: string;
|
||||
electronDistPath: string;
|
||||
electronVersion: string;
|
||||
namespace: string;
|
||||
platform: ToolPackPlatform;
|
||||
portable: boolean;
|
||||
removeData: boolean;
|
||||
removeLogs: boolean;
|
||||
removeProductUserData: boolean;
|
||||
removeSidecars: boolean;
|
||||
roots: ToolPackRoots;
|
||||
silent: boolean;
|
||||
signed: boolean;
|
||||
to: ToolPackBuildOutput;
|
||||
workspaceRoot: string;
|
||||
};
|
||||
|
||||
function resolveToolPackBuildOutput(platform: ToolPackPlatform, value: string | undefined): ToolPackBuildOutput {
|
||||
if (value == null || value.length === 0) return platform === "win" ? "nsis" : "all";
|
||||
if (platform === "mac" && (value === "all" || value === "app" || value === "dmg" || value === "zip")) return value;
|
||||
if (platform === "win" && (value === "all" || value === "dir" || value === "nsis")) return value;
|
||||
if (platform === "linux" && (value === "all" || value === "appimage" || value === "dir")) return value;
|
||||
throw new Error(`unsupported ${platform} --to target: ${value}`);
|
||||
}
|
||||
|
||||
function resolveElectronVersion(workspaceRoot: string): string {
|
||||
const require = createRequire(join(workspaceRoot, "apps/desktop/package.json"));
|
||||
const desktopPackage = require(join(workspaceRoot, "apps/desktop/package.json")) as {
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
const version = desktopPackage.devDependencies?.electron;
|
||||
if (version == null || version.length === 0) {
|
||||
throw new Error("apps/desktop/package.json must declare electron");
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
function resolveElectronDistPath(workspaceRoot: string): string {
|
||||
const require = createRequire(join(workspaceRoot, "apps/desktop/package.json"));
|
||||
const electronEntry = require.resolve("electron");
|
||||
return join(path.dirname(electronEntry), "dist");
|
||||
}
|
||||
|
||||
function resolveElectronBuilderCliPath(): string {
|
||||
const require = createRequire(import.meta.url);
|
||||
return require.resolve("electron-builder/out/cli/cli.js");
|
||||
}
|
||||
|
||||
export function resolveToolPackConfig(
|
||||
platform: ToolPackPlatform,
|
||||
options: ToolPackCliOptions = {},
|
||||
): ToolPackConfig {
|
||||
const namespace = resolveNamespace({
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
env: process.env,
|
||||
namespace: options.namespace ?? SIDECAR_DEFAULTS.namespace,
|
||||
});
|
||||
const toolPackRoot = resolve(options.dir ?? join(WORKSPACE_ROOT, ".tmp", "tools-pack"));
|
||||
const outputRoot = join(toolPackRoot, "out");
|
||||
const outputPlatformRoot = join(outputRoot, platform);
|
||||
const outputNamespaceRoot = join(outputPlatformRoot, "namespaces", namespace);
|
||||
const runtimeNamespaceBaseRoot = join(toolPackRoot, "runtime", platform, "namespaces");
|
||||
|
||||
return {
|
||||
containerized: options.containerized === true,
|
||||
electronBuilderCliPath: resolveElectronBuilderCliPath(),
|
||||
electronDistPath: resolveElectronDistPath(WORKSPACE_ROOT),
|
||||
electronVersion: resolveElectronVersion(WORKSPACE_ROOT),
|
||||
namespace,
|
||||
platform,
|
||||
portable: options.portable === true,
|
||||
roots: {
|
||||
output: {
|
||||
appBuilderRoot: join(outputNamespaceRoot, "builder"),
|
||||
namespaceRoot: outputNamespaceRoot,
|
||||
platformRoot: outputPlatformRoot,
|
||||
root: outputRoot,
|
||||
},
|
||||
runtime: {
|
||||
namespaceBaseRoot: runtimeNamespaceBaseRoot,
|
||||
namespaceRoot: join(runtimeNamespaceBaseRoot, namespace),
|
||||
},
|
||||
toolPackRoot,
|
||||
},
|
||||
removeData: options.removeData === true,
|
||||
removeLogs: options.removeLogs === true,
|
||||
removeProductUserData: options.removeProductUserData === true,
|
||||
removeSidecars: options.removeSidecars === true,
|
||||
silent: options.silent !== false,
|
||||
signed: options.signed === true,
|
||||
to: resolveToolPackBuildOutput(platform, options.to),
|
||||
workspaceRoot: WORKSPACE_ROOT,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { cac } from "cac";
|
||||
import type { CAC } from "cac";
|
||||
|
||||
import { resolveToolPackConfig, type ToolPackCliOptions, type ToolPackPlatform } from "./config.js";
|
||||
import {
|
||||
cleanupPackedMacNamespace,
|
||||
installPackedMacDmg,
|
||||
packMac,
|
||||
readPackedMacLogs,
|
||||
startPackedMacApp,
|
||||
stopPackedMacApp,
|
||||
uninstallPackedMacApp,
|
||||
} from "./mac.js";
|
||||
import {
|
||||
cleanupPackedWinNamespace,
|
||||
installPackedWinApp,
|
||||
inspectPackedWinApp,
|
||||
listPackedWinNamespaces,
|
||||
packWin,
|
||||
readPackedWinLogs,
|
||||
resetPackedWinNamespaces,
|
||||
startPackedWinApp,
|
||||
stopPackedWinApp,
|
||||
uninstallPackedWinApp,
|
||||
} from "./win.js";
|
||||
import {
|
||||
cleanupPackedLinuxNamespace,
|
||||
installPackedLinuxApp,
|
||||
packLinux,
|
||||
readPackedLinuxLogs,
|
||||
startPackedLinuxApp,
|
||||
stopPackedLinuxApp,
|
||||
uninstallPackedLinuxApp,
|
||||
} from "./linux.js";
|
||||
|
||||
type CliOptions = ToolPackCliOptions;
|
||||
|
||||
function printJson(payload: unknown): void {
|
||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function printLogs(result: { logs: Record<string, { lines: string[]; logPath: string }>; namespace: string }, options: CliOptions): void {
|
||||
if (options.json === true) {
|
||||
printJson(result);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [app, entry] of Object.entries(result.logs)) {
|
||||
process.stdout.write(`[${app}] ${entry.logPath}\n`);
|
||||
process.stdout.write(entry.lines.length > 0 ? `${entry.lines.join("\n")}\n` : "(no log lines)\n");
|
||||
}
|
||||
}
|
||||
|
||||
type CacCommand = ReturnType<CAC["command"]>;
|
||||
|
||||
function addSharedOptions(command: CacCommand) {
|
||||
return command
|
||||
.option("--dir <path>", "tools-pack root directory")
|
||||
.option("--json", "print JSON")
|
||||
.option("--namespace <name>", "runtime namespace")
|
||||
.option("--expr <expression>", "desktop inspect eval expression")
|
||||
.option("--path <path>", "desktop inspect screenshot path");
|
||||
}
|
||||
|
||||
// Per-platform `--to` help text mirroring resolveToolPackBuildOutput in
|
||||
// config.ts. Keep these in sync: the resolver throws on any value not listed
|
||||
// here for the given platform.
|
||||
const TO_HELP_BY_PLATFORM: Record<ToolPackPlatform, string> = {
|
||||
linux: "build target: all|appimage|dir (default: all)",
|
||||
mac: "build target: all|app|dmg|zip (default: all)",
|
||||
win: "build target: all|dir|nsis (default: nsis)",
|
||||
};
|
||||
|
||||
function addBuildOptions(command: CacCommand, platform: ToolPackPlatform) {
|
||||
return command
|
||||
.option("--portable", "do not bake local tools-pack runtime roots into the packaged config")
|
||||
.option("--signed", "build a signed/notarized mac artifact")
|
||||
.option("--to <target>", TO_HELP_BY_PLATFORM[platform]);
|
||||
}
|
||||
|
||||
function addWinLifecycleOptions(command: CacCommand) {
|
||||
return command
|
||||
.option("--remove-data", "remove packaged data during uninstall/reset/cleanup")
|
||||
.option("--remove-logs", "remove packaged logs during uninstall/reset/cleanup")
|
||||
.option("--remove-product-user-data", "remove the public Electron app userData root during Windows uninstall/reset/cleanup")
|
||||
.option("--remove-sidecars", "remove packaged sidecar runtime during uninstall/reset/cleanup")
|
||||
.option("--silent", "run installer/uninstaller silently", { default: true });
|
||||
}
|
||||
|
||||
const cli = cac("tools-pack");
|
||||
|
||||
addBuildOptions(addSharedOptions(cli.command("mac <action>", "Mac packaging commands: build|install|start|stop|logs|uninstall|cleanup")), "mac").action(
|
||||
async (action: string, options: CliOptions) => {
|
||||
const config = resolveToolPackConfig("mac", options);
|
||||
switch (action) {
|
||||
case "build":
|
||||
printJson(await packMac(config));
|
||||
return;
|
||||
case "install":
|
||||
printJson(await installPackedMacDmg(config));
|
||||
return;
|
||||
case "start":
|
||||
printJson(await startPackedMacApp(config));
|
||||
return;
|
||||
case "stop":
|
||||
printJson(await stopPackedMacApp(config));
|
||||
return;
|
||||
case "logs":
|
||||
printLogs(await readPackedMacLogs(config), options);
|
||||
return;
|
||||
case "uninstall":
|
||||
printJson(await uninstallPackedMacApp(config));
|
||||
return;
|
||||
case "cleanup":
|
||||
printJson(await cleanupPackedMacNamespace(config));
|
||||
return;
|
||||
default:
|
||||
throw new Error(`unsupported mac action: ${action}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
addWinLifecycleOptions(
|
||||
addBuildOptions(
|
||||
addSharedOptions(
|
||||
cli.command(
|
||||
"win <action>",
|
||||
"Windows packaging commands: build|install|start|stop|logs|uninstall|cleanup|list|reset|inspect",
|
||||
),
|
||||
),
|
||||
"win",
|
||||
),
|
||||
).action(async (action: string, options: CliOptions) => {
|
||||
const config = resolveToolPackConfig("win", options);
|
||||
switch (action) {
|
||||
case "build":
|
||||
printJson(await packWin(config));
|
||||
return;
|
||||
case "install":
|
||||
printJson(await installPackedWinApp(config));
|
||||
return;
|
||||
case "start":
|
||||
printJson(await startPackedWinApp(config));
|
||||
return;
|
||||
case "stop":
|
||||
printJson(await stopPackedWinApp(config));
|
||||
return;
|
||||
case "logs":
|
||||
printLogs(await readPackedWinLogs(config), options);
|
||||
return;
|
||||
case "uninstall":
|
||||
printJson(await uninstallPackedWinApp(config));
|
||||
return;
|
||||
case "cleanup":
|
||||
printJson(await cleanupPackedWinNamespace(config));
|
||||
return;
|
||||
case "list":
|
||||
printJson(await listPackedWinNamespaces(config));
|
||||
return;
|
||||
case "reset":
|
||||
printJson(await resetPackedWinNamespaces(config));
|
||||
return;
|
||||
case "inspect":
|
||||
printJson(await inspectPackedWinApp(config, options));
|
||||
return;
|
||||
default:
|
||||
throw new Error(`unsupported win action: ${action}`);
|
||||
}
|
||||
});
|
||||
|
||||
addBuildOptions(addSharedOptions(cli.command("linux <action>", "Linux packaging commands: build|install|start|stop|logs|uninstall|cleanup")), "linux")
|
||||
.option("--containerized", "build inside electronuserland/builder Docker for distro-agnostic glibc compat")
|
||||
.action(async (action: string, options: CliOptions) => {
|
||||
const config = resolveToolPackConfig("linux", options);
|
||||
switch (action) {
|
||||
case "build":
|
||||
printJson(await packLinux(config));
|
||||
return;
|
||||
case "install":
|
||||
printJson(await installPackedLinuxApp(config));
|
||||
return;
|
||||
case "start":
|
||||
printJson(await startPackedLinuxApp(config));
|
||||
return;
|
||||
case "stop":
|
||||
printJson(await stopPackedLinuxApp(config));
|
||||
return;
|
||||
case "logs":
|
||||
printLogs(await readPackedLinuxLogs(config), options);
|
||||
return;
|
||||
case "uninstall":
|
||||
printJson(await uninstallPackedLinuxApp(config));
|
||||
return;
|
||||
case "cleanup":
|
||||
printJson(await cleanupPackedLinuxNamespace(config));
|
||||
return;
|
||||
default:
|
||||
throw new Error(`unsupported linux action: ${action}`);
|
||||
}
|
||||
});
|
||||
|
||||
cli.help();
|
||||
cli.parse();
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ToolPackConfig } from "./config.js";
|
||||
import {
|
||||
buildDockerArgs,
|
||||
matchesAppImageProcess,
|
||||
renderDesktopTemplate,
|
||||
sanitizeNamespace,
|
||||
} from "./linux.js";
|
||||
|
||||
function makeConfig(): ToolPackConfig {
|
||||
return {
|
||||
containerized: true,
|
||||
electronBuilderCliPath: "/x/electron-builder/cli.js",
|
||||
electronDistPath: "/x/electron/dist",
|
||||
electronVersion: "41.3.0",
|
||||
namespace: "default",
|
||||
platform: "linux",
|
||||
portable: false,
|
||||
removeData: false,
|
||||
removeLogs: false,
|
||||
removeProductUserData: false,
|
||||
removeSidecars: false,
|
||||
roots: {
|
||||
output: {
|
||||
appBuilderRoot: "/work/.tmp/tools-pack/out/linux/namespaces/default/builder",
|
||||
namespaceRoot: "/work/.tmp/tools-pack/out/linux/namespaces/default",
|
||||
platformRoot: "/work/.tmp/tools-pack/out/linux",
|
||||
root: "/work/.tmp/tools-pack/out",
|
||||
},
|
||||
runtime: {
|
||||
namespaceBaseRoot: "/work/.tmp/tools-pack/runtime/linux/namespaces",
|
||||
namespaceRoot: "/work/.tmp/tools-pack/runtime/linux/namespaces/default",
|
||||
},
|
||||
toolPackRoot: "/work/.tmp/tools-pack",
|
||||
},
|
||||
silent: true,
|
||||
signed: false,
|
||||
to: "all",
|
||||
workspaceRoot: "/work",
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildDockerArgs", () => {
|
||||
it("returns the expected docker argv array", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
expect(args[0]).toBe("run");
|
||||
expect(args).toContain("--rm");
|
||||
expect(args).toContain("--user");
|
||||
expect(args).toContain("1000:1000");
|
||||
expect(args).toContain("electronuserland/builder:base");
|
||||
});
|
||||
|
||||
it("mounts the workspace at /project", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
expect(args).toContain("-v");
|
||||
expect(args).toContain("/work:/project");
|
||||
});
|
||||
|
||||
it("mounts docker home and electron caches under .tmp/tools-pack/.docker-*", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
expect(args).toContain("/work/.tmp/tools-pack/.docker-home:/home/builder");
|
||||
expect(args).toContain("/work/.tmp/tools-pack/.docker-cache/electron:/home/builder/.cache/electron");
|
||||
expect(args).toContain(
|
||||
"/work/.tmp/tools-pack/.docker-cache/electron-builder:/home/builder/.cache/electron-builder",
|
||||
);
|
||||
});
|
||||
|
||||
it("mounts the tool-pack root at /tools-pack so inner build writes to host-visible output dir", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
expect(args).toContain("/work/.tmp/tools-pack:/tools-pack");
|
||||
});
|
||||
|
||||
it("sets HOME and ELECTRON_CACHE env vars", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
expect(args).toContain("HOME=/home/builder");
|
||||
expect(args).toContain("ELECTRON_CACHE=/home/builder/.cache/electron");
|
||||
expect(args).toContain("ELECTRON_BUILDER_CACHE=/home/builder/.cache/electron-builder");
|
||||
});
|
||||
|
||||
it("re-invokes pnpm tools-pack linux build inside the container without --containerized", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
expect(last).toMatch(/corepack pnpm install --frozen-lockfile/);
|
||||
expect(last).toMatch(/corepack pnpm tools-pack linux build --to all --namespace default/);
|
||||
expect(last).not.toMatch(/--containerized/);
|
||||
});
|
||||
|
||||
it("invokes pnpm via `corepack pnpm` rather than `corepack enable` (non-root container can't write Node shim dir)", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
expect(last).not.toMatch(/corepack enable/);
|
||||
expect(last).toMatch(/corepack pnpm/);
|
||||
});
|
||||
|
||||
it("forwards --dir /tools-pack so inner build output lands under the mounted host dir", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
expect(last).toMatch(/--dir \/tools-pack/);
|
||||
});
|
||||
|
||||
it("forwards --portable when config.portable is true", () => {
|
||||
const args = buildDockerArgs({ ...makeConfig(), portable: true }, { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
expect(last).toMatch(/--portable/);
|
||||
});
|
||||
|
||||
it("omits --portable when config.portable is false", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
expect(last).not.toMatch(/--portable/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDesktopTemplate", () => {
|
||||
const template = `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Open Design (@@NAMESPACE@@)
|
||||
Exec=env OD_PACKAGED_NAMESPACE=@@NAMESPACE@@ @@EXEC_PATH@@ --appimage-extract-and-run %U
|
||||
Icon=@@ICON_PATH@@
|
||||
MimeType=x-scheme-handler/od;
|
||||
`;
|
||||
|
||||
it("substitutes all @@TOKEN@@ placeholders", () => {
|
||||
const out = renderDesktopTemplate(template, {
|
||||
namespace: "default",
|
||||
execPath: "/home/u/.local/bin/Open-Design.default.AppImage",
|
||||
iconName: "open-design-default",
|
||||
});
|
||||
expect(out).toContain("Name=Open Design (default)");
|
||||
expect(out).toContain(
|
||||
"Exec=env OD_PACKAGED_NAMESPACE=default /home/u/.local/bin/Open-Design.default.AppImage --appimage-extract-and-run %U",
|
||||
);
|
||||
expect(out).toContain("Icon=open-design-default");
|
||||
});
|
||||
|
||||
it("uses OD_PACKAGED_NAMESPACE (not OD_NAMESPACE) so apps/packaged actually picks up the namespace override", () => {
|
||||
const out = renderDesktopTemplate(template, {
|
||||
namespace: "ns",
|
||||
execPath: "/x",
|
||||
iconName: "open-design-ns",
|
||||
});
|
||||
expect(out).toMatch(/^Exec=env OD_PACKAGED_NAMESPACE=ns /m);
|
||||
expect(out).not.toMatch(/OD_NAMESPACE=/);
|
||||
});
|
||||
|
||||
it("preserves --appimage-extract-and-run on the Exec= line so menu launches bypass FUSE", () => {
|
||||
const out = renderDesktopTemplate(template, {
|
||||
namespace: "ns",
|
||||
execPath: "/x",
|
||||
iconName: "open-design-ns",
|
||||
});
|
||||
expect(out).toMatch(/^Exec=.*--appimage-extract-and-run .*%U$/m);
|
||||
});
|
||||
|
||||
it("leaves no @@...@@ tokens unsubstituted", () => {
|
||||
const out = renderDesktopTemplate(template, {
|
||||
namespace: "ns",
|
||||
execPath: "/x",
|
||||
iconName: "open-design-ns",
|
||||
});
|
||||
expect(out).not.toMatch(/@@[A-Z_]+@@/);
|
||||
});
|
||||
|
||||
it("preserves the MimeType=x-scheme-handler/od; line", () => {
|
||||
const out = renderDesktopTemplate(template, {
|
||||
namespace: "ns",
|
||||
execPath: "/x",
|
||||
iconName: "open-design-ns",
|
||||
});
|
||||
expect(out).toContain("MimeType=x-scheme-handler/od;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeNamespace", () => {
|
||||
it("replaces non-alphanumeric chars with hyphens", () => {
|
||||
expect(sanitizeNamespace("a/b c")).toBe("a-b-c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesAppImageProcess", () => {
|
||||
const installPath = "/home/u/.local/bin/Open-Design.default.AppImage";
|
||||
|
||||
it("matches FUSE-mode (executable === installPath)", () => {
|
||||
const ok = matchesAppImageProcess(
|
||||
{ pid: 1234, executable: installPath, env: {} },
|
||||
installPath,
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it("matches extracted-mode (env.APPIMAGE === installPath, executable matches /tmp/.mount_*/AppRun)", () => {
|
||||
const ok = matchesAppImageProcess(
|
||||
{ pid: 1234, executable: "/tmp/.mount_abc123/AppRun", env: { APPIMAGE: installPath } },
|
||||
installPath,
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unrelated processes", () => {
|
||||
const ok = matchesAppImageProcess(
|
||||
{ pid: 9999, executable: "/usr/bin/node", env: {} },
|
||||
installPath,
|
||||
);
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects extracted-mode with mismatched APPIMAGE env", () => {
|
||||
const ok = matchesAppImageProcess(
|
||||
{ pid: 1234, executable: "/tmp/.mount_abc/AppRun", env: { APPIMAGE: "/other/path.AppImage" } },
|
||||
installPath,
|
||||
);
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects extracted-mode when APPIMAGE env is missing", () => {
|
||||
const ok = matchesAppImageProcess(
|
||||
{ pid: 1234, executable: "/tmp/.mount_abc123/AppRun", env: {} },
|
||||
installPath,
|
||||
);
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
it("matches --appimage-extract-and-run mode (executable in /tmp/appimage_extracted_*/<binary>)", () => {
|
||||
const ok = matchesAppImageProcess(
|
||||
{
|
||||
pid: 1234,
|
||||
executable: "/tmp/appimage_extracted_fe548e54/Open Design",
|
||||
env: { APPIMAGE: installPath },
|
||||
},
|
||||
installPath,
|
||||
);
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects extract-and-run mode with mismatched APPIMAGE env", () => {
|
||||
const ok = matchesAppImageProcess(
|
||||
{
|
||||
pid: 1234,
|
||||
executable: "/tmp/appimage_extracted_fe548e54/Open Design",
|
||||
env: { APPIMAGE: "/elsewhere/Other.AppImage" },
|
||||
},
|
||||
installPath,
|
||||
);
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { copyBundledResourceTrees } from "./resources.js";
|
||||
|
||||
describe("copyBundledResourceTrees", () => {
|
||||
it("includes prompt templates", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "open-design-tools-pack-"));
|
||||
const workspaceRoot = join(root, "workspace");
|
||||
const resourceRoot = join(root, "resources");
|
||||
|
||||
try {
|
||||
const promptTemplatePath = join(
|
||||
workspaceRoot,
|
||||
"prompt-templates",
|
||||
"image",
|
||||
"sample.json",
|
||||
);
|
||||
const communityPetPath = join(
|
||||
workspaceRoot,
|
||||
"assets",
|
||||
"community-pets",
|
||||
"sample",
|
||||
"pet.json",
|
||||
);
|
||||
await mkdir(join(workspaceRoot, "skills", "sample"), { recursive: true });
|
||||
await mkdir(join(workspaceRoot, "design-systems", "sample"), {
|
||||
recursive: true,
|
||||
});
|
||||
await mkdir(join(workspaceRoot, "craft", "sample"), { recursive: true });
|
||||
await mkdir(join(workspaceRoot, "assets", "frames"), { recursive: true });
|
||||
await mkdir(join(workspaceRoot, "assets", "community-pets", "sample"), {
|
||||
recursive: true,
|
||||
});
|
||||
await mkdir(join(workspaceRoot, "prompt-templates", "image"), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(promptTemplatePath, "{\"id\":\"sample\"}\n", "utf8");
|
||||
await writeFile(communityPetPath, "{\"name\":\"sample\"}\n", "utf8");
|
||||
|
||||
await copyBundledResourceTrees({ workspaceRoot, resourceRoot });
|
||||
|
||||
await expect(
|
||||
readFile(
|
||||
join(resourceRoot, "prompt-templates", "image", "sample.json"),
|
||||
"utf8",
|
||||
),
|
||||
).resolves.toBe("{\"id\":\"sample\"}\n");
|
||||
await expect(
|
||||
readFile(
|
||||
join(resourceRoot, "community-pets", "sample", "pet.json"),
|
||||
"utf8",
|
||||
),
|
||||
).resolves.toBe("{\"name\":\"sample\"}\n");
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { cp } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function resolveToolsPackRoot(startDir: string): string {
|
||||
const maxDepth = 6;
|
||||
let current = startDir;
|
||||
|
||||
for (let depth = 0; depth < maxDepth; depth += 1) {
|
||||
try {
|
||||
const raw = readFileSync(join(current, "package.json"), "utf8");
|
||||
const parsed = JSON.parse(raw) as { name?: unknown };
|
||||
if (parsed.name === "@open-design/tools-pack") {
|
||||
return current;
|
||||
}
|
||||
} catch {
|
||||
// Keep walking until we find the tools-pack package root.
|
||||
}
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
throw new Error(`tools-pack: unable to resolve package root from ${startDir}`);
|
||||
}
|
||||
|
||||
export const toolsPackRoot = resolveToolsPackRoot(dirname(fileURLToPath(import.meta.url)));
|
||||
export const resourcesRoot = join(toolsPackRoot, "resources");
|
||||
|
||||
export const macResources = {
|
||||
entitlements: join(resourcesRoot, "mac", "entitlements.mac.plist"),
|
||||
entitlementsInherit: join(resourcesRoot, "mac", "entitlements.mac.inherit.plist"),
|
||||
icon: join(resourcesRoot, "mac", "icon.icns"),
|
||||
iconPng: join(resourcesRoot, "mac", "icon.png"),
|
||||
notarizeHook: join(resourcesRoot, "mac", "notarize.cjs"),
|
||||
} as const;
|
||||
|
||||
export const winResources = {
|
||||
icon: join(resourcesRoot, "win", "icon.ico"),
|
||||
} as const;
|
||||
|
||||
export const linuxResources = {
|
||||
icon: join(resourcesRoot, "linux", "icon.png"),
|
||||
desktopTemplate: join(resourcesRoot, "linux", "open-design.desktop.template"),
|
||||
} as const;
|
||||
|
||||
const BUNDLED_RESOURCE_TREES = [
|
||||
{ from: "skills", to: "skills" },
|
||||
{ from: "design-systems", to: "design-systems" },
|
||||
{ from: "craft", to: "craft" },
|
||||
{ from: join("assets", "frames"), to: "frames" },
|
||||
{ from: join("assets", "community-pets"), to: "community-pets" },
|
||||
{ from: "prompt-templates", to: "prompt-templates" },
|
||||
] as const;
|
||||
|
||||
export async function copyBundledResourceTrees({
|
||||
workspaceRoot,
|
||||
resourceRoot,
|
||||
}: {
|
||||
workspaceRoot: string;
|
||||
resourceRoot: string;
|
||||
}): Promise<void> {
|
||||
for (const entry of BUNDLED_RESOURCE_TREES) {
|
||||
await cp(join(workspaceRoot, entry.from), join(resourceRoot, entry.to), {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"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"]
|
||||
}
|
||||
Reference in New Issue
Block a user