Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions apps/cli/scripts/buildSharedDeps.mjs
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 2 additions & 22 deletions apps/cli/scripts/bundleWorkspaceDeps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,15 @@ import {
findRepoRoot,
vendorBundledPackageRuntimeDependencies,
} from '../../../packages/cli-common/dist/workspaces/index.js';
import { createBundledWorkspaceBundles } from './bundledWorkspacePackages.mjs';

const __dirname = dirname(fileURLToPath(import.meta.url));

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) {
Expand Down
59 changes: 59 additions & 0 deletions apps/cli/scripts/bundledWorkspacePackages.mjs
Original file line number Diff line number Diff line change
@@ -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<string>}
*/
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<string>}
*/
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),
}));
}
42 changes: 42 additions & 0 deletions apps/cli/scripts/prepack-script.test.mjs
Original file line number Diff line number Diff line change
@@ -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)', () => {
Expand All @@ -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]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tautological assertion — re-export compared to itself

bundledWorkspaceDirs in buildSharedDeps.mjs is defined as:

export const bundledWorkspaceDirs = bundledWorkspaceDirNames;

…a direct re-export of bundledWorkspaceDirNames. So when the test imports both and spreads them, it is comparing the same underlying frozen array to itself — deepEqual([...x], [...x]) — which will always pass regardless of what value x holds.

The intent of the assertion appears to be guarding against a future divergence where buildSharedDeps.mjs might override bundledWorkspaceDirs with a manually maintained subset. That guard is real and useful, but the assertion can only protect against that if the two symbols are independently defined. Consider adding a comment to the assertion explaining its forward-looking intent, or alternatively move the assertion's value closer to what it actually tests — that the main() loop in buildSharedDeps compiles every entry in the canonical list:

// Verify buildSharedDeps re-exports the canonical list without filtering.
// If buildSharedDeps.mjs ever defines bundledWorkspaceDirs independently,
// this assertion catches any divergence from the source of truth.
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'),
);
});