// Prepack step for the published `cotal-ai` binary: materialize each built-in connector's PUBLISHED // file set into `bin/seeded-connectors//`, so the shipped binary carries a stable, durable // payload the first-run reconcile can `ext add --install-links` from (see implementations/cli/src/ // seed/paths.ts `shippedSourceDir` — the published branch resolves `/seeded-connectors/ // `). In a source checkout the reconcile resolves the live `extensions/` dirs instead, so this // only matters for a real publish. // // `npm pack` is used per connector because it honors each package's own `files`/`.npmignore` exactly // (dotfiles like claude's `.claude-plugin`, hermes' `plugin/**` globs), producing the same bytes a // standalone publish of that connector would. The connectors must be BUILT first (their `dist/`); we // fail loud rather than silently ship an empty payload. import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { SEEDED_EXTENSIONS, SEEDED_SHARED_DIR, SEEDED_SHARED_FILES } from "@cotal-ai/workspace"; const binRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const repoRoot = join(binRoot, ".."); const outRoot = join(binRoot, "seeded-connectors"); // The generation the reconcile stamps is this binary's version; every bundled extension must carry // the SAME version (the `fixed` changeset group versions them in lockstep). Assert it at pack time so // a skewed payload can never be published inside the umbrella and then installed as the current // generation (see verifyInstalled in seed/reconcile.ts for the install-time half of this guard). const umbrellaVersion = JSON.parse(readFileSync(join(binRoot, "package.json"), "utf8")).version; rmSync(outRoot, { recursive: true, force: true }); mkdirSync(outRoot, { recursive: true }); // Windows has no `npm` executable to spawn without a shell, only `npm.cmd`; run the npm that ships // next to node.exe through this node instead, as the seat native build does for its node-gyp. const npm = process.platform === "win32" ? (args) => [process.execPath, [join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"), ...args]] : (args) => ["npm", args]; for (const [name, { pkg, srcDir: srcSubdir }] of Object.entries(SEEDED_EXTENSIONS)) { const srcDir = join(repoRoot, srcSubdir); const meta = JSON.parse(readFileSync(join(srcDir, "package.json"), "utf8")); if (meta.name !== pkg) { throw new Error(`seeded extension "${name}": package.json name "${meta.name}" != expected "${pkg}"`); } if (meta.version !== umbrellaVersion) { throw new Error(`seeded extension ${pkg} is version "${meta.version}" but cotal-ai is "${umbrellaVersion}" - the fixed changeset group must version every bundled extension in lockstep with the umbrella`); } if (!existsSync(join(srcDir, "dist"))) { throw new Error(`first-party extension ${pkg} is not built (no dist at ${srcDir}) - run \`pnpm build\` before packing cotal-ai`); } // The dashboard's page is its own build step (vite) beside the server's tsc, so a dist holding the // server alone is possible, and an install of it would answer 503 on its own front page. if (pkg === "@cotal-ai/web" && !existsSync(join(srcDir, "dist", "web", "index.html"))) { throw new Error(`first-party extension ${pkg} has no client build (no dist/web/index.html at ${srcDir}) - run \`pnpm --filter @cotal-ai/web build\` before packing cotal-ai`); } const tmp = mkdtempSync(join(tmpdir(), "cotal-seed-")); try { // --ignore-scripts: dist is already built above; don't re-run the connector's own prepack. const stdout = execFileSync(...npm(["pack", "--ignore-scripts", "--silent", "--pack-destination", tmp, srcDir]), { encoding: "utf8" }); const tgz = stdout.trim().split("\n").filter(Boolean).pop(); if (!tgz) throw new Error(`npm pack produced no tarball for ${pkg}`); const dest = join(outRoot, name); mkdirSync(dest, { recursive: true }); // The tarball's top-level dir is always `package/`; strip it so files land directly under . execFileSync("tar", ["xzf", join(tmp, tgz), "-C", dest, "--strip-components=1"]); console.log(`seeded-connectors/${name} <- ${pkg}`); } finally { rmSync(tmp, { recursive: true, force: true }); } } // Every connector bundle reads the same docs corpus and index from beside itself, so the payloads above // hold identical copies of each. Ship one copy of each file and the paths it was removed from; the seed // store puts a copy back at each path (implementations/cli/src/seed/shared.ts). if (Object.hasOwn(SEEDED_EXTENSIONS, SEEDED_SHARED_DIR)) throw new Error(`"${SEEDED_SHARED_DIR}" is a seeded extension name and the shared-files directory`); const sharedRoot = join(outRoot, SEEDED_SHARED_DIR); mkdirSync(sharedRoot); const files = {}; const placements = {}; for (const name of Object.keys(SEEDED_EXTENSIONS)) { const payload = join(outRoot, name); for (const rel of readdirSync(payload, { recursive: true }).sort()) { const path = join(payload, rel); const file = basename(rel); if (!SEEDED_SHARED_FILES.includes(file) || !statSync(path).isFile()) continue; const digest = createHash("sha256").update(readFileSync(path)).digest("hex"); if (files[file] === undefined) { files[file] = digest; copyFileSync(path, join(sharedRoot, file)); } else if (files[file] !== digest) { throw new Error(`seeded-connectors/${name}/${rel} differs from the other copies of ${file} - every connector must ship the docs data built with this release`); } rmSync(path); (placements[name] ??= []).push(rel.split(sep).join("/")); } } writeFileSync(join(sharedRoot, "placements.json"), `${JSON.stringify({ v: 1, files, placements }, null, 2)}\n`); const placed = Object.values(placements).flat().length; console.log(`seeded-connectors/${SEEDED_SHARED_DIR} <- ${Object.keys(files).length} shared files for ${placed} placements`);