diff --git a/packages/cli/package.json b/packages/cli/package.json index c551aa918..e9af48721 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -38,7 +38,7 @@ "scripts" ], "scripts": { - "build": "tsc && chmod +x dist/bin-wrapper.js && mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/ && mkdir -p dist/lib/scope-templates && cp src/lib/scope-templates/*.md dist/lib/scope-templates/ && cp ../../LICENSE LICENSE && cp ../../README.md README.md", + "build": "tsc && node ../../scripts/prepare-workspace-build.mjs cli", "postinstall": "node scripts/check-abi.mjs", "prepublishOnly": "bash ../../scripts/build-package.sh", "test": "vitest run", diff --git a/packages/tui/package.json b/packages/tui/package.json index 379a8da82..7772da143 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -9,7 +9,7 @@ "openrig-tui": "dist/main.js" }, "scripts": { - "build": "tsc && chmod +x dist/main.js", + "build": "tsc && node ../../scripts/prepare-workspace-build.mjs tui", "pretest": "npm run build", "test": "vitest run", "test:watch": "vitest" diff --git a/scripts/cli-license.test.mjs b/scripts/cli-license.test.mjs index c80a5f620..d4a6f1e20 100644 --- a/scripts/cli-license.test.mjs +++ b/scripts/cli-license.test.mjs @@ -14,6 +14,8 @@ test("the CLI build carries the unchanged repository LICENSE and README into npm copyFileSync("LICENSE", join(root, "LICENSE")); copyFileSync("README.md", join(root, "README.md")); copyFileSync("packages/cli/package.json", join(cli, "package.json")); + mkdirSync(join(root, "scripts"), { recursive: true }); + copyFileSync("scripts/prepare-workspace-build.mjs", join(root, "scripts/prepare-workspace-build.mjs")); writeFileSync(join(cli, "src/schemas/fixture.json"), "{}\n"); writeFileSync(join(cli, "src/lib/scope-templates/fixture.md"), "fixture\n"); // Compilation is outside this packaging regression. Run the actual build diff --git a/scripts/prepare-workspace-build.mjs b/scripts/prepare-workspace-build.mjs new file mode 100644 index 000000000..eb7b62de8 --- /dev/null +++ b/scripts/prepare-workspace-build.mjs @@ -0,0 +1,34 @@ +import { chmodSync, copyFileSync, mkdirSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +/** Finish a compiled workspace with the same assets/modes on every host. */ +export function prepareWorkspaceBuild(root, workspace, platform = process.platform) { + if (workspace !== "cli" && workspace !== "tui") throw new Error(`Unknown workspace: ${workspace}`); + const dir = path.join(root, "packages", workspace); + const entry = path.join(dir, "dist", workspace === "cli" ? "bin-wrapper.js" : "main.js"); + const mode = statSync(entry).mode; + if (platform !== "win32") chmodSync(entry, mode | 0o111); + if (workspace === "tui") return; + for (const [source, target, extension] of [ + ["src/schemas", "dist/schemas", ".json"], + ["src/lib/scope-templates", "dist/lib/scope-templates", ".md"], + ]) { + const destination = path.join(dir, target); + mkdirSync(destination, { recursive: true }); + let copied = 0; + for (const item of readdirSync(path.join(dir, source), { withFileTypes: true })) { + if (item.name.startsWith(".") || !item.name.endsWith(extension)) continue; + const file = path.join(dir, source, item.name); + if (item.isFile() || (item.isSymbolicLink() && statSync(file).isFile())) { + copyFileSync(file, path.join(destination, item.name)); + copied++; + } + } + if (!copied) throw new Error(`No ${extension} files matched in ${source}`); + } + for (const name of ["LICENSE", "README.md"]) copyFileSync(path.join(root, name), path.join(dir, name)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) + prepareWorkspaceBuild(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."), process.argv[2]); diff --git a/scripts/prepare-workspace-build.test.mjs b/scripts/prepare-workspace-build.test.mjs new file mode 100644 index 000000000..5b5e404c2 --- /dev/null +++ b/scripts/prepare-workspace-build.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, statSync, rmSync, symlinkSync, existsSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { prepareWorkspaceBuild } from "./prepare-workspace-build.mjs"; + +test("CLI build copies assets and root documents without shell utilities", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "openrig-build-assets-")); + try { + for (const dir of ["packages/cli/dist", "packages/cli/src/schemas", "packages/cli/src/lib/scope-templates"]) + mkdirSync(path.join(root, dir), { recursive: true }); + const files = { + "LICENSE": "license", "README.md": "readme", "packages/cli/dist/bin-wrapper.js": "entry", + "packages/cli/src/schemas/spec.json": "{}", "packages/cli/src/schemas/ignored.txt": "ignore", + "packages/cli/src/lib/scope-templates/team.md": "team", + "packages/cli/src/schemas/.hidden.json": "hidden schema", + "packages/cli/src/lib/scope-templates/.hidden.md": "hidden template", + }; + for (const [name, content] of Object.entries(files)) writeFileSync(path.join(root, name), content); + prepareWorkspaceBuild(root, "cli", process.platform); + for (const [name, content] of [["dist/schemas/spec.json", "{}"], ["dist/lib/scope-templates/team.md", "team"], ["LICENSE", "license"], ["README.md", "readme"]]) + assert.equal(readFileSync(path.join(root, "packages/cli", name), "utf8"), content); + assert.throws(() => readFileSync(path.join(root, "packages/cli/dist/schemas/ignored.txt"))); + assert.equal(existsSync(path.join(root, "packages/cli/dist/schemas/.hidden.json")), false); + assert.equal(existsSync(path.join(root, "packages/cli/dist/lib/scope-templates/.hidden.md")), false); + if (process.platform !== "win32") assert.equal(statSync(path.join(root, "packages/cli/dist/bin-wrapper.js")).mode & 0o111, 0o111); + writeFileSync(path.join(root, "packages/cli/src/schemas/spec.json"), "updated"); + prepareWorkspaceBuild(root, "cli", process.platform); + assert.equal(readFileSync(path.join(root, "packages/cli/dist/schemas/spec.json"), "utf8"), "updated"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +function cliFixture() { + const root = mkdtempSync(path.join(os.tmpdir(), "openrig-build-selection-")); + for (const dir of ["packages/cli/dist", "packages/cli/src/schemas", "packages/cli/src/lib/scope-templates"]) + mkdirSync(path.join(root, dir), { recursive: true }); + for (const name of ["LICENSE", "README.md", "packages/cli/dist/bin-wrapper.js"]) + writeFileSync(path.join(root, name), "fixture"); + return root; +} + +test("CLI preparation follows file symlinks like the old cp glob", t => { + const root = cliFixture(); + try { + writeFileSync(path.join(root, "schema-source.txt"), "linked schema"); + writeFileSync(path.join(root, "template-source.txt"), "linked template"); + try { + symlinkSync(path.join(root, "schema-source.txt"), path.join(root, "packages/cli/src/schemas/linked.json"), "file"); + symlinkSync(path.join(root, "template-source.txt"), path.join(root, "packages/cli/src/lib/scope-templates/linked.md"), "file"); + } catch (error) { + if (process.platform === "win32" && ["EPERM", "EACCES"].includes(error.code)) { + t.skip("Windows file-symlink privilege unavailable; requires POSIX validation"); return; + } + throw error; + } + prepareWorkspaceBuild(root, "cli"); + assert.equal(readFileSync(path.join(root, "packages/cli/dist/schemas/linked.json"), "utf8"), "linked schema"); + assert.equal(readFileSync(path.join(root, "packages/cli/dist/lib/scope-templates/linked.md"), "utf8"), "linked template"); + assert.equal(statSync(path.join(root, "packages/cli/dist/schemas/linked.json")).isFile(), true); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("CLI preparation fails when a source glob would have no matches", () => { + for (const [source, other, extension] of [ + ["src/schemas", "src/lib/scope-templates/team.md", ".json"], + ["src/lib/scope-templates", "src/schemas/spec.json", ".md"], + ]) { + const root = cliFixture(); + try { + writeFileSync(path.join(root, "packages/cli", other), "fixture"); + writeFileSync(path.join(root, "packages/cli", source, `.hidden${extension}`), "not a glob match"); + assert.throws(() => prepareWorkspaceBuild(root, "cli"), /No .* files matched/); + } finally { rmSync(root, { recursive: true, force: true }); } + } +}); + +test("TUI preparation requires the compiled entry and rejects unknown workspaces", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "openrig-build-tui-")); + try { + assert.throws(() => prepareWorkspaceBuild(root, "tui"), /ENOENT/); + mkdirSync(path.join(root, "packages/tui/dist"), { recursive: true }); + writeFileSync(path.join(root, "packages/tui/dist/main.js"), "entry"); + prepareWorkspaceBuild(root, "tui", process.platform); + if (process.platform !== "win32") assert.equal(statSync(path.join(root, "packages/tui/dist/main.js")).mode & 0o111, 0o111); + assert.throws(() => prepareWorkspaceBuild(root, "other"), /Unknown workspace/); + } finally { rmSync(root, { recursive: true, force: true }); } +});