mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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
This commit is contained in:
parent
def2e9fd2e
commit
333a62cda6
8 changed files with 187 additions and 4 deletions
16
apps/daemon/bin/od.mjs
Executable file
16
apps/daemon/bin/od.mjs
Executable file
|
|
@ -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);
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"main": "./dist/cli.js",
|
"main": "./dist/cli.js",
|
||||||
"types": "./dist/cli.d.ts",
|
"types": "./dist/cli.d.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
"od": "./dist/cli.js"
|
"od": "./bin/od.mjs"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
"bin",
|
||||||
"dist",
|
"dist",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,5 @@
|
||||||
# 2. Run the relevant nix build/flake check
|
# 2. Run the relevant nix build/flake check
|
||||||
# 3. Copy the expected hash printed by Nix into the matching field below
|
# 3. Copy the expected hash printed by Nix into the matching field below
|
||||||
daemonHash = "sha256-nSMVyVSHfcXV5fLMXM3tfdQxZRb+FNF6P4iuJw/Z8Mo=";
|
daemonHash = "sha256-nSMVyVSHfcXV5fLMXM3tfdQxZRb+FNF6P4iuJw/Z8Mo=";
|
||||||
webHash = "sha256-QOufFb3Hb5js3jK6QEl3WfnxNAa4DdZfMKoALTHY4hI=";
|
webHash = "sha256-IlXE7iNoT/+mcVbtzhJdcP5fNs7Hk8AYZMxfJ33dXck=";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
"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",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"od": "./apps/daemon/dist/cli.js"
|
"od": "./apps/daemon/bin/od.mjs"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "node ./scripts/postinstall.mjs",
|
"postinstall": "node ./scripts/postinstall.mjs",
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
"tools-pack": "pnpm exec tools-pack",
|
"tools-pack": "pnpm exec tools-pack",
|
||||||
"tools-serve": "pnpm exec tools-serve",
|
"tools-serve": "pnpm exec tools-serve",
|
||||||
"nix:update-hash": "node --experimental-strip-types ./scripts/update-nix-pnpm-deps-hash.ts",
|
"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:check": "tsx ./scripts/i18n-check.ts",
|
||||||
"i18n:coverage": "tsx ./scripts/i18n-coverage-report.ts",
|
"i18n:coverage": "tsx ./scripts/i18n-coverage-report.ts",
|
||||||
"sync:community-pets": "node --experimental-strip-types scripts/sync-community-pets.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"
|
"typecheck": "pnpm -r --workspace-concurrency=4 --if-present run typecheck && tsc -p scripts/tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@open-design/daemon": "workspace:*",
|
||||||
"@open-design/tools-dev": "workspace:*",
|
"@open-design/tools-dev": "workspace:*",
|
||||||
"@open-design/tools-pack": "workspace:*",
|
"@open-design/tools-pack": "workspace:*",
|
||||||
"@open-design/tools-serve": "workspace:*",
|
"@open-design/tools-serve": "workspace:*",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@open-design/daemon':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:apps/daemon
|
||||||
'@open-design/tools-dev':
|
'@open-design/tools-dev':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:tools/dev
|
version: link:tools/dev
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,8 @@ const residualAllowedExactPaths = new Set([
|
||||||
// executed directly by Node and are not loaded by the app runtime.
|
// executed directly by Node and are not loaded by the app runtime.
|
||||||
"scripts/import-prompt-templates.mjs",
|
"scripts/import-prompt-templates.mjs",
|
||||||
"scripts/postinstall.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",
|
"apps/packaged/esbuild.config.mjs",
|
||||||
// Browser service workers must be served as JavaScript files.
|
// Browser service workers must be served as JavaScript files.
|
||||||
"apps/web/public/od-notifications-sw.js",
|
"apps/web/public/od-notifications-sw.js",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const buildTargets = [
|
||||||
"packages/sidecar-proto",
|
"packages/sidecar-proto",
|
||||||
"packages/sidecar",
|
"packages/sidecar",
|
||||||
"packages/diagnostics",
|
"packages/diagnostics",
|
||||||
|
"apps/daemon",
|
||||||
"tools/dev",
|
"tools/dev",
|
||||||
"tools/pack",
|
"tools/pack",
|
||||||
"tools/serve",
|
"tools/serve",
|
||||||
|
|
|
||||||
159
scripts/postinstall.test.ts
Normal file
159
scripts/postinstall.test.ts
Normal file
|
|
@ -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<string, unknown> {
|
||||||
|
const manifest = readJson(path);
|
||||||
|
assert(typeof manifest === "object" && manifest !== null);
|
||||||
|
return manifest as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>, 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<string, unknown>)[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<string> {
|
||||||
|
assert(typeof manifest === "object" && manifest !== null);
|
||||||
|
const dependencyFields = includeDevDependencies
|
||||||
|
? ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]
|
||||||
|
: ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||||
|
const names = new Set<string>();
|
||||||
|
|
||||||
|
for (const field of dependencyFields) {
|
||||||
|
const dependencies = (manifest as Record<string, unknown>)[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<string> {
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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, []);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue