happy-stacks 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +64 -33
  2. package/bin/happys.mjs +44 -1
  3. package/docs/codex-mcp-resume.md +130 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
  6. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
  8. package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
  9. package/docs/happy-development.md +1 -2
  10. package/docs/monorepo-migration.md +286 -0
  11. package/docs/server-flavors.md +19 -3
  12. package/docs/stacks.md +35 -0
  13. package/package.json +1 -1
  14. package/scripts/auth.mjs +21 -3
  15. package/scripts/build.mjs +1 -1
  16. package/scripts/dev.mjs +20 -7
  17. package/scripts/doctor.mjs +0 -4
  18. package/scripts/edison.mjs +2 -2
  19. package/scripts/env.mjs +150 -0
  20. package/scripts/env_cmd.test.mjs +128 -0
  21. package/scripts/init.mjs +5 -2
  22. package/scripts/install.mjs +99 -57
  23. package/scripts/migrate.mjs +3 -12
  24. package/scripts/monorepo.mjs +1096 -0
  25. package/scripts/monorepo_port.test.mjs +1470 -0
  26. package/scripts/review.mjs +715 -24
  27. package/scripts/review_pr.mjs +5 -20
  28. package/scripts/run.mjs +21 -15
  29. package/scripts/setup.mjs +147 -25
  30. package/scripts/setup_pr.mjs +19 -28
  31. package/scripts/stack.mjs +493 -157
  32. package/scripts/stack_archive_cmd.test.mjs +91 -0
  33. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  34. package/scripts/stack_env_cmd.test.mjs +87 -0
  35. package/scripts/stack_happy_cmd.test.mjs +126 -0
  36. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  37. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  38. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  39. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  40. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  41. package/scripts/stack_wt_list.test.mjs +128 -0
  42. package/scripts/tui.mjs +88 -2
  43. package/scripts/utils/cli/cli_registry.mjs +20 -5
  44. package/scripts/utils/cli/cwd_scope.mjs +56 -2
  45. package/scripts/utils/cli/cwd_scope.test.mjs +40 -7
  46. package/scripts/utils/cli/prereqs.mjs +8 -5
  47. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  48. package/scripts/utils/cli/wizard.mjs +17 -9
  49. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  50. package/scripts/utils/dev/daemon.mjs +14 -1
  51. package/scripts/utils/dev/expo_dev.mjs +188 -4
  52. package/scripts/utils/dev/server.mjs +21 -17
  53. package/scripts/utils/edison/git_roots.mjs +29 -0
  54. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  55. package/scripts/utils/env/env.mjs +7 -3
  56. package/scripts/utils/env/env_file.mjs +4 -2
  57. package/scripts/utils/env/env_file.test.mjs +44 -0
  58. package/scripts/utils/git/worktrees.mjs +63 -12
  59. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  60. package/scripts/utils/net/tcp_forward.mjs +162 -0
  61. package/scripts/utils/paths/paths.mjs +118 -3
  62. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  63. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  64. package/scripts/utils/proc/commands.mjs +2 -3
  65. package/scripts/utils/proc/pm.mjs +113 -16
  66. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  67. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  68. package/scripts/utils/proc/proc.mjs +68 -10
  69. package/scripts/utils/proc/proc.test.mjs +77 -0
  70. package/scripts/utils/review/chunks.mjs +55 -0
  71. package/scripts/utils/review/chunks.test.mjs +51 -0
  72. package/scripts/utils/review/findings.mjs +165 -0
  73. package/scripts/utils/review/findings.test.mjs +85 -0
  74. package/scripts/utils/review/head_slice.mjs +153 -0
  75. package/scripts/utils/review/head_slice.test.mjs +91 -0
  76. package/scripts/utils/review/instructions/deep.md +20 -0
  77. package/scripts/utils/review/runners/coderabbit.mjs +56 -14
  78. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  79. package/scripts/utils/review/runners/codex.mjs +32 -22
  80. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  81. package/scripts/utils/review/slices.mjs +140 -0
  82. package/scripts/utils/review/slices.test.mjs +32 -0
  83. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  84. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  85. package/scripts/utils/server/prisma_import.mjs +37 -0
  86. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  87. package/scripts/utils/server/ui_env.mjs +14 -0
  88. package/scripts/utils/server/ui_env.test.mjs +46 -0
  89. package/scripts/utils/server/validate.mjs +53 -16
  90. package/scripts/utils/server/validate.test.mjs +89 -0
  91. package/scripts/utils/stack/editor_workspace.mjs +4 -4
  92. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  93. package/scripts/utils/stack/startup.mjs +113 -13
  94. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  95. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  96. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  97. package/scripts/utils/tailscale/ip.mjs +116 -0
  98. package/scripts/utils/ui/ansi.mjs +39 -0
  99. package/scripts/where.mjs +2 -2
  100. package/scripts/worktrees.mjs +627 -137
  101. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  102. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  103. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  104. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
