From 98300068a6220740be9920dbc6707ab9bbe29e28 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Tue, 13 Oct 2026 12:17:05 +0530 Subject: [PATCH 1/5] chore(tui): drop Intel Mac support Stop building and publishing @codeworksh/tui-darwin-x64; the package is deprecated on npm. The launcher now reports darwin-x64 as unsupported instead of naming a package that no longer ships. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 3 --- CLAUDE.md | 2 +- packages/tui/bump.config.ts | 2 +- packages/tui/npm/darwin-x64/README.md | 3 --- packages/tui/npm/darwin-x64/package.json | 24 ------------------------ packages/tui/package.json | 1 - packages/tui/src/binary.ts | 2 ++ pnpm-lock.yaml | 5 ----- 8 files changed, 4 insertions(+), 38 deletions(-) delete mode 100644 packages/tui/npm/darwin-x64/README.md delete mode 100644 packages/tui/npm/darwin-x64/package.json diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1f5e12c7..36cc67ac 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,9 +41,6 @@ jobs: - package: tui-darwin-arm64 runner: macos-14 target: aarch64-apple-darwin - - package: tui-darwin-x64 - runner: macos-14 - target: x86_64-apple-darwin # Linux binaries link musl statically, so one build runs on any distro. - package: tui-linux-arm64 runner: ubuntu-24.04-arm diff --git a/CLAUDE.md b/CLAUDE.md index 423a06f8..3aef3cc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ Every package is published by CI from `.github/workflows/publish.yml`, which run - **dev**: every push to `dev`. Versions are `-dev.` (UTC). - **latest**: run the workflow by hand on `main`. It publishes each package whose manifest version is not on npm yet and skips the rest. -Versions are bumped locally with `vp run bump [version]`, which runs `bumpp` with the package's `bump.config.ts` and commits and tags `@codeworksh/@` without pushing. `vp run bump tui` moves the launcher and its four platform packages together. +Versions are bumped locally with `vp run bump [version]`, which runs `bumpp` with the package's `bump.config.ts` and commits and tags `@codeworksh/@` without pushing. `vp run bump tui` moves the launcher and its three platform packages together. Flow: diff --git a/packages/tui/bump.config.ts b/packages/tui/bump.config.ts index 30795f89..97595af4 100644 --- a/packages/tui/bump.config.ts +++ b/packages/tui/bump.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "bumpp"; // Release config for @codeworksh/tui, loaded automatically by `bumpp`. The platform -// packages ship in lockstep with the launcher, so all five versions move together. +// packages ship in lockstep with the launcher, so all four versions move together. export default defineConfig({ files: ["package.json", "npm/*/package.json"], tag: "@codeworksh/tui@%s", diff --git a/packages/tui/npm/darwin-x64/README.md b/packages/tui/npm/darwin-x64/README.md deleted file mode 100644 index 4397ac51..00000000 --- a/packages/tui/npm/darwin-x64/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# @codeworksh/tui-darwin-x64 - -The prebuilt CodeWork terminal UI for macOS on Intel. Installed automatically with `@codeworksh/cli`; run it with `codework tui`. diff --git a/packages/tui/npm/darwin-x64/package.json b/packages/tui/npm/darwin-x64/package.json deleted file mode 100644 index b69edd63..00000000 --- a/packages/tui/npm/darwin-x64/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@codeworksh/tui-darwin-x64", - "version": "0.0.2", - "description": "The CodeWork terminal UI binary for macOS on Intel", - "homepage": "https://www.codework.sh", - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/codeworksh/codework.git", - "directory": "packages/tui/npm/darwin-x64" - }, - "os": [ - "darwin" - ], - "cpu": [ - "x64" - ], - "publishConfig": { - "access": "public" - }, - "scripts": { - "build": "node ../../scripts/stage.mjs" - } -} diff --git a/packages/tui/package.json b/packages/tui/package.json index 488df243..d0794f5a 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -49,7 +49,6 @@ }, "optionalDependencies": { "@codeworksh/tui-darwin-arm64": "workspace:*", - "@codeworksh/tui-darwin-x64": "workspace:*", "@codeworksh/tui-linux-arm64": "workspace:*", "@codeworksh/tui-linux-x64": "workspace:*" } diff --git a/packages/tui/src/binary.ts b/packages/tui/src/binary.ts index 6de2d740..ef243d01 100644 --- a/packages/tui/src/binary.ts +++ b/packages/tui/src/binary.ts @@ -18,6 +18,8 @@ export function platform(): Platform | undefined { const arch = process.arch; if (os !== "darwin" && os !== "linux") return undefined; if (arch !== "arm64" && arch !== "x64") return undefined; + // Intel Macs are no longer supported; `@codeworksh/tui-darwin-x64` is deprecated. + if (os === "darwin" && arch === "x64") return undefined; return { os, arch }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cef4678..a9fb21ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -593,9 +593,6 @@ importers: '@codeworksh/tui-darwin-arm64': specifier: workspace:* version: link:npm/darwin-arm64 - '@codeworksh/tui-darwin-x64': - specifier: workspace:* - version: link:npm/darwin-x64 '@codeworksh/tui-linux-arm64': specifier: workspace:* version: link:npm/linux-arm64 @@ -605,8 +602,6 @@ importers: packages/tui/npm/darwin-arm64: {} - packages/tui/npm/darwin-x64: {} - packages/tui/npm/linux-arm64: {} packages/tui/npm/linux-x64: {} From 6498c0fba6e239c9707b7e8681625ab457a0f8be Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Tue, 13 Oct 2026 12:42:21 +0530 Subject: [PATCH 2/5] feat(harness): share effect with plugins from a bundled host A host bundled into one file, like the standalone binary, has no effect on disk for plugins to resolve to. It now publishes its modules through Plugin.share on globalThis[Symbol.for("codework.v1.effect")] (and codework.v1.plugin), and the plugin loader serves them as virtual modules that load each one on first import. Unbundled hosts resolve as before. scripts/shared.mjs generates that registry, with every public module's export names, for the CLI bundle. Co-Authored-By: Claude Opus 5.5 --- packages/codework/scripts/shared.mjs | 61 ++++++++++++++++++++++ packages/harness/src/plugin/index.ts | 1 + packages/harness/src/util/module.ts | 78 +++++++++++++++++++++++++--- 3 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 packages/codework/scripts/shared.mjs diff --git a/packages/codework/scripts/shared.mjs b/packages/codework/scripts/shared.mjs new file mode 100644 index 00000000..901ef7e6 --- /dev/null +++ b/packages/codework/scripts/shared.mjs @@ -0,0 +1,61 @@ +// Generates the module a bundled CLI passes to the harness's `Plugin.share`: every public module +// of the packages plugins share with it, with its export names and a lazy loader. +// +// node --conditions=development scripts/shared.mjs +// +// `development` resolves `@codeworksh/plugin` to source, as the bundle does. +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +const packages = ["effect", "@codeworksh/plugin"]; +// The harness owns the sharing, so its dependencies are the copies to describe. +const require = createRequire(new URL("../../harness/package.json", import.meta.url)); + +/** Every subpath the package exports, with `*` patterns expanded against its files. */ +function specifiers(name) { + const manifest = require.resolve(`${name}/package.json`); + const { exports } = JSON.parse(readFileSync(manifest, "utf8")); + const result = []; + for (const [key, target] of Object.entries(exports)) { + if (key === "./package.json" || target === null) continue; + if (!key.includes("*")) { + result.push(name + key.slice(1)); + continue; + } + const pattern = typeof target === "string" ? target : (target.import ?? target.default); + const [prefix, suffix] = pattern.split("*"); + const directory = join(dirname(manifest), prefix); + for (const file of readdirSync(directory, { recursive: true })) { + if (file.endsWith(suffix)) result.push(name + key.slice(1).replace("*", file.slice(0, -suffix.length))); + } + } + return [...new Set(result)].sort(); +} + +async function describe(name) { + const entries = []; + for (const specifier of specifiers(name)) { + let namespace; + try { + namespace = await import(require.resolve(specifier)); + } catch (error) { + // A `*` expansion also reaches files a more specific `null` export hides. + if (error.code === "ERR_PACKAGE_PATH_NOT_EXPORTED") continue; + throw error; + } + const names = JSON.stringify(Object.keys(namespace).sort()); + entries.push( + `\t\t${JSON.stringify(specifier)}: { names: ${names}, load: () => import(${JSON.stringify(specifier)}) },`, + ); + } + return `\t${JSON.stringify(name)}: {\n${entries.join("\n")}\n\t},`; +} + +const [outfile] = process.argv.slice(2); +if (outfile === undefined) { + console.error("usage: node --conditions=development scripts/shared.mjs "); + process.exit(1); +} +const body = await Promise.all(packages.map(describe)); +writeFileSync(outfile, `// Generated by scripts/shared.mjs.\nexport default {\n${body.join("\n")}\n};\n`); diff --git a/packages/harness/src/plugin/index.ts b/packages/harness/src/plugin/index.ts index be984540..8ff48c6c 100644 --- a/packages/harness/src/plugin/index.ts +++ b/packages/harness/src/plugin/index.ts @@ -22,3 +22,4 @@ export { type Updated, } from "./store.ts"; export * as Tool from "./tool/schema.ts"; +export { share, type SharedModule, type SharedRegistry } from "../util/module.ts"; diff --git a/packages/harness/src/util/module.ts b/packages/harness/src/util/module.ts index fa0f32d2..6c5e62a8 100644 --- a/packages/harness/src/util/module.ts +++ b/packages/harness/src/util/module.ts @@ -48,8 +48,54 @@ import { Script, constants } from "node:vm"; * immutable and shared by every project on the machine, while the harness doing the loading is * whichever one is running now, and two applications embedding the harness need not agree on * where their `effect` lives. + * + * Each package maps to the `Symbol.for` key of its {@link SharedRegistry}. + */ +const sharedPackages: ReadonlyMap = new Map([ + ["effect", "codework.v1.effect"], + ["@codeworksh/plugin", "codework.v1.plugin"], +]); + +/** One module of a shared package, as a bundled host publishes it. */ +export interface SharedModule { + /** Its export names; an ES module's exports must be known before it runs. */ + readonly names: ReadonlyArray; + readonly load: () => Promise>; +} + +/** + * A shared package's modules by specifier (`effect/Schema`), published on + * `globalThis[Symbol.for(key)]` by a host that is a single bundled file, such as the standalone + * binary. Such a host has no `effect` on disk to resolve to, so plugins get virtual modules that + * re-export its own instances. Modules load on first import: `effect` alone has hundreds. */ -const sharedPackages: ReadonlySet = new Set(["effect", "@codeworksh/plugin"]); +export type SharedRegistry = Readonly>; + +const sharedScheme = "codeworkshared:"; + +function registryOf(key: string): SharedRegistry | undefined { + return (globalThis as Record)[Symbol.for(key)]; +} + +/** Publish a bundled host's registries, by package name, before any plugin loads. */ +export function share(packages: Readonly>): void { + for (const [name, registry] of Object.entries(packages)) { + const key = sharedPackages.get(name); + if (key === undefined) throw new Error(`${name} is not a package shared with plugins`); + (globalThis as Record)[Symbol.for(key)] = registry; + } +} + +/** A virtual module re-exporting the host's instance of `specifier`. */ +function sharedSource(key: string, specifier: string, names: ReadonlyArray): string { + const registry = `globalThis[Symbol.for(${JSON.stringify(key)})]`; + return [ + `const m = await ${registry}[${JSON.stringify(specifier)}].load();`, + ...names.map((name, index) => `const $${index} = m[${JSON.stringify(name)}];`), + // String export names, since some are reserved words (`Effect.void`). + `export { ${names.map((name, index) => `$${index} as ${JSON.stringify(name)}`).join(", ")} };`, + ].join("\n"); +} /** The package a bare specifier names: `effect/Schema` -> `effect`, `@a/b/c` -> `@a/b`. */ function packageOf(specifier: string): string { @@ -59,18 +105,34 @@ function packageOf(specifier: string): string { } /** - * Resolve the shared packages as though this module had imported them, for as long as `run` - * takes. Anything else resolves exactly as it would have. + * Resolve the shared packages to the host's own instances, for as long as `run` takes: from its + * registry when it published one, otherwise as though this module had imported them. Anything + * else resolves exactly as it would have. */ -function withSharedModules(run: () => Promise): Promise { +async function withSharedModules(run: () => Promise): Promise { const hook = registerHooks({ resolve(specifier, context, nextResolve) { - return sharedPackages.has(packageOf(specifier)) - ? nextResolve(specifier, { ...context, parentURL: import.meta.url }) - : nextResolve(specifier, context); + const key = sharedPackages.get(packageOf(specifier)); + if (key === undefined) return nextResolve(specifier, context); + const registry = registryOf(key); + if (registry === undefined) return nextResolve(specifier, { ...context, parentURL: import.meta.url }); + // Falling back to the plugin's own copy would bring back the two-instance failure above. + if (!Object.hasOwn(registry, specifier)) throw new Error(`${specifier} is not shared with plugins`); + return { url: `${sharedScheme}${specifier}`, shortCircuit: true }; + }, + load(url, context, nextLoad) { + if (!url.startsWith(sharedScheme)) return nextLoad(url, context); + const specifier = url.slice(sharedScheme.length); + const key = sharedPackages.get(packageOf(specifier))!; + const shared = registryOf(key)![specifier]!; + return { format: "module", source: sharedSource(key, specifier, shared.names), shortCircuit: true }; }, }); - return run().finally(() => hook.deregister()); + try { + return await run(); + } finally { + hook.deregister(); + } } /** Use Node's main loader even when the caller runs inside a bundler or test VM. */ From ace892af3a4e619e7486eb9eb9a36f929ac41238 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Tue, 13 Oct 2026 14:14:32 +0530 Subject: [PATCH 3/5] feat(cli): bundle the CLI for a standalone binary `vp run bundle` writes dist/sea: the whole CLI as one ESM file, entry src/sea.ts, beside the assets the binary embeds. Before the CLI starts, the entry unpacks them to a per-user cache keyed by their content hash, publishes the CLI's effect for plugins, and names the unpacked paths. Packages that load files from beside their own modules are unpacked as a real node_modules instead of bundled: just-bash (its sqlite3 worker and zstd and xz addons) and photon. The harness resolves them from CODEWORK_PACKAGES. node-liblzma is rebuilt with xz linked statically, so the binary needs no system liblzma. The image worker is bundled on its own, and a managed server re-spawns the binary itself. Co-Authored-By: Claude Opus 5.5 --- packages/codework/package.json | 4 +- packages/codework/scripts/bundle.mjs | 156 ++++++++++++++++++ packages/codework/scripts/shared.mjs | 19 +-- packages/codework/src/sea.ts | 66 ++++++++ packages/codework/src/server/registration.ts | 3 + packages/codework/src/virtual.d.ts | 7 + .../harness/src/sandbox/shell/justbash.ts | 25 ++- packages/harness/src/tool/image/photon.ts | 19 ++- packages/harness/src/tool/image/resize.ts | 75 +++++---- packages/harness/src/util/unpacked.ts | 14 ++ pnpm-lock.yaml | 3 + 11 files changed, 331 insertions(+), 60 deletions(-) create mode 100644 packages/codework/scripts/bundle.mjs create mode 100644 packages/codework/src/sea.ts create mode 100644 packages/codework/src/virtual.d.ts create mode 100644 packages/harness/src/util/unpacked.ts diff --git a/packages/codework/package.json b/packages/codework/package.json index 6ec1e59b..114bc7b0 100644 --- a/packages/codework/package.json +++ b/packages/codework/package.json @@ -37,6 +37,7 @@ }, "scripts": { "build": "vp pack", + "bundle": "node --conditions=development scripts/bundle.mjs", "check": "vp check", "check:effect": "effect-tsgo diagnostics --project tsconfig.json --format text", "lint": "vp lint", @@ -55,7 +56,8 @@ }, "devDependencies": { "@types/node": "catalog:", - "bumpp": "^12.3.0" + "bumpp": "^12.3.0", + "node-gyp": "^13.0.2" }, "optionalDependencies": { "@daytona/sdk": "^0.211.2", diff --git a/packages/codework/scripts/bundle.mjs b/packages/codework/scripts/bundle.mjs new file mode 100644 index 00000000..252b5f33 --- /dev/null +++ b/packages/codework/scripts/bundle.mjs @@ -0,0 +1,156 @@ +// Bundles the CLI into one ESM file for the standalone binary, beside the assets the binary +// embeds and unpacks. Writes `dist/sea`: +// +// codework.mjs the CLI, entry `src/sea.ts` +// assets/ worker.mjs, codework-tui, and node_modules/ for the packages that load +// files from beside their own modules (WASM, workers, native addons) +// +// Run under `--conditions=development` (see `scripts/shared.mjs`). The TUI comes from +// `CODEWORK_TUI_BIN`, else the local release build. Rebuilding xz statically needs cmake. +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + chmodSync, + copyFileSync, + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { basename, dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "vite-plus"; +import { source } from "./shared.mjs"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const out = join(root, "dist/sea"); +const assets = join(out, "assets"); +const harness = join(root, "../harness/node_modules"); + +// Resolved by the harness from `CODEWORK_PACKAGES` (see `src/sea.ts`) instead of being bundled. +const unpacked = ["just-bash", "@silvia-odwyer/photon-node"]; +// Only run at install, where @mongodb-js/zstd fetches its prebuilt addon. +// Only run at install, where @mongodb-js/zstd fetches its prebuilt addon; or only serve just-bash's +// `js-exec`, which the harness leaves off (no `javascript` option). +const skipped = new Set(["prebuild-install", "node-addon-api", "quickjs-emscripten"]); +// Documentation and types, never loaded. +const unused = /\.(map|d\.[cm]?ts|md|zip)$/; +/** Per package, which of its files to ship, by path inside it: the rest serve what the harness leaves off. */ +const ships = { + // `python` (CPython, in vendor/) is off, and the harness imports dist/bundle, not the CLI in dist/bin. + "just-bash": (file) => !file.startsWith("vendor") && !file.startsWith("dist/bin"), + // The Node WASM build; the rest are asm.js, debug, browser and web worker builds. + "sql.js": (file) => file === "dist" || !file.startsWith("dist/") || /^dist\/sql-wasm\.(js|wasm)$/.test(file), +}; + +/** Bundle `entry` and everything it imports into the single file `out/name`. */ +const bundle = (entry, outDir, name, extra = {}) => + build({ + configFile: join(root, "vite.config.ts"), + root, + logLevel: "warn", + ssr: { noExternal: true }, + ...extra, + build: { + ssr: entry, + target: "node26", + outDir, + emptyOutDir: false, + minify: true, + rollupOptions: { output: { format: "esm", entryFileNames: name, codeSplitting: false } }, + }, + }); + +rmSync(out, { recursive: true, force: true }); +mkdirSync(assets, { recursive: true }); + +await bundle(join(root, "../harness/src/tool/image/worker.ts"), assets, "worker.mjs"); + +/** Every package `names` needs at runtime, by name, with its directory in pnpm's store. */ +function closure(names) { + const found = new Map(); + const visit = (directory) => { + const { name, dependencies, optionalDependencies } = JSON.parse( + readFileSync(join(directory, "package.json"), "utf8"), + ); + const seen = found.get(name); + if (seen !== undefined) { + if (seen !== directory) throw new Error(`two versions of ${name} to unpack: ${seen} and ${directory}`); + return; + } + found.set(name, directory); + // pnpm links a package's dependencies beside it, in the node_modules that holds it. + const modules = name.startsWith("@") ? dirname(dirname(directory)) : dirname(directory); + for (const dependency of Object.keys({ ...dependencies, ...optionalDependencies })) { + const linked = join(modules, dependency); + // An optional dependency for another platform is not installed. + if (!skipped.has(dependency) && existsSync(linked)) visit(realpathSync(linked)); + } + }; + for (const name of names) visit(realpathSync(join(harness, name))); + return found; +} + +const packages = closure(unpacked); +for (const [name, directory] of packages) { + cpSync(directory, join(assets, "node_modules", name), { + recursive: true, + filter: (path) => + path === directory || + (basename(path) !== "node_modules" && + !unused.test(path) && + (ships[name]?.(relative(directory, path)) ?? true)), + }); +} + +// node-liblzma links the system liblzma by default, which a user's machine may not have. Link +// xz in statically, and keep only the addon from the build. +const lzma = join(assets, "node_modules/node-liblzma"); +const store = packages.get("node-liblzma"); +const gyp = createRequire(import.meta.url).resolve("node-gyp/bin/node-gyp.js"); +// The build reads node-addon-api's headers, which the addon does not need at runtime. +cpSync(realpathSync(join(dirname(store), "node-addon-api")), join(lzma, "node_modules/node-addon-api"), { + recursive: true, +}); +execFileSync(process.execPath, [gyp, "rebuild"], { + cwd: lzma, + stdio: "inherit", + env: { ...process.env, USE_GLOBAL: "false", RUNTIME_LINK: "static" }, +}); +const addon = readFileSync(join(lzma, "build/Release/node_lzma.node")); +// Its build tree, downloaded xz sources and borrowed headers are not part of the addon. +for (const directory of ["build", "deps", "node_modules"]) rmSync(join(lzma, directory), { recursive: true }); +mkdirSync(join(lzma, "build/Release"), { recursive: true }); +writeFileSync(join(lzma, "build/Release/node_lzma.node"), addon); +const [inspect, ...flags] = process.platform === "darwin" ? ["otool", "-L"] : ["ldd"]; +const linked = execFileSync(inspect, [...flags, join(lzma, "build/Release/node_lzma.node")]).toString(); +if (/liblzma[.\d]*\.(dylib|so)/.test(linked)) + throw new Error(`node-liblzma still links liblzma dynamically:\n${linked}`); + +const tui = process.env["CODEWORK_TUI_BIN"] ?? join(root, "../tui/target/release/codework-tui"); +if (!existsSync(tui)) throw new Error(`missing TUI binary ${tui}: set CODEWORK_TUI_BIN or run cargo build --release`); +copyFileSync(tui, join(assets, "codework-tui")); +chmodSync(join(assets, "codework-tui"), 0o755); + +const hash = createHash("sha256"); +const files = readdirSync(assets, { recursive: true, withFileTypes: true }).filter((entry) => entry.isFile()); +for (const file of files.map((entry) => join(entry.parentPath, entry.name)).sort()) { + hash.update(relative(assets, file)); + hash.update(readFileSync(file)); +} + +await bundle(join(root, "src/sea.ts"), out, "codework.mjs", { + define: { CODEWORK_ASSETS: JSON.stringify(hash.digest("hex").slice(0, 16)) }, + plugins: [ + { + name: "codework:shared", + resolveId: (id) => (id === "virtual:codework-shared" ? "\0virtual:codework-shared" : undefined), + load: (id) => (id === "\0virtual:codework-shared" ? source() : undefined), + }, + ], +}); diff --git a/packages/codework/scripts/shared.mjs b/packages/codework/scripts/shared.mjs index 901ef7e6..b90fd508 100644 --- a/packages/codework/scripts/shared.mjs +++ b/packages/codework/scripts/shared.mjs @@ -1,10 +1,7 @@ // Generates the module a bundled CLI passes to the harness's `Plugin.share`: every public module -// of the packages plugins share with it, with its export names and a lazy loader. -// -// node --conditions=development scripts/shared.mjs -// -// `development` resolves `@codeworksh/plugin` to source, as the bundle does. -import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +// of the packages plugins share with it, with its export names and a lazy loader. Run under +// `--conditions=development`, so `@codeworksh/plugin` resolves to source as the bundle does. +import { readdirSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join } from "node:path"; @@ -52,10 +49,8 @@ async function describe(name) { return `\t${JSON.stringify(name)}: {\n${entries.join("\n")}\n\t},`; } -const [outfile] = process.argv.slice(2); -if (outfile === undefined) { - console.error("usage: node --conditions=development scripts/shared.mjs "); - process.exit(1); +/** The module's source text. */ +export async function source() { + const body = await Promise.all(packages.map(describe)); + return `export default {\n${body.join("\n")}\n};\n`; } -const body = await Promise.all(packages.map(describe)); -writeFileSync(outfile, `// Generated by scripts/shared.mjs.\nexport default {\n${body.join("\n")}\n};\n`); diff --git a/packages/codework/src/sea.ts b/packages/codework/src/sea.ts new file mode 100644 index 00000000..f2083389 --- /dev/null +++ b/packages/codework/src/sea.ts @@ -0,0 +1,66 @@ +/* @effect-diagnostics nodeBuiltinImport:off -- this runs before the CLI, and with it any Effect runtime. */ +/* oxlint-disable effecttsgo/process-env -- it hands the CLI its paths, before Effect's Config exists. */ +/** + * Entry of the standalone binary, a Node single executable application. Before the CLI starts it + * unpacks the files that must exist on disk (the TUI, the image worker, and packages such as + * just-bash that load files from beside their own modules) and gives plugins + * the CLI's own `effect`, which has no copy on disk to resolve to. + */ +import { chmodSync, existsSync, lstatSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { getAssetKeys, getRawAsset, isSea } from "node:sea"; + +/** Content hash of the embedded assets, so a build never runs another build's files. */ +declare const CODEWORK_ASSETS: string; + +const executables: ReadonlySet = new Set(["codework-tui"]); + +/** Where the assets live: a per-user cache in the binary, beside the bundle when it runs directly. */ +function root(): string { + if (!isSea()) return path.join(import.meta.dirname, "assets"); + const uid = process.getuid?.(); + const cache = path.join(tmpdir(), `codework-node-${uid ?? "user"}`); + try { + mkdirSync(cache, { mode: 0o700 }); + } catch (error) { + if (!existsSync(cache)) throw error; + } + // The temp directory is shared: refuse a cache another user created or swapped for a symlink. + const info = lstatSync(cache); + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`unsafe asset cache: ${cache}`); + if (uid !== undefined && info.uid !== uid) throw new Error(`asset cache is owned by another user: ${cache}`); + chmodSync(cache, 0o700); + return path.join(cache, `${CODEWORK_ASSETS}-${process.platform}-${process.arch}`); +} + +function unpack(root: string): void { + for (const key of getAssetKeys()) { + const target = path.join(root, key); + if (existsSync(target)) continue; + mkdirSync(path.dirname(target), { recursive: true }); + // Write then rename, so a concurrent start never runs a partial file. + const temporary = `${target}.${process.pid}.${process.hrtime.bigint()}.tmp`; + writeFileSync(temporary, new Uint8Array(getRawAsset(key))); + if (executables.has(key)) chmodSync(temporary, 0o755); + try { + renameSync(temporary, target); + } catch (error) { + rmSync(temporary, { force: true }); + if (!existsSync(target)) throw error; + } + } +} + +const assets = root(); +if (isSea()) unpack(assets); +process.env["CODEWORK_TUI_BIN"] ??= path.join(assets, "codework-tui"); +process.env["CODEWORK_PACKAGES"] = assets; +process.env["CODEWORK_IMAGE_WORKER"] = path.join(assets, "worker.mjs"); + +const [{ Plugin }, { default: shared }] = await Promise.all([ + import("@codeworksh/harness/effect"), + import("virtual:codework-shared"), +]); +Plugin.share(shared); +await import("./index.ts"); diff --git a/packages/codework/src/server/registration.ts b/packages/codework/src/server/registration.ts index 4a7c270d..b6370cd6 100644 --- a/packages/codework/src/server/registration.ts +++ b/packages/codework/src/server/registration.ts @@ -1,4 +1,5 @@ import { Effect, FileSystem, Layer, Option, Path, Schedule, Schema } from "effect"; +import { isSea } from "node:sea"; /** * How a running server advertises itself so later invocations can find it @@ -90,8 +91,10 @@ export const register = (options: { readonly home: string; readonly info: Info } * A packaged install runs `dist/pack/index.mjs`; under `--conditions=development` * the entry is TypeScript, and `process.execArgv` carries the conditions forward * so a managed server resolves workspace sources the same way this process did. + * The standalone binary is its own entry. */ export const command = (): ReadonlyArray => { + if (isSea()) return [process.execPath]; const entry = process.argv[1]; if (entry === undefined) return [process.execPath]; const runtime = import.meta.url.endsWith(".ts") ? process.execArgv : []; diff --git a/packages/codework/src/virtual.d.ts b/packages/codework/src/virtual.d.ts new file mode 100644 index 00000000..56168145 --- /dev/null +++ b/packages/codework/src/virtual.d.ts @@ -0,0 +1,7 @@ +/** The shared-module registry `scripts/bundle.mjs` generates for the standalone binary. */ +declare module "virtual:codework-shared" { + import type { Plugin } from "@codeworksh/harness/effect"; + + const shared: Readonly>; + export default shared; +} diff --git a/packages/harness/src/sandbox/shell/justbash.ts b/packages/harness/src/sandbox/shell/justbash.ts index 8503878e..6fb594a3 100644 --- a/packages/harness/src/sandbox/shell/justbash.ts +++ b/packages/harness/src/sandbox/shell/justbash.ts @@ -1,8 +1,9 @@ /* oxlint-disable effecttsgo/async-function -- just-bash exposes a Promise-based shell adapter contract. */ import { RealFSProvider, type VirtualFileSystem } from "@platformatic/vfs"; import { Effect, Layer } from "effect"; -import { Bash, type IFileSystem } from "just-bash"; +import type { Bash, IFileSystem } from "just-bash"; import { Buffer } from "node:buffer"; +import { specifier } from "../../util/unpacked.ts"; import { posix } from "../../util/posix.ts"; import { Local } from "../fs/vfs.ts"; import { SandboxIO } from "../io.ts"; @@ -271,14 +272,25 @@ const fromBash = (bash: Bash): ISandboxExe => { return Shell.of(fromExec({ exec })); }; -const make = (vfs: VirtualFileSystem, cwd: string): ISandboxExe => fromBash(new Bash({ fs: bridge(vfs, cwd), cwd })); +type JustBash = typeof import("just-bash"); + +// Imported rather than bundled: its sqlite3 worker, WASM and compressors load from beside its modules. +const JustBash = Effect.flatMap(specifier("just-bash"), (resolved) => + Effect.promise(() => import(resolved) as Promise), +); + +const make = (module: JustBash, vfs: VirtualFileSystem, cwd: string): ISandboxExe => + fromBash(new module.Bash({ fs: bridge(vfs, cwd), cwd })); // just-bash only needs the async IFileSystem surface. The lone synchronous // touchpoint, getAllPaths for glob expansion, degrades to an empty list. const shell = (cwd: string) => Layer.effect( Shell, - Effect.map(Local.Vfs, (vfs) => make(vfs, cwd)), + Effect.gen(function* () { + const module = yield* JustBash; + return make(module, yield* Local.Vfs, cwd); + }), ); /** @@ -292,9 +304,10 @@ export const transport = ( Layer.provideMerge( Layer.effect( Shell, - Effect.map(Local.Vfs, (vfs) => { - const root = make(vfs, "/"); - return perMount(root, (cwd) => make(vfs, cwd)); + Effect.gen(function* () { + const module = yield* JustBash; + const vfs = yield* Local.Vfs; + return perMount(make(module, vfs, "/"), (cwd) => make(module, vfs, cwd)); }), ), inner, diff --git a/packages/harness/src/tool/image/photon.ts b/packages/harness/src/tool/image/photon.ts index adc3045d..28d00619 100644 --- a/packages/harness/src/tool/image/photon.ts +++ b/packages/harness/src/tool/image/photon.ts @@ -1,4 +1,5 @@ import { Effect } from "effect"; +import { specifier } from "../../util/unpacked.ts"; import { applyOrientation } from "./exif.ts"; export type Photon = typeof import("@silvia-odwyer/photon-node"); @@ -7,13 +8,17 @@ export type PhotonImage = import("@silvia-odwyer/photon-node").PhotonImage; let loading: Promise | undefined; /** Photon, loaded once on first use; `undefined` when its WASM cannot load, so images degrade instead of failing reads. */ -export const load: Effect.Effect = Effect.promise(() => { - loading ??= import("@silvia-odwyer/photon-node").then( - (module) => module, - () => undefined, - ); - return loading; -}); +export const load: Effect.Effect = Effect.flatMap( + specifier("@silvia-odwyer/photon-node"), + (resolved) => + Effect.promise(() => { + loading ??= (import(resolved) as Promise).then( + (module) => module, + () => undefined, + ); + return loading; + }), +); /** Decode with EXIF orientation applied. The caller frees the result. */ export const decode = (photon: Photon, bytes: Uint8Array): PhotonImage => { diff --git a/packages/harness/src/tool/image/resize.ts b/packages/harness/src/tool/image/resize.ts index 35bbe76a..0f02da43 100644 --- a/packages/harness/src/tool/image/resize.ts +++ b/packages/harness/src/tool/image/resize.ts @@ -1,4 +1,5 @@ -import { Effect, Schema } from "effect"; +import { Config, Effect, Option, Schema } from "effect"; +import { pathToFileURL } from "node:url"; import { Worker } from "node:worker_threads"; import { decode, load } from "./photon.ts"; @@ -122,47 +123,53 @@ export const resizeInProcess = ( }); // From source the worker sits beside this module; packed, this module lands in a -// chunk at the package's output root while the worker keeps its own entry path. -const workerUrl = (): URL => - import.meta.url.endsWith(".ts") +// chunk at the package's output root while the worker keeps its own entry path. The +// standalone binary unpacks a bundled worker and names it. +const workerUrl = Effect.gen(function* () { + const unpacked = yield* Config.option(Config.String("CODEWORK_IMAGE_WORKER")); + if (Option.isSome(unpacked)) return pathToFileURL(unpacked.value); + return import.meta.url.endsWith(".ts") ? new URL("./worker.ts", import.meta.url) : new URL("./tool/image/worker.mjs", import.meta.url); +}).pipe(Effect.orDie); /** The worker could not resize; the caller falls back to this thread. */ class WorkerError extends Schema.TaggedError()("ImageResizeWorkerError", { message: Schema.String }) {} const inWorker = (input: Uint8Array, mimeType: string, options: ResizeOptions) => - Effect.callback((resume) => { - let worker: Worker; - try { - worker = new Worker(workerUrl()); - } catch (cause) { - // e.g. a Node permission model without worker access: resize in this thread instead. - resume(Effect.fail(new WorkerError({ message: String(cause) }))); - return; - } - const done = (effect: Effect.Effect) => { - resume(effect); - void worker.terminate().catch(() => undefined); - }; - worker.on("message", (message: unknown) => { - const reply = message as Partial | null; - if (reply?.type !== REPLY) return; - done( - "error" in reply - ? Effect.fail(new WorkerError({ message: reply.error })) - : Effect.succeed((reply as { result: Resized | null }).result), + Effect.flatMap(workerUrl, (url) => + Effect.callback((resume) => { + let worker: Worker; + try { + worker = new Worker(url); + } catch (cause) { + // e.g. a Node permission model without worker access: resize in this thread instead. + resume(Effect.fail(new WorkerError({ message: String(cause) }))); + return; + } + const done = (effect: Effect.Effect) => { + resume(effect); + void worker.terminate().catch(() => undefined); + }; + worker.on("message", (message: unknown) => { + const reply = message as Partial | null; + if (reply?.type !== REPLY) return; + done( + "error" in reply + ? Effect.fail(new WorkerError({ message: reply.error })) + : Effect.succeed((reply as { result: Resized | null }).result), + ); + }); + worker.once("error", (error: Error) => done(Effect.fail(new WorkerError({ message: error.message })))); + worker.once("exit", (code) => + done(Effect.fail(new WorkerError({ message: `Image resize worker exited with code ${code}` }))), ); - }); - worker.once("error", (error: Error) => done(Effect.fail(new WorkerError({ message: error.message })))); - worker.once("exit", (code) => - done(Effect.fail(new WorkerError({ message: `Image resize worker exited with code ${code}` }))), - ); - // Transferring detaches the buffer, so hand over a copy and leave the caller's bytes intact. - const bytes = new Uint8Array(input); - worker.postMessage({ input: bytes, mimeType, options }, [bytes.buffer]); - return Effect.promise(() => worker.terminate()); - }); + // Transferring detaches the buffer, so hand over a copy and leave the caller's bytes intact. + const bytes = new Uint8Array(input); + worker.postMessage({ input: bytes, mimeType, options }, [bytes.buffer]); + return Effect.promise(() => worker.terminate()); + }), + ); /** * {@link resizeInProcess} on a worker thread, so decoding and encoding a large diff --git a/packages/harness/src/util/unpacked.ts b/packages/harness/src/util/unpacked.ts new file mode 100644 index 00000000..6430789d --- /dev/null +++ b/packages/harness/src/util/unpacked.ts @@ -0,0 +1,14 @@ +import { Config, Effect, Option } from "effect"; +import { resolveModule } from "./module.ts"; + +/** + * What to import for a package that reads files from beside its own modules (WASM, workers, + * native addons), so cannot be bundled. The standalone binary unpacks such packages into + * `CODEWORK_PACKAGES/node_modules`; anywhere else they resolve by name as usual. + */ +export const specifier = (name: string): Effect.Effect => + Effect.gen(function* () { + const directory = yield* Config.option(Config.String("CODEWORK_PACKAGES")); + if (Option.isNone(directory)) return name; + return yield* Effect.try(() => resolveModule(name, directory.value)); + }).pipe(Effect.orDie); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9fb21ca..1a6f01fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -478,6 +478,9 @@ importers: bumpp: specifier: ^12.3.0 version: 12.3.0 + node-gyp: + specifier: ^13.0.2 + version: 13.0.2 optionalDependencies: '@daytona/sdk': specifier: ^0.211.2 From 3c6bef87435bdf94d73e6c253639d985ea308340 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Tue, 13 Oct 2026 17:07:28 +0530 Subject: [PATCH 4/5] feat(cli): build the standalone binary as a Node 26 SEA `vp run sea` injects the bundle into the official Node 26.11.1 for this platform, checked against its pinned sha256, as a single executable application with every asset embedded, and signs it ad hoc on macOS. A SEA's main module has no import.meta.resolve and its own import() reaches only built-ins. The bundled entry now borrows a resolver through Plugin.lendResolve, and unpacked packages load through importModule. The bundle also defines __dirname and __filename, which npm's installer reads, so plugins install from git and the registry inside the binary. test/sea.test.ts runs the binary with no Node on PATH: it unpacks its assets, runs a plugin through the shared effect, runs sqlite3, zstd and xz in the virtual shell against live models, and installs and runs plugins from git and npm when CODEWORK_PLUGIN_LIVE=1. Co-Authored-By: Claude Opus 5.5 --- packages/codework/package.json | 1 + packages/codework/scripts/bundle.mjs | 10 +- packages/codework/scripts/sea.mjs | 67 +++++ packages/codework/src/sea.ts | 1 + .../test/__artifacts__/sea.darwin-arm64.json | 50 ++++ .../test/__artifacts__/sea.live.openai.json | 6 + .../__artifacts__/sea.live.openrouter.json | 6 + .../test/__artifacts__/sea.plugins.json | 5 + packages/codework/test/sea.test.ts | 239 ++++++++++++++++++ packages/harness/src/plugin/index.ts | 2 +- .../harness/src/sandbox/shell/justbash.ts | 7 +- packages/harness/src/tool/image/photon.ts | 21 +- packages/harness/src/util/module.ts | 22 +- packages/harness/src/util/unpacked.ts | 23 +- 14 files changed, 430 insertions(+), 30 deletions(-) create mode 100644 packages/codework/scripts/sea.mjs create mode 100644 packages/codework/test/__artifacts__/sea.darwin-arm64.json create mode 100644 packages/codework/test/__artifacts__/sea.live.openai.json create mode 100644 packages/codework/test/__artifacts__/sea.live.openrouter.json create mode 100644 packages/codework/test/__artifacts__/sea.plugins.json create mode 100644 packages/codework/test/sea.test.ts diff --git a/packages/codework/package.json b/packages/codework/package.json index 114bc7b0..8343f2dd 100644 --- a/packages/codework/package.json +++ b/packages/codework/package.json @@ -38,6 +38,7 @@ "scripts": { "build": "vp pack", "bundle": "node --conditions=development scripts/bundle.mjs", + "sea": "node scripts/sea.mjs", "check": "vp check", "check:effect": "effect-tsgo diagnostics --project tsconfig.json --format text", "lint": "vp lint", diff --git a/packages/codework/scripts/bundle.mjs b/packages/codework/scripts/bundle.mjs index 252b5f33..5d7440c1 100644 --- a/packages/codework/scripts/bundle.mjs +++ b/packages/codework/scripts/bundle.mjs @@ -62,7 +62,15 @@ const bundle = (entry, outDir, name, extra = {}) => outDir, emptyOutDir: false, minify: true, - rollupOptions: { output: { format: "esm", entryFileNames: name, codeSplitting: false } }, + rollupOptions: { + output: { + format: "esm", + entryFileNames: name, + codeSplitting: false, + // Inlined CommonJS dependencies (npm's installer among them) read these module globals. + banner: "const __filename = import.meta.filename, __dirname = import.meta.dirname;", + }, + }, }, }); diff --git a/packages/codework/scripts/sea.mjs b/packages/codework/scripts/sea.mjs new file mode 100644 index 00000000..7c2cd4e5 --- /dev/null +++ b/packages/codework/scripts/sea.mjs @@ -0,0 +1,67 @@ +// Builds the standalone binary `dist/sea/codework` for this machine's platform: official Node with +// the bundle from `scripts/bundle.mjs` injected as a single executable application, and its +// assets embedded. Injecting breaks a macOS binary's signature, so it is signed again ad hoc. +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const version = "26.11.1"; +// From https://nodejs.org/dist/v26.11.1/SHASUMS256.txt. Intel Macs are not a target. +const checksums = { + "darwin-arm64": "d916511b55965e91be7a88e17f88d895b6793ceaba3e6c9b194aefe51c2c65ac", + "linux-arm64": "170454fb022f5e9b5f6cc76c25fa32ef5100895be44eb04e92f9780591491bbf", + "linux-x64": "15fc043a51a5783f2f4b634561091658f5a7f0e3db03f997dca3a8033a33e45b", +}; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const out = join(root, "dist/sea"); +const assets = join(out, "assets"); +const target = `${process.platform}-${process.arch}`; + +/** This platform's official Node, downloaded once and checked against its pinned hash. */ +async function node() { + const checksum = checksums[target]; + if (checksum === undefined) throw new Error(`no standalone binary is built for ${target}`); + const name = `node-v${version}-${target}`; + const cache = join(root, ".cache/node"); + const binary = join(cache, name, "bin/node"); + if (existsSync(binary)) return binary; + const response = await fetch(`https://nodejs.org/dist/v${version}/${name}.tar.gz`); + if (!response.ok) throw new Error(`downloading ${name}: HTTP ${response.status}`); + const archive = Buffer.from(await response.arrayBuffer()); + const actual = createHash("sha256").update(archive).digest("hex"); + if (actual !== checksum) throw new Error(`${name}.tar.gz has sha256 ${actual}, expected ${checksum}`); + mkdirSync(cache, { recursive: true }); + writeFileSync(join(cache, `${name}.tar.gz`), archive); + execFileSync("tar", ["-xzf", `${name}.tar.gz`, `${name}/bin/node`], { cwd: cache }); + rmSync(join(cache, `${name}.tar.gz`)); + return binary; +} + +if (!existsSync(join(out, "codework.mjs"))) throw new Error("no bundle in dist/sea: run `vp run bundle` first"); +const files = readdirSync(assets, { recursive: true, withFileTypes: true }).filter((entry) => entry.isFile()); +const config = { + main: join(out, "codework.mjs"), + mainFormat: "module", + output: join(out, "codework"), + disableExperimentalSEAWarning: true, + // Snapshots and code cache would tie the blob to the building machine's V8 flags. + useSnapshot: false, + useCodeCache: false, + // Plugins load through vm's main-context loader, which Node still marks experimental. + execArgv: ["--disable-warning=ExperimentalWarning"], + // The binary is not a Node: NODE_OPTIONS and runtime flags are the user's, not its own. + execArgvExtension: "none", + // Keyed by path inside assets/, which is where `src/sea.ts` unpacks each one. + assets: Object.fromEntries( + files.map((entry) => { + const file = join(entry.parentPath, entry.name); + return [relative(assets, file).split(sep).join("/"), file]; + }), + ), +}; +writeFileSync(join(out, "sea.json"), `${JSON.stringify(config, null, "\t")}\n`); +execFileSync(await node(), ["--build-sea", join(out, "sea.json")], { stdio: "inherit" }); +if (process.platform === "darwin") execFileSync("codesign", ["--sign", "-", "--force", config.output]); diff --git a/packages/codework/src/sea.ts b/packages/codework/src/sea.ts index f2083389..f0aa9275 100644 --- a/packages/codework/src/sea.ts +++ b/packages/codework/src/sea.ts @@ -63,4 +63,5 @@ const [{ Plugin }, { default: shared }] = await Promise.all([ import("virtual:codework-shared"), ]); Plugin.share(shared); +await Plugin.lendResolve(); await import("./index.ts"); diff --git a/packages/codework/test/__artifacts__/sea.darwin-arm64.json b/packages/codework/test/__artifacts__/sea.darwin-arm64.json new file mode 100644 index 00000000..7fc62175 --- /dev/null +++ b/packages/codework/test/__artifacts__/sea.darwin-arm64.json @@ -0,0 +1,50 @@ +{ + "version": "codework v0.2.0", + "cache": "-darwin-arm64", + "assets": [ + "codework-tui", + "node_modules", + "worker.mjs" + ], + "packages": [ + "@borewit", + "@mixmark-io", + "@mongodb-js", + "@nodable", + "@silvia-odwyer", + "@tokenizer", + "anynum", + "balanced-match", + "brace-expansion", + "commander", + "debug", + "diff", + "fast-xml-builder", + "fast-xml-parser", + "file-type", + "ieee754", + "ini", + "is-unsafe", + "just-bash", + "minimatch", + "modern-tar", + "ms", + "node-gyp-build", + "node-liblzma", + "papaparse", + "path-expression-matcher", + "re2js", + "seek-bzip", + "smol-toml", + "sprintf-js", + "sql.js", + "strnum", + "strtok3", + "token-types", + "turndown", + "uint8array-extras", + "undici", + "xml-naming", + "yaml" + ] +} diff --git a/packages/codework/test/__artifacts__/sea.live.openai.json b/packages/codework/test/__artifacts__/sea.live.openai.json new file mode 100644 index 00000000..6cf86c87 --- /dev/null +++ b/packages/codework/test/__artifacts__/sea.live.openai.json @@ -0,0 +1,6 @@ +{ + "lines": [ + "finite: 22800", + "bash: 42, hi, hi" + ] +} diff --git a/packages/codework/test/__artifacts__/sea.live.openrouter.json b/packages/codework/test/__artifacts__/sea.live.openrouter.json new file mode 100644 index 00000000..6cf86c87 --- /dev/null +++ b/packages/codework/test/__artifacts__/sea.live.openrouter.json @@ -0,0 +1,6 @@ +{ + "lines": [ + "finite: 22800", + "bash: 42, hi, hi" + ] +} diff --git a/packages/codework/test/__artifacts__/sea.plugins.json b/packages/codework/test/__artifacts__/sea.plugins.json new file mode 100644 index 00000000..7521d4a1 --- /dev/null +++ b/packages/codework/test/__artifacts__/sea.plugins.json @@ -0,0 +1,5 @@ +{ + "lines": [ + "heaviest: Starship V3" + ] +} diff --git a/packages/codework/test/sea.test.ts b/packages/codework/test/sea.test.ts new file mode 100644 index 00000000..d82fe57c --- /dev/null +++ b/packages/codework/test/sea.test.ts @@ -0,0 +1,239 @@ +/* @effect-diagnostics nodeBuiltinImport:off -- this suite spawns the standalone binary as a child process. */ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; +import pkg from "../package.json" with { type: "json" }; +import "./utils/env.ts"; + +/** + * The standalone binary as a user without Node runs it: `PATH` holds only the system directories. + * It must unpack its assets, give an installed plugin the CLI's own `effect` (the plugin has no + * dependencies, so nothing else could resolve its imports), and run just-bash's sqlite3, zstd and + * xz from the unpacked files. Build it first with `vp run bundle && vp run sea`. + */ + +const binary = fileURLToPath(new URL("../dist/sea/codework", import.meta.url)); +const models = fileURLToPath(new URL("../../../models.gen.json", import.meta.url)); + +const LIVE = [ + { name: "openai", provider: "openai", id: "gpt-5.6-luna", env: "OPENAI_API_KEY" }, + { name: "openrouter", provider: "openrouter", id: "meta/muse-spark-1.3-contributor", env: "OPENROUTER_API_KEY" }, +] as const; + +const PLUGIN = `import { Plugin, Tool } from "@codeworksh/plugin"; +import { Effect, Schema } from "effect"; + +export default Plugin.define({ + id: "acme.tool.finite", + kind: "tool", + setup(ctx) { + ctx.plugin.tools.add( + Tool.register( + Tool.make({ + name: "finite", + description: "Return a finite number for a label.", + parameters: Schema.Struct({ label: Schema.String }), + success: Schema.Finite, + handler: () => Effect.succeed(22800), + }), + ), + ); + }, +}); +`; + +const BASH = + "sqlite3 :memory: 'select 40 + 2;' && mkdir d && echo hi > d/a && tar --zstd -cf a.tzst d && tar -cJf a.txz d" + + " && rm -r d && tar --zstd -xf a.tzst && cat d/a && rm -r d && tar -xJf a.txz && cat d/a"; + +const PROMPT = [ + "Use only your tools; do not guess. Do both:", + '1. Call the finite tool with the label "sea".', + `2. Run exactly this bash command: ${BASH}`, + "Then reply with exactly two lines and nothing else:", + "finite: ", + "bash: ", +].join("\n"); + +const root = mkdtempSync(join(tmpdir(), "codework-sea-")); +const home = join(root, "home"); +const temp = join(root, "tmp"); +const project = join(root, "project"); +const plugin = join(root, "plugin"); +mkdirSync(temp); +mkdirSync(project); +mkdirSync(plugin); +writeFileSync( + join(plugin, "package.json"), + JSON.stringify({ name: "acme-finite", version: "1.0.0", type: "module", exports: { ".": "./index.mjs" } }), +); +writeFileSync(join(plugin, "index.mjs"), PLUGIN); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +/** The binary, or a shell command, with nothing of this machine's Node in reach. */ +const spawn = (command: string, args: ReadonlyArray, env: Record = {}, cwd = project) => + spawnSync(command, args, { + encoding: "utf8", + cwd, + env: { HOME: home, PATH: "/usr/bin:/bin", TMPDIR: temp, CODEWORK_MODELS_FILE: models, ...env }, + timeout: 240_000, + }); + +describe.skipIf(!existsSync(binary))("the standalone binary", () => { + // Every case runs with the plugin installed, whichever of them runs. + let added: ReturnType; + beforeAll(() => { + added = spawn(binary, ["plugin", "add", plugin]); + }); + + it("runs without Node, unpacks its assets privately, and loads a plugin through its own effect", async () => { + expect(spawn("/bin/sh", ["-c", "command -v node"]).status, "node is on the test PATH").not.toBe(0); + + const version = spawn(binary, ["--version"]); + expect(version.status, version.stderr).toBe(0); + expect(version.stdout.trim()).toBe(`codework v${pkg.version}`); + + expect(added.status, added.stderr).toBe(0); + const listed = spawn(binary, ["plugin", "list"]); + expect(listed.status, listed.stderr).toBe(0); + expect(listed.stdout).toContain("acme.tool.finite"); + + const cache = join(temp, `codework-node-${process.getuid?.() ?? "user"}`); + expect(statSync(cache).mode & 0o777).toBe(0o700); + const [unpacked, ...others] = readdirSync(cache); + expect(others).toEqual([]); + const assets = join(cache, unpacked!); + expect(statSync(join(assets, "codework-tui")).mode & 0o111).not.toBe(0); + + await expect( + `${JSON.stringify( + { + version: version.stdout.trim(), + cache: unpacked!.replace(/^[0-9a-f]{16}-/, "-"), + assets: readdirSync(assets).sort(), + packages: readdirSync(join(assets, "node_modules")).sort(), + }, + null, + "\t", + )}\n`, + ).toMatchFileSnapshot(`./__artifacts__/sea.${process.platform}-${process.arch}.json`); + }); + + describe.each(LIVE)("against live $name", (live) => { + it.skipIf(!process.env[live.env]?.trim())( + "calls the plugin's tool and runs sqlite3, zstd and xz in the virtual shell", + async () => { + const database = join(root, `${live.name}.sqlite`); + const result = spawn( + binary, + [ + "--database", + database, + "run", + "--sandbox-driver", + "memory", + "--provider", + live.provider, + "--model", + live.id, + PROMPT, + ], + { [live.env]: process.env[live.env]! }, + ); + const db = new DatabaseSync(database); + const completed = db + .prepare("SELECT tool_name FROM session_entry_part WHERE type = 'toolCall' AND status = 'completed'") + .all() + .map((row) => String(row["tool_name"])); + db.close(); + expect(completed).toEqual(expect.arrayContaining(["finite", "bash"])); + + // Some models run their preamble into the first answer line. + const lines = result.stdout + .split("\n") + .flatMap((line) => line.match(/(?:finite|bash):.*/) ?? []) + .map((line) => line.trim().replace(/`$/, "").replace(/,\s*/g, ", ")); + expect(lines).toEqual(["finite: 22800", "bash: 42, hi, hi"]); + expect(result.status, result.stderr).toBe(0); + + await expect(`${JSON.stringify({ lines }, null, "\t")}\n`).toMatchFileSnapshot( + `./__artifacts__/sea.live.${live.name}.json`, + ); + }, + 600_000, + ); + }); + + // Both repositories are private, so like plugin.git.live.test.ts this needs git credentials that + // can read them: opt in with CODEWORK_PLUGIN_LIVE=1. git reaches them through the real HOME's ssh + // setup, while `--home` keeps CodeWork's own state in the temp root. + it.skipIf(process.env["CODEWORK_PLUGIN_LIVE"] !== "1" || !process.env["OPENAI_API_KEY"]?.trim())( + "installs plugins from git and the npm registry, then runs them", + async () => { + const remote = join(root, "remote"); + mkdirSync(remote); + const git = { + HOME: process.env["HOME"]!, + ...(process.env["SSH_AUTH_SOCK"] === undefined ? {} : { SSH_AUTH_SOCK: process.env["SSH_AUTH_SOCK"] }), + }; + const add = (spec: string) => + spawn(binary, ["--home", join(root, "remote-home"), "plugin", "add", spec], git, remote); + + // Arborist fetches and installs from the registry inside the binary; the loader then + // rejects the package for not being a plugin. + const npm = add("is-number@7.0.0"); + expect(npm.status).not.toBe(0); + expect(npm.stderr).toContain("error[plugin-invalid-definition]"); + + for (const spec of [ + "github:codeworksh/codework-tool-spacex#v0.5.0", + "github:codeworksh/codework-prompt-musk#v0.4.0", + ]) { + const added = add(spec); + expect(added.status, added.stderr).toBe(0); + } + + const database = join(root, "remote.sqlite"); + const result = spawn( + binary, + [ + "--home", + join(root, "remote-home"), + "--database", + database, + "run", + "--provider", + "openai", + "--model", + "gpt-5.6-luna", + [ + "Use the spacex_launch_manifest tool to list the vehicles that lift at least 50000 kg.", + "Then reply with exactly one line and nothing else:", + "heaviest: ", + ].join("\n"), + ], + { ...git, OPENAI_API_KEY: process.env["OPENAI_API_KEY"]! }, + remote, + ); + const db = new DatabaseSync(database); + const completed = db + .prepare("SELECT tool_name FROM session_entry_part WHERE type = 'toolCall' AND status = 'completed'") + .all() + .map((row) => String(row["tool_name"])); + db.close(); + expect(completed).toContain("spacex_launch_manifest"); + const lines = result.stdout.split("\n").flatMap((line) => line.match(/heaviest:.*/) ?? []); + expect(lines).toEqual(["heaviest: Starship V3"]); + expect(result.status, result.stderr).toBe(0); + + await expect(`${JSON.stringify({ lines }, null, "\t")}\n`).toMatchFileSnapshot( + "./__artifacts__/sea.plugins.json", + ); + }, + 600_000, + ); +}); diff --git a/packages/harness/src/plugin/index.ts b/packages/harness/src/plugin/index.ts index 8ff48c6c..3608c310 100644 --- a/packages/harness/src/plugin/index.ts +++ b/packages/harness/src/plugin/index.ts @@ -22,4 +22,4 @@ export { type Updated, } from "./store.ts"; export * as Tool from "./tool/schema.ts"; -export { share, type SharedModule, type SharedRegistry } from "../util/module.ts"; +export { lendResolve, share, type SharedModule, type SharedRegistry } from "../util/module.ts"; diff --git a/packages/harness/src/sandbox/shell/justbash.ts b/packages/harness/src/sandbox/shell/justbash.ts index 6fb594a3..96b8d3ac 100644 --- a/packages/harness/src/sandbox/shell/justbash.ts +++ b/packages/harness/src/sandbox/shell/justbash.ts @@ -3,7 +3,7 @@ import { RealFSProvider, type VirtualFileSystem } from "@platformatic/vfs"; import { Effect, Layer } from "effect"; import type { Bash, IFileSystem } from "just-bash"; import { Buffer } from "node:buffer"; -import { specifier } from "../../util/unpacked.ts"; +import { load } from "../../util/unpacked.ts"; import { posix } from "../../util/posix.ts"; import { Local } from "../fs/vfs.ts"; import { SandboxIO } from "../io.ts"; @@ -275,8 +275,9 @@ const fromBash = (bash: Bash): ISandboxExe => { type JustBash = typeof import("just-bash"); // Imported rather than bundled: its sqlite3 worker, WASM and compressors load from beside its modules. -const JustBash = Effect.flatMap(specifier("just-bash"), (resolved) => - Effect.promise(() => import(resolved) as Promise), +const JustBash = load("just-bash").pipe( + Effect.map((module) => module as JustBash), + Effect.orDie, ); const make = (module: JustBash, vfs: VirtualFileSystem, cwd: string): ISandboxExe => diff --git a/packages/harness/src/tool/image/photon.ts b/packages/harness/src/tool/image/photon.ts index 28d00619..55bb646d 100644 --- a/packages/harness/src/tool/image/photon.ts +++ b/packages/harness/src/tool/image/photon.ts @@ -1,24 +1,17 @@ import { Effect } from "effect"; -import { specifier } from "../../util/unpacked.ts"; +import { load as unpacked } from "../../util/unpacked.ts"; import { applyOrientation } from "./exif.ts"; export type Photon = typeof import("@silvia-odwyer/photon-node"); export type PhotonImage = import("@silvia-odwyer/photon-node").PhotonImage; -let loading: Promise | undefined; - /** Photon, loaded once on first use; `undefined` when its WASM cannot load, so images degrade instead of failing reads. */ -export const load: Effect.Effect = Effect.flatMap( - specifier("@silvia-odwyer/photon-node"), - (resolved) => - Effect.promise(() => { - loading ??= (import(resolved) as Promise).then( - (module) => module, - () => undefined, - ); - return loading; - }), -); +export const load: Effect.Effect = Effect.cached( + unpacked("@silvia-odwyer/photon-node").pipe( + Effect.map((module) => module as Photon), + Effect.orElseSucceed(() => undefined), + ), +).pipe(Effect.runSync); /** Decode with EXIF orientation applied. The caller frees the result. */ export const decode = (photon: Photon, bytes: Uint8Array): PhotonImage => { diff --git a/packages/harness/src/util/module.ts b/packages/harness/src/util/module.ts index 6c5e62a8..a4d5ea3e 100644 --- a/packages/harness/src/util/module.ts +++ b/packages/harness/src/util/module.ts @@ -151,6 +151,26 @@ export async function importModule(specifier: string): Promise { return Object.assign({}, module, exports); } +/** + * `import.meta.resolve`. The main module of a single executable application, the standalone + * binary, has none, and its own `import()` reaches only built-ins: there the host calls + * {@link lendResolve} before anything resolves. + */ +let metaResolve = (specifier: string): string => import.meta.resolve(specifier); + +/** + * Borrow `import.meta.resolve` from a `data:` module loaded through the main loader, when this + * module has none. Not done at the top level: the binary bundles this module into an import cycle, + * where a top-level `await` never settles. + */ +export async function lendResolve(): Promise { + if (typeof import.meta.resolve === "function") return; + const lender = (await importModule("data:text/javascript,export default (s) => import.meta.resolve(s)")) as { + readonly default: (specifier: string) => string; + }; + metaResolve = lender.default; +} + /** Node resolution scoped synchronously to the caller. */ export function resolveModule(specifier: string, directory: string, conditions?: ReadonlyArray): string { const hook = registerHooks({ @@ -164,7 +184,7 @@ export function resolveModule(specifier: string, directory: string, conditions?: }); try { const resolve = (target: string) => { - const resolved = import.meta.resolve(path.isAbsolute(target) ? pathToFileURL(target).href : target); + const resolved = metaResolve(path.isAbsolute(target) ? pathToFileURL(target).href : target); if (resolved.startsWith("file:")) statSync(new URL(resolved)); return resolved; }; diff --git a/packages/harness/src/util/unpacked.ts b/packages/harness/src/util/unpacked.ts index 6430789d..803730e5 100644 --- a/packages/harness/src/util/unpacked.ts +++ b/packages/harness/src/util/unpacked.ts @@ -1,14 +1,17 @@ -import { Config, Effect, Option } from "effect"; -import { resolveModule } from "./module.ts"; +import { type Cause, Config, Effect, Option } from "effect"; +import { importModule, resolveModule } from "./module.ts"; /** - * What to import for a package that reads files from beside its own modules (WASM, workers, - * native addons), so cannot be bundled. The standalone binary unpacks such packages into - * `CODEWORK_PACKAGES/node_modules`; anywhere else they resolve by name as usual. + * Import a package that reads files from beside its own modules (WASM, workers, native addons), + * so cannot be bundled. The standalone binary unpacks such packages into + * `CODEWORK_PACKAGES/node_modules`; anywhere else they resolve from here as usual. */ -export const specifier = (name: string): Effect.Effect => +export const load = (name: string): Effect.Effect => Effect.gen(function* () { - const directory = yield* Config.option(Config.String("CODEWORK_PACKAGES")); - if (Option.isNone(directory)) return name; - return yield* Effect.try(() => resolveModule(name, directory.value)); - }).pipe(Effect.orDie); + const directory = yield* Config.option(Config.String("CODEWORK_PACKAGES")).pipe(Effect.orDie); + const url = Option.isNone(directory) + ? import.meta.resolve(name) + : yield* Effect.try(() => resolveModule(name, directory.value)); + // The binary's own `import()` reaches only built-ins. + return yield* Effect.tryPromise(() => importModule(url)); + }); From 405bb2376397234b24cbfe2fcb6c894e60fb9aa4 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Tue, 13 Oct 2026 17:43:24 +0530 Subject: [PATCH 5/5] ci: release the standalone binary from dev and main dev and main now build the binary for darwin-arm64, linux-arm64 and linux-x64, each on its own runner, run the offline binary test, and attach cli--.tar.gz to a GitHub Release: `devel`, one prerelease replaced on every dev push, or `cli-v`, marked latest. canary stays npm-only, and npm never waits on the binary. The run computes its release id once (scripts/version.mjs) and every job uses it, so the binary reports the version npm publishes. The binary test's artifact no longer depends on the platform. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 107 +++++++++++++++++- packages/codework/scripts/bundle.mjs | 12 +- .../{sea.darwin-arm64.json => sea.json} | 2 - packages/codework/test/sea.test.ts | 15 ++- scripts/release.mjs | 20 +--- scripts/version.mjs | 47 ++++++++ 6 files changed, 175 insertions(+), 28 deletions(-) rename packages/codework/test/__artifacts__/{sea.darwin-arm64.json => sea.json} (91%) create mode 100644 scripts/version.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 36cc67ac..4c644d2a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,6 +3,8 @@ name: Publish # One workflow publishes every channel, since npm trusted publishing names a single workflow file. # - canary / dev: every push to that branch # - latest: run by hand on `main`; publishes each package whose version is not on npm yet +# dev and latest also build the standalone binary and attach it to a GitHub Release: `devel`, one +# prerelease replaced on every dev push, or `cli-v`, marked latest. canary stays npm-only. on: push: branches: [canary, dev] @@ -26,9 +28,20 @@ env: jobs: guard: runs-on: ubuntu-latest + # The run's release id, computed once so every job publishes under the same version. + outputs: + id: ${{ steps.release.outputs.id }} steps: - if: github.event_name == 'workflow_dispatch' && github.ref_name != 'main' run: echo "::error::latest is published from main only" && exit 1 + - uses: actions/checkout@v6 + - uses: voidzero-dev/setup-vp@v1.21.1 + with: + node-version: 24 + run-install: false + cache: false + - id: release + run: echo "id=$(node scripts/version.mjs "$CHANNEL")" >> "$GITHUB_OUTPUT" tui: needs: guard @@ -75,8 +88,10 @@ jobs: retention-days: 1 publish: - needs: tui + needs: [guard, tui] runs-on: ubuntu-latest + env: + CODEWORK_RELEASE_ID: ${{ needs.guard.outputs.id }} steps: - uses: actions/checkout@v6 - uses: voidzero-dev/setup-vp@v1.21.1 @@ -95,3 +110,93 @@ jobs: pattern: tui-* path: packages/tui/dist/bin - run: vp run release "$CHANNEL" + + binary: + needs: [guard, tui] + if: github.ref_name != 'canary' + name: binary (${{ matrix.binary }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: true + matrix: + # Each on its own platform: --build-sea runs that platform's Node, and macOS signs. + include: + - binary: cli-darwin-arm64 + tui: tui-darwin-arm64 + runner: macos-14 + - binary: cli-linux-arm64 + tui: tui-linux-arm64 + runner: ubuntu-24.04-arm + - binary: cli-linux-x64 + tui: tui-linux-x64 + runner: ubuntu-24.04 + env: + CODEWORK_RELEASE_ID: ${{ needs.guard.outputs.id }} + steps: + - uses: actions/checkout@v6 + # node-liblzma builds against the system liblzma at install. The bundle rebuilds it with xz + # linked statically, which needs cmake; the runners ship it. + - if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y liblzma-dev + - uses: voidzero-dev/setup-vp@v1.21.1 + with: + node-version: 24 + run-install: false + cache: false + - run: vp install --frozen-lockfile --filter '!@codeworksh/www' + - uses: actions/download-artifact@v8 + with: + name: ${{ matrix.tui }} + path: tui + - run: | + chmod +x tui/codework-tui + echo "CODEWORK_TUI_BIN=$GITHUB_WORKSPACE/tui/codework-tui" >> "$GITHUB_ENV" + echo "CODEWORK_VERSION=$(node scripts/version.mjs "$CHANNEL" packages/codework)" >> "$GITHUB_ENV" + - working-directory: packages/codework + run: vp run bundle && vp run sea + # Without API keys only the offline case runs: the binary with no Node on PATH unpacks its + # assets and runs a plugin through its own effect. + - working-directory: packages/codework + run: vp test test/sea.test.ts + - run: tar -czf ${{ matrix.binary }}.tar.gz -C packages/codework/dist/sea codework + - uses: actions/upload-artifact@v7 + with: + name: ${{ matrix.binary }} + path: ${{ matrix.binary }}.tar.gz + if-no-files-found: error + retention-days: 1 + + # After npm, so a release never names a version npm does not have. + release: + needs: [guard, publish, binary] + if: github.ref_name != 'canary' + runs-on: ubuntu-latest + permissions: + contents: write + env: + CODEWORK_RELEASE_ID: ${{ needs.guard.outputs.id }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v6 + - uses: voidzero-dev/setup-vp@v1.21.1 + with: + node-version: 24 + run-install: false + cache: false + - uses: actions/download-artifact@v8 + with: + pattern: cli-* + path: release + merge-multiple: true + - run: | + version="$(node scripts/version.mjs "$CHANNEL" packages/codework)" + if [ "$CHANNEL" = dev ]; then + gh release delete devel --cleanup-tag --yes || true + gh release create devel release/*.tar.gz --prerelease --target "$GITHUB_SHA" \ + --title "CLI $version" --notes "Development build $version, from $GITHUB_SHA." + elif gh release view "cli-v$version" > /dev/null 2>&1; then + echo "cli-v$version is already released" + else + gh release create "cli-v$version" release/*.tar.gz --latest --target "$GITHUB_SHA" \ + --title "CLI $version" --generate-notes + fi diff --git a/packages/codework/scripts/bundle.mjs b/packages/codework/scripts/bundle.mjs index 5d7440c1..edbf2466 100644 --- a/packages/codework/scripts/bundle.mjs +++ b/packages/codework/scripts/bundle.mjs @@ -6,7 +6,9 @@ // files from beside their own modules (WASM, workers, native addons) // // Run under `--conditions=development` (see `scripts/shared.mjs`). The TUI comes from -// `CODEWORK_TUI_BIN`, else the local release build. Rebuilding xz statically needs cmake. +// `CODEWORK_TUI_BIN`, else the local release build. `CODEWORK_VERSION` replaces the version the CLI +// reports, which a canary or dev release computes (scripts/version.mjs). Rebuilding xz statically +// needs cmake. import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { @@ -155,6 +157,14 @@ for (const file of files.map((entry) => join(entry.parentPath, entry.name)).sort await bundle(join(root, "src/sea.ts"), out, "codework.mjs", { define: { CODEWORK_ASSETS: JSON.stringify(hash.digest("hex").slice(0, 16)) }, plugins: [ + { + name: "codework:version", + enforce: "pre", + load: (id) => + id === join(root, "package.json") && process.env["CODEWORK_VERSION"] !== undefined + ? JSON.stringify({ ...JSON.parse(readFileSync(id, "utf8")), version: process.env["CODEWORK_VERSION"] }) + : undefined, + }, { name: "codework:shared", resolveId: (id) => (id === "virtual:codework-shared" ? "\0virtual:codework-shared" : undefined), diff --git a/packages/codework/test/__artifacts__/sea.darwin-arm64.json b/packages/codework/test/__artifacts__/sea.json similarity index 91% rename from packages/codework/test/__artifacts__/sea.darwin-arm64.json rename to packages/codework/test/__artifacts__/sea.json index 7fc62175..13cd63e5 100644 --- a/packages/codework/test/__artifacts__/sea.darwin-arm64.json +++ b/packages/codework/test/__artifacts__/sea.json @@ -1,6 +1,4 @@ { - "version": "codework v0.2.0", - "cache": "-darwin-arm64", "assets": [ "codework-tui", "node_modules", diff --git a/packages/codework/test/sea.test.ts b/packages/codework/test/sea.test.ts index d82fe57c..3381b831 100644 --- a/packages/codework/test/sea.test.ts +++ b/packages/codework/test/sea.test.ts @@ -95,7 +95,8 @@ describe.skipIf(!existsSync(binary))("the standalone binary", () => { const version = spawn(binary, ["--version"]); expect(version.status, version.stderr).toBe(0); - expect(version.stdout.trim()).toBe(`codework v${pkg.version}`); + // A canary or dev release builds in the version it publishes under (scripts/version.mjs). + expect(version.stdout.trim()).toBe(`codework v${process.env["CODEWORK_VERSION"] ?? pkg.version}`); expect(added.status, added.stderr).toBe(0); const listed = spawn(binary, ["plugin", "list"]); @@ -109,18 +110,16 @@ describe.skipIf(!existsSync(binary))("the standalone binary", () => { const assets = join(cache, unpacked!); expect(statSync(join(assets, "codework-tui")).mode & 0o111).not.toBe(0); + expect(unpacked).toMatch(new RegExp(`^[0-9a-f]{16}-${process.platform}-${process.arch}$`)); + + // One artifact for every platform: what is unpacked does not depend on it. await expect( `${JSON.stringify( - { - version: version.stdout.trim(), - cache: unpacked!.replace(/^[0-9a-f]{16}-/, "-"), - assets: readdirSync(assets).sort(), - packages: readdirSync(join(assets, "node_modules")).sort(), - }, + { assets: readdirSync(assets).sort(), packages: readdirSync(join(assets, "node_modules")).sort() }, null, "\t", )}\n`, - ).toMatchFileSnapshot(`./__artifacts__/sea.${process.platform}-${process.arch}.json`); + ).toMatchFileSnapshot("./__artifacts__/sea.json"); }); describe.each(LIVE)("against live $name", (live) => { diff --git a/scripts/release.mjs b/scripts/release.mjs index dc9b2db9..5a29878a 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -1,7 +1,7 @@ // Publishes the package chain for one channel: `canary`, `dev` or `latest`. CI runs it (see // .github/workflows/publish.yml) after the TUI binaries are staged under packages/tui/dist/bin. // -// canary/dev publish every package as a prerelease of its version (see previewId). +// canary/dev publish every package as a prerelease of its version (see scripts/version.mjs). // latest publishes each package whose manifest version is not on npm yet, so a release is a // local `vp run bump` followed by a run on main. import { spawnSync } from "node:child_process"; @@ -9,6 +9,7 @@ import { resolve } from "node:path"; import { setTimeout } from "node:timers/promises"; import { distTags, isPublished } from "./registry.mjs"; +import { releaseId, releaseVersion } from "./version.mjs"; import { publishablePackages, repoRoot as root, workspaceDependencies } from "./workspace.mjs"; const [channel, ...forwardArgs] = process.argv.slice(2); @@ -18,24 +19,11 @@ if (!["canary", "dev", "latest"].includes(channel)) { } const dryRun = forwardArgs.includes("--dry-run"); -// dev: `` (UTC). canary: `..g`. -function previewId() { - const { GITHUB_RUN_NUMBER: run = "0", GITHUB_RUN_ATTEMPT: attempt = "0" } = process.env; - if (!process.env.GITHUB_RUN_NUMBER && !dryRun) { - throw new Error(`A ${channel} release runs in CI; locally only --dry-run is supported`); - } - if (channel === "dev") return new Date().toISOString().slice(0, 19).replace(/\D/g, ""); - const sha = spawnSync("git", ["rev-parse", "--short=8", "HEAD"], { cwd: root }).stdout.toString().trim(); - return `${run}.${attempt}.g${sha}`; -} - -const id = channel === "latest" ? undefined : previewId(); +const id = releaseId(channel, { dryRun }); const packages = []; for (const { manifest } of publishablePackages()) { - const base = /^\d+\.\d+\.\d+/.exec(manifest.version)?.[0]; - if (!base) throw new Error(`Invalid package version: ${manifest.version}`); if (id) { - packages.push({ manifest, version: `${base}-${channel}.${id}` }); + packages.push({ manifest, version: releaseVersion(manifest.version, channel, id) }); } else if (await isPublished(manifest.name, manifest.version)) { console.error(`Skipping ${manifest.name}@${manifest.version}: already published`); } else { diff --git a/scripts/version.mjs b/scripts/version.mjs new file mode 100644 index 00000000..48528222 --- /dev/null +++ b/scripts/version.mjs @@ -0,0 +1,47 @@ +// The versions one publish run gives its packages. canary and dev publish each package as a +// prerelease of its manifest version; latest publishes the manifest version as is. +// +// node scripts/version.mjs the run's release id (empty for latest) +// node scripts/version.mjs that package's version, e.g. packages/codework +// +// The first job of .github/workflows/publish.yml computes the id once and hands it to the later +// jobs as CODEWORK_RELEASE_ID, so the npm packages and the standalone binary carry one version. +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { repoRoot as root } from "./workspace.mjs"; + +/** dev: `` (UTC). canary: `..g`. latest: none. */ +export function releaseId(channel, { dryRun = false } = {}) { + if (channel === "latest") return undefined; + if (process.env.CODEWORK_RELEASE_ID) return process.env.CODEWORK_RELEASE_ID; + const { GITHUB_RUN_NUMBER: run = "0", GITHUB_RUN_ATTEMPT: attempt = "0" } = process.env; + if (!process.env.GITHUB_RUN_NUMBER && !dryRun) { + throw new Error(`A ${channel} release runs in CI; locally only --dry-run is supported`); + } + if (channel === "dev") return new Date().toISOString().slice(0, 19).replace(/\D/g, ""); + const sha = spawnSync("git", ["rev-parse", "--short=8", "HEAD"], { cwd: root }).stdout.toString().trim(); + return `${run}.${attempt}.g${sha}`; +} + +/** `version` as `channel` publishes it under the run's `id`. */ +export function releaseVersion(version, channel, id) { + const base = /^\d+\.\d+\.\d+/.exec(version)?.[0]; + if (!base) throw new Error(`Invalid package version: ${version}`); + return id ? `${base}-${channel}.${id}` : version; +} + +if (import.meta.main) { + const [channel, pkg] = process.argv.slice(2); + if (!["canary", "dev", "latest"].includes(channel)) { + console.error("Usage: node scripts/version.mjs [package directory]"); + process.exit(1); + } + const id = releaseId(channel); + if (pkg === undefined) console.log(id ?? ""); + else { + const { version } = JSON.parse(readFileSync(resolve(root, pkg, "package.json"), "utf8")); + console.log(releaseVersion(version, channel, id)); + } +}