From 1eedaa8ca51c06790eaae7c4bfee8e35cfce25a5 Mon Sep 17 00:00:00 2001 From: aphrody-dev Date: Tue, 13 Oct 2026 21:59:53 +0200 Subject: [PATCH] Unify Bun workspace API ownership --- bun.lock | 3 - docs/APP-BUILDS.md | 5 + packages/bun-workspace/package.json | 3 - packages/bun-workspace/src/index.ts | 1063 ++++++++++++++++++++++++++- packages/workspace/src/index.ts | 1062 +------------------------- 5 files changed, 1067 insertions(+), 1069 deletions(-) diff --git a/bun.lock b/bun.lock index 0af3fe19..62d979bb 100644 --- a/bun.lock +++ b/bun.lock @@ -62,9 +62,6 @@ "bin": { "aphrody-bun": "./src/cli.ts", }, - "dependencies": { - "@aphrody/bun-os-workspace": "workspace:*", - }, }, "packages/package-converter": { "name": "@bun-os/package-converter", diff --git a/docs/APP-BUILDS.md b/docs/APP-BUILDS.md index 9f9d21a8..8ec549f9 100644 --- a/docs/APP-BUILDS.md +++ b/docs/APP-BUILDS.md @@ -25,6 +25,11 @@ context prevents an independent build. | Shared object plans | `infra/assets.ts` | | Edge and public catalog | The edge/catalog capability owner | +`@aphrody/bun-workspace` is the single implementation owner for workspace, +runtime, process, hashing, capability and asset APIs. The older +`@aphrody/bun-os-workspace` package is a compatibility facade over that owner; +dependency direction stays one way, from the facade to `bun-workspace`. + ## Configuration A build uses the selected native runtime binary or an explicit target snapshot. diff --git a/packages/bun-workspace/package.json b/packages/bun-workspace/package.json index 3ea03a2c..a68a1bf8 100644 --- a/packages/bun-workspace/package.json +++ b/packages/bun-workspace/package.json @@ -15,9 +15,6 @@ "./process": "./src/process.ts", "./runtime": "./src/runtime.ts" }, - "dependencies": { - "@aphrody/bun-os-workspace": "workspace:*" - }, "engines": { "bun": "1.4.4" } diff --git a/packages/bun-workspace/src/index.ts b/packages/bun-workspace/src/index.ts index a58c5089..7e80c960 100644 --- a/packages/bun-workspace/src/index.ts +++ b/packages/bun-workspace/src/index.ts @@ -1,2 +1,1061 @@ -/** Unified SDK facade; the OS owner retains Cargo and product assembly. */ -export * from "@aphrody/bun-os-workspace"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + assertRuntimeFacts, + currentRuntimeFacts, + nativeRuntimeTarget, + runtimeFromFacts, + type Runtime, + type RuntimePin, + type RuntimeTarget, +} from "./runtime.ts"; +import { runProcess } from "./process.ts"; +import { inspectModuleCoverage, type ModuleCoverage } from "./coverage.ts"; + +export { appCapabilities, appCapabilityFor, appHostname, capabilityFor, type AppCapability } from "./capabilities.ts"; +export { inspectModuleCoverage, type ModuleCoverage } from "./coverage.ts"; +export { hashContent, hashFile, hashStream, type FileHashOptions } from "./hash.ts"; +export { + ProcessError, + runProcess, + type ProcessErrorCode, + type ProcessLimits, + type ProcessOptions, + type ProcessResult, +} from "./process.ts"; +export { + assertRuntimeFacts, + currentRuntimeFacts, + runtimeFromFacts, + type Runtime, + type RuntimeFacts, + type RuntimePin, + type RuntimeTarget, +} from "./runtime.ts"; + +/** Resolve from this checkout; no workstation or host path is embedded. */ +export const repositoryRoot = resolve(import.meta.dir, "../../.."); + +export interface Module { + id: string; + path: string; + upstream: string; + fork: string; + branch: string; + revision: string; + upstreamRevision: string; + license: string; + kind: "library"; + role?: "engine" | "shared" | "fonts"; + description: string; + binary?: string; + cargoPackage?: string; + cargoPackages?: string[]; + sourceKind?: "workspace" | "submodule"; + manifestPath?: string; + protocol?: "cli" | "cli-json" | "jsonl" | "mcp-stdio"; +} + +export interface Config { + schemaVersion: 1; + runtime: RuntimePin; + runtimeTargets?: Partial>; + modules: Module[]; + priorities?: string[]; + desktop: Record & { source?: Source }; + deployment?: Deployment; +} + +/** Application release settings reference the canonical Infra host owner. */ +export interface Deployment { + domain: string; + appPrefix: string; + assets: { bucket: string; url: string }; + host: string; +} + +export interface Source { + path: string; + repository: string; + revision: string; +} + +export interface ModuleStatus { + sourceKind: "workspace" | "submodule"; + id: string; + path: string; + initialized: boolean; + revision: string | null; + expectedRevision: string; + indexRevision: string | null; + pointerMatches: boolean; + revisionMatches: boolean; + clean: boolean | null; + launchable: boolean; + launchReason: string; + error?: string; +} + +export interface ExecutionOptions { + root?: string; + timeoutMs?: number; +} + +export interface NativeExecutionOptions extends ExecutionOptions { + cwd?: string; + env?: Record; + serialize?: boolean; +} + +export interface ModuleBinaryOptions extends ExecutionOptions { + binary?: string; + maxOutputBytes?: number; + signal?: AbortSignal; +} + +interface CommandResult { + code: number; + stdout: string; + stderr: string; +} + +const shortDeadline = 20_000; +const syncDeadline = 300_000; +const cargoDeadline = 1_800_000; +const runDeadline = 43_200_000; +const revisionPattern = /^[0-9a-f]{40}$/; + +export interface WorkspaceDiscoveryOptions { + root?: string; + starts?: readonly string[]; +} + +/** Discovery is shared by checkout shells; standalone applications use their manifest. */ +export async function findWorkspace(options: WorkspaceDiscoveryOptions = {}): Promise { + const configured = options.root ?? Bun.env.APHRODY_ROOT; + if (configured !== undefined) { + const root = resolve(configured); + if (await Bun.file(join(root, "aphrody.config.json")).exists()) return root; + if (options.root !== undefined) { + throw new Error("APHRODY_ROOT must contain aphrody.config.json."); + } + } + for (const start of options.starts ?? [process.cwd(), import.meta.dir]) { + let cursor = resolve(start); + for (;;) { + if (await Bun.file(join(cursor, "aphrody.config.json")).exists()) return cursor; + const parent = dirname(cursor); + if (parent === cursor) break; + cursor = parent; + } + } + throw new Error("Run from the Aphrody checkout or set APHRODY_ROOT."); +} + +/** A lexical path guard for trusted checkout files and manifest arguments. */ +export function checkoutPath(root: string, path: string): string { + if (!path || isAbsolute(path)) throw new Error(`Relative path required: ${path}`); + const target = resolve(root, path); + const local = relative(resolve(root), target); + if (!local || local === ".." || local.startsWith(`..${sep}`) || isAbsolute(local)) { + throw new Error(`Path outside checkout: ${path}`); + } + return target; +} + +function object(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export { object as isObject }; + +export async function loadConfig(root = repositoryRoot): Promise { + const value: unknown = await Bun.file(join(root, "aphrody.config.json")).json(); + if (!object(value) || value.schemaVersion !== 1 || !object(value.runtime)) { + throw new Error("aphrody.config.json: schemaVersion 1 and runtime required."); + } + if ( + value.runtime.version !== "1.4.4" || + value.runtime.repository !== "aphrody-labs/bun" || + typeof value.runtime.revision !== "string" || + !revisionPattern.test(value.runtime.revision) + ) { + throw new Error("aphrody.config.json: Aphrody Bun 1.4.4 and full revision required."); + } + if (value.runtimeTargets !== undefined) { + if (!object(value.runtimeTargets)) throw new Error("aphrody.config.json: invalid target runtime pins."); + for (const [target, pin] of Object.entries(value.runtimeTargets)) { + if ( + ![ + "windows-x64", + "windows-arm64", + "linux-x64", + "linux-arm64", + "linux-x64-musl", + "linux-arm64-musl", + "darwin-x64", + "darwin-arm64", + ].includes(target) || + !object(pin) || + pin.version !== "1.4.4" || + pin.repository !== "aphrody-labs/bun" || + typeof pin.revision !== "string" || + !revisionPattern.test(pin.revision) + ) { + throw new Error(`aphrody.config.json: invalid runtime pin for ${target}.`); + } + } + } + if (!Array.isArray(value.modules) || !object(value.desktop)) { + throw new Error("aphrody.config.json: modules and desktop required."); + } + if (value.deployment !== undefined) { + const deployment = value.deployment; + if ( + !object(deployment) || + typeof deployment.domain !== "string" || + deployment.domain.length > 253 || + !deployment.domain.split(".").every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) || + !deployment.domain.includes(".") || + typeof deployment.appPrefix !== "string" || + !/^[a-z0-9-]*$/.test(deployment.appPrefix) || + deployment.appPrefix.length > 40 || + typeof deployment.host !== "string" || + !/^[a-z][a-z0-9-]*$/.test(deployment.host) || + !object(deployment.assets) || + typeof deployment.assets.bucket !== "string" || + !/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(deployment.assets.bucket) || + typeof deployment.assets.url !== "string" + ) { + throw new Error("aphrody.config.json: invalid application deployment settings."); + } + try { + const url = new URL(deployment.assets.url); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error(); + } catch { + throw new Error("aphrody.config.json: assets require an HTTPS URL without credentials."); + } + } + if (value.desktop.source !== undefined) { + const source = value.desktop.source; + if ( + !object(source) || + typeof source.path !== "string" || + source.repository !== "aphrody-labs/bun" || + typeof source.revision !== "string" || + !revisionPattern.test(source.revision) + ) { + throw new Error("aphrody.config.json: invalid pinned Bun SDK source."); + } + checkoutPath(root, source.path); + } + const ids = new Set(); + const paths = new Set(); + for (const entry of value.modules) { + if (!object(entry) || typeof entry.id !== "string" || !/^[a-z][a-z0-9-]*$/.test(entry.id)) { + throw new Error("aphrody.config.json: invalid library identifier."); + } + if (ids.has(entry.id)) throw new Error(`Duplicate library: ${entry.id}`); + ids.add(entry.id); + if (typeof entry.path !== "string") throw new Error(`Path required: ${entry.id}`); + const modulePath = checkoutPath(root, entry.path); + if (paths.has(modulePath)) throw new Error(`Duplicate path: ${entry.path}`); + paths.add(modulePath); + if (entry.upstream !== `storytold/${entry.id}` || entry.fork !== `aphrody-labs/${entry.id}`) { + throw new Error(`Invalid source owners: ${entry.id}`); + } + for (const key of ["revision", "upstreamRevision"] as const) { + if (typeof entry[key] !== "string" || !revisionPattern.test(entry[key])) { + throw new Error(`Full revision required: ${entry.id}.${key}`); + } + } + if ( + typeof entry.branch !== "string" || + !entry.branch || + typeof entry.license !== "string" || + !entry.license || + typeof entry.description !== "string" || + entry.kind !== "library" || + (entry.sourceKind !== undefined && !["workspace", "submodule"].includes(String(entry.sourceKind))) || + (entry.role !== undefined && !["engine", "shared", "fonts"].includes(String(entry.role))) + ) { + throw new Error(`Invalid library metadata: ${entry.id}`); + } + for (const key of ["binary", "cargoPackage"] as const) { + if (entry[key] !== undefined && (typeof entry[key] !== "string" || !/^[a-zA-Z0-9_-]+$/.test(entry[key]))) { + throw new Error(`Invalid Cargo name: ${entry.id}.${key}`); + } + } + if ( + entry.cargoPackages !== undefined && + (!Array.isArray(entry.cargoPackages) || + entry.cargoPackages.length === 0 || + entry.cargoPackages.some(name => typeof name !== "string" || !/^[a-zA-Z0-9_-]+$/.test(name)) || + new Set(entry.cargoPackages).size !== entry.cargoPackages.length) + ) { + throw new Error(`Invalid Cargo package group: ${entry.id}`); + } + if (entry.binary !== undefined && (entry.role !== "engine" || entry.binary !== `${entry.id}-cli`)) { + throw new Error(`Only an explicit headless engine CLI is allowed: ${entry.id}`); + } + if (entry.manifestPath !== undefined) { + if (typeof entry.manifestPath !== "string") throw new Error(`Invalid manifest: ${entry.id}`); + checkoutPath(modulePath, entry.manifestPath); + } + if (entry.protocol !== undefined && !["cli", "cli-json", "jsonl", "mcp-stdio"].includes(String(entry.protocol))) { + throw new Error(`Invalid native protocol: ${entry.id}`); + } + } + if ( + value.priorities !== undefined && + (!Array.isArray(value.priorities) || value.priorities.some(id => typeof id !== "string" || !ids.has(id))) + ) { + throw new Error("Priorities must reference catalog libraries."); + } + return value as unknown as Config; +} + +export function runtimePinFor(config: Config, target = nativeRuntimeTarget()): RuntimePin { + if (!target) return config.runtime; + const pin = config.runtimeTargets?.[target]; + if (!pin) throw new Error(`No Bun runtime pin is declared for ${target}.`); + return pin; +} + +export function inspectRuntime(config: Config, target = nativeRuntimeTarget()): Runtime { + return runtimeFromFacts(runtimePinFor(config, target), currentRuntimeFacts()); +} + +/** All commands require the declared release fork; SHA drift is explicit. */ +export function assertRuntime(config: Config, allowRevisionDrift = false, target = nativeRuntimeTarget()): Runtime { + return assertRuntimeFacts(runtimePinFor(config, target), currentRuntimeFacts(), allowRevisionDrift); +} + +/** Compiled sidecars must use a separate, verified Bun executable. */ +export async function resolveBunExecutable(config: Config, options: ExecutionOptions = {}): Promise { + const root = options.root ?? repositoryRoot; + const configured = Bun.env.APHRODY_BUN_BINARY; + let candidate = Bun.which("bun"); + if (configured) { + if (isAbsolute(configured)) candidate = configured; + else if (configured.includes("/") || configured.includes("\\")) candidate = checkoutPath(root, configured); + else candidate = Bun.which(configured); + } + if (!candidate || !(await Bun.file(candidate).exists())) { + throw new Error("Native Bun missing; set APHRODY_BUN_BINARY or provide bun on PATH."); + } + const probe = await command( + [ + candidate, + "--no-env-file", + "-e", + "console.log(JSON.stringify({version:Bun.version,revision:Bun.revision,sourceUrl:process.release.sourceUrl??null}))", + ], + root, + Math.min(options.timeoutMs ?? 5_000, 5_000), + ); + if (probe.code !== 0) throw new Error("Native Bun provenance probe failed."); + let facts: unknown; + try { + facts = JSON.parse(probe.stdout.trim()); + } catch { + throw new Error("Native Bun returned invalid provenance metadata."); + } + if ( + !object(facts) || + typeof facts.version !== "string" || + typeof facts.revision !== "string" || + (facts.sourceUrl !== null && typeof facts.sourceUrl !== "string") + ) { + throw new Error("Native Bun returned invalid provenance metadata."); + } + assertRuntimeFacts(runtimePinFor(config), { + version: facts.version, + revision: facts.revision, + sourceUrl: facts.sourceUrl as string | null, + executable: candidate, + }); + return candidate; +} + +async function command( + argv: string[], + cwd: string, + timeoutMs = shortDeadline, + inherit = false, + env: Record = Bun.env, + captureStdout = false, +): Promise { + return runProcess(argv, cwd, { + timeoutMs, + maxOutputBytes: 64 * 1024 * 1024, + stdin: inherit ? "inherit" : "ignore", + stdout: inherit && !captureStdout ? "inherit" : "capture", + stderr: inherit ? "inherit" : "capture", + env, + }); +} + +async function git(args: string[], root: string): Promise { + const result = await command(["git", ...args], root); + if (result.code !== 0) throw new Error(result.stderr.trim() || `Git failed (${result.code}).`); + return result.stdout.trim(); +} + +export function findModule(config: Config, id: string): Module { + const module = config.modules.find(entry => entry.id === id); + if (!module) throw new Error(`Unknown library: ${id}`); + return module; +} + +export async function inspectSourceCoverage(config: Config, root = repositoryRoot): Promise { + const result: ModuleCoverage[] = []; + for (const module of config.modules) result.push(await inspectModuleCoverage(module, root)); + return result; +} + +interface Pin { + id: string; + path: string; + fork: string; + revision: string; +} + +function pinnedSources(config: Config): Pin[] { + const pins: Pin[] = config.modules.filter(module => module.sourceKind !== "workspace"); + if (config.desktop.source) { + pins.push({ + id: "bun-sdk", + path: config.desktop.source.path, + fork: config.desktop.source.repository, + revision: config.desktop.source.revision, + }); + } + return pins; +} + +/** Owner-selected local artifacts stay outside Git in .local/config.json. */ +export async function localBinaries(config: Config, root = repositoryRoot): Promise> { + const file = Bun.file(join(root, ".local", "config.json")); + if (!(await file.exists())) return {}; + const value: unknown = await file.json(); + if (!object(value) || (value.binaries !== undefined && !object(value.binaries))) { + throw new Error(".local/config.json: binaries must map library IDs to paths."); + } + const binaries: Record = {}; + for (const [id, entry] of Object.entries(value.binaries ?? {})) { + const module = findModule(config, id); + if ( + module.role !== "engine" || + !module.binary || + module.binary !== `${id}-cli` || + typeof entry !== "string" || + !entry + ) { + throw new Error(`Invalid local headless CLI: ${id}`); + } + const path = isAbsolute(entry) ? resolve(entry) : checkoutPath(root, entry); + const expected = nativeBinaryName(module); + if (basename(path).toLowerCase() !== expected.toLowerCase()) { + throw new Error(`Local CLI ${id} must be named ${expected}.`); + } + if (!(await Bun.file(path).exists())) throw new Error(`Local CLI missing: ${id}`); + binaries[id] = path; + } + return binaries; +} + +/** Keep native engine names separate from public application identities. */ +export function nativeBinaryName(module: Module, platform = process.platform): string { + if (module.role !== "engine" || module.binary !== `${module.id}-cli`) { + throw new Error(`Library has no headless CLI: ${module.id}`); + } + return `${module.binary}${platform === "win32" ? ".exe" : ""}`; +} + +export async function listModuleStatus(config: Config, root = repositoryRoot): Promise { + const result: ModuleStatus[] = []; + const binaries = await localBinaries(config, root); + // Keep process and signal ownership bounded, even for a large catalogue. + for (const module of pinnedSources(config)) { + const library = config.modules.find(entry => entry.id === module.id); + const hasHeadlessBinary = library?.role === "engine" && library.binary === `${module.id}-cli`; + const status: ModuleStatus = { + sourceKind: "submodule", + id: module.id, + path: module.path, + initialized: false, + revision: null, + expectedRevision: module.revision, + indexRevision: null, + pointerMatches: false, + revisionMatches: false, + clean: null, + launchable: false, + launchReason: hasHeadlessBinary ? "Library source not initialized." : "Library has no headless CLI.", + }; + try { + const index = await git(["ls-files", "--stage", "--", module.path], root); + const match = /^160000 ([0-9a-f]{40}) 0\t/.exec(index); + status.indexRevision = match?.[1] ?? null; + status.pointerMatches = status.indexRevision === module.revision; + const directory = checkoutPath(root, module.path); + status.initialized = await Bun.file(join(directory, ".git")).exists(); + if (status.initialized) { + status.revision = await git(["rev-parse", "HEAD"], directory); + status.revisionMatches = status.revision === module.revision; + status.clean = (await git(["status", "--porcelain=v1", "--untracked-files=all"], directory)) === ""; + const manifestExists = library + ? await Bun.file(checkoutPath(directory, library.manifestPath ?? "Cargo.toml")).exists() + : false; + status.launchable = Boolean( + hasHeadlessBinary && + (manifestExists || binaries[module.id]) && + status.pointerMatches && + status.revisionMatches, + ); + if (hasHeadlessBinary) + status.launchReason = status.launchable + ? binaries[module.id] + ? "Headless artifact selected in .local/config.json." + : "Headless CLI; native build may be required." + : !manifestExists + ? "Cargo manifest missing." + : "Revision differs from catalog."; + } + } catch (error) { + status.error = error instanceof Error ? error.message : String(error); + } + result.push(status); + } + for (const module of config.modules.filter(entry => entry.sourceKind === "workspace")) { + const status: ModuleStatus = { + sourceKind: "workspace", + id: module.id, + path: module.path, + initialized: false, + revision: null, + expectedRevision: module.revision, + indexRevision: null, + pointerMatches: true, + revisionMatches: false, + clean: null, + launchable: false, + launchReason: "Library has no headless CLI.", + }; + try { + status.initialized = await Bun.file( + checkoutPath(root, `${module.path}/${module.manifestPath ?? "Cargo.toml"}`), + ).exists(); + status.revision = await git(["rev-parse", "HEAD"], root); + // Preserve import ancestry and qualify every crate's independent Git pin. + const ancestry = await command(["git", "merge-base", "--is-ancestor", module.revision, status.revision], root); + if (ancestry.code > 1) throw new Error(`Imported source history is unavailable: ${module.id}`); + status.revisionMatches = ancestry.code === 0; + status.clean = (await git(["status", "--porcelain=v1", "--ignore-submodules=all"], root)) === ""; + for (const name of module.cargoPackages ?? []) { + const cratePath = `crates/${name}`; + const indexEntry = await git(["ls-files", "--stage", "--", cratePath], root); + const pin = /^160000 ([0-9a-f]{40}) 0\t/.exec(indexEntry)?.[1]; + if (!pin) throw new Error(`Product crate lacks its Git submodule: ${name}`); + const crateRoot = checkoutPath(root, cratePath); + status.pointerMatches &&= (await git(["rev-parse", "HEAD"], crateRoot)) === pin; + status.clean &&= (await git(["status", "--porcelain=v1"], crateRoot)) === ""; + } + const headless = module.role === "engine" && module.binary === `${module.id}-cli`; + status.launchable = headless && status.initialized && status.revisionMatches && status.pointerMatches; + if (headless) + status.launchReason = status.launchable + ? "Workspace CLI; native build may be required." + : "Imported source history differs."; + } catch (error) { + status.error = error instanceof Error ? error.message : String(error); + } + result.push(status); + } + return result; +} + +async function requireModule(config: Config, id: string, root: string): Promise { + const module = findModule(config, id); + const status = (await listModuleStatus(config, root)).find(entry => entry.id === id)!; + if (status.error || !status.initialized || !status.pointerMatches || !status.revisionMatches) { + throw new Error( + `${id} differs from the pinned source. Run aphrody-bun status and sync from a clean checkout. ${status.error ?? ""}`.trim(), + ); + } + return module; +} + +export async function syncModules(config: Config, root = repositoryRoot): Promise { + assertRuntime(config); + const states = await listModuleStatus(config, root); + for (const state of states) { + if ( + state.error || + !state.pointerMatches || + state.clean === false || + (state.initialized && !state.revisionMatches) + ) { + throw new Error( + `Sync refused: ${state.id}; local changes or revision differs from catalog. ${state.error ?? ""}`.trim(), + ); + } + } + if (await git(["status", "--porcelain=v1", "--untracked-files=all"], root)) { + throw new Error("Sync refused: monorepo has local changes."); + } + const paths = await git(["config", "--file", ".gitmodules", "--get-regexp", "^submodule\\..*\\.path$"], root); + for (const module of pinnedSources(config)) { + const line = paths.split("\n").find(entry => entry.endsWith(` ${module.path}`)); + if (!line) throw new Error(`Source missing from .gitmodules: ${module.id}`); + const key = line.slice(0, line.indexOf(" ")).replace(/\.path$/, ".url"); + const url = await git(["config", "--file", ".gitmodules", "--get", key], root); + const accepted = [ + `https://github.com/${module.fork}.git`, + `https://github.com/${module.fork}`, + `git@github.com:${module.fork}.git`, + ]; + if (!accepted.includes(url)) throw new Error(`Unexpected fork URL: ${module.id}`); + } + for (const module of pinnedSources(config)) { + const current = (await listModuleStatus(config, root)).find(entry => entry.id === module.id)!; + if ( + current.error || + !current.pointerMatches || + current.clean === false || + (current.initialized && !current.revisionMatches) + ) { + throw new Error(`Sync stopped: ${module.id} changed during the operation.`); + } + if (current.initialized) continue; + if (await git(["status", "--porcelain=v1", "--untracked-files=all"], root)) { + throw new Error("Sync stopped: monorepo received local changes."); + } + const result = await command( + ["git", "submodule", "update", "--init", "--checkout", "--", module.path], + root, + syncDeadline, + true, + ); + if (result.code !== 0) throw new Error(`Source initialization failed: ${module.id} (${result.code}).`); + } + const initialized = await listModuleStatus(config, root); + if ( + initialized.some( + state => state.error || !state.initialized || !state.revisionMatches || !state.pointerMatches || !state.clean, + ) + ) { + throw new Error("Initialized sources differ from the pinned catalog."); + } +} + +interface CargoModule { + directory: string; + manifest: string; + cargoPackage: string | null; + cargoPackages: string[]; + binary: string | null; +} + +export async function resolveCargoModule(module: Module, root = repositoryRoot): Promise { + if (module.binary && (module.role !== "engine" || module.binary !== `${module.id}-cli`)) { + throw new Error(`Only an explicit headless engine CLI is allowed: ${module.id}`); + } + const directory = checkoutPath(root, module.path); + const rootManifest = join(root, "Cargo.toml"); + if (await Bun.file(rootManifest).exists()) { + const workspace = Bun.TOML.parse(await Bun.file(rootManifest).text()) as { + workspace?: { members?: string[] }; + }; + const prefix = `${relative(root, directory).replaceAll("\\", "/")}/`; + const members = + workspace.workspace?.members?.filter(member => + module.sourceKind === "workspace" ? true : member.startsWith(prefix), + ) ?? []; + if (members.length) { + const cargoPackages: string[] = []; + for (const member of members) { + const memberManifest = checkoutPath(root, `${member}/Cargo.toml`); + const value = Bun.TOML.parse(await Bun.file(memberManifest).text()) as { + package?: { name?: string }; + }; + if (!value.package?.name) throw new Error(`Cargo member lacks a package name: ${member}`); + if ( + module.sourceKind !== "workspace" || + (module.cargoPackages ?? [module.cargoPackage]).includes(value.package.name) + ) { + cargoPackages.push(value.package.name); + } + } + if (module.cargoPackages?.some(name => !cargoPackages.includes(name))) + throw new Error(`Cargo package group is incomplete: ${module.id}`); + if (!cargoPackages.length) throw new Error(`No root Cargo packages belong to the library: ${module.id}`); + if (module.cargoPackage && !cargoPackages.includes(module.cargoPackage)) { + throw new Error(`Cargo package is outside its library: ${module.id}`); + } + return { + directory: module.sourceKind === "workspace" ? root : directory, + manifest: rootManifest, + cargoPackage: module.cargoPackage ?? null, + cargoPackages, + binary: module.binary ?? null, + }; + } + } + const manifest = checkoutPath(directory, module.manifestPath ?? "Cargo.toml"); + if (!(await Bun.file(manifest).exists())) throw new Error(`Cargo manifest missing: ${module.id}`); + const value = Bun.TOML.parse(await Bun.file(manifest).text()) as { + package?: { name?: string }; + }; + const cargoPackage = module.cargoPackage ?? value.package?.name ?? null; + if (!cargoPackage && module.binary) throw new Error(`cargoPackage required for the headless CLI: ${module.id}`); + const binary = module.binary ?? null; + return { directory, manifest, cargoPackage, cargoPackages: [], binary }; +} + +/** Discover an existing checkout artifact. Builds and native protocol validation stay explicit. */ +export async function resolveModuleBinary( + config: Config, + id: string, + options: ModuleBinaryOptions = {}, +): Promise { + const root = options.root ?? repositoryRoot; + const module = await requireModule(config, id, root); + const expected = nativeBinaryName(module); + const selected = options.binary ?? (await localBinaries(config, root))[id]; + if (selected) { + const path = isAbsolute(selected) ? resolve(selected) : checkoutPath(root, selected); + if (basename(path).toLowerCase() !== expected.toLowerCase()) + throw new Error(`Native CLI ${id} must be named ${expected}.`); + if (!(await Bun.file(path).exists())) throw new Error(`Native CLI missing: ${id}`); + return path; + } + const cargo = await resolveCargoModule(module, root); + const executable = Bun.which("cargo"); + if (!executable) throw new Error("Native Cargo missing on this host."); + const metadata = await runProcess( + [executable, "metadata", "--offline", "--no-deps", "--format-version", "1", "--manifest-path", cargo.manifest], + cargo.directory, + { + timeoutMs: Math.min(options.timeoutMs ?? shortDeadline, shortDeadline), + maxOutputBytes: options.maxOutputBytes ?? 16 * 1024 * 1024, + signal: options.signal, + }, + ); + if (metadata.code !== 0) throw new Error(`Cargo artifact discovery failed: ${id}`); + const value: unknown = JSON.parse(metadata.stdout); + if ( + !object(value) || + typeof value.target_directory !== "string" || + !isAbsolute(value.target_directory) || + value.target_directory.includes("\0") + ) { + throw new Error(`Cargo returned an invalid artifact directory: ${id}`); + } + for (const profile of ["release", "debug"]) { + const candidate = join(value.target_directory, profile, expected); + if (await Bun.file(candidate).exists()) return candidate; + } + throw new Error(`Native CLI missing: ${id}; build its existing library owner.`); +} + +/** Serialize a native build owner, including the Tauri CLI, without a shell. */ +async function runNativeResult( + config: Config, + nativeArguments: string[], + options: NativeExecutionOptions = {}, + captureStdout = false, +): Promise { + assertRuntime(config); + if (!nativeArguments.length || nativeArguments.some(arg => typeof arg !== "string" || arg.includes("\0"))) { + throw new Error("Invalid native arguments."); + } + const root = options.root ?? repositoryRoot; + const cwd = options.cwd ?? root; + const timeoutMs = options.timeoutMs ?? cargoDeadline; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > runDeadline) { + throw new Error("Invalid native deadline; maximum 12 hours."); + } + const env: Record = { + ...Bun.env, + ...options.env, + CARGO_BUILD_JOBS: "1", + CARGO_TARGET_DIR: join(root, "target"), + CARGO_INSTALL_ROOT: join(root, "install"), + }; + const toolchainFile = Bun.file(join(root, "rust-toolchain.toml")); + if (await toolchainFile.exists()) { + const toolchain = Bun.TOML.parse(await toolchainFile.text()) as { + toolchain?: { channel?: string }; + }; + if (!toolchain.toolchain?.channel) throw new Error("Root Rust toolchain channel is missing."); + env.RUSTUP_TOOLCHAIN = toolchain.toolchain.channel; + } + // Preserve valid owner wrappers; an unavailable sccache path must not break builds. + if (env.RUSTC_WRAPPER && !Bun.which(env.RUSTC_WRAPPER)) { + const sccache = Bun.which("sccache"); + if (sccache) env.RUSTC_WRAPPER = sccache; + else { + delete env.RUSTC_WRAPPER; + console.error("Optional Rust cache unavailable; using a direct build."); + } + } + if (options.serialize === false) { + return command(nativeArguments, cwd, timeoutMs, true, env, captureStdout); + } + let argv: string[]; + if (process.platform === "win32") { + const powershell = Bun.which("pwsh") ?? Bun.which("powershell"); + if (!powershell) throw new Error("Native PowerShell missing on this host."); + // JSON travels in the environment, avoiding PowerShell's native argv re-quoting. + env.APHRODY_NATIVE_ARGUMENTS = JSON.stringify(nativeArguments.slice(1)); + env.APHRODY_NATIVE_EXECUTABLE = Bun.which(nativeArguments[0]!) ?? nativeArguments[0]; + env.APHRODY_NATIVE_BUN = await resolveBunExecutable(config, { root }); + env.APHRODY_NATIVE_WORKER = join(root, "scripts", "native-worker.ts"); + const wrapperSeconds = Math.max(1, Math.ceil(Math.min(timeoutMs, runDeadline - 5_000) / 1000)); + argv = [ + powershell, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-File", + join(root, "scripts", "cargo-serial.ps1"), + "-WorkingDirectory", + cwd, + "-TimeoutSeconds", + String(wrapperSeconds), + ]; + } else { + const flock = Bun.which("flock"); + if (!flock) throw new Error("Native flock required for build serialization on this Unix host."); + const temporaryDirectory = Bun.env.TMPDIR ?? "/tmp"; + const identity = process.getuid?.() ?? "user"; + argv = [flock, "--nonblock", join(temporaryDirectory, `aphrody-bun-native-${identity}.lock`), ...nativeArguments]; + } + // The wrapper kills its process tree before the outer Bun deadline. + return command( + argv, + cwd, + Math.min(runDeadline, timeoutMs + (process.platform === "win32" ? 5_000 : 0)), + true, + env, + captureStdout, + ); +} + +export async function runNative(config: Config, argv: string[], options: NativeExecutionOptions = {}): Promise { + return (await runNativeResult(config, argv, options)).code; +} + +/** Resolve Bun's existing native MSVC owner, with an explicit checkout override. */ +async function serializedCargo( + config: Config, + args: string[], + cwd: string, + root: string, + timeoutMs: number, + captureStdout = false, +): Promise { + const cargo = Bun.which("cargo"); + if (!cargo) throw new Error("Native Cargo missing on this host."); + let argv = [cargo, ...args]; + if (process.platform === "win32") { + const bun = await resolveBunExecutable(config, { root }); + argv = [bun, "msvc", "exec", "--", cargo, ...args]; + if (Bun.env.APHRODY_BUN_CHECKOUT) { + const owner = join(resolve(Bun.env.APHRODY_BUN_CHECKOUT), "scripts", "build", "rust", "cargo-serial.ts"); + if (!(await Bun.file(owner).exists())) + throw new Error("Cargo owner wrapper missing from the configured APHRODY_BUN_CHECKOUT."); + argv = [bun, owner, "--msvc", ...args]; + } + } + return runNativeResult(config, argv, { root, cwd, timeoutMs }, captureStdout); +} + +export interface CargoWorkspaceReport { + version: string; + bunPackageNames: string[]; + workspaceRoot: string; + targetDirectory: string; + packageNames: string[]; +} + +/** Verify the actual Cargo owner and output path through the native admission wrapper. */ +export async function inspectCargoWorkspace( + config: Config, + options: ExecutionOptions = {}, +): Promise { + const root = resolve(options.root ?? repositoryRoot); + const product = (await Bun.file(join(root, "package.json")).json()) as { + version?: string; + workspaces?: string[]; + }; + const manifest = Bun.TOML.parse(await Bun.file(join(root, "Cargo.toml")).text()) as { + workspace?: { package?: { version?: string } }; + }; + if (!product.version || manifest.workspace?.package?.version !== product.version) + throw new Error("Bun and Cargo product versions differ."); + const bunPackageNames: string[] = []; + for (const member of product.workspaces ?? []) { + const entry = (await Bun.file(checkoutPath(root, `${member}/package.json`)).json()) as { + name?: string; + version?: string; + }; + if (!entry.name || entry.version !== product.version) + throw new Error(`Bun workspace package version differs: ${member}`); + bunPackageNames.push(entry.name); + } + const result = await serializedCargo( + config, + ["metadata", "--locked", "--no-deps", "--format-version", "1", "--manifest-path", join(root, "Cargo.toml")], + root, + root, + options.timeoutMs ?? shortDeadline, + true, + ); + if (result.code !== 0) throw new Error("Root Cargo workspace metadata failed."); + const value: unknown = JSON.parse(result.stdout); + if ( + !object(value) || + typeof value.workspace_root !== "string" || + typeof value.target_directory !== "string" || + resolve(value.workspace_root) !== root || + resolve(value.target_directory) !== join(root, "target") || + !Array.isArray(value.workspace_members) || + !Array.isArray(value.packages) + ) { + throw new Error("Cargo workspace or target directory differs from the product root."); + } + const members = new Set(value.workspace_members); + const gitlinks = await command(["git", "ls-files", "--stage", "--", "crates"], root); + if (gitlinks.code !== 0) throw new Error("Crate Git index inspection failed."); + const pinnedCrates = new Map(); + for (const line of gitlinks.stdout.split("\n")) { + const match = /^160000 ([0-9a-f]{40}) 0\t(crates\/[^/]+)$/.exec(line.trim()); + if (match) pinnedCrates.set(match[2], match[1]); + } + const packageNames: string[] = []; + for (const entry of value.packages) { + if (!object(entry) || !members.has(entry.id)) continue; + if ( + typeof entry.name !== "string" || + typeof entry.manifest_path !== "string" || + entry.version !== product.version + ) { + throw new Error("Cargo workspace member lacks its package identity."); + } + const cratePath = `crates/${entry.name}`; + if (resolve(entry.manifest_path) !== join(root, cratePath, "Cargo.toml")) + throw new Error(`Cargo crate must be flat under crates: ${entry.name}`); + const expectedRevision = pinnedCrates.get(cratePath); + if (!expectedRevision) throw new Error(`Cargo crate is not a pinned Git submodule: ${entry.name}`); + const actualRevision = await command(["git", "rev-parse", "HEAD"], join(root, cratePath)); + if (actualRevision.code !== 0 || actualRevision.stdout.trim() !== expectedRevision) + throw new Error(`Cargo crate revision differs from its Git pin: ${entry.name}`); + packageNames.push(entry.name); + } + if (packageNames.length !== members.size || packageNames.length === 0) { + throw new Error("Cargo workspace membership is incomplete."); + } + return { + version: product.version, + bunPackageNames: bunPackageNames.sort(), + workspaceRoot: root, + targetDirectory: join(root, "target"), + packageNames: packageNames.sort(), + }; +} + +export async function cargoModule( + config: Config, + id: string, + action: "check" | "build" | "run", + args: string[] = [], + options: ExecutionOptions = {}, +): Promise { + assertRuntime(config); + const root = options.root ?? repositoryRoot; + const module = await requireModule(config, id, root); + const cargo = await resolveCargoModule(module, root); + const flags = [action, "--locked", "--manifest-path", cargo.manifest]; + if (cargo.cargoPackage) flags.push("--package", cargo.cargoPackage); + else if (cargo.cargoPackages.length) { + for (const name of cargo.cargoPackages) flags.push("--package", name); + } else flags.push("--workspace"); + if (action === "run") { + if (!cargo.binary) throw new Error(`Library has no headless CLI: ${id}`); + flags.push("--bin", cargo.binary, "--", ...args); + } else { + if (args.length) throw new Error(`${action} accepts only a library ID.`); + if (action === "build") flags.push("--release"); + } + return (await serializedCargo(config, flags, cargo.directory, root, options.timeoutMs ?? cargoDeadline)).code; +} + +export async function runModule( + config: Config, + id: string, + args: string[] = [], + options: ExecutionOptions = {}, +): Promise { + assertRuntime(config); + const root = options.root ?? repositoryRoot; + const module = await requireModule(config, id, root); + const selected = (await localBinaries(config, root))[id]; + if (selected) { + return (await command([selected, ...args], checkoutPath(root, module.path), options.timeoutMs ?? runDeadline, true)) + .code; + } + const cargo = await resolveCargoModule(module, root); + if (!cargo.binary || module.role !== "engine" || cargo.binary !== `${id}-cli`) + throw new Error(`Library has no headless CLI: ${id}`); + const build = await serializedCargo( + config, + [ + "build", + "--release", + "--locked", + "--manifest-path", + cargo.manifest, + "--package", + cargo.cargoPackage!, + "--bin", + cargo.binary, + "--message-format=json", + ], + cargo.directory, + root, + Math.min(cargoDeadline, options.timeoutMs ?? cargoDeadline), + true, + ); + let binary: string | null = null; + for (const line of build.stdout.split("\n")) { + try { + const artifact = JSON.parse(line) as { + reason?: string; + executable?: string; + target?: { name?: string; kind?: string[] }; + message?: { rendered?: string }; + }; + if (artifact.reason === "compiler-message" && artifact.message?.rendered) { + console.error(artifact.message.rendered); + } + if ( + artifact.reason === "compiler-artifact" && + artifact.target?.name === cargo.binary && + artifact.target.kind?.includes("bin") && + typeof artifact.executable === "string" + ) { + binary = artifact.executable; + } + } catch { + // Native owner diagnostics may accompany Cargo's JSON output. + } + } + if (build.code !== 0) return build.code; + if (!binary || !(await Bun.file(binary).exists())) { + throw new Error(`Cargo did not report a headless CLI artifact: ${id}`); + } + // Release the build mutex before starting the headless CLI. + return (await command([binary, ...args], cargo.directory, options.timeoutMs ?? runDeadline, true)).code; +} diff --git a/packages/workspace/src/index.ts b/packages/workspace/src/index.ts index 7e80c960..70ef32bc 100644 --- a/packages/workspace/src/index.ts +++ b/packages/workspace/src/index.ts @@ -1,1061 +1 @@ -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; -import { - assertRuntimeFacts, - currentRuntimeFacts, - nativeRuntimeTarget, - runtimeFromFacts, - type Runtime, - type RuntimePin, - type RuntimeTarget, -} from "./runtime.ts"; -import { runProcess } from "./process.ts"; -import { inspectModuleCoverage, type ModuleCoverage } from "./coverage.ts"; - -export { appCapabilities, appCapabilityFor, appHostname, capabilityFor, type AppCapability } from "./capabilities.ts"; -export { inspectModuleCoverage, type ModuleCoverage } from "./coverage.ts"; -export { hashContent, hashFile, hashStream, type FileHashOptions } from "./hash.ts"; -export { - ProcessError, - runProcess, - type ProcessErrorCode, - type ProcessLimits, - type ProcessOptions, - type ProcessResult, -} from "./process.ts"; -export { - assertRuntimeFacts, - currentRuntimeFacts, - runtimeFromFacts, - type Runtime, - type RuntimeFacts, - type RuntimePin, - type RuntimeTarget, -} from "./runtime.ts"; - -/** Resolve from this checkout; no workstation or host path is embedded. */ -export const repositoryRoot = resolve(import.meta.dir, "../../.."); - -export interface Module { - id: string; - path: string; - upstream: string; - fork: string; - branch: string; - revision: string; - upstreamRevision: string; - license: string; - kind: "library"; - role?: "engine" | "shared" | "fonts"; - description: string; - binary?: string; - cargoPackage?: string; - cargoPackages?: string[]; - sourceKind?: "workspace" | "submodule"; - manifestPath?: string; - protocol?: "cli" | "cli-json" | "jsonl" | "mcp-stdio"; -} - -export interface Config { - schemaVersion: 1; - runtime: RuntimePin; - runtimeTargets?: Partial>; - modules: Module[]; - priorities?: string[]; - desktop: Record & { source?: Source }; - deployment?: Deployment; -} - -/** Application release settings reference the canonical Infra host owner. */ -export interface Deployment { - domain: string; - appPrefix: string; - assets: { bucket: string; url: string }; - host: string; -} - -export interface Source { - path: string; - repository: string; - revision: string; -} - -export interface ModuleStatus { - sourceKind: "workspace" | "submodule"; - id: string; - path: string; - initialized: boolean; - revision: string | null; - expectedRevision: string; - indexRevision: string | null; - pointerMatches: boolean; - revisionMatches: boolean; - clean: boolean | null; - launchable: boolean; - launchReason: string; - error?: string; -} - -export interface ExecutionOptions { - root?: string; - timeoutMs?: number; -} - -export interface NativeExecutionOptions extends ExecutionOptions { - cwd?: string; - env?: Record; - serialize?: boolean; -} - -export interface ModuleBinaryOptions extends ExecutionOptions { - binary?: string; - maxOutputBytes?: number; - signal?: AbortSignal; -} - -interface CommandResult { - code: number; - stdout: string; - stderr: string; -} - -const shortDeadline = 20_000; -const syncDeadline = 300_000; -const cargoDeadline = 1_800_000; -const runDeadline = 43_200_000; -const revisionPattern = /^[0-9a-f]{40}$/; - -export interface WorkspaceDiscoveryOptions { - root?: string; - starts?: readonly string[]; -} - -/** Discovery is shared by checkout shells; standalone applications use their manifest. */ -export async function findWorkspace(options: WorkspaceDiscoveryOptions = {}): Promise { - const configured = options.root ?? Bun.env.APHRODY_ROOT; - if (configured !== undefined) { - const root = resolve(configured); - if (await Bun.file(join(root, "aphrody.config.json")).exists()) return root; - if (options.root !== undefined) { - throw new Error("APHRODY_ROOT must contain aphrody.config.json."); - } - } - for (const start of options.starts ?? [process.cwd(), import.meta.dir]) { - let cursor = resolve(start); - for (;;) { - if (await Bun.file(join(cursor, "aphrody.config.json")).exists()) return cursor; - const parent = dirname(cursor); - if (parent === cursor) break; - cursor = parent; - } - } - throw new Error("Run from the Aphrody checkout or set APHRODY_ROOT."); -} - -/** A lexical path guard for trusted checkout files and manifest arguments. */ -export function checkoutPath(root: string, path: string): string { - if (!path || isAbsolute(path)) throw new Error(`Relative path required: ${path}`); - const target = resolve(root, path); - const local = relative(resolve(root), target); - if (!local || local === ".." || local.startsWith(`..${sep}`) || isAbsolute(local)) { - throw new Error(`Path outside checkout: ${path}`); - } - return target; -} - -function object(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -export { object as isObject }; - -export async function loadConfig(root = repositoryRoot): Promise { - const value: unknown = await Bun.file(join(root, "aphrody.config.json")).json(); - if (!object(value) || value.schemaVersion !== 1 || !object(value.runtime)) { - throw new Error("aphrody.config.json: schemaVersion 1 and runtime required."); - } - if ( - value.runtime.version !== "1.4.4" || - value.runtime.repository !== "aphrody-labs/bun" || - typeof value.runtime.revision !== "string" || - !revisionPattern.test(value.runtime.revision) - ) { - throw new Error("aphrody.config.json: Aphrody Bun 1.4.4 and full revision required."); - } - if (value.runtimeTargets !== undefined) { - if (!object(value.runtimeTargets)) throw new Error("aphrody.config.json: invalid target runtime pins."); - for (const [target, pin] of Object.entries(value.runtimeTargets)) { - if ( - ![ - "windows-x64", - "windows-arm64", - "linux-x64", - "linux-arm64", - "linux-x64-musl", - "linux-arm64-musl", - "darwin-x64", - "darwin-arm64", - ].includes(target) || - !object(pin) || - pin.version !== "1.4.4" || - pin.repository !== "aphrody-labs/bun" || - typeof pin.revision !== "string" || - !revisionPattern.test(pin.revision) - ) { - throw new Error(`aphrody.config.json: invalid runtime pin for ${target}.`); - } - } - } - if (!Array.isArray(value.modules) || !object(value.desktop)) { - throw new Error("aphrody.config.json: modules and desktop required."); - } - if (value.deployment !== undefined) { - const deployment = value.deployment; - if ( - !object(deployment) || - typeof deployment.domain !== "string" || - deployment.domain.length > 253 || - !deployment.domain.split(".").every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) || - !deployment.domain.includes(".") || - typeof deployment.appPrefix !== "string" || - !/^[a-z0-9-]*$/.test(deployment.appPrefix) || - deployment.appPrefix.length > 40 || - typeof deployment.host !== "string" || - !/^[a-z][a-z0-9-]*$/.test(deployment.host) || - !object(deployment.assets) || - typeof deployment.assets.bucket !== "string" || - !/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/.test(deployment.assets.bucket) || - typeof deployment.assets.url !== "string" - ) { - throw new Error("aphrody.config.json: invalid application deployment settings."); - } - try { - const url = new URL(deployment.assets.url); - if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error(); - } catch { - throw new Error("aphrody.config.json: assets require an HTTPS URL without credentials."); - } - } - if (value.desktop.source !== undefined) { - const source = value.desktop.source; - if ( - !object(source) || - typeof source.path !== "string" || - source.repository !== "aphrody-labs/bun" || - typeof source.revision !== "string" || - !revisionPattern.test(source.revision) - ) { - throw new Error("aphrody.config.json: invalid pinned Bun SDK source."); - } - checkoutPath(root, source.path); - } - const ids = new Set(); - const paths = new Set(); - for (const entry of value.modules) { - if (!object(entry) || typeof entry.id !== "string" || !/^[a-z][a-z0-9-]*$/.test(entry.id)) { - throw new Error("aphrody.config.json: invalid library identifier."); - } - if (ids.has(entry.id)) throw new Error(`Duplicate library: ${entry.id}`); - ids.add(entry.id); - if (typeof entry.path !== "string") throw new Error(`Path required: ${entry.id}`); - const modulePath = checkoutPath(root, entry.path); - if (paths.has(modulePath)) throw new Error(`Duplicate path: ${entry.path}`); - paths.add(modulePath); - if (entry.upstream !== `storytold/${entry.id}` || entry.fork !== `aphrody-labs/${entry.id}`) { - throw new Error(`Invalid source owners: ${entry.id}`); - } - for (const key of ["revision", "upstreamRevision"] as const) { - if (typeof entry[key] !== "string" || !revisionPattern.test(entry[key])) { - throw new Error(`Full revision required: ${entry.id}.${key}`); - } - } - if ( - typeof entry.branch !== "string" || - !entry.branch || - typeof entry.license !== "string" || - !entry.license || - typeof entry.description !== "string" || - entry.kind !== "library" || - (entry.sourceKind !== undefined && !["workspace", "submodule"].includes(String(entry.sourceKind))) || - (entry.role !== undefined && !["engine", "shared", "fonts"].includes(String(entry.role))) - ) { - throw new Error(`Invalid library metadata: ${entry.id}`); - } - for (const key of ["binary", "cargoPackage"] as const) { - if (entry[key] !== undefined && (typeof entry[key] !== "string" || !/^[a-zA-Z0-9_-]+$/.test(entry[key]))) { - throw new Error(`Invalid Cargo name: ${entry.id}.${key}`); - } - } - if ( - entry.cargoPackages !== undefined && - (!Array.isArray(entry.cargoPackages) || - entry.cargoPackages.length === 0 || - entry.cargoPackages.some(name => typeof name !== "string" || !/^[a-zA-Z0-9_-]+$/.test(name)) || - new Set(entry.cargoPackages).size !== entry.cargoPackages.length) - ) { - throw new Error(`Invalid Cargo package group: ${entry.id}`); - } - if (entry.binary !== undefined && (entry.role !== "engine" || entry.binary !== `${entry.id}-cli`)) { - throw new Error(`Only an explicit headless engine CLI is allowed: ${entry.id}`); - } - if (entry.manifestPath !== undefined) { - if (typeof entry.manifestPath !== "string") throw new Error(`Invalid manifest: ${entry.id}`); - checkoutPath(modulePath, entry.manifestPath); - } - if (entry.protocol !== undefined && !["cli", "cli-json", "jsonl", "mcp-stdio"].includes(String(entry.protocol))) { - throw new Error(`Invalid native protocol: ${entry.id}`); - } - } - if ( - value.priorities !== undefined && - (!Array.isArray(value.priorities) || value.priorities.some(id => typeof id !== "string" || !ids.has(id))) - ) { - throw new Error("Priorities must reference catalog libraries."); - } - return value as unknown as Config; -} - -export function runtimePinFor(config: Config, target = nativeRuntimeTarget()): RuntimePin { - if (!target) return config.runtime; - const pin = config.runtimeTargets?.[target]; - if (!pin) throw new Error(`No Bun runtime pin is declared for ${target}.`); - return pin; -} - -export function inspectRuntime(config: Config, target = nativeRuntimeTarget()): Runtime { - return runtimeFromFacts(runtimePinFor(config, target), currentRuntimeFacts()); -} - -/** All commands require the declared release fork; SHA drift is explicit. */ -export function assertRuntime(config: Config, allowRevisionDrift = false, target = nativeRuntimeTarget()): Runtime { - return assertRuntimeFacts(runtimePinFor(config, target), currentRuntimeFacts(), allowRevisionDrift); -} - -/** Compiled sidecars must use a separate, verified Bun executable. */ -export async function resolveBunExecutable(config: Config, options: ExecutionOptions = {}): Promise { - const root = options.root ?? repositoryRoot; - const configured = Bun.env.APHRODY_BUN_BINARY; - let candidate = Bun.which("bun"); - if (configured) { - if (isAbsolute(configured)) candidate = configured; - else if (configured.includes("/") || configured.includes("\\")) candidate = checkoutPath(root, configured); - else candidate = Bun.which(configured); - } - if (!candidate || !(await Bun.file(candidate).exists())) { - throw new Error("Native Bun missing; set APHRODY_BUN_BINARY or provide bun on PATH."); - } - const probe = await command( - [ - candidate, - "--no-env-file", - "-e", - "console.log(JSON.stringify({version:Bun.version,revision:Bun.revision,sourceUrl:process.release.sourceUrl??null}))", - ], - root, - Math.min(options.timeoutMs ?? 5_000, 5_000), - ); - if (probe.code !== 0) throw new Error("Native Bun provenance probe failed."); - let facts: unknown; - try { - facts = JSON.parse(probe.stdout.trim()); - } catch { - throw new Error("Native Bun returned invalid provenance metadata."); - } - if ( - !object(facts) || - typeof facts.version !== "string" || - typeof facts.revision !== "string" || - (facts.sourceUrl !== null && typeof facts.sourceUrl !== "string") - ) { - throw new Error("Native Bun returned invalid provenance metadata."); - } - assertRuntimeFacts(runtimePinFor(config), { - version: facts.version, - revision: facts.revision, - sourceUrl: facts.sourceUrl as string | null, - executable: candidate, - }); - return candidate; -} - -async function command( - argv: string[], - cwd: string, - timeoutMs = shortDeadline, - inherit = false, - env: Record = Bun.env, - captureStdout = false, -): Promise { - return runProcess(argv, cwd, { - timeoutMs, - maxOutputBytes: 64 * 1024 * 1024, - stdin: inherit ? "inherit" : "ignore", - stdout: inherit && !captureStdout ? "inherit" : "capture", - stderr: inherit ? "inherit" : "capture", - env, - }); -} - -async function git(args: string[], root: string): Promise { - const result = await command(["git", ...args], root); - if (result.code !== 0) throw new Error(result.stderr.trim() || `Git failed (${result.code}).`); - return result.stdout.trim(); -} - -export function findModule(config: Config, id: string): Module { - const module = config.modules.find(entry => entry.id === id); - if (!module) throw new Error(`Unknown library: ${id}`); - return module; -} - -export async function inspectSourceCoverage(config: Config, root = repositoryRoot): Promise { - const result: ModuleCoverage[] = []; - for (const module of config.modules) result.push(await inspectModuleCoverage(module, root)); - return result; -} - -interface Pin { - id: string; - path: string; - fork: string; - revision: string; -} - -function pinnedSources(config: Config): Pin[] { - const pins: Pin[] = config.modules.filter(module => module.sourceKind !== "workspace"); - if (config.desktop.source) { - pins.push({ - id: "bun-sdk", - path: config.desktop.source.path, - fork: config.desktop.source.repository, - revision: config.desktop.source.revision, - }); - } - return pins; -} - -/** Owner-selected local artifacts stay outside Git in .local/config.json. */ -export async function localBinaries(config: Config, root = repositoryRoot): Promise> { - const file = Bun.file(join(root, ".local", "config.json")); - if (!(await file.exists())) return {}; - const value: unknown = await file.json(); - if (!object(value) || (value.binaries !== undefined && !object(value.binaries))) { - throw new Error(".local/config.json: binaries must map library IDs to paths."); - } - const binaries: Record = {}; - for (const [id, entry] of Object.entries(value.binaries ?? {})) { - const module = findModule(config, id); - if ( - module.role !== "engine" || - !module.binary || - module.binary !== `${id}-cli` || - typeof entry !== "string" || - !entry - ) { - throw new Error(`Invalid local headless CLI: ${id}`); - } - const path = isAbsolute(entry) ? resolve(entry) : checkoutPath(root, entry); - const expected = nativeBinaryName(module); - if (basename(path).toLowerCase() !== expected.toLowerCase()) { - throw new Error(`Local CLI ${id} must be named ${expected}.`); - } - if (!(await Bun.file(path).exists())) throw new Error(`Local CLI missing: ${id}`); - binaries[id] = path; - } - return binaries; -} - -/** Keep native engine names separate from public application identities. */ -export function nativeBinaryName(module: Module, platform = process.platform): string { - if (module.role !== "engine" || module.binary !== `${module.id}-cli`) { - throw new Error(`Library has no headless CLI: ${module.id}`); - } - return `${module.binary}${platform === "win32" ? ".exe" : ""}`; -} - -export async function listModuleStatus(config: Config, root = repositoryRoot): Promise { - const result: ModuleStatus[] = []; - const binaries = await localBinaries(config, root); - // Keep process and signal ownership bounded, even for a large catalogue. - for (const module of pinnedSources(config)) { - const library = config.modules.find(entry => entry.id === module.id); - const hasHeadlessBinary = library?.role === "engine" && library.binary === `${module.id}-cli`; - const status: ModuleStatus = { - sourceKind: "submodule", - id: module.id, - path: module.path, - initialized: false, - revision: null, - expectedRevision: module.revision, - indexRevision: null, - pointerMatches: false, - revisionMatches: false, - clean: null, - launchable: false, - launchReason: hasHeadlessBinary ? "Library source not initialized." : "Library has no headless CLI.", - }; - try { - const index = await git(["ls-files", "--stage", "--", module.path], root); - const match = /^160000 ([0-9a-f]{40}) 0\t/.exec(index); - status.indexRevision = match?.[1] ?? null; - status.pointerMatches = status.indexRevision === module.revision; - const directory = checkoutPath(root, module.path); - status.initialized = await Bun.file(join(directory, ".git")).exists(); - if (status.initialized) { - status.revision = await git(["rev-parse", "HEAD"], directory); - status.revisionMatches = status.revision === module.revision; - status.clean = (await git(["status", "--porcelain=v1", "--untracked-files=all"], directory)) === ""; - const manifestExists = library - ? await Bun.file(checkoutPath(directory, library.manifestPath ?? "Cargo.toml")).exists() - : false; - status.launchable = Boolean( - hasHeadlessBinary && - (manifestExists || binaries[module.id]) && - status.pointerMatches && - status.revisionMatches, - ); - if (hasHeadlessBinary) - status.launchReason = status.launchable - ? binaries[module.id] - ? "Headless artifact selected in .local/config.json." - : "Headless CLI; native build may be required." - : !manifestExists - ? "Cargo manifest missing." - : "Revision differs from catalog."; - } - } catch (error) { - status.error = error instanceof Error ? error.message : String(error); - } - result.push(status); - } - for (const module of config.modules.filter(entry => entry.sourceKind === "workspace")) { - const status: ModuleStatus = { - sourceKind: "workspace", - id: module.id, - path: module.path, - initialized: false, - revision: null, - expectedRevision: module.revision, - indexRevision: null, - pointerMatches: true, - revisionMatches: false, - clean: null, - launchable: false, - launchReason: "Library has no headless CLI.", - }; - try { - status.initialized = await Bun.file( - checkoutPath(root, `${module.path}/${module.manifestPath ?? "Cargo.toml"}`), - ).exists(); - status.revision = await git(["rev-parse", "HEAD"], root); - // Preserve import ancestry and qualify every crate's independent Git pin. - const ancestry = await command(["git", "merge-base", "--is-ancestor", module.revision, status.revision], root); - if (ancestry.code > 1) throw new Error(`Imported source history is unavailable: ${module.id}`); - status.revisionMatches = ancestry.code === 0; - status.clean = (await git(["status", "--porcelain=v1", "--ignore-submodules=all"], root)) === ""; - for (const name of module.cargoPackages ?? []) { - const cratePath = `crates/${name}`; - const indexEntry = await git(["ls-files", "--stage", "--", cratePath], root); - const pin = /^160000 ([0-9a-f]{40}) 0\t/.exec(indexEntry)?.[1]; - if (!pin) throw new Error(`Product crate lacks its Git submodule: ${name}`); - const crateRoot = checkoutPath(root, cratePath); - status.pointerMatches &&= (await git(["rev-parse", "HEAD"], crateRoot)) === pin; - status.clean &&= (await git(["status", "--porcelain=v1"], crateRoot)) === ""; - } - const headless = module.role === "engine" && module.binary === `${module.id}-cli`; - status.launchable = headless && status.initialized && status.revisionMatches && status.pointerMatches; - if (headless) - status.launchReason = status.launchable - ? "Workspace CLI; native build may be required." - : "Imported source history differs."; - } catch (error) { - status.error = error instanceof Error ? error.message : String(error); - } - result.push(status); - } - return result; -} - -async function requireModule(config: Config, id: string, root: string): Promise { - const module = findModule(config, id); - const status = (await listModuleStatus(config, root)).find(entry => entry.id === id)!; - if (status.error || !status.initialized || !status.pointerMatches || !status.revisionMatches) { - throw new Error( - `${id} differs from the pinned source. Run aphrody-bun status and sync from a clean checkout. ${status.error ?? ""}`.trim(), - ); - } - return module; -} - -export async function syncModules(config: Config, root = repositoryRoot): Promise { - assertRuntime(config); - const states = await listModuleStatus(config, root); - for (const state of states) { - if ( - state.error || - !state.pointerMatches || - state.clean === false || - (state.initialized && !state.revisionMatches) - ) { - throw new Error( - `Sync refused: ${state.id}; local changes or revision differs from catalog. ${state.error ?? ""}`.trim(), - ); - } - } - if (await git(["status", "--porcelain=v1", "--untracked-files=all"], root)) { - throw new Error("Sync refused: monorepo has local changes."); - } - const paths = await git(["config", "--file", ".gitmodules", "--get-regexp", "^submodule\\..*\\.path$"], root); - for (const module of pinnedSources(config)) { - const line = paths.split("\n").find(entry => entry.endsWith(` ${module.path}`)); - if (!line) throw new Error(`Source missing from .gitmodules: ${module.id}`); - const key = line.slice(0, line.indexOf(" ")).replace(/\.path$/, ".url"); - const url = await git(["config", "--file", ".gitmodules", "--get", key], root); - const accepted = [ - `https://github.com/${module.fork}.git`, - `https://github.com/${module.fork}`, - `git@github.com:${module.fork}.git`, - ]; - if (!accepted.includes(url)) throw new Error(`Unexpected fork URL: ${module.id}`); - } - for (const module of pinnedSources(config)) { - const current = (await listModuleStatus(config, root)).find(entry => entry.id === module.id)!; - if ( - current.error || - !current.pointerMatches || - current.clean === false || - (current.initialized && !current.revisionMatches) - ) { - throw new Error(`Sync stopped: ${module.id} changed during the operation.`); - } - if (current.initialized) continue; - if (await git(["status", "--porcelain=v1", "--untracked-files=all"], root)) { - throw new Error("Sync stopped: monorepo received local changes."); - } - const result = await command( - ["git", "submodule", "update", "--init", "--checkout", "--", module.path], - root, - syncDeadline, - true, - ); - if (result.code !== 0) throw new Error(`Source initialization failed: ${module.id} (${result.code}).`); - } - const initialized = await listModuleStatus(config, root); - if ( - initialized.some( - state => state.error || !state.initialized || !state.revisionMatches || !state.pointerMatches || !state.clean, - ) - ) { - throw new Error("Initialized sources differ from the pinned catalog."); - } -} - -interface CargoModule { - directory: string; - manifest: string; - cargoPackage: string | null; - cargoPackages: string[]; - binary: string | null; -} - -export async function resolveCargoModule(module: Module, root = repositoryRoot): Promise { - if (module.binary && (module.role !== "engine" || module.binary !== `${module.id}-cli`)) { - throw new Error(`Only an explicit headless engine CLI is allowed: ${module.id}`); - } - const directory = checkoutPath(root, module.path); - const rootManifest = join(root, "Cargo.toml"); - if (await Bun.file(rootManifest).exists()) { - const workspace = Bun.TOML.parse(await Bun.file(rootManifest).text()) as { - workspace?: { members?: string[] }; - }; - const prefix = `${relative(root, directory).replaceAll("\\", "/")}/`; - const members = - workspace.workspace?.members?.filter(member => - module.sourceKind === "workspace" ? true : member.startsWith(prefix), - ) ?? []; - if (members.length) { - const cargoPackages: string[] = []; - for (const member of members) { - const memberManifest = checkoutPath(root, `${member}/Cargo.toml`); - const value = Bun.TOML.parse(await Bun.file(memberManifest).text()) as { - package?: { name?: string }; - }; - if (!value.package?.name) throw new Error(`Cargo member lacks a package name: ${member}`); - if ( - module.sourceKind !== "workspace" || - (module.cargoPackages ?? [module.cargoPackage]).includes(value.package.name) - ) { - cargoPackages.push(value.package.name); - } - } - if (module.cargoPackages?.some(name => !cargoPackages.includes(name))) - throw new Error(`Cargo package group is incomplete: ${module.id}`); - if (!cargoPackages.length) throw new Error(`No root Cargo packages belong to the library: ${module.id}`); - if (module.cargoPackage && !cargoPackages.includes(module.cargoPackage)) { - throw new Error(`Cargo package is outside its library: ${module.id}`); - } - return { - directory: module.sourceKind === "workspace" ? root : directory, - manifest: rootManifest, - cargoPackage: module.cargoPackage ?? null, - cargoPackages, - binary: module.binary ?? null, - }; - } - } - const manifest = checkoutPath(directory, module.manifestPath ?? "Cargo.toml"); - if (!(await Bun.file(manifest).exists())) throw new Error(`Cargo manifest missing: ${module.id}`); - const value = Bun.TOML.parse(await Bun.file(manifest).text()) as { - package?: { name?: string }; - }; - const cargoPackage = module.cargoPackage ?? value.package?.name ?? null; - if (!cargoPackage && module.binary) throw new Error(`cargoPackage required for the headless CLI: ${module.id}`); - const binary = module.binary ?? null; - return { directory, manifest, cargoPackage, cargoPackages: [], binary }; -} - -/** Discover an existing checkout artifact. Builds and native protocol validation stay explicit. */ -export async function resolveModuleBinary( - config: Config, - id: string, - options: ModuleBinaryOptions = {}, -): Promise { - const root = options.root ?? repositoryRoot; - const module = await requireModule(config, id, root); - const expected = nativeBinaryName(module); - const selected = options.binary ?? (await localBinaries(config, root))[id]; - if (selected) { - const path = isAbsolute(selected) ? resolve(selected) : checkoutPath(root, selected); - if (basename(path).toLowerCase() !== expected.toLowerCase()) - throw new Error(`Native CLI ${id} must be named ${expected}.`); - if (!(await Bun.file(path).exists())) throw new Error(`Native CLI missing: ${id}`); - return path; - } - const cargo = await resolveCargoModule(module, root); - const executable = Bun.which("cargo"); - if (!executable) throw new Error("Native Cargo missing on this host."); - const metadata = await runProcess( - [executable, "metadata", "--offline", "--no-deps", "--format-version", "1", "--manifest-path", cargo.manifest], - cargo.directory, - { - timeoutMs: Math.min(options.timeoutMs ?? shortDeadline, shortDeadline), - maxOutputBytes: options.maxOutputBytes ?? 16 * 1024 * 1024, - signal: options.signal, - }, - ); - if (metadata.code !== 0) throw new Error(`Cargo artifact discovery failed: ${id}`); - const value: unknown = JSON.parse(metadata.stdout); - if ( - !object(value) || - typeof value.target_directory !== "string" || - !isAbsolute(value.target_directory) || - value.target_directory.includes("\0") - ) { - throw new Error(`Cargo returned an invalid artifact directory: ${id}`); - } - for (const profile of ["release", "debug"]) { - const candidate = join(value.target_directory, profile, expected); - if (await Bun.file(candidate).exists()) return candidate; - } - throw new Error(`Native CLI missing: ${id}; build its existing library owner.`); -} - -/** Serialize a native build owner, including the Tauri CLI, without a shell. */ -async function runNativeResult( - config: Config, - nativeArguments: string[], - options: NativeExecutionOptions = {}, - captureStdout = false, -): Promise { - assertRuntime(config); - if (!nativeArguments.length || nativeArguments.some(arg => typeof arg !== "string" || arg.includes("\0"))) { - throw new Error("Invalid native arguments."); - } - const root = options.root ?? repositoryRoot; - const cwd = options.cwd ?? root; - const timeoutMs = options.timeoutMs ?? cargoDeadline; - if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > runDeadline) { - throw new Error("Invalid native deadline; maximum 12 hours."); - } - const env: Record = { - ...Bun.env, - ...options.env, - CARGO_BUILD_JOBS: "1", - CARGO_TARGET_DIR: join(root, "target"), - CARGO_INSTALL_ROOT: join(root, "install"), - }; - const toolchainFile = Bun.file(join(root, "rust-toolchain.toml")); - if (await toolchainFile.exists()) { - const toolchain = Bun.TOML.parse(await toolchainFile.text()) as { - toolchain?: { channel?: string }; - }; - if (!toolchain.toolchain?.channel) throw new Error("Root Rust toolchain channel is missing."); - env.RUSTUP_TOOLCHAIN = toolchain.toolchain.channel; - } - // Preserve valid owner wrappers; an unavailable sccache path must not break builds. - if (env.RUSTC_WRAPPER && !Bun.which(env.RUSTC_WRAPPER)) { - const sccache = Bun.which("sccache"); - if (sccache) env.RUSTC_WRAPPER = sccache; - else { - delete env.RUSTC_WRAPPER; - console.error("Optional Rust cache unavailable; using a direct build."); - } - } - if (options.serialize === false) { - return command(nativeArguments, cwd, timeoutMs, true, env, captureStdout); - } - let argv: string[]; - if (process.platform === "win32") { - const powershell = Bun.which("pwsh") ?? Bun.which("powershell"); - if (!powershell) throw new Error("Native PowerShell missing on this host."); - // JSON travels in the environment, avoiding PowerShell's native argv re-quoting. - env.APHRODY_NATIVE_ARGUMENTS = JSON.stringify(nativeArguments.slice(1)); - env.APHRODY_NATIVE_EXECUTABLE = Bun.which(nativeArguments[0]!) ?? nativeArguments[0]; - env.APHRODY_NATIVE_BUN = await resolveBunExecutable(config, { root }); - env.APHRODY_NATIVE_WORKER = join(root, "scripts", "native-worker.ts"); - const wrapperSeconds = Math.max(1, Math.ceil(Math.min(timeoutMs, runDeadline - 5_000) / 1000)); - argv = [ - powershell, - "-NoLogo", - "-NoProfile", - "-NonInteractive", - "-File", - join(root, "scripts", "cargo-serial.ps1"), - "-WorkingDirectory", - cwd, - "-TimeoutSeconds", - String(wrapperSeconds), - ]; - } else { - const flock = Bun.which("flock"); - if (!flock) throw new Error("Native flock required for build serialization on this Unix host."); - const temporaryDirectory = Bun.env.TMPDIR ?? "/tmp"; - const identity = process.getuid?.() ?? "user"; - argv = [flock, "--nonblock", join(temporaryDirectory, `aphrody-bun-native-${identity}.lock`), ...nativeArguments]; - } - // The wrapper kills its process tree before the outer Bun deadline. - return command( - argv, - cwd, - Math.min(runDeadline, timeoutMs + (process.platform === "win32" ? 5_000 : 0)), - true, - env, - captureStdout, - ); -} - -export async function runNative(config: Config, argv: string[], options: NativeExecutionOptions = {}): Promise { - return (await runNativeResult(config, argv, options)).code; -} - -/** Resolve Bun's existing native MSVC owner, with an explicit checkout override. */ -async function serializedCargo( - config: Config, - args: string[], - cwd: string, - root: string, - timeoutMs: number, - captureStdout = false, -): Promise { - const cargo = Bun.which("cargo"); - if (!cargo) throw new Error("Native Cargo missing on this host."); - let argv = [cargo, ...args]; - if (process.platform === "win32") { - const bun = await resolveBunExecutable(config, { root }); - argv = [bun, "msvc", "exec", "--", cargo, ...args]; - if (Bun.env.APHRODY_BUN_CHECKOUT) { - const owner = join(resolve(Bun.env.APHRODY_BUN_CHECKOUT), "scripts", "build", "rust", "cargo-serial.ts"); - if (!(await Bun.file(owner).exists())) - throw new Error("Cargo owner wrapper missing from the configured APHRODY_BUN_CHECKOUT."); - argv = [bun, owner, "--msvc", ...args]; - } - } - return runNativeResult(config, argv, { root, cwd, timeoutMs }, captureStdout); -} - -export interface CargoWorkspaceReport { - version: string; - bunPackageNames: string[]; - workspaceRoot: string; - targetDirectory: string; - packageNames: string[]; -} - -/** Verify the actual Cargo owner and output path through the native admission wrapper. */ -export async function inspectCargoWorkspace( - config: Config, - options: ExecutionOptions = {}, -): Promise { - const root = resolve(options.root ?? repositoryRoot); - const product = (await Bun.file(join(root, "package.json")).json()) as { - version?: string; - workspaces?: string[]; - }; - const manifest = Bun.TOML.parse(await Bun.file(join(root, "Cargo.toml")).text()) as { - workspace?: { package?: { version?: string } }; - }; - if (!product.version || manifest.workspace?.package?.version !== product.version) - throw new Error("Bun and Cargo product versions differ."); - const bunPackageNames: string[] = []; - for (const member of product.workspaces ?? []) { - const entry = (await Bun.file(checkoutPath(root, `${member}/package.json`)).json()) as { - name?: string; - version?: string; - }; - if (!entry.name || entry.version !== product.version) - throw new Error(`Bun workspace package version differs: ${member}`); - bunPackageNames.push(entry.name); - } - const result = await serializedCargo( - config, - ["metadata", "--locked", "--no-deps", "--format-version", "1", "--manifest-path", join(root, "Cargo.toml")], - root, - root, - options.timeoutMs ?? shortDeadline, - true, - ); - if (result.code !== 0) throw new Error("Root Cargo workspace metadata failed."); - const value: unknown = JSON.parse(result.stdout); - if ( - !object(value) || - typeof value.workspace_root !== "string" || - typeof value.target_directory !== "string" || - resolve(value.workspace_root) !== root || - resolve(value.target_directory) !== join(root, "target") || - !Array.isArray(value.workspace_members) || - !Array.isArray(value.packages) - ) { - throw new Error("Cargo workspace or target directory differs from the product root."); - } - const members = new Set(value.workspace_members); - const gitlinks = await command(["git", "ls-files", "--stage", "--", "crates"], root); - if (gitlinks.code !== 0) throw new Error("Crate Git index inspection failed."); - const pinnedCrates = new Map(); - for (const line of gitlinks.stdout.split("\n")) { - const match = /^160000 ([0-9a-f]{40}) 0\t(crates\/[^/]+)$/.exec(line.trim()); - if (match) pinnedCrates.set(match[2], match[1]); - } - const packageNames: string[] = []; - for (const entry of value.packages) { - if (!object(entry) || !members.has(entry.id)) continue; - if ( - typeof entry.name !== "string" || - typeof entry.manifest_path !== "string" || - entry.version !== product.version - ) { - throw new Error("Cargo workspace member lacks its package identity."); - } - const cratePath = `crates/${entry.name}`; - if (resolve(entry.manifest_path) !== join(root, cratePath, "Cargo.toml")) - throw new Error(`Cargo crate must be flat under crates: ${entry.name}`); - const expectedRevision = pinnedCrates.get(cratePath); - if (!expectedRevision) throw new Error(`Cargo crate is not a pinned Git submodule: ${entry.name}`); - const actualRevision = await command(["git", "rev-parse", "HEAD"], join(root, cratePath)); - if (actualRevision.code !== 0 || actualRevision.stdout.trim() !== expectedRevision) - throw new Error(`Cargo crate revision differs from its Git pin: ${entry.name}`); - packageNames.push(entry.name); - } - if (packageNames.length !== members.size || packageNames.length === 0) { - throw new Error("Cargo workspace membership is incomplete."); - } - return { - version: product.version, - bunPackageNames: bunPackageNames.sort(), - workspaceRoot: root, - targetDirectory: join(root, "target"), - packageNames: packageNames.sort(), - }; -} - -export async function cargoModule( - config: Config, - id: string, - action: "check" | "build" | "run", - args: string[] = [], - options: ExecutionOptions = {}, -): Promise { - assertRuntime(config); - const root = options.root ?? repositoryRoot; - const module = await requireModule(config, id, root); - const cargo = await resolveCargoModule(module, root); - const flags = [action, "--locked", "--manifest-path", cargo.manifest]; - if (cargo.cargoPackage) flags.push("--package", cargo.cargoPackage); - else if (cargo.cargoPackages.length) { - for (const name of cargo.cargoPackages) flags.push("--package", name); - } else flags.push("--workspace"); - if (action === "run") { - if (!cargo.binary) throw new Error(`Library has no headless CLI: ${id}`); - flags.push("--bin", cargo.binary, "--", ...args); - } else { - if (args.length) throw new Error(`${action} accepts only a library ID.`); - if (action === "build") flags.push("--release"); - } - return (await serializedCargo(config, flags, cargo.directory, root, options.timeoutMs ?? cargoDeadline)).code; -} - -export async function runModule( - config: Config, - id: string, - args: string[] = [], - options: ExecutionOptions = {}, -): Promise { - assertRuntime(config); - const root = options.root ?? repositoryRoot; - const module = await requireModule(config, id, root); - const selected = (await localBinaries(config, root))[id]; - if (selected) { - return (await command([selected, ...args], checkoutPath(root, module.path), options.timeoutMs ?? runDeadline, true)) - .code; - } - const cargo = await resolveCargoModule(module, root); - if (!cargo.binary || module.role !== "engine" || cargo.binary !== `${id}-cli`) - throw new Error(`Library has no headless CLI: ${id}`); - const build = await serializedCargo( - config, - [ - "build", - "--release", - "--locked", - "--manifest-path", - cargo.manifest, - "--package", - cargo.cargoPackage!, - "--bin", - cargo.binary, - "--message-format=json", - ], - cargo.directory, - root, - Math.min(cargoDeadline, options.timeoutMs ?? cargoDeadline), - true, - ); - let binary: string | null = null; - for (const line of build.stdout.split("\n")) { - try { - const artifact = JSON.parse(line) as { - reason?: string; - executable?: string; - target?: { name?: string; kind?: string[] }; - message?: { rendered?: string }; - }; - if (artifact.reason === "compiler-message" && artifact.message?.rendered) { - console.error(artifact.message.rendered); - } - if ( - artifact.reason === "compiler-artifact" && - artifact.target?.name === cargo.binary && - artifact.target.kind?.includes("bin") && - typeof artifact.executable === "string" - ) { - binary = artifact.executable; - } - } catch { - // Native owner diagnostics may accompany Cargo's JSON output. - } - } - if (build.code !== 0) return build.code; - if (!binary || !(await Bun.file(binary).exists())) { - throw new Error(`Cargo did not report a headless CLI artifact: ${id}`); - } - // Release the build mutex before starting the headless CLI. - return (await command([binary, ...args], cargo.directory, options.timeoutMs ?? runDeadline, true)).code; -} +export * from "@aphrody/bun-workspace";