@@ -50,12 +50,12 @@ export async function requirePnpm() {
50
50
  );
51
51
  }
52
52
 
53
- async function getComponentPm(dir) {
53
+ async function getComponentPm(dir, env = process.env) {
54
54
  const yarnLock = join(dir, 'yarn.lock');
55
55
  if (await pathExists(yarnLock)) {
56
56
  // IMPORTANT: when happy-stacks itself is pinned to pnpm via Corepack, running `yarn`
57
57
  // from the happy-stacks cwd can be blocked. Always probe yarn with cwd=componentDir.
58
- if (!(await commandExists('yarn', { cwd: dir }))) {
58
+ if (!(await commandExists('yarn', { cwd: dir, env }))) {
59
59
  throw new Error(`[local] yarn is required for component at ${dir} (yarn.lock present). Install it via Corepack: \`corepack enable\``);
60
60
  }
61
61
  return { name: 'yarn', cmd: 'yarn' };
@@ -66,6 +66,23 @@ async function getComponentPm(dir) {
66
66
  return { name: 'pnpm', cmd: 'pnpm' };
67
67
  }
68
68
 
69
+ const _yarnReadyKeys = new Set();
70
+
71
+ async function ensureYarnReady({ dir, env, quiet = false }) {
72
+ const e = env && typeof env === 'object' ? env : process.env;
73
+ // In stack mode we isolate HOME/cache; key by effective HOME+XDG cache so we only do this once.
74
+ const key = `${resolve(dir)}|${String(e.HOME ?? '')}|${String(e.XDG_CACHE_HOME ?? '')}`;
75
+ if (_yarnReadyKeys.has(key)) return;
76
+
77
+ // If stdin isn't a TTY (e.g. `happys tui ...` uses stdio:ignore for child stdin),
78
+ // Corepack prompts can deadlock. Provide a single "yes" to unblock initial downloads.
79
+ const isTui = (e.HAPPY_STACKS_TUI ?? e.HAPPY_LOCAL_TUI ?? '').toString().trim() === '1';
80
+ const autoYes = isTui || !process.stdin.isTTY;
81
+ const stdio = quiet ? 'ignore' : 'inherit';
82
+ await run('yarn', ['--version'], { cwd: dir, env: e, stdio, ...(autoYes ? { input: 'y\n' } : {}) });
83
+ _yarnReadyKeys.add(key);
84
+ }
85
+
69
86
  export async function requireDir(label, dir) {
70
87
  if (await pathExists(dir)) {
71
88
  return;
@@ -76,7 +93,71 @@ export async function requireDir(label, dir) {
76
93
  );
77
94
  }
78
95
 
79
- export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
96
+ function resolveStackCacheBaseDirFromEnv(env) {
97
+ const envFile = (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim();
98
+ if (!envFile) return null;
99
+ try {
100
+ return join(dirname(envFile), 'cache');
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ async function applyStackCacheEnv(baseEnv) {
107
+ const env = { ...(baseEnv && typeof baseEnv === 'object' ? baseEnv : process.env) };
108
+ const envFile = (env.HAPPY_STACKS_ENV_FILE ?? env.HAPPY_LOCAL_ENV_FILE ?? '').toString().trim();
109
+ const stackCacheBase = resolveStackCacheBaseDirFromEnv(env);
110
+ if (!stackCacheBase) return env;
111
+
112
+ // Prisma engines currently default to ~/.cache/prisma (via os.homedir()).
113
+ // In stack mode, isolate HOME for package-manager driven commands so Prisma/Yarn/NPM don't
114
+ // depend on global home caches (and so sandboxed runs can succeed).
115
+ const isolateHomeRaw = (env.HAPPY_STACKS_PM_ISOLATE_HOME ?? env.HAPPY_LOCAL_PM_ISOLATE_HOME ?? '').toString().trim();
116
+ const isolateHome = isolateHomeRaw ? isolateHomeRaw !== '0' : true;
117
+ if (isolateHome && envFile) {
118
+ const stackHome = join(dirname(envFile), 'home');
119
+ env.HOME = stackHome;
120
+ env.USERPROFILE = stackHome;
121
+ try {
122
+ await mkdir(stackHome, { recursive: true });
123
+ } catch {
124
+ // best-effort
125
+ }
126
+ }
127
+
128
+ if (!(env.XDG_CACHE_HOME ?? '').toString().trim()) {
129
+ env.XDG_CACHE_HOME = join(stackCacheBase, 'xdg');
130
+ }
131
+ if (!(env.YARN_CACHE_FOLDER ?? '').toString().trim()) {
132
+ env.YARN_CACHE_FOLDER = join(stackCacheBase, 'yarn');
133
+ }
134
+ if (!(env.npm_config_cache ?? '').toString().trim()) {
135
+ env.npm_config_cache = join(stackCacheBase, 'npm');
136
+ }
137
+ // Corepack caches downloaded package managers (like Yarn) under COREPACK_HOME.
138
+ // In stack mode we want this to be stable and writable so first-run downloads don't prompt/hang in TUI.
139
+ if (!(env.COREPACK_HOME ?? '').toString().trim()) {
140
+ env.COREPACK_HOME = join(stackCacheBase, 'corepack');
141
+ }
142
+ // Avoid Corepack mutating package.json by auto-adding a packageManager field.
143
+ // (This is safe and reduces noise when Corepack is used implicitly.)
144
+ if (!(env.COREPACK_ENABLE_AUTO_PIN ?? '').toString().trim()) {
145
+ env.COREPACK_ENABLE_AUTO_PIN = '0';
146
+ }
147
+
148
+ try {
149
+ await mkdir(env.XDG_CACHE_HOME, { recursive: true });
150
+ await mkdir(env.YARN_CACHE_FOLDER, { recursive: true });
151
+ await mkdir(env.npm_config_cache, { recursive: true });
152
+ await mkdir(env.COREPACK_HOME, { recursive: true });
153
+ } catch {
154
+ // best-effort
155
+ }
156
+
157
+ return env;
158
+ }
159
+
160
+ export async function ensureDepsInstalled(dir, label, { quiet = false, env: envIn = process.env } = {}) {
80
161
  const pkgJson = join(dir, 'package.json');
81
162
  if (!(await pathExists(pkgJson))) {
82
163
  return;
@@ -84,8 +165,12 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
84
165
 
85
166
  const nodeModules = join(dir, 'node_modules');
86
167
  const pnpmModulesMeta = join(dir, 'node_modules', '.modules.yaml');
87
- const pm = await getComponentPm(dir);
88
168
  const stdio = quiet ? 'ignore' : 'inherit';
169
+ const env = await applyStackCacheEnv(envIn);
170
+ const pm = await getComponentPm(dir, env);
171
+ if (pm.name === 'yarn') {
172
+ await ensureYarnReady({ dir, env, quiet });
173
+ }
89
174
 
90
175
  if (await pathExists(nodeModules)) {
91
176
  const yarnLock = join(dir, 'yarn.lock');
@@ -100,7 +185,7 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
100
185
  console.log(`[local] converting ${label} dependencies back to yarn (reinstalling node_modules)...`);
101
186
  }
102
187
  await rm(nodeModules, { recursive: true, force: true });
103
- await run(pm.cmd, ['install'], { cwd: dir, stdio });
188
+ await run(pm.cmd, ['install'], { cwd: dir, stdio, env });
104
189
  }
105
190
 
106
191
  // If dependencies changed since the last install, re-run install even if node_modules exists.
@@ -145,7 +230,7 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
145
230
  // eslint-disable-next-line no-console
146
231
  console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json/patches changed)...`);
147
232
  }
148
- await run(pm.cmd, ['install'], { cwd: dir, stdio });
233
+ await run(pm.cmd, ['install'], { cwd: dir, stdio, env });
149
234
  }
150
235
  }
151
236
 
@@ -157,7 +242,7 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
157
242
  // eslint-disable-next-line no-console
158
243
  console.log(`[local] refreshing ${label} dependencies (pnpm-lock changed)...`);
159
244
  }
160
- await run(pm.cmd, ['install'], { cwd: dir, stdio });
245
+ await run(pm.cmd, ['install'], { cwd: dir, stdio, env });
161
246
  }
162
247
  }
163
248
 
@@ -168,7 +253,7 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
168
253
  // eslint-disable-next-line no-console
169
254
  console.log(`[local] installing ${label} dependencies (first run)...`);
170
255
  }
171
- await run(pm.cmd, ['install'], { cwd: dir, stdio });
256
+ await run(pm.cmd, ['install'], { cwd: dir, stdio, env });
172
257
  }
173
258
 
174
259
  export async function ensureCliBuilt(cliDir, { buildCli }) {
@@ -329,11 +414,15 @@ export async function pmExecBin(dirOrOpts, binArg, argsArg, optsArg) {
329
414
  const bin = usesObjectStyle ? dirOrOpts.bin : binArg;
330
415
  const args = usesObjectStyle ? (dirOrOpts.args ?? []) : (argsArg ?? []);
331
416
 
332
- const env = usesObjectStyle ? (dirOrOpts.env ?? process.env) : (optsArg?.env ?? process.env);
417
+ const envIn = usesObjectStyle ? (dirOrOpts.env ?? process.env) : (optsArg?.env ?? process.env);
418
+ const env = await applyStackCacheEnv(envIn);
333
419
  const quiet = usesObjectStyle ? Boolean(dirOrOpts.quiet) : Boolean(optsArg?.quiet);
334
420
  const stdio = quiet ? 'ignore' : 'inherit';
335
421
 
336
- const pm = await getComponentPm(dir);
422
+ const pm = await getComponentPm(dir, env);
423
+ if (pm.name === 'yarn') {
424
+ await ensureYarnReady({ dir, env, quiet });
425
+ }
337
426
  if (pm.name === 'yarn') {
338
427
  await run(pm.cmd, ['run', bin, ...args], { cwd: dir, env, stdio });
339
428
  return;
@@ -350,11 +439,15 @@ export async function pmSpawnBin(dir, label, bin, args, { env = process.env } =
350
439
  const componentEnv = usesObjectStyle ? (dir.env ?? process.env) : (env ?? process.env);
351
440
  const options = usesObjectStyle ? (dir.options ?? {}) : {};
352
441
 
353
- const pm = await getComponentPm(componentDir);
442
+ const effectiveEnv = await applyStackCacheEnv(componentEnv);
443
+ const pm = await getComponentPm(componentDir, effectiveEnv);
354
444
  if (pm.name === 'yarn') {
355
- return spawnProc(componentLabel, pm.cmd, ['run', componentBin, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
445
+ await ensureYarnReady({ dir: componentDir, env: effectiveEnv, quiet: false });
356
446
  }
357
- return spawnProc(componentLabel, pm.cmd, ['exec', componentBin, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
447
+ if (pm.name === 'yarn') {
448
+ return spawnProc(componentLabel, pm.cmd, ['run', componentBin, ...componentArgs], effectiveEnv, { cwd: componentDir, ...options });
449
+ }
450
+ return spawnProc(componentLabel, pm.cmd, ['exec', componentBin, ...componentArgs], effectiveEnv, { cwd: componentDir, ...options });
358
451
  }
359
452
 
360
453
  export async function pmSpawnScript(dir, label, script, args, { env = process.env } = {}) {
@@ -366,9 +459,13 @@ export async function pmSpawnScript(dir, label, script, args, { env = process.en
366
459
  const componentEnv = usesObjectStyle ? (dir.env ?? process.env) : (env ?? process.env);
367
460
  const options = usesObjectStyle ? (dir.options ?? {}) : {};
368
461
 
369
- const pm = await getComponentPm(componentDir);
462
+ const effectiveEnv = await applyStackCacheEnv(componentEnv);
463
+ const pm = await getComponentPm(componentDir, effectiveEnv);
464
+ if (pm.name === 'yarn') {
465
+ await ensureYarnReady({ dir: componentDir, env: effectiveEnv, quiet: false });
466
+ }
370
467
  if (pm.name === 'yarn') {
371
- return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
468
+ return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], effectiveEnv, { cwd: componentDir, ...options });
372
469
  }
373
- return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], componentEnv, { cwd: componentDir, ...options });
470
+ return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], effectiveEnv, { cwd: componentDir, ...options });
374
471
  }
@@ -0,0 +1,76 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { pmSpawnBin, pmSpawnScript } from './pm.mjs';
8
+
9
+ async function writeJson(path, obj) {
10
+ await writeFile(path, JSON.stringify(obj, null, 2) + '\n', 'utf-8');
11
+ }
12
+
13
+ async function waitExit(child) {
14
+ return await new Promise((resolve) => {
15
+ child.on('exit', (code, signal) => resolve({ code, signal }));
16
+ });
17
+ }
18
+
19
+ async function writeStubYarn({ binDir }) {
20
+ await mkdir(binDir, { recursive: true });
21
+ const yarnPath = join(binDir, 'yarn');
22
+ await writeFile(
23
+ yarnPath,
24
+ [
25
+ '#!/usr/bin/env node',
26
+ 'const args = process.argv.slice(2);',
27
+ // ensureYarnReady calls: yarn --version
28
+ "if (args.includes('--version')) { console.log('1.22.22'); process.exit(0); }",
29
+ // pmSpawn* calls: yarn run <script/bin> ...
30
+ 'if (args[0] === "run") process.exit(0);',
31
+ 'process.exit(0);',
32
+ ].join('\n') + '\n',
33
+ 'utf-8'
34
+ );
35
+ await chmod(yarnPath, 0o755);
36
+ }
37
+
38
+ test('pmSpawnScript does not reference effectiveEnv before initialization', async (t) => {
39
+ const root = await mkdtemp(join(tmpdir(), 'hs-pm-spawn-script-'));
40
+ t.after(async () => {
41
+ await rm(root, { recursive: true, force: true });
42
+ });
43
+
44
+ const componentDir = join(root, 'component');
45
+ await mkdir(componentDir, { recursive: true });
46
+ await writeJson(join(componentDir, 'package.json'), { name: 'component', version: '0.0.0' });
47
+ await writeFile(join(componentDir, 'yarn.lock'), '# yarn\n', 'utf-8');
48
+
49
+ const binDir = join(root, 'bin');
50
+ await writeStubYarn({ binDir });
51
+
52
+ const env = { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` };
53
+ const child = await pmSpawnScript(componentDir, 'spawn-test', 'noop', [], { env });
54
+ const res = await waitExit(child);
55
+ assert.equal(res.code, 0);
56
+ });
57
+
58
+ test('pmSpawnBin does not reference effectiveEnv before initialization', async (t) => {
59
+ const root = await mkdtemp(join(tmpdir(), 'hs-pm-spawn-bin-'));
60
+ t.after(async () => {
61
+ await rm(root, { recursive: true, force: true });
62
+ });
63
+
64
+ const componentDir = join(root, 'component');
65
+ await mkdir(componentDir, { recursive: true });
66
+ await writeJson(join(componentDir, 'package.json'), { name: 'component', version: '0.0.0' });
67
+ await writeFile(join(componentDir, 'yarn.lock'), '# yarn\n', 'utf-8');
68
+
69
+ const binDir = join(root, 'bin');
70
+ await writeStubYarn({ binDir });
71
+
72
+ const env = { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` };
73
+ const child = await pmSpawnBin(componentDir, 'spawn-test', 'prisma', ['generate'], { env });
74
+ const res = await waitExit(child);
75
+ assert.equal(res.code, 0);
76
+ });
@@ -0,0 +1,142 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ import { ensureDepsInstalled, pmExecBin } from './pm.mjs';
8
+
9
+ async function writeYarnEnvDumpStub({ binDir, outputPath }) {
10
+ await mkdir(binDir, { recursive: true });
11
+ const yarnPath = join(binDir, 'yarn');
12
+ await writeFile(
13
+ yarnPath,
14
+ [
15
+ '#!/usr/bin/env node',
16
+ "const { writeFileSync } = require('node:fs');",
17
+ "const out = {",
18
+ ' XDG_CACHE_HOME: process.env.XDG_CACHE_HOME ?? null,',
19
+ ' YARN_CACHE_FOLDER: process.env.YARN_CACHE_FOLDER ?? null,',
20
+ ' npm_config_cache: process.env.npm_config_cache ?? null,',
21
+ '};',
22
+ "writeFileSync(process.env.OUTPUT_PATH, JSON.stringify(out, null, 2) + '\\n');",
23
+ 'process.exit(0);',
24
+ ].join('\n') + '\n',
25
+ 'utf-8'
26
+ );
27
+ await chmod(yarnPath, 0o755);
28
+ await writeFile(outputPath, '', 'utf-8');
29
+ }
30
+
31
+ function expectedCacheEnv({ envPath }) {
32
+ const base = join(dirname(envPath), 'cache');
33
+ return {
34
+ xdg: join(base, 'xdg'),
35
+ yarn: join(base, 'yarn'),
36
+ npm: join(base, 'npm'),
37
+ };
38
+ }
39
+
40
+ async function withEnv(vars, fn) {
41
+ const old = {};
42
+ for (const k of Object.keys(vars)) old[k] = process.env[k];
43
+ try {
44
+ for (const [k, v] of Object.entries(vars)) {
45
+ if (v == null) delete process.env[k];
46
+ else process.env[k] = String(v);
47
+ }
48
+ return await fn();
49
+ } finally {
50
+ for (const [k, v] of Object.entries(old)) {
51
+ if (v == null) delete process.env[k];
52
+ else process.env[k] = v;
53
+ }
54
+ }
55
+ }
56
+
57
+ test('ensureDepsInstalled sets stack-scoped cache env vars for yarn installs', async (t) => {
58
+ const root = await mkdtemp(join(tmpdir(), 'hs-pm-stack-cache-install-'));
59
+ t.after(async () => {
60
+ await rm(root, { recursive: true, force: true });
61
+ });
62
+
63
+ const stackDir = join(root, 'stacks', 'exp1');
64
+ const envPath = join(stackDir, 'env');
65
+ await mkdir(dirname(envPath), { recursive: true });
66
+ await writeFile(envPath, 'HAPPY_STACKS_STACK=exp1\n', 'utf-8');
67
+
68
+ const componentDir = join(root, 'component');
69
+ await mkdir(componentDir, { recursive: true });
70
+ await writeFile(join(componentDir, 'package.json'), '{}\n', 'utf-8');
71
+ await writeFile(join(componentDir, 'yarn.lock'), '# yarn\n', 'utf-8');
72
+
73
+ const binDir = join(root, 'bin');
74
+ const outputPath = join(root, 'env.json');
75
+ await writeYarnEnvDumpStub({ binDir, outputPath });
76
+
77
+ const exp = expectedCacheEnv({ envPath });
78
+ const oldPath = process.env.PATH;
79
+
80
+ await withEnv(
81
+ {
82
+ PATH: `${binDir}:${oldPath ?? ''}`,
83
+ OUTPUT_PATH: outputPath,
84
+ HAPPY_STACKS_ENV_FILE: envPath,
85
+ HAPPY_LOCAL_ENV_FILE: envPath,
86
+ XDG_CACHE_HOME: null,
87
+ YARN_CACHE_FOLDER: null,
88
+ npm_config_cache: null,
89
+ },
90
+ async () => {
91
+ await ensureDepsInstalled(componentDir, 'test-component', { quiet: true });
92
+ const parsed = JSON.parse(await readFile(outputPath, 'utf-8'));
93
+ assert.equal(parsed.XDG_CACHE_HOME, exp.xdg);
94
+ assert.equal(parsed.YARN_CACHE_FOLDER, exp.yarn);
95
+ assert.equal(parsed.npm_config_cache, exp.npm);
96
+ }
97
+ );
98
+ });
99
+
100
+ test('pmExecBin sets stack-scoped cache env vars for yarn runs', async (t) => {
101
+ const root = await mkdtemp(join(tmpdir(), 'hs-pm-stack-cache-exec-'));
102
+ t.after(async () => {
103
+ await rm(root, { recursive: true, force: true });
104
+ });
105
+
106
+ const stackDir = join(root, 'stacks', 'exp1');
107
+ const envPath = join(stackDir, 'env');
108
+ await mkdir(dirname(envPath), { recursive: true });
109
+ await writeFile(envPath, 'HAPPY_STACKS_STACK=exp1\n', 'utf-8');
110
+
111
+ const componentDir = join(root, 'component');
112
+ await mkdir(componentDir, { recursive: true });
113
+ await writeFile(join(componentDir, 'package.json'), '{}\n', 'utf-8');
114
+ await writeFile(join(componentDir, 'yarn.lock'), '# yarn\n', 'utf-8');
115
+
116
+ const binDir = join(root, 'bin');
117
+ const outputPath = join(root, 'env.json');
118
+ await writeYarnEnvDumpStub({ binDir, outputPath });
119
+
120
+ const exp = expectedCacheEnv({ envPath });
121
+ const oldPath = process.env.PATH;
122
+
123
+ await withEnv(
124
+ {
125
+ PATH: `${binDir}:${oldPath ?? ''}`,
126
+ OUTPUT_PATH: outputPath,
127
+ HAPPY_STACKS_ENV_FILE: envPath,
128
+ HAPPY_LOCAL_ENV_FILE: envPath,
129
+ XDG_CACHE_HOME: null,
130
+ YARN_CACHE_FOLDER: null,
131
+ npm_config_cache: null,
132
+ },
133
+ async () => {
134
+ await pmExecBin({ dir: componentDir, bin: 'prisma', args: ['generate'], env: process.env, quiet: true });
135
+ const parsed = JSON.parse(await readFile(outputPath, 'utf-8'));
136
+ assert.equal(parsed.XDG_CACHE_HOME, exp.xdg);
137
+ assert.equal(parsed.YARN_CACHE_FOLDER, exp.yarn);
138
+ assert.equal(parsed.npm_config_cache, exp.npm);
139
+ }
140
+ );
141
+ });
142
+
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { createWriteStream } from 'node:fs';
2
3
 
3
4
  function nextLineBreakIndex(s) {
4
5
  const n = s.indexOf('\n');
@@ -80,9 +81,25 @@ export function killProcessTree(child, signal) {
80
81
  }
81
82
 
82
83
  export async function run(cmd, args, options = {}) {
83
- const { timeoutMs, ...spawnOptions } = options ?? {};
84
+ const { timeoutMs, input, ...spawnOptions } = options ?? {};
84
85
  await new Promise((resolvePromise, rejectPromise) => {
85
- const proc = spawn(cmd, args, { stdio: 'inherit', shell: false, ...spawnOptions });
86
+ const baseStdio = spawnOptions.stdio ?? 'inherit';
87
+ const stdio =
88
+ input != null
89
+ ? Array.isArray(baseStdio)
90
+ ? ['pipe', baseStdio[1] ?? 'inherit', baseStdio[2] ?? 'inherit']
91
+ : ['pipe', baseStdio, baseStdio]
92
+ : baseStdio;
93
+
94
+ const proc = spawn(cmd, args, { stdio, shell: false, ...spawnOptions });
95
+ if (input != null && proc.stdin) {
96
+ try {
97
+ proc.stdin.write(String(input));
98
+ proc.stdin.end();
99
+ } catch {
100
+ // ignore
101
+ }
102
+ }
86
103
  const t =
87
104
  Number.isFinite(timeoutMs) && timeoutMs > 0
88
105
  ? setTimeout(() => {
@@ -148,13 +165,46 @@ export async function runCapture(cmd, args, options = {}) {
148
165
  }
149
166
 
150
167
  export async function runCaptureResult(cmd, args, options = {}) {
151
- const { timeoutMs, ...spawnOptions } = options ?? {};
168
+ const { timeoutMs, streamLabel, teeFile, teeLabel, ...spawnOptions } = options ?? {};
152
169
  const startedAt = Date.now();
153
170
  return await new Promise((resolvePromise) => {
154
171
  const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], shell: false, ...spawnOptions });
155
172
  let out = '';
156
173
  let err = '';
157
- const t =
174
+ const label = String(streamLabel ?? '').trim();
175
+ const shouldStream = Boolean(label);
176
+ const outState = { buf: '' };
177
+ const errState = { buf: '' };
178
+ const prefix = shouldStream ? `[${label}] ` : '';
179
+
180
+ const teePath = String(teeFile ?? '').trim();
181
+ const shouldTee = Boolean(teePath);
182
+ const teeOutState = { buf: '' };
183
+ const teeErrState = { buf: '' };
184
+ const teePrefix = (() => {
185
+ const t = String(teeLabel ?? '').trim();
186
+ if (t) return `[${t}] `;
187
+ if (label) return `[${label}] `;
188
+ return '';
189
+ })();
190
+ const teeStream = shouldTee ? createWriteStream(teePath, { flags: 'a' }) : null;
191
+
192
+ function resolveWith(res) {
193
+ if (shouldStream) {
194
+ flushPrefixed(process.stdout, prefix, outState);
195
+ flushPrefixed(process.stderr, prefix, errState);
196
+ }
197
+ if (shouldTee && teeStream) {
198
+ flushPrefixed(teeStream, teePrefix, teeOutState);
199
+ flushPrefixed(teeStream, teePrefix, teeErrState);
200
+ try {
201
+ teeStream.end();
202
+ } catch {
203
+ // ignore
204
+ }
205
+ }
206
+ resolvePromise(res);
207
+ } const t =
158
208
  Number.isFinite(timeoutMs) && timeoutMs > 0
159
209
  ? setTimeout(() => {
160
210
  try {
@@ -162,7 +212,7 @@ export async function runCaptureResult(cmd, args, options = {}) {
162
212
  } catch {
163
213
  // ignore
164
214
  }
165
- resolvePromise({
215
+ resolveWith({
166
216
  ok: false,
167
217
  exitCode: null,
168
218
  signal: null,
@@ -175,11 +225,19 @@ export async function runCaptureResult(cmd, args, options = {}) {
175
225
  });
176
226
  }, timeoutMs)
177
227
  : null;
178
- proc.stdout?.on('data', (d) => (out += d.toString()));
179
- proc.stderr?.on('data', (d) => (err += d.toString()));
228
+ proc.stdout?.on('data', (d) => {
229
+ out += d.toString();
230
+ if (shouldStream) writeWithPrefix(process.stdout, prefix, outState, d);
231
+ if (shouldTee && teeStream) writeWithPrefix(teeStream, teePrefix, teeOutState, d);
232
+ });
233
+ proc.stderr?.on('data', (d) => {
234
+ err += d.toString();
235
+ if (shouldStream) writeWithPrefix(process.stderr, prefix, errState, d);
236
+ if (shouldTee && teeStream) writeWithPrefix(teeStream, teePrefix, teeErrState, d);
237
+ });
180
238
  proc.on('error', (e) => {
181
239
  if (t) clearTimeout(t);
182
- resolvePromise({
240
+ resolveWith({
183
241
  ok: false,
184
242
  exitCode: null,
185
243
  signal: null,
@@ -193,7 +251,7 @@ export async function runCaptureResult(cmd, args, options = {}) {
193
251
  });
194
252
  proc.on('close', (code, signal) => {
195
253
  if (t) clearTimeout(t);
196
- resolvePromise({
254
+ resolveWith({
197
255
  ok: code === 0,
198
256
  exitCode: code,
199
257
  signal: signal ?? null,
@@ -206,4 +264,4 @@ export async function runCaptureResult(cmd, args, options = {}) {
206
264
  });
207
265
  });
208
266
  });
209
- }
267
+ }
@@ -0,0 +1,77 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { runCaptureResult } from './proc.mjs';
8
+
9
+ test('runCaptureResult captures stdout/stderr', async () => {
10
+ const res = await runCaptureResult(process.execPath, ['-e', 'console.log("hello"); console.error("oops")'], {
11
+ env: process.env,
12
+ });
13
+ assert.equal(res.ok, true);
14
+ assert.equal(res.exitCode, 0);
15
+ assert.match(res.out, /hello/);
16
+ assert.match(res.err, /oops/);
17
+ });
18
+
19
+ test('runCaptureResult streams output when streamLabel is set (without affecting captured output)', async () => {
20
+ const stdoutWrites = [];
21
+ const stderrWrites = [];
22
+
23
+ const origStdoutWrite = process.stdout.write.bind(process.stdout);
24
+ const origStderrWrite = process.stderr.write.bind(process.stderr);
25
+
26
+ // Capture streaming output without polluting the test runner output.
27
+ // eslint-disable-next-line no-console
28
+ process.stdout.write = (chunk) => {
29
+ stdoutWrites.push(String(chunk));
30
+ return true;
31
+ };
32
+ // eslint-disable-next-line no-console
33
+ process.stderr.write = (chunk) => {
34
+ stderrWrites.push(String(chunk));
35
+ return true;
36
+ };
37
+
38
+ try {
39
+ const res = await runCaptureResult(process.execPath, ['-e', 'console.log("hello"); console.error("oops")'], {
40
+ env: process.env,
41
+ streamLabel: 'proc-test',
42
+ });
43
+ assert.equal(res.ok, true);
44
+ assert.equal(res.exitCode, 0);
45
+ assert.match(res.out, /hello/);
46
+ assert.match(res.err, /oops/);
47
+
48
+ const streamedOut = stdoutWrites.join('');
49
+ const streamedErr = stderrWrites.join('');
50
+ assert.match(streamedOut, /\[proc-test\] hello/);
51
+ assert.match(streamedErr, /\[proc-test\] oops/);
52
+ } finally {
53
+ process.stdout.write = origStdoutWrite;
54
+ process.stderr.write = origStderrWrite;
55
+ }
56
+ });
57
+
58
+ test('runCaptureResult can tee streamed output to a file', async () => {
59
+ const teeFile = join(tmpdir(), `happy-proc-tee-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
60
+ try {
61
+ const res = await runCaptureResult(process.execPath, ['-e', 'console.log("hello"); console.error("oops")'], {
62
+ env: process.env,
63
+ teeFile,
64
+ teeLabel: 'tee-test',
65
+ });
66
+ assert.equal(res.ok, true);
67
+ const raw = readFileSync(teeFile, 'utf-8');
68
+ assert.match(raw, /\[tee-test\] hello/);
69
+ assert.match(raw, /\[tee-test\] oops/);
70
+ } finally {
71
+ try {
72
+ rmSync(teeFile, { force: true });
73
+ } catch {
74
+ // ignore
75
+ }
76
+ }
77
+ });