import { execFileSync, execSync } from 'node:child_process'; import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { readFile, readdir, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, join, relative } from 'node:path'; import { gunzipSync } from 'node:zlib'; import { create as tarCreate } from 'tar'; import { parse as parseYaml } from 'yaml'; import { log } from '../../cli/prompts'; import { formatViolations, scanModuleDirectory } from '../../policy/module-script-scan'; import { validateModuleDirectory } from '../import'; import { resolveBuildCommandPaths } from './build-paths'; import { computeFileChecksum } from './checksum'; import { classifyModulePath, includeNodeModulesPath } from './package-rules'; import { signChecksums } from './signature'; import { rewriteWorkspaceDeps } from './workspace-deps'; /** * Checksums data structure */ export interface ChecksumsData { version: string; generated: string; files: Record; // filepath -> xxhash } /** * Put `scripts/node_modules` back after a `bun pm pack` staging. * * `bun pm pack` drops every `node_modules` unconditionally — that is npm-pack * semantics, and a `files` entry naming the path does not override it. But the * packager is explicit downstream (`includeNodeModulesPath`) that * `scripts/node_modules` is the module's HOOK RUNTIME and ships in full, so * hooks resolve their third-party deps on a target with no reachable registry. * * Those two rules disagreed, and which one won was decided by whether a module * happened to have a root `package.json`. Every module in `modules/` has only * `scripts/package.json`, so all of them take the `cpSync` branch below and * keep their runtime. A module that is ALSO a bun project — a build script, a * data pipeline, its own tests — takes this branch instead and shipped without * one. Nothing caught it: package, publish, import and deploy all succeed, and * the first symptom is the hook dying on the target with * `Cannot find package '@celilo/capabilities'` (celilo#1310). * * The hook runtime is not build input, so `files` has no business filtering it. * Symlinks are materialised for the same reason the `cpSync` branch does it: a * `file:` workspace link would point at a path the target does not have. */ function stageHookRuntime(sourceDir: string, buildDir: string): void { const from = join(sourceDir, 'scripts', 'node_modules'); if (!existsSync(from)) return; cpSync(from, join(buildDir, 'scripts', 'node_modules'), { recursive: true, dereference: true, }); } /** * Module build options */ export interface ModuleBuildOptions { sourceDir: string; outputPath?: string; masterKeyPath?: string; /** * Optional release metadata to stamp into the package as `release.json`. * `celilo module publish` collects this; `celilo module package` runs * without it (no git/CLI context) and ships a package without * release.json — that's fine; audit treats absent metadata as * "unknown release info" rather than as an error. */ releaseMetadata?: import('./release-metadata').ReleaseMetadata; } /** * Module build result */ export interface ModuleBuildResult { success: boolean; packagePath?: string; error?: string; } /** * Check if a path inside the source dir should be excluded from the package. * * `classifyModulePath` (package-rules.ts) is the one answer to what belongs to * a module. Packaging differs from it in exactly one place: the hook runtime * closure under `scripts/node_modules/` is `derived` (celilo's `bun install` * owns the on-disk copy) and still SHIPS, because a target may have no * reachable registry (ISS-0046). Everything else `derived` is celilo's own * output, or the checksum manifest that cannot list itself. */ function shouldExclude(filePath: string): boolean { const cls = classifyModulePath(filePath); if (cls === 'package') return false; if (cls === 'derived' && filePath.split('/').includes('node_modules')) { return !includeNodeModulesPath(filePath); } return true; } /** * Recursively scan directory and collect all files * * @param dir - Directory to scan * @param baseDir - Base directory for relative paths * @returns Array of relative file paths */ async function scanDirectory(dir: string, baseDir: string): Promise { const files: string[] = []; const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(dir, entry.name); const relativePath = relative(baseDir, fullPath); if (shouldExclude(relativePath)) { continue; } if (entry.isDirectory()) { const subFiles = await scanDirectory(fullPath, baseDir); files.push(...subFiles); } else if (entry.isFile()) { files.push(relativePath); } } return files; } /** * Compute checksums for all files in module directory * * @param sourceDir - Module source directory * @returns Checksums data */ export async function computeChecksums(sourceDir: string): Promise { const files = await scanDirectory(sourceDir, sourceDir); const checksums: Record = {}; for (const file of files) { const fullPath = join(sourceDir, file); const checksum = await computeFileChecksum(fullPath); checksums[file] = checksum; } return { version: '1.0', generated: new Date().toISOString(), files: checksums, }; } /** * Ceiling on the final tar+gzip write of the .netapp. Every step before it * (staging, the module's own build command) already carries a timeout, but the * tar stream did not — and 2026-09-04 it stalled mid-write (all samples in * kevent64, artifact truncated at 59 percent) and sat there for two hours with * no error and no exit. A bounded wait turns that into a nameable failure. */ const PACKAGE_STREAM_TIMEOUT_MS = 300_000; /** * Validate that a produced .netapp is a complete gzip stream. * * A .netapp is a gzipped tar (tarCreate above runs with gzip: true), so a * full gunzip pass is the whole check: a stream truncated mid-write fails * with 'unexpected end of file' instead of surviving to the consumer, where * the same zlib error reads as a truncated DOWNLOAD from an unrelated cause. * Returns null when valid, otherwise the reason. */ export function verifyNetappIntegrity(packagePath: string): string | null { try { gunzipSync(readFileSync(packagePath)); return null; } catch (err) { return err instanceof Error ? err.message : String(err); } } /** * Build a module package (.netapp file) * * @param options - Build options * @returns Build result */ export async function buildModule(options: ModuleBuildOptions): Promise { const { sourceDir, outputPath, masterKeyPath } = options; // Validate source directory const dirError = validateModuleDirectory(sourceDir); if (dirError) { return { success: false, error: dirError }; } // Refuse to package a module whose hook scripts hand-build SSH or take the // raw-exec escape hatch without justifying it // (openspec/changes/unified-management-no-ssh/proposal.md). // // The same rules run as a `bun test` gate over this repo's modules. This is // the enforcement point that catches what that one cannot: a module built // outside CI. `bun run publish` is a documented escape hatch for when the // runners are down and it runs no tests, and a module authored outside this // repo never passes through the suite at all — in both cases packaging is the // last place anything looks at the code before it becomes an artifact the // fleet installs. // // Scans the SOURCE scripts, before staging: it fails in under a second rather // than after a `bun pm pack` and a full module build, and the staged copy // bundles `@celilo/capabilities` — whose `remote.ts` builds the very // `ssh … root@` string these rules exist to keep out of module code — so // scanning the bundle would fail every module in the fleet on the // implementation of the primitives they were told to use. const policyViolations = scanModuleDirectory(sourceDir); if (policyViolations.length > 0) { return { success: false, error: `Refusing to package ${basename(sourceDir)}: module script policy violations.\n${formatViolations( policyViolations, )}\n\nSee apps/celilo/MODULE_PRIMITIVES.md.`, }; } // Copy source to a temp dir for building. Strategy: // - If the source has a package.json, use `bun pm pack` to respect the // `files` field (or .npmignore), copying only what the build needs. // Avoids copying node_modules, build artifacts, git history. // - If no package.json (simple modules, test fixtures), fall back to // recursive copy with EXCLUDE_PATTERNS filter. const buildDir = mkdtempSync(join(tmpdir(), 'celilo-package-')); const hasPackageJson = existsSync(join(sourceDir, 'package.json')); try { if (hasPackageJson) { console.log('Staging source for build (via bun pm pack)...'); try { execSync(`bun pm pack --destination ${buildDir}`, { cwd: sourceDir, stdio: 'pipe', timeout: 60_000, }); const tarballs = execSync(`ls ${buildDir}/*.tgz`, { encoding: 'utf-8' }).trim().split('\n'); if (tarballs.length === 0) throw new Error('bun pm pack produced no tarball'); execSync(`tar -xzf ${tarballs[0]} --strip-components=1 -C ${buildDir}`, { timeout: 60_000, }); rmSync(tarballs[0], { force: true }); stageHookRuntime(sourceDir, buildDir); } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); return { success: false, error: `Failed to stage source for build: ${errMsg}\n\nTip: Add a "files" field to your package.json listing the files/directories needed for the build.`, }; } } else { console.log('Staging source for build (no package.json, using file copy)...'); cpSync(sourceDir, buildDir, { recursive: true, // MATERIALISE symlinks. `modules/*/scripts` depends on // `@celilo/capabilities` by `file:` path so an in-repo module builds // against the LIVE workspace rather than the last published tarball — // without which a module cannot consume a capability added in the same // PR, and `check:modules` typechecks every module against a version // that no longer matches the source it ships beside. // // bun installs a `file:` dep as a tree of symlinks into the monorepo. // Copied as symlinks they would point at a path that does not exist on // the target, and because `scripts/node_modules` would still LOOK // present, `installScriptDependencies` skips its `bun install` and the // hooks fail at runtime with unresolvable imports. Dereferencing here // is what keeps the shipped closure a real, self-contained tree. dereference: true, filter: (src) => !shouldExclude(relative(sourceDir, src)), }); } // Read manifest to get module ID const manifestPath = join(buildDir, 'manifest.yml'); const manifestContent = await readFile(manifestPath, 'utf-8'); const idMatch = manifestContent.match(/^id:\s*["']?([a-z0-9-]+)["']?/m); if (!idMatch) { return { success: false, error: 'Could not extract module ID from manifest.yml' }; } const moduleId = idMatch[1]; // Run build if manifest declares one const manifest = parseYaml(manifestContent); if (manifest.build?.command || manifest.build?.script) { log.info(`Building module (${manifest.build.command ? 'command' : 'script'})...`); // Publish-time gate on the build command's paths (D3 of // control-plane-stops-building-modules). A `cd` whose target does not // resolve where the command runs means the module depends on inputs it // does not carry. celilo-registry did exactly this for four months // (`cd ../../packages/registry-server`) and the failure surfaced on a // production control plane, months after publish, as a /bin/sh error. // Refuse here, at the only point where a human is watching, and name the // path. Targets the gate cannot resolve (untracked shell variables, // command substitution) are skipped, never guessed at. if (manifest.build.command) { const pathViolations = resolveBuildCommandPaths(manifest.build.command, { moduleSourceDir: sourceDir, buildDir, }); if (pathViolations.length > 0) { return { success: false, error: [ `Refusing to build ${moduleId}: the build command references a path that does not exist where the command runs.`, ...pathViolations.map((v) => ` ${v.rawPath}\n resolves to: ${v.resolvedPath}`), '', 'A build input that does not resolve at publish time makes the module unbuildable wherever it is installed. Fix the path in manifest.yml (keep it module-local, or reach the monorepo through $CELILO_MODULE_SOURCE_DIR) and re-publish.', ].join('\n'), }; } } // Resolve `workspace:` build deps (ISS-0147 / celilo#216). The staged // buildDir has no workspace root, so a sibling monorepo package pulled in // via `workspace:^` won't resolve during the build's `bun install`. // Rewrite those specs to `file:` paths pointing at the live monorepo // members — always-fresh, no npm-pin lag, no hand-rolled // CELILO_MODULE_SOURCE_DIR dance. Fails loudly naming any unresolvable dep. try { for (const rewrite of rewriteWorkspaceDeps(buildDir, sourceDir)) { log.info(`Resolved workspace dep: ${rewrite}`); } } catch (rewriteError) { return { success: false, error: rewriteError instanceof Error ? rewriteError.message : String(rewriteError), }; } // The build runs in a staged copy of the module (buildDir), so // relative paths that reach outside the module (e.g. sibling packages // in a monorepo) don't resolve. Expose the ORIGINAL unstaged source // path so build commands can find siblings via e.g. // `$CELILO_MODULE_SOURCE_DIR/../../packages/...`. celilo-registry // uses this to compile packages/registry-server into a single-file // binary and drop it into the staged ansible files/ dir. // // Use execFileSync (not execSync) so the command string goes straight // to bash without a /bin/sh wrapping pass. Otherwise outer-shell // variable expansion would strip shell-only bash variables like // $STAGE before bash ever sees them. const buildEnv = { ...process.env, CELILO_MODULE_SOURCE_DIR: sourceDir }; try { if (manifest.build.command) { execFileSync('bash', ['-c', manifest.build.command], { cwd: buildDir, stdio: 'inherit', timeout: 300_000, env: buildEnv, }); } else { const script = manifest.build.script as string; const cmd = script.endsWith('.sh') ? 'bash' : 'ansible-playbook'; execFileSync(cmd, [script], { cwd: buildDir, stdio: 'inherit', timeout: 300_000, env: buildEnv, }); } } catch (buildError) { const msg = buildError instanceof Error ? buildError.message : String(buildError); return { success: false, error: `Auto-build failed: Build exited with code ${msg}` }; } // Verify artifacts exist if (manifest.build.artifacts) { const missing = manifest.build.artifacts.filter( (a: string) => !existsSync(join(buildDir, a)), ); if (missing.length > 0) { return { success: false, error: `Build succeeded but artifacts missing: ${missing.join(', ')}`, }; } } } // Stamp release metadata into the build dir BEFORE computing // checksums, so the metadata is part of the integrity-verified // payload (a future restore can trust the SHA / version it sees). if (options.releaseMetadata) { const { RELEASE_METADATA_FILENAME } = await import('./release-metadata'); const metadataPath = join(buildDir, RELEASE_METADATA_FILENAME); await writeFile(metadataPath, JSON.stringify(options.releaseMetadata, null, 2)); } // Compute checksums const checksumsData = await computeChecksums(buildDir); const checksumsJson = JSON.stringify(checksumsData, null, 2); // Sign checksums const signature = await signChecksums(checksumsJson, masterKeyPath); // Write checksums.json and signature.sig into build dir const checksumsPath = join(buildDir, 'checksums.json'); const signaturePath = join(buildDir, 'signature.sig'); await writeFile(checksumsPath, checksumsJson); await writeFile(signaturePath, signature); // Create tarball const finalOutputPath = outputPath || join(process.cwd(), `${moduleId}.netapp`); const tarPromise = tarCreate( { file: finalOutputPath, cwd: buildDir, gzip: true, }, ['checksums.json', 'signature.sig', ...Object.keys(checksumsData.files)], ); let timer: ReturnType | undefined; const timeout = new Promise((_, reject) => { timer = setTimeout( () => reject( new Error( `packaging tar stream exceeded ${PACKAGE_STREAM_TIMEOUT_MS / 1000}s and was abandoned mid-write`, ), ), PACKAGE_STREAM_TIMEOUT_MS, ); }); timer?.unref?.(); try { await Promise.race([tarPromise, timeout]); } catch (tarError) { // The losing side may still hold the output file open: swallow its // eventual rejection and remove the partial artifact so nothing // downstream mistakes a truncated write for a package. tarPromise.catch(() => {}); rmSync(finalOutputPath, { force: true }); return { success: false, error: `Failed to build module: ${ tarError instanceof Error ? tarError.message : 'Unknown error' }`, }; } // Refuse to declare success on an artifact we cannot read back. The tar // step resolved, so this only trips if the stream left the file truncated // anyway — the exact corruption build-infra shipped on 2026-09-04. const integrityError = verifyNetappIntegrity(finalOutputPath); if (integrityError) { rmSync(finalOutputPath, { force: true }); return { success: false, error: `Produced package failed gzip integrity check (${finalOutputPath}): ${integrityError}`, }; } return { success: true, packagePath: finalOutputPath, }; } catch (error) { return { success: false, error: `Failed to build module: ${error instanceof Error ? error.message : 'Unknown error'}`, }; } finally { // Clean up temp build directory rmSync(buildDir, { recursive: true, force: true }); } }