Skip to content

Commit cd84d1a

Browse files
committed
feat(scripts): vendor hoisted deps for apps/cli
Vendor hoisted runtime/crypto dependencies into apps/cli/node_modules so npm pack includes them (Yarn hoists to repo root by default). Add vendorBundledDependencyFromRootNodeModules and call it for base64-js, @noble/hashes and tweetnacl in bundleWorkspaceDeps.mjs. Also: - Add/adjust tests: update bundleWorkspaceDeps tests and add publishBundledDependencies.test to assert bundledDependencies and dependencies are present; add a test for Windows tsc invocation. - Make runTsc use cmd.exe for .cmd/.bat tsc on win32 to ensure TypeScript builds on Windows. - Update apps/cli/package.json to include the runtime deps in bundledDependencies and dependencies (move @noble/hashes out of devDependencies); add base64-js and tweetnacl entries. - Swap workspace build order in top-level package.json build:packages script. These changes ensure packaged CLI releases include necessary runtime crypto libs and improve cross-platform build reliability.
1 parent 8c08fa0 commit cd84d1a

File tree

7 files changed

+168
-6
lines changed

7 files changed

+168
-6
lines changed

apps/cli/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,10 @@
5959
"bundledDependencies": [
6060
"@happier-dev/agents",
6161
"@happier-dev/cli-common",
62-
"@happier-dev/protocol"
62+
"@happier-dev/protocol",
63+
"base64-js",
64+
"@noble/hashes",
65+
"tweetnacl"
6366
],
6467
"scripts": {
6568
"typecheck": "tsc --noEmit",
@@ -110,6 +113,7 @@
110113
"@happier-dev/cli-common": "0.0.0",
111114
"@happier-dev/protocol": "0.0.0",
112115
"@modelcontextprotocol/sdk": "^1.25.3",
116+
"@noble/hashes": "^1.8.0",
113117
"@stablelib/base64": "^2.0.1",
114118
"@stablelib/hex": "^2.0.1",
115119
"@types/cross-spawn": "^6.0.6",
@@ -120,6 +124,7 @@
120124
"@types/tmp": "^0.2.6",
121125
"ai": "^5.0.107",
122126
"axios": "^1.13.2",
127+
"base64-js": "^1.5.1",
123128
"chalk": "^5.6.2",
124129
"cross-spawn": "^7.0.6",
125130
"expo-server-sdk": "^3.15.0",
@@ -144,7 +149,6 @@
144149
},
145150
"devDependencies": {
146151
"@eslint/compat": "^1",
147-
"@noble/hashes": "^1.8.0",
148152
"@types/node": ">=20",
149153
"cross-env": "^10.1.0",
150154
"dotenv": "^16.6.1",

apps/cli/scripts/__tests__/buildSharedDeps.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,24 @@ describe('buildSharedDeps', () => {
1414
);
1515
});
1616

17+
it('invokes tsc.cmd via cmd.exe on Windows', () => {
18+
const execFileSync = vi.fn(() => undefined);
19+
20+
runTsc('C:\\repo\\packages\\protocol\\tsconfig.json', {
21+
execFileSync,
22+
tscBin: 'C:\\repo\\node_modules\\.bin\\tsc.cmd',
23+
platform: 'win32',
24+
});
25+
26+
expect(execFileSync).toHaveBeenCalled();
27+
const [cmd, args, opts] = execFileSync.mock.calls[0] ?? [];
28+
expect(cmd).toBe('cmd.exe');
29+
expect(args.slice(0, 3)).toEqual(['/d', '/s', '/c']);
30+
expect(String(args[3])).toContain('tsc.cmd');
31+
expect(String(args[3])).toContain('-p');
32+
expect(opts).toHaveProperty('stdio', 'inherit');
33+
});
34+
1735
it('prefers the workspace root tsc binary when present', () => {
1836
const bin = resolveTscBin({
1937
exists: (candidate: string) =>

apps/cli/scripts/__tests__/bundleWorkspaceDeps.test.ts

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { describe, expect, it } from 'vitest';
2-
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
2+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
33
import { tmpdir } from 'node:os';
44
import { join, resolve } from 'node:path';
55

6-
import { bundleWorkspaceDeps } from '../bundleWorkspaceDeps.mjs';
6+
import { bundleWorkspaceDeps, vendorBundledDependencyFromRootNodeModules } from '../bundleWorkspaceDeps.mjs';
77

88
function writeJson(path: string, value: unknown) {
99
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
@@ -15,6 +15,29 @@ describe('bundleWorkspaceDeps', () => {
1515
writeJson(resolve(repoRoot, 'package.json'), { name: 'repo', private: true });
1616
writeFileSync(resolve(repoRoot, 'yarn.lock'), '# lock\n', 'utf8');
1717

18+
// Hoisted runtime deps that must be vendored into apps/cli/node_modules for npm pack.
19+
mkdirSync(resolve(repoRoot, 'node_modules', 'base64-js'), { recursive: true });
20+
writeJson(resolve(repoRoot, 'node_modules', 'base64-js', 'package.json'), {
21+
name: 'base64-js',
22+
version: '1.5.1',
23+
main: 'index.js',
24+
});
25+
writeFileSync(resolve(repoRoot, 'node_modules', 'base64-js', 'index.js'), 'module.exports = {};\n', 'utf8');
26+
mkdirSync(resolve(repoRoot, 'node_modules', '@noble', 'hashes'), { recursive: true });
27+
writeJson(resolve(repoRoot, 'node_modules', '@noble', 'hashes', 'package.json'), {
28+
name: '@noble/hashes',
29+
version: '1.8.0',
30+
main: 'index.js',
31+
});
32+
writeFileSync(resolve(repoRoot, 'node_modules', '@noble', 'hashes', 'index.js'), 'module.exports = {};\n', 'utf8');
33+
mkdirSync(resolve(repoRoot, 'node_modules', 'tweetnacl'), { recursive: true });
34+
writeJson(resolve(repoRoot, 'node_modules', 'tweetnacl', 'package.json'), {
35+
name: 'tweetnacl',
36+
version: '1.0.3',
37+
main: 'nacl-fast.js',
38+
});
39+
writeFileSync(resolve(repoRoot, 'node_modules', 'tweetnacl', 'nacl-fast.js'), 'module.exports = {};', 'utf8');
40+
1841
const agentsDir = resolve(repoRoot, 'packages', 'agents');
1942
const cliCommonDir = resolve(repoRoot, 'packages', 'cli-common');
2043
const protocolDir = resolve(repoRoot, 'packages', 'protocol');
@@ -60,6 +83,8 @@ describe('bundleWorkspaceDeps', () => {
6083

6184
bundleWorkspaceDeps({ repoRoot, happyCliDir });
6285

86+
expect(existsSync(join(happyCliDir, 'node_modules', 'base64-js', 'package.json'))).toBe(true);
87+
expect(existsSync(join(happyCliDir, 'node_modules', '@noble', 'hashes', 'package.json'))).toBe(true);
6388
const bundledAgentsPkgJson = JSON.parse(
6489
readFileSync(resolve(happyCliDir, 'node_modules', '@happier-dev', 'agents', 'package.json'), 'utf8'),
6590
);
@@ -86,6 +111,29 @@ describe('bundleWorkspaceDeps', () => {
86111
writeJson(resolve(repoRoot, 'package.json'), { name: 'repo', private: true });
87112
writeFileSync(resolve(repoRoot, 'yarn.lock'), '# lock\n', 'utf8');
88113

114+
// Hoisted runtime deps that must be vendored into apps/cli/node_modules for npm pack.
115+
mkdirSync(resolve(repoRoot, 'node_modules', 'base64-js'), { recursive: true });
116+
writeJson(resolve(repoRoot, 'node_modules', 'base64-js', 'package.json'), {
117+
name: 'base64-js',
118+
version: '1.5.1',
119+
main: 'index.js',
120+
});
121+
writeFileSync(resolve(repoRoot, 'node_modules', 'base64-js', 'index.js'), 'module.exports = {};\n', 'utf8');
122+
mkdirSync(resolve(repoRoot, 'node_modules', '@noble', 'hashes'), { recursive: true });
123+
writeJson(resolve(repoRoot, 'node_modules', '@noble', 'hashes', 'package.json'), {
124+
name: '@noble/hashes',
125+
version: '1.8.0',
126+
main: 'index.js',
127+
});
128+
writeFileSync(resolve(repoRoot, 'node_modules', '@noble', 'hashes', 'index.js'), 'module.exports = {};\n', 'utf8');
129+
mkdirSync(resolve(repoRoot, 'node_modules', 'tweetnacl'), { recursive: true });
130+
writeJson(resolve(repoRoot, 'node_modules', 'tweetnacl', 'package.json'), {
131+
name: 'tweetnacl',
132+
version: '1.0.3',
133+
main: 'nacl-fast.js',
134+
});
135+
writeFileSync(resolve(repoRoot, 'node_modules', 'tweetnacl', 'nacl-fast.js'), 'module.exports = {};', 'utf8');
136+
89137
const protocolDir = resolve(repoRoot, 'packages', 'protocol');
90138
const happyCliDir = resolve(repoRoot, 'apps', 'cli');
91139

@@ -145,6 +193,9 @@ describe('bundleWorkspaceDeps', () => {
145193

146194
bundleWorkspaceDeps({ repoRoot, happyCliDir });
147195

196+
expect(existsSync(join(happyCliDir, 'node_modules', 'base64-js', 'package.json'))).toBe(true);
197+
expect(existsSync(join(happyCliDir, 'node_modules', '@noble', 'hashes', 'package.json'))).toBe(true);
198+
148199
// dep-a is vendored because protocol declares it.
149200
expect(() =>
150201
readFileSync(
@@ -171,4 +222,28 @@ describe('bundleWorkspaceDeps', () => {
171222
),
172223
).not.toThrow();
173224
});
225+
226+
it('vendors a hoisted dependency into apps/cli/node_modules', () => {
227+
const repoRoot = mkdtempSync(join(tmpdir(), 'happier-bundle-workspaces-'));
228+
const happyCliDir = join(repoRoot, 'apps', 'cli');
229+
230+
try {
231+
mkdirSync(resolve(repoRoot, 'node_modules', 'tweetnacl'), { recursive: true });
232+
writeJson(resolve(repoRoot, 'node_modules', 'tweetnacl', 'package.json'), {
233+
name: 'tweetnacl',
234+
version: '1.0.3',
235+
main: 'nacl-fast.js',
236+
});
237+
writeFileSync(resolve(repoRoot, 'node_modules', 'tweetnacl', 'nacl-fast.js'), 'module.exports = {};', 'utf8');
238+
239+
mkdirSync(resolve(happyCliDir, 'node_modules'), { recursive: true });
240+
241+
vendorBundledDependencyFromRootNodeModules({ repoRoot, happyCliDir, packageName: 'tweetnacl' });
242+
243+
expect(existsSync(join(happyCliDir, 'node_modules', 'tweetnacl', 'package.json'))).toBe(true);
244+
expect(existsSync(join(happyCliDir, 'node_modules', 'tweetnacl', 'nacl-fast.js'))).toBe(true);
245+
} finally {
246+
rmSync(repoRoot, { recursive: true, force: true });
247+
}
248+
});
174249
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { readFileSync } from 'node:fs';
3+
import { dirname, resolve } from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
6+
describe('apps/cli package publish contract', () => {
7+
it('bundles critical crypto/runtime deps for global installs', () => {
8+
const here = dirname(fileURLToPath(import.meta.url));
9+
const packageJsonPath = resolve(here, '..', '..', 'package.json');
10+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
11+
bundledDependencies?: unknown;
12+
dependencies?: Record<string, string> | undefined;
13+
};
14+
15+
const bundled = Array.isArray(packageJson.bundledDependencies)
16+
? packageJson.bundledDependencies.map((v) => String(v))
17+
: [];
18+
19+
for (const name of ['base64-js', '@noble/hashes', 'tweetnacl']) {
20+
expect(packageJson.dependencies?.[name]).toBeTruthy();
21+
expect(bundled).toContain(name);
22+
}
23+
});
24+
});
25+

apps/cli/scripts/buildSharedDeps.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,14 @@ const tscBin = resolveTscBin();
4343
export function runTsc(tsconfigPath, opts) {
4444
const exec = opts?.execFileSync ?? execFileSync;
4545
const tsc = opts?.tscBin ?? tscBin;
46+
const platform = opts?.platform ?? process.platform;
4647
try {
47-
exec(tsc, ['-p', tsconfigPath], { stdio: 'inherit' });
48+
if (platform === 'win32' && (tsc.endsWith('.cmd') || tsc.endsWith('.bat'))) {
49+
const command = `"${tsc}" -p "${tsconfigPath}"`;
50+
exec('cmd.exe', ['/d', '/s', '/c', command], { stdio: 'inherit' });
51+
} else {
52+
exec(tsc, ['-p', tsconfigPath], { stdio: 'inherit' });
53+
}
4854
} catch (error) {
4955
const suffix = tsconfigPath ? ` (${tsconfigPath})` : '';
5056
const message = error instanceof Error ? error.message : String(error);

apps/cli/scripts/bundleWorkspaceDeps.mjs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
12
import { resolve } from 'node:path';
23
import { dirname } from 'node:path';
34
import { fileURLToPath } from 'node:url';
@@ -10,6 +11,33 @@ import {
1011

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

14+
export function vendorBundledDependencyFromRootNodeModules(params) {
15+
const repoRoot = params.repoRoot;
16+
const happyCliDir = params.happyCliDir;
17+
const packageName = params.packageName;
18+
19+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
20+
throw new Error('vendorBundledDependencyFromRootNodeModules requires repoRoot');
21+
}
22+
if (typeof happyCliDir !== 'string' || happyCliDir.length === 0) {
23+
throw new Error('vendorBundledDependencyFromRootNodeModules requires happyCliDir');
24+
}
25+
if (typeof packageName !== 'string' || packageName.length === 0) {
26+
throw new Error('vendorBundledDependencyFromRootNodeModules requires packageName');
27+
}
28+
29+
const srcDir = resolve(repoRoot, 'node_modules', packageName);
30+
const destDir = resolve(happyCliDir, 'node_modules', packageName);
31+
32+
if (!existsSync(srcDir)) {
33+
throw new Error(`Unable to vendor dependency '${packageName}': missing ${srcDir}`);
34+
}
35+
36+
mkdirSync(resolve(happyCliDir, 'node_modules'), { recursive: true });
37+
rmSync(destDir, { recursive: true, force: true });
38+
cpSync(srcDir, destDir, { recursive: true });
39+
}
40+
1341
export function bundleWorkspaceDeps(opts = {}) {
1442
const repoRoot = opts.repoRoot ?? findRepoRoot(__dirname);
1543
const happyCliDir = opts.happyCliDir ?? resolve(repoRoot, 'apps', 'cli');
@@ -39,6 +67,12 @@ export function bundleWorkspaceDeps(opts = {}) {
3967
destPackageDir: b.destDir,
4068
});
4169
}
70+
71+
// `npm pack` only includes `bundledDependencies` if they're present under apps/cli/node_modules.
72+
// Yarn hoists to the repo root by default, so we explicitly vendor these from the root node_modules.
73+
vendorBundledDependencyFromRootNodeModules({ repoRoot, happyCliDir, packageName: 'base64-js' });
74+
vendorBundledDependencyFromRootNodeModules({ repoRoot, happyCliDir, packageName: '@noble/hashes' });
75+
vendorBundledDependencyFromRootNodeModules({ repoRoot, happyCliDir, packageName: 'tweetnacl' });
4276
}
4377

4478
const invokedAsMain = (() => {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "monorepo",
33
"private": true,
44
"scripts": {
5-
"build:packages": "yarn workspace @happier-dev/agents build && yarn workspace @happier-dev/protocol build",
5+
"build:packages": "yarn workspace @happier-dev/protocol build && yarn workspace @happier-dev/agents build",
66
"ci:act": "bash scripts/ci/run-act-tests.sh",
77
"test": "yarn -s test:unit",
88
"test:unit": "yarn workspace @happier-dev/protocol test && yarn workspace @happier-dev/agents test && yarn workspace @happier-dev/app test && yarn workspace @happier-dev/cli test:unit && yarn --cwd apps/server test:unit && yarn --cwd packages/relay-server test && yarn --cwd apps/stack test:unit",

0 commit comments

Comments
 (0)