From 333a62cda61d5c6f2f24ef18394089782ef0da2f Mon Sep 17 00:00:00 2001 From: kami <31983330+bulai0408@users.noreply.github.com> Date: Sun, 31 May 2026 12:36:49 +0800 Subject: [PATCH] fix: link od bin after fresh install (#2069) * fix: link od bin after fresh install * test: lock root od bin shim path * test: cover root workspace deps in postinstall scan * chore(nix): refresh pnpm deps hash --- apps/daemon/bin/od.mjs | 16 ++++ apps/daemon/package.json | 3 +- nix/pnpm-deps.nix | 2 +- package.json | 5 +- pnpm-lock.yaml | 3 + scripts/guard.ts | 2 + scripts/postinstall.mjs | 1 + scripts/postinstall.test.ts | 159 ++++++++++++++++++++++++++++++++++++ 8 files changed, 187 insertions(+), 4 deletions(-) create mode 100755 apps/daemon/bin/od.mjs create mode 100644 scripts/postinstall.test.ts diff --git a/apps/daemon/bin/od.mjs b/apps/daemon/bin/od.mjs new file mode 100755 index 000000000..1d6c26a70 --- /dev/null +++ b/apps/daemon/bin/od.mjs @@ -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/cli.js"); + +if (!existsSync(distEntry)) { + throw new Error( + `Open Design daemon dist entry not found at ${distEntry}. Run "pnpm --filter @open-design/daemon build" first.`, + ); +} + +await import(pathToFileURL(distEntry).href); diff --git a/apps/daemon/package.json b/apps/daemon/package.json index fc33d934a..46d642fdf 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -6,7 +6,7 @@ "main": "./dist/cli.js", "types": "./dist/cli.d.ts", "bin": { - "od": "./dist/cli.js" + "od": "./bin/od.mjs" }, "exports": { ".": { @@ -20,6 +20,7 @@ } }, "files": [ + "bin", "dist", "package.json" ], diff --git a/nix/pnpm-deps.nix b/nix/pnpm-deps.nix index 9d12e8fc8..1c03d1bc3 100644 --- a/nix/pnpm-deps.nix +++ b/nix/pnpm-deps.nix @@ -10,5 +10,5 @@ # 2. Run the relevant nix build/flake check # 3. Copy the expected hash printed by Nix into the matching field below daemonHash = "sha256-nSMVyVSHfcXV5fLMXM3tfdQxZRb+FNF6P4iuJw/Z8Mo="; - webHash = "sha256-QOufFb3Hb5js3jK6QEl3WfnxNAa4DdZfMKoALTHY4hI="; + webHash = "sha256-IlXE7iNoT/+mcVbtzhJdcP5fNs7Hk8AYZMxfJ33dXck="; } diff --git a/package.json b/package.json index bee2749ae..320e9277c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "description": "Local-first design product: detects your installed code-agent CLI, runs design skills + design systems, streams artifacts into a sandboxed preview.", "license": "Apache-2.0", "bin": { - "od": "./apps/daemon/dist/cli.js" + "od": "./apps/daemon/bin/od.mjs" }, "scripts": { "postinstall": "node ./scripts/postinstall.mjs", @@ -15,7 +15,7 @@ "tools-pack": "pnpm exec tools-pack", "tools-serve": "pnpm exec tools-serve", "nix:update-hash": "node --experimental-strip-types ./scripts/update-nix-pnpm-deps-hash.ts", - "guard": "tsx ./scripts/guard.ts && node --import tsx --test scripts/style-policy.test.ts scripts/approve-fork-pr-workflows.test.ts", + "guard": "tsx ./scripts/guard.ts && node --import tsx --test scripts/style-policy.test.ts scripts/approve-fork-pr-workflows.test.ts scripts/postinstall.test.ts", "i18n:check": "tsx ./scripts/i18n-check.ts", "i18n:coverage": "tsx ./scripts/i18n-coverage-report.ts", "sync:community-pets": "node --experimental-strip-types scripts/sync-community-pets.ts", @@ -25,6 +25,7 @@ "typecheck": "pnpm -r --workspace-concurrency=4 --if-present run typecheck && tsc -p scripts/tsconfig.json --noEmit" }, "devDependencies": { + "@open-design/daemon": "workspace:*", "@open-design/tools-dev": "workspace:*", "@open-design/tools-pack": "workspace:*", "@open-design/tools-serve": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2f825bfc..0187b2bc7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,9 @@ importers: .: devDependencies: + '@open-design/daemon': + specifier: workspace:* + version: link:apps/daemon '@open-design/tools-dev': specifier: workspace:* version: link:tools/dev diff --git a/scripts/guard.ts b/scripts/guard.ts index 6d7d1af88..68f5af4af 100644 --- a/scripts/guard.ts +++ b/scripts/guard.ts @@ -70,6 +70,8 @@ const residualAllowedExactPaths = new Set([ // executed directly by Node and are not loaded by the app runtime. "scripts/import-prompt-templates.mjs", "scripts/postinstall.mjs", + // Checked-in bin shim so pnpm can link `od` before daemon dist output exists. + "apps/daemon/bin/od.mjs", "apps/packaged/esbuild.config.mjs", // Browser service workers must be served as JavaScript files. "apps/web/public/od-notifications-sw.js", diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index dc55364cd..ce2586e04 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -17,6 +17,7 @@ const buildTargets = [ "packages/sidecar-proto", "packages/sidecar", "packages/diagnostics", + "apps/daemon", "tools/dev", "tools/pack", "tools/serve", diff --git a/scripts/postinstall.test.ts b/scripts/postinstall.test.ts new file mode 100644 index 000000000..9518ae050 --- /dev/null +++ b/scripts/postinstall.test.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; + +const repoRoot = join(import.meta.dirname, ".."); + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(join(repoRoot, path), "utf8")); +} + +function readPackageJson(path: string): Record { + const manifest = readJson(path); + assert(typeof manifest === "object" && manifest !== null); + return manifest as Record; +} + +function packageName(manifest: unknown): string { + assert(typeof manifest === "object" && manifest !== null); + const name = (manifest as { name?: unknown }).name; + assert(typeof name === "string"); + return name; +} + +function packageBinTargets(manifest: unknown): string[] { + assert(typeof manifest === "object" && manifest !== null); + const bin = (manifest as { bin?: unknown }).bin; + if (typeof bin === "string") return [bin]; + if (typeof bin !== "object" || bin === null) return []; + return Object.values(bin).filter((value): value is string => typeof value === "string"); +} + +function dependencySpecifier(manifest: Record, name: string): string | undefined { + const dependencyFields = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"] as const; + for (const field of dependencyFields) { + const dependencies = manifest[field]; + if (typeof dependencies !== "object" || dependencies === null) continue; + const specifier = (dependencies as Record)[name]; + if (typeof specifier === "string") return specifier; + } + return undefined; +} + +function distDelegatingBinTargets(directory: string, manifest: unknown): string[] { + return packageBinTargets(manifest).filter((binTarget) => { + if (binTarget.startsWith("./dist/")) return true; + const source = readFileSync(join(repoRoot, directory, binTarget), "utf8"); + return source.includes("../dist/") || source.includes("./dist/") || source.includes("/dist/"); + }); +} + +function workspaceDependencyNames(manifest: unknown, includeDevDependencies = false): Set { + assert(typeof manifest === "object" && manifest !== null); + const dependencyFields = includeDevDependencies + ? ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"] + : ["dependencies", "optionalDependencies", "peerDependencies"]; + const names = new Set(); + + for (const field of dependencyFields) { + const dependencies = (manifest as Record)[field]; + if (typeof dependencies !== "object" || dependencies === null) continue; + for (const [name, version] of Object.entries(dependencies)) { + if (typeof version === "string" && version.startsWith("workspace:")) { + names.add(name); + } + } + } + + return names; +} + +function postinstallBuildTargets(): Set { + const source = readFileSync(join(repoRoot, "scripts/postinstall.mjs"), "utf8"); + const targets = [...source.matchAll(/"([^"]+)"/g)] + .map((match) => match[1]) + .filter((value): value is string => value != null && /^(?:apps|packages|tools)\//.test(value)); + return new Set(targets); +} + +function workspacePackageDirectories(): string[] { + const scopedPackageDirectories = ["apps", "packages", "tools"].flatMap((scope) => + readdirSync(join(repoRoot, scope), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => `${scope}/${entry.name}`), + ); + return ["e2e", ...scopedPackageDirectories] + .filter((directory) => existsSync(join(repoRoot, directory, "package.json"))) + .sort(); +} + +test("workspace bin entries use checked-in targets so pnpm can link them before postinstall", () => { + const manifests = new Map( + workspacePackageDirectories().map((directory) => [ + directory, + readJson(`${directory}/package.json`), + ]), + ); + const consumedWorkspacePackages = new Set(); + for (const manifest of manifests.values()) { + for (const name of workspaceDependencyNames(manifest)) { + consumedWorkspacePackages.add(name); + } + } + + const unlinkableBins = [...manifests.entries()] + .filter(([, manifest]) => consumedWorkspacePackages.has(packageName(manifest))) + .flatMap(([directory, manifest]) => + packageBinTargets(manifest).map((binTarget) => ({ + binTarget, + directory, + resolvedPath: join(repoRoot, directory, binTarget), + })), + ) + .filter(({ resolvedPath }) => !existsSync(resolvedPath)) + .map(({ binTarget, directory }) => `${directory}:${binTarget}`); + + assert.deepEqual(unlinkableBins, []); +}); + +test("root workspace depends on the daemon package so pnpm exec resolves the od bin", () => { + const rootManifest = readPackageJson("package.json"); + const daemonManifest = readPackageJson("apps/daemon/package.json"); + + assert.equal(dependencySpecifier(rootManifest, "@open-design/daemon"), "workspace:*"); + assert.deepEqual((rootManifest as { bin?: unknown }).bin, { + od: "./apps/daemon/bin/od.mjs", + }); + assert.deepEqual((daemonManifest as { bin?: unknown }).bin, { + od: "./bin/od.mjs", + }); + assert.equal(existsSync(join(repoRoot, "apps/daemon/bin/od.mjs")), true); +}); + +test("postinstall builds workspace packages whose linkable bins delegate to dist", () => { + const rootManifest = readPackageJson("package.json"); + const manifests = new Map( + workspacePackageDirectories().map((directory) => [ + directory, + readJson(`${directory}/package.json`), + ]), + ); + const consumedWorkspacePackages = new Set(); + for (const name of workspaceDependencyNames(rootManifest, true)) { + consumedWorkspacePackages.add(name); + } + for (const manifest of manifests.values()) { + for (const name of workspaceDependencyNames(manifest)) { + consumedWorkspacePackages.add(name); + } + } + + const missingBuildTargets = [...manifests.entries()] + .filter(([, manifest]) => consumedWorkspacePackages.has(packageName(manifest))) + .filter(([directory, manifest]) => distDelegatingBinTargets(directory, manifest).length > 0) + .map(([directory]) => directory) + .filter((directory) => !postinstallBuildTargets().has(directory)); + + assert.deepEqual(missingBuildTargets, []); +});