#!/usr/bin/env bun /** * Publishes the public @executor-js/* workspace packages to npm. * * Walks a hard-coded list of publishable package directories, determines the * dist-tag from the version string (anything containing `-` is treated as beta), * and packs + publishes each package whose current version is not already on npm. * * Invoked from `.github/workflows/release.yml` via the `publish:` input on * changesets/action after the Version Packages PR has been merged, and locally * via `bun run release:publish:packages` (or `--dry-run`). */ import { $ } from "bun"; import { existsSync } from "node:fs"; import { readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; type Channel = "latest" | "beta"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); /** * The repo uses `@executor/*` internally (matching historical package names * and the workspace graph) but publishes under `@executor-js/*` because the * short scope was taken on npm. At pack time we rewrite package manifests and * compiled `dist/` artifacts so the tarball ships with the public scope — * source code stays unchanged. Only package names in `PUBLIC_PACKAGE_DIRS` * are rewritten; unpublished peer deps like `@executor/api` / `@executor/react` * are left alone (they're optional peers and users who don't install them * just see a warning). */ const INTERNAL_SCOPE = "@executor"; const PUBLISHED_SCOPE = "@executor-js"; /** * Workspace-relative paths of the public packages. Kept explicit so a new * directory under `packages/plugins/` does not accidentally ship to npm. */ const PUBLIC_PACKAGE_DIRS = [ "packages/core/storage-core", "packages/kernel/core", "packages/kernel/runtime-quickjs", "packages/core/sdk", "packages/core/execution", "packages/core/cli", "packages/plugins/file-secrets", "packages/plugins/google-discovery", "packages/plugins/graphql", "packages/plugins/keychain", "packages/plugins/mcp", "packages/plugins/onepassword", "packages/plugins/openapi", ] as const; const parseArgs = (argv: ReadonlyArray): { dryRun: boolean } => { let dryRun = false; for (const arg of argv) { if (arg === "--dry-run") { dryRun = true; continue; } throw new Error(`Unknown argument: ${arg}`); } return { dryRun }; }; const resolveChannel = (version: string): Channel => (version.includes("-") ? "beta" : "latest"); const readPackageMeta = async (pkgDir: string) => { const pkgJsonPath = join(pkgDir, "package.json"); const pkg = (await Bun.file(pkgJsonPath).json()) as { name?: string; version?: string; private?: boolean; }; if (!pkg.name || !pkg.version) { throw new Error(`Missing name/version in ${pkgJsonPath}`); } if (pkg.private === true) { throw new Error(`${pkg.name} is marked private and cannot be published`); } return { name: pkg.name, version: pkg.version }; }; const packageAlreadyPublished = async (name: string, version: string): Promise => { const proc = Bun.spawn(["npm", "view", `${name}@${version}`, "version"], { stdio: ["ignore", "ignore", "ignore"], }); return (await proc.exited) === 0; }; type DependencyBlock = Record; type MutablePackageJson = { name?: string; dependencies?: DependencyBlock; devDependencies?: DependencyBlock; peerDependencies?: DependencyBlock; optionalDependencies?: DependencyBlock; [key: string]: unknown; }; /** * Rewrites the internal `@executor/*` scope to the published `@executor-js/*` * scope for the given set of names, inside both `package.json` (name + * dep blocks) and compiled `dist/` artifacts (`.js`, `.d.ts`). Also resolves * `workspace:*` dep references to concrete versions, since `bun pm pack` * can't resolve workspace specifiers that no longer match any workspace name * (after the rename). Returns a restore function that reverts every file we * touched. Only names in `publishable` are rewritten — `@executor/api` and * `@executor/react` are left alone because they're unpublished optional peers. */ const applyScopeRename = async ( pkgDir: string, publishable: ReadonlySet, publishableVersions: ReadonlyMap, ): Promise<() => Promise> => { const toPublished = (internal: string): string => internal.replace(`${INTERNAL_SCOPE}/`, `${PUBLISHED_SCOPE}/`); const toInternal = (published: string): string => published.replace(`${PUBLISHED_SCOPE}/`, `${INTERNAL_SCOPE}/`); const publishableInternalNames = new Set(); for (const published of publishable) publishableInternalNames.add(toInternal(published)); const renameDepBlock = (block: DependencyBlock | undefined): DependencyBlock | undefined => { if (!block) return block; const next: DependencyBlock = {}; let mutated = false; for (const [key, value] of Object.entries(block)) { if (publishableInternalNames.has(key)) { const newKey = toPublished(key); // Resolve workspace:* to the concrete version of the published package. const newValue = value.startsWith("workspace:") ? (publishableVersions.get(newKey) ?? value) : value; next[newKey] = newValue; mutated = true; } else { next[key] = value; } } return mutated ? next : block; }; const snapshots = new Map(); const writeIfChanged = async (absPath: string, next: string): Promise => { const original = await readFile(absPath, "utf8"); if (next === original) return; snapshots.set(absPath, original); await writeFile(absPath, next); }; // 1. package.json — rewrite `name` and every dep block structurally so we // can also resolve workspace specifiers while we're here. const pkgJsonPath = join(pkgDir, "package.json"); const pkgRaw = await readFile(pkgJsonPath, "utf8"); const pkg = JSON.parse(pkgRaw) as MutablePackageJson; if (pkg.name && publishableInternalNames.has(pkg.name)) { pkg.name = toPublished(pkg.name); } pkg.dependencies = renameDepBlock(pkg.dependencies); pkg.devDependencies = renameDepBlock(pkg.devDependencies); pkg.peerDependencies = renameDepBlock(pkg.peerDependencies); pkg.optionalDependencies = renameDepBlock(pkg.optionalDependencies); const pkgNext = `${JSON.stringify(pkg, null, 2)}\n`; if (pkgNext !== pkgRaw) { snapshots.set(pkgJsonPath, pkgRaw); await writeFile(pkgJsonPath, pkgNext); } // 2. dist/**/*.{js,d.ts} — plain string replace of import specifiers, one // package name at a time. Longest-first avoids partial matches. const orderedInternal = [...publishableInternalNames].sort((a, b) => b.length - a.length); const replaceAllInText = (text: string): string => { let out = text; for (const internal of orderedInternal) { out = out.split(internal).join(toPublished(internal)); } return out; }; const distDir = join(pkgDir, "dist"); if (existsSync(distDir)) { const walk = async (dir: string): Promise => { const entries = await readdir(dir); for (const entry of entries) { const abs = join(dir, entry); const info = await stat(abs); if (info.isDirectory()) { await walk(abs); continue; } if (entry.endsWith(".js") || entry.endsWith(".d.ts")) { const original = await readFile(abs, "utf8"); await writeIfChanged(abs, replaceAllInText(original)); } } }; await walk(distDir); } return async () => { for (const [path, original] of snapshots) { await writeFile(path, original); } }; }; /** * Applies `publishConfig` field overrides to package.json in place, returning a * function that restores the original file. `bun pm pack` does not substitute * `publishConfig.exports` / `publishConfig.main` etc at pack time (npm does, * but only for a subset of fields and only for `npm pack`), so we rewrite the * file ourselves so the packed tarball has the correct `exports` pointing at * `dist/` instead of the dev-time `src/index.ts`. */ const applyPublishConfig = async (pkgDir: string): Promise<() => Promise> => { const pkgJsonPath = join(pkgDir, "package.json"); const original = await readFile(pkgJsonPath, "utf8"); const parsed = JSON.parse(original) as { publishConfig?: Record; [key: string]: unknown; }; const publishConfig = parsed.publishConfig; if (!publishConfig || typeof publishConfig !== "object") { return async () => {}; } // Fields we allow publishConfig to override. `access`/`tag`/`registry` are // real npm publish-time config keys — they must NOT be hoisted into the // top-level manifest. const overridable = new Set([ "exports", "main", "module", "types", "typings", "bin", "browser", "files", ]); const nextPublishConfig: Record = {}; let mutated = false; for (const [key, value] of Object.entries(publishConfig)) { if (overridable.has(key)) { parsed[key] = value; mutated = true; } else { nextPublishConfig[key] = value; } } if (!mutated) { return async () => {}; } if (Object.keys(nextPublishConfig).length === 0) { delete parsed.publishConfig; } else { parsed.publishConfig = nextPublishConfig; } await writeFile(pkgJsonPath, `${JSON.stringify(parsed, null, 2)}\n`); return async () => { await writeFile(pkgJsonPath, original); }; }; const publishPackage = async ( pkgDir: string, dryRun: boolean, publishable: ReadonlySet, publishableVersions: ReadonlyMap, ) => { const { name: internalName, version } = await readPackageMeta(pkgDir); const publishedName = internalName.replace(`${INTERNAL_SCOPE}/`, `${PUBLISHED_SCOPE}/`); const channel = resolveChannel(version); if (!existsSync(join(pkgDir, "dist"))) { throw new Error(`Missing dist/ in ${pkgDir}. Did you run 'bun run build:packages'?`); } if (await packageAlreadyPublished(publishedName, version)) { console.log(`[skip] ${publishedName}@${version} already on npm`); return; } console.log(`[publish] ${publishedName}@${version} (${channel})${dryRun ? " [dry-run]" : ""}`); // Clean any stale tarballs from previous runs so our readdir finds exactly // the archive produced by the pack below. const stale = (await readdir(pkgDir)).filter((entry) => entry.endsWith(".tgz")); for (const entry of stale) { await rm(join(pkgDir, entry), { force: true }); } // Order matters: rename the scope first so publishConfig sees the final // package.json, then apply publishConfig on top. Restore in reverse. const restoreScope = await applyScopeRename(pkgDir, publishable, publishableVersions); const restorePublishConfig = await applyPublishConfig(pkgDir); try { await $`bun pm pack`.cwd(pkgDir); } finally { await restorePublishConfig(); await restoreScope(); } const produced = (await readdir(pkgDir)).filter((entry) => entry.endsWith(".tgz")); if (produced.length !== 1) { throw new Error( `Expected exactly 1 .tgz in ${pkgDir}, found ${produced.length}: ${produced.join(", ")}`, ); } const tarball = produced[0]!; if (dryRun) { return; } const args = ["publish", tarball, "--access", "public", "--tag", channel]; if (process.env.GITHUB_ACTIONS === "true") { args.push("--provenance"); } await $`npm ${args}`.cwd(pkgDir); }; const main = async () => { const { dryRun } = parseArgs(process.argv.slice(2)); // Each package's own version determines its dist-tag (pre-release versions // with `-` publish to `beta`, everything else to `latest`). Packages are // only skipped when their current version is already on npm. console.log(`Publishing ${PUBLISHED_SCOPE} packages${dryRun ? " [dry-run]" : ""}`); await $`bun run build:packages`.cwd(repoRoot); // Snapshot the internal package names and versions up front so // applyScopeRename knows (a) which `@executor/*` references to rewrite // (vs. unpublished peer deps like `@executor/api` that should be left // alone) and (b) how to resolve `workspace:*` dep specifiers after the // rename — `bun pm pack` can no longer resolve them itself once the name // doesn't match any workspace. const publishable = new Set(); const publishableVersions = new Map(); for (const relDir of PUBLIC_PACKAGE_DIRS) { const pkg = await readPackageMeta(join(repoRoot, relDir)); const publishedName = pkg.name.replace(`${INTERNAL_SCOPE}/`, `${PUBLISHED_SCOPE}/`); publishable.add(publishedName); publishableVersions.set(publishedName, pkg.version); } for (const relDir of PUBLIC_PACKAGE_DIRS) { await publishPackage(join(repoRoot, relDir), dryRun, publishable, publishableVersions); } }; await main();