diff --git a/apps/cli/scripts/buildSharedDeps.mjs b/apps/cli/scripts/buildSharedDeps.mjs index 97f5c95a5..fe17b0b67 100644 --- a/apps/cli/scripts/buildSharedDeps.mjs +++ b/apps/cli/scripts/buildSharedDeps.mjs @@ -1,8 +1,10 @@ import { execFileSync } from 'node:child_process'; -import { cpSync, existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { bundledWorkspaceDirNames } from './bundledWorkspacePackages.mjs'; + const __dirname = dirname(fileURLToPath(import.meta.url)); function findRepoRoot(startDir) { @@ -51,6 +53,7 @@ export function resolveTscBin({ exists } = {}) { } const tscBin = resolveTscBin(); +export const bundledWorkspaceDirs = bundledWorkspaceDirNames; export function runTsc(tsconfigPath, opts) { const exec = opts?.execFileSync ?? execFileSync; @@ -78,7 +81,8 @@ export function syncBundledWorkspaceDist(opts = {}) { const cp = opts.cpSync ?? cpSync; const readFile = opts.readFileSync ?? readFileSync; const writeFile = opts.writeFileSync ?? writeFileSync; - const packages = Array.isArray(opts.packages) && opts.packages.length > 0 ? opts.packages : ['agents', 'cli-common', 'protocol']; + const packages = + Array.isArray(opts.packages) && opts.packages.length > 0 ? opts.packages : bundledWorkspaceDirs; for (const pkg of packages) { const srcDist = resolve(repoRoot, 'packages', pkg, 'dist'); @@ -134,13 +138,12 @@ function sanitizeBundledWorkspacePackageJson(raw) { } export function main() { - runTsc(resolve(repoRoot, 'packages', 'agents', 'tsconfig.json')); - runTsc(resolve(repoRoot, 'packages', 'cli-common', 'tsconfig.json')); - runTsc(resolve(repoRoot, 'packages', 'protocol', 'tsconfig.json')); - - const protocolDist = resolve(repoRoot, 'packages', 'protocol', 'dist', 'index.js'); - if (!existsSync(protocolDist)) { - throw new Error(`Expected @happier-dev/protocol build output missing: ${protocolDist}`); + for (const pkg of bundledWorkspaceDirs) { + runTsc(resolve(repoRoot, 'packages', pkg, 'tsconfig.json')); + const distDir = resolve(repoRoot, 'packages', pkg, 'dist'); + if (!existsSync(distDir)) { + throw new Error(`Expected @happier-dev/${pkg} build output missing: ${distDir}`); + } } // If the CLI currently has bundled workspace deps under apps/cli/node_modules, diff --git a/apps/cli/scripts/bundleWorkspaceDeps.mjs b/apps/cli/scripts/bundleWorkspaceDeps.mjs index 2f78a673f..99d756043 100644 --- a/apps/cli/scripts/bundleWorkspaceDeps.mjs +++ b/apps/cli/scripts/bundleWorkspaceDeps.mjs @@ -7,6 +7,7 @@ import { findRepoRoot, vendorBundledPackageRuntimeDependencies, } from '../../../packages/cli-common/dist/workspaces/index.js'; +import { createBundledWorkspaceBundles } from './bundledWorkspacePackages.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -14,28 +15,7 @@ export function bundleWorkspaceDeps(opts = {}) { const repoRoot = opts.repoRoot ?? findRepoRoot(__dirname); const happyCliDir = opts.happyCliDir ?? resolve(repoRoot, 'apps', 'cli'); - const bundles = [ - { - packageName: '@happier-dev/agents', - srcDir: resolve(repoRoot, 'packages', 'agents'), - destDir: resolve(happyCliDir, 'node_modules', '@happier-dev', 'agents'), - }, - { - packageName: '@happier-dev/cli-common', - srcDir: resolve(repoRoot, 'packages', 'cli-common'), - destDir: resolve(happyCliDir, 'node_modules', '@happier-dev', 'cli-common'), - }, - { - packageName: '@happier-dev/protocol', - srcDir: resolve(repoRoot, 'packages', 'protocol'), - destDir: resolve(happyCliDir, 'node_modules', '@happier-dev', 'protocol'), - }, - { - packageName: '@happier-dev/release-runtime', - srcDir: resolve(repoRoot, 'packages', 'release-runtime'), - destDir: resolve(happyCliDir, 'node_modules', '@happier-dev', 'release-runtime'), - }, - ]; + const bundles = createBundledWorkspaceBundles({ repoRoot, happyCliDir }); bundleWorkspacePackages({ bundles }); for (const b of bundles) { diff --git a/apps/cli/scripts/bundledWorkspacePackages.mjs b/apps/cli/scripts/bundledWorkspacePackages.mjs new file mode 100644 index 000000000..ee411aed9 --- /dev/null +++ b/apps/cli/scripts/bundledWorkspacePackages.mjs @@ -0,0 +1,59 @@ +/** + * Canonical list of workspace packages that are bundled into the CLI npm package. + * + * This module is the single source of truth consumed by both the build step + * (`buildSharedDeps.mjs`) and the bundle step (`bundleWorkspaceDeps.mjs`). + * Adding or removing a package here automatically keeps both steps in sync. + */ + +import { resolve } from 'node:path'; + +/** + * Frozen array of all workspace packages that must be compiled and bundled + * during `yarn prepack`. Each entry is itself frozen to prevent accidental + * mutation that could desync the derived exports. + * + * @type {ReadonlyArray<{dirName: string, packageName: string}>} + */ +export const bundledWorkspacePackages = Object.freeze([ + Object.freeze({ dirName: 'agents', packageName: '@happier-dev/agents' }), + Object.freeze({ dirName: 'cli-common', packageName: '@happier-dev/cli-common' }), + Object.freeze({ dirName: 'protocol', packageName: '@happier-dev/protocol' }), + Object.freeze({ dirName: 'release-runtime', packageName: '@happier-dev/release-runtime' }), +]); + +/** + * Frozen array of directory names derived from {@link bundledWorkspacePackages}. + * Matches the subdirectory names under `packages/` in the monorepo root. + * + * @type {ReadonlyArray} + */ +export const bundledWorkspaceDirNames = Object.freeze( + bundledWorkspacePackages.map(({ dirName }) => dirName), +); + +/** + * Frozen array of npm package names derived from {@link bundledWorkspacePackages}. + * Must stay in sync with the `bundledDependencies` field in `apps/cli/package.json`. + * + * @type {ReadonlyArray} + */ +export const bundledWorkspacePackageNames = Object.freeze( + bundledWorkspacePackages.map(({ packageName }) => packageName), +); + +/** + * Creates the bundle descriptor objects used by `bundleWorkspaceDeps.mjs`. + * Each descriptor maps a workspace package to its source directory (in the + * monorepo) and the destination directory under `apps/cli/node_modules`. + * + * @param {{ repoRoot: string, happyCliDir: string }} opts + * @returns {Array<{ packageName: string, srcDir: string, destDir: string }>} + */ +export function createBundledWorkspaceBundles({ repoRoot, happyCliDir }) { + return bundledWorkspacePackages.map(({ dirName, packageName }) => ({ + packageName, + srcDir: resolve(repoRoot, 'packages', dirName), + destDir: resolve(happyCliDir, 'node_modules', '@happier-dev', dirName), + })); +} diff --git a/apps/cli/scripts/prepack-script.test.mjs b/apps/cli/scripts/prepack-script.test.mjs index 8934c76ed..49c01a973 100644 --- a/apps/cli/scripts/prepack-script.test.mjs +++ b/apps/cli/scripts/prepack-script.test.mjs @@ -1,12 +1,24 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { bundledWorkspaceDirs } from './buildSharedDeps.mjs'; +import { + bundledWorkspaceDirNames, + bundledWorkspacePackageNames, + createBundledWorkspaceBundles, +} from './bundledWorkspacePackages.mjs'; test('apps/cli prepack builds dist for npm pack', () => { const pkgPath = new URL('../package.json', import.meta.url); const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); const prepack = String(pkg?.scripts?.prepack ?? ''); assert.ok(prepack.includes('build'), `expected scripts.prepack to include a build step, got: ${prepack || '(missing)'}`); + assert.ok( + prepack.includes('bundleWorkspaceDeps.mjs'), + `expected scripts.prepack to bundle workspace deps, got: ${prepack || '(missing)'}`, + ); }); test('apps/cli npm files list ships archives (not unpacked tools)', () => { @@ -23,3 +35,33 @@ test('apps/cli npm files list ships archives (not unpacked tools)', () => { assert.ok(!files.includes('tools'), 'expected not to ship entire tools/ tree (would include unpacked binaries)'); assert.ok(!files.includes('tools/unpacked'), 'expected tools/unpacked to be excluded'); }); + +test('apps/cli bundled workspace package definitions stay in sync', () => { + const pkgPath = new URL('../package.json', import.meta.url); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + const bundledDependencies = Array.isArray(pkg?.bundledDependencies) + ? pkg.bundledDependencies.map((value) => String(value)).sort() + : []; + + assert.deepEqual(bundledDependencies, [...bundledWorkspacePackageNames].sort()); + assert.deepEqual([...bundledWorkspaceDirs], [...bundledWorkspaceDirNames]); + + const repoRoot = resolve('/tmp', 'happier-repo'); + const happyCliDir = resolve(repoRoot, 'apps', 'cli'); + const bundles = createBundledWorkspaceBundles({ repoRoot, happyCliDir }); + + assert.deepEqual( + bundles.map(({ packageName }) => packageName).sort(), + [...bundledWorkspacePackageNames].sort(), + ); + + const releaseRuntimeBundle = bundles.find( + ({ packageName }) => packageName === '@happier-dev/release-runtime', + ); + assert.ok(releaseRuntimeBundle, 'expected release-runtime to be bundled for npm publish'); + assert.equal(releaseRuntimeBundle.srcDir, resolve(repoRoot, 'packages', 'release-runtime')); + assert.equal( + releaseRuntimeBundle.destDir, + resolve(happyCliDir, 'node_modules', '@happier-dev', 'release-runtime'), + ); +});