happy-stacks 0.3.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 (165) hide show
  1. package/README.md +93 -40
  2. package/bin/happys.mjs +158 -16
  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 +3 -4
  10. package/docs/isolated-linux-vm.md +82 -0
  11. package/docs/mobile-ios.md +112 -54
  12. package/docs/monorepo-migration.md +286 -0
  13. package/docs/server-flavors.md +19 -3
  14. package/docs/stacks.md +35 -0
  15. package/package.json +5 -1
  16. package/scripts/auth.mjs +32 -10
  17. package/scripts/build.mjs +55 -8
  18. package/scripts/daemon.mjs +166 -10
  19. package/scripts/dev.mjs +198 -50
  20. package/scripts/doctor.mjs +0 -4
  21. package/scripts/edison.mjs +6 -4
  22. package/scripts/env.mjs +150 -0
  23. package/scripts/env_cmd.test.mjs +128 -0
  24. package/scripts/init.mjs +8 -3
  25. package/scripts/install.mjs +207 -69
  26. package/scripts/lint.mjs +24 -4
  27. package/scripts/migrate.mjs +3 -12
  28. package/scripts/mobile.mjs +88 -104
  29. package/scripts/mobile_dev_client.mjs +83 -0
  30. package/scripts/monorepo.mjs +1096 -0
  31. package/scripts/monorepo_port.test.mjs +1470 -0
  32. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  33. package/scripts/review.mjs +908 -0
  34. package/scripts/review_pr.mjs +353 -0
  35. package/scripts/run.mjs +101 -21
  36. package/scripts/service.mjs +2 -2
  37. package/scripts/setup.mjs +189 -68
  38. package/scripts/setup_pr.mjs +586 -38
  39. package/scripts/stack.mjs +990 -196
  40. package/scripts/stack_archive_cmd.test.mjs +91 -0
  41. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  42. package/scripts/stack_env_cmd.test.mjs +87 -0
  43. package/scripts/stack_happy_cmd.test.mjs +126 -0
  44. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  45. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  46. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  47. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  48. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  49. package/scripts/stack_wt_list.test.mjs +128 -0
  50. package/scripts/tailscale.mjs +37 -1
  51. package/scripts/test.mjs +45 -8
  52. package/scripts/tui.mjs +395 -39
  53. package/scripts/typecheck.mjs +24 -4
  54. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  55. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  56. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  57. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  58. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  59. package/scripts/utils/auth/login_ux.mjs +32 -13
  60. package/scripts/utils/auth/sources.mjs +26 -0
  61. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  62. package/scripts/utils/cli/cli_registry.mjs +43 -4
  63. package/scripts/utils/cli/cwd_scope.mjs +136 -0
  64. package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
  65. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  66. package/scripts/utils/cli/prereqs.mjs +75 -0
  67. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  68. package/scripts/utils/cli/progress.mjs +126 -0
  69. package/scripts/utils/cli/verbosity.mjs +12 -0
  70. package/scripts/utils/cli/wizard.mjs +17 -9
  71. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  72. package/scripts/utils/dev/daemon.mjs +61 -4
  73. package/scripts/utils/dev/expo_dev.mjs +430 -0
  74. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  75. package/scripts/utils/dev/server.mjs +36 -42
  76. package/scripts/utils/dev_auth_key.mjs +169 -0
  77. package/scripts/utils/edison/git_roots.mjs +29 -0
  78. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  79. package/scripts/utils/env/env.mjs +7 -3
  80. package/scripts/utils/env/env_file.mjs +4 -2
  81. package/scripts/utils/env/env_file.test.mjs +44 -0
  82. package/scripts/utils/expo/command.mjs +52 -0
  83. package/scripts/utils/expo/expo.mjs +20 -1
  84. package/scripts/utils/expo/metro_ports.mjs +114 -0
  85. package/scripts/utils/git/git.mjs +67 -0
  86. package/scripts/utils/git/worktrees.mjs +80 -25
  87. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  88. package/scripts/utils/handy_master_secret.mjs +94 -0
  89. package/scripts/utils/mobile/config.mjs +31 -0
  90. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  91. package/scripts/utils/mobile/identifiers.mjs +47 -0
  92. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  93. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  94. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  95. package/scripts/utils/net/lan_ip.mjs +24 -0
  96. package/scripts/utils/net/ports.mjs +9 -1
  97. package/scripts/utils/net/tcp_forward.mjs +162 -0
  98. package/scripts/utils/net/url.mjs +30 -0
  99. package/scripts/utils/net/url.test.mjs +20 -0
  100. package/scripts/utils/paths/localhost_host.mjs +50 -3
  101. package/scripts/utils/paths/paths.mjs +159 -40
  102. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  103. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  104. package/scripts/utils/proc/commands.mjs +2 -3
  105. package/scripts/utils/proc/parallel.mjs +25 -0
  106. package/scripts/utils/proc/pm.mjs +176 -22
  107. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  108. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  109. package/scripts/utils/proc/proc.mjs +136 -4
  110. package/scripts/utils/proc/proc.test.mjs +77 -0
  111. package/scripts/utils/review/base_ref.mjs +74 -0
  112. package/scripts/utils/review/base_ref.test.mjs +54 -0
  113. package/scripts/utils/review/chunks.mjs +55 -0
  114. package/scripts/utils/review/chunks.test.mjs +51 -0
  115. package/scripts/utils/review/findings.mjs +165 -0
  116. package/scripts/utils/review/findings.test.mjs +85 -0
  117. package/scripts/utils/review/head_slice.mjs +153 -0
  118. package/scripts/utils/review/head_slice.test.mjs +91 -0
  119. package/scripts/utils/review/instructions/deep.md +20 -0
  120. package/scripts/utils/review/runners/coderabbit.mjs +61 -0
  121. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  122. package/scripts/utils/review/runners/codex.mjs +61 -0
  123. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  124. package/scripts/utils/review/slices.mjs +140 -0
  125. package/scripts/utils/review/slices.test.mjs +32 -0
  126. package/scripts/utils/review/targets.mjs +24 -0
  127. package/scripts/utils/review/targets.test.mjs +36 -0
  128. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  129. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  130. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  131. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  132. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  133. package/scripts/utils/server/prisma_import.mjs +37 -0
  134. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  135. package/scripts/utils/server/ui_env.mjs +14 -0
  136. package/scripts/utils/server/ui_env.test.mjs +46 -0
  137. package/scripts/utils/server/urls.mjs +14 -4
  138. package/scripts/utils/server/validate.mjs +53 -16
  139. package/scripts/utils/server/validate.test.mjs +89 -0
  140. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  141. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  142. package/scripts/utils/stack/context.mjs +2 -2
  143. package/scripts/utils/stack/editor_workspace.mjs +6 -6
  144. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  145. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  146. package/scripts/utils/stack/runtime_state.mjs +2 -1
  147. package/scripts/utils/stack/startup.mjs +120 -13
  148. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  149. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  150. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  151. package/scripts/utils/stack/stop.mjs +15 -4
  152. package/scripts/utils/stack_context.mjs +23 -0
  153. package/scripts/utils/stack_runtime_state.mjs +104 -0
  154. package/scripts/utils/stacks.mjs +38 -0
  155. package/scripts/utils/tailscale/ip.mjs +116 -0
  156. package/scripts/utils/ui/ansi.mjs +39 -0
  157. package/scripts/utils/ui/qr.mjs +17 -0
  158. package/scripts/utils/validate.mjs +88 -0
  159. package/scripts/where.mjs +2 -2
  160. package/scripts/worktrees.mjs +755 -179
  161. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  162. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  163. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  164. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
  165. package/scripts/utils/dev/expo_web.mjs +0 -112
@@ -1,7 +1,7 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { dirname, join, resolve, sep } from 'node:path';
3
3
  import { existsSync } from 'node:fs';
4
- import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
5
5
  import { createHash } from 'node:crypto';
6
6
 
7
7
  import { pathExists } from '../fs/fs.mjs';
@@ -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.
@@ -113,16 +198,39 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
113
198
  }
114
199
  };
115
200
 
201
+ const patchesMtimeMs = async () => {
202
+ // Happy's mobile app (and some other repos) use patch-package and keep patches under `patches/`.
203
+ // If a patch file changes but yarn.lock/package.json do not, Yarn won't reinstall and
204
+ // patch-package won't re-apply the patch, leading to confusing "why isn't my patch wired?"
205
+ // failures later (e.g. during iOS pod install).
206
+ const patchesDir = join(dir, 'patches');
207
+ if (!(await pathExists(patchesDir))) return 0;
208
+ try {
209
+ const entries = await readdir(patchesDir, { withFileTypes: true });
210
+ let max = 0;
211
+ for (const e of entries) {
212
+ if (!e.isFile()) continue;
213
+ if (!e.name.endsWith('.patch')) continue;
214
+ const m = await mtimeMs(join(patchesDir, e.name));
215
+ if (m > max) max = m;
216
+ }
217
+ return max;
218
+ } catch {
219
+ return 0;
220
+ }
221
+ };
222
+
116
223
  if (pm.name === 'yarn' && (await pathExists(yarnLock))) {
117
224
  const lockM = await mtimeMs(yarnLock);
118
225
  const pkgM = await mtimeMs(pkgJson);
119
226
  const intM = await mtimeMs(yarnIntegrity);
120
- if (!intM || lockM > intM || pkgM > intM) {
227
+ const patchM = await patchesMtimeMs();
228
+ if (!intM || lockM > intM || pkgM > intM || patchM > intM) {
121
229
  if (!quiet) {
122
230
  // eslint-disable-next-line no-console
123
- console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json changed)...`);
231
+ console.log(`[local] refreshing ${label} dependencies (yarn.lock/package.json/patches changed)...`);
124
232
  }
125
- await run(pm.cmd, ['install'], { cwd: dir, stdio });
233
+ await run(pm.cmd, ['install'], { cwd: dir, stdio, env });
126
234
  }
127
235
  }
128
236
 
@@ -134,7 +242,7 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
134
242
  // eslint-disable-next-line no-console
135
243
  console.log(`[local] refreshing ${label} dependencies (pnpm-lock changed)...`);
136
244
  }
137
- await run(pm.cmd, ['install'], { cwd: dir, stdio });
245
+ await run(pm.cmd, ['install'], { cwd: dir, stdio, env });
138
246
  }
139
247
  }
140
248
 
@@ -145,7 +253,7 @@ export async function ensureDepsInstalled(dir, label, { quiet = false } = {}) {
145
253
  // eslint-disable-next-line no-console
146
254
  console.log(`[local] installing ${label} dependencies (first run)...`);
147
255
  }
148
- await run(pm.cmd, ['install'], { cwd: dir, stdio });
256
+ await run(pm.cmd, ['install'], { cwd: dir, stdio, env });
149
257
  }
150
258
 
151
259
  export async function ensureCliBuilt(cliDir, { buildCli }) {
@@ -161,14 +269,20 @@ export async function ensureCliBuilt(cliDir, { buildCli }) {
161
269
  // - HAPPY_STACKS_CLI_BUILD=0 (legacy: HAPPY_LOCAL_CLI_BUILD=0)
162
270
  const modeRaw = (process.env.HAPPY_STACKS_CLI_BUILD_MODE ?? process.env.HAPPY_LOCAL_CLI_BUILD_MODE ?? 'auto').trim().toLowerCase();
163
271
  const mode = modeRaw === 'always' || modeRaw === 'auto' || modeRaw === 'never' ? modeRaw : 'auto';
164
- if (mode === 'never') {
165
- return { built: false, reason: 'mode_never' };
166
- }
167
272
  const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
168
273
  const buildStatePath = resolveBuildStatePath({ label: 'happy-cli', dir: cliDir });
169
274
  const gitSig = await computeGitWorktreeSignature(cliDir);
170
275
  const prev = await readJsonIfExists(buildStatePath);
171
276
 
277
+ // "never" should prevent rebuild churn, but it must not make the stack unrunnable.
278
+ // If the dist entrypoint is missing, build once even in "never" mode.
279
+ if (mode === 'never') {
280
+ if (await pathExists(distEntrypoint)) {
281
+ return { built: false, reason: 'mode_never' };
282
+ }
283
+ // fallthrough to build
284
+ }
285
+
172
286
  if (mode === 'auto') {
173
287
  // If dist doesn't exist, we must build.
174
288
  if (!(await pathExists(distEntrypoint))) {
@@ -186,6 +300,18 @@ export async function ensureCliBuilt(cliDir, { buildCli }) {
186
300
  const pm = await getComponentPm(cliDir);
187
301
  await run(pm.cmd, ['build'], { cwd: cliDir });
188
302
 
303
+ // Sanity check: happy-cli daemon entrypoint must exist after a successful build.
304
+ // Without this, watch-based rebuilds can restart the daemon into a MODULE_NOT_FOUND crash,
305
+ // which looks like the UI "dies out of nowhere" even though the root cause is missing build output.
306
+ if (!(await pathExists(distEntrypoint))) {
307
+ throw new Error(
308
+ `[local] happy-cli build finished but did not produce expected entrypoint.\n` +
309
+ `Expected: ${distEntrypoint}\n` +
310
+ `Fix: run the component build directly and inspect its output:\n` +
311
+ ` cd "${cliDir}" && ${pm.cmd} build`
312
+ );
313
+ }
314
+
189
315
  // Persist new build state (best-effort).
190
316
  const nowSig = gitSig ?? (await computeGitWorktreeSignature(cliDir));
191
317
  if (nowSig) {
@@ -288,11 +414,15 @@ export async function pmExecBin(dirOrOpts, binArg, argsArg, optsArg) {
288
414
  const bin = usesObjectStyle ? dirOrOpts.bin : binArg;
289
415
  const args = usesObjectStyle ? (dirOrOpts.args ?? []) : (argsArg ?? []);
290
416
 
291
- 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);
292
419
  const quiet = usesObjectStyle ? Boolean(dirOrOpts.quiet) : Boolean(optsArg?.quiet);
293
420
  const stdio = quiet ? 'ignore' : 'inherit';
294
421
 
295
- const pm = await getComponentPm(dir);
422
+ const pm = await getComponentPm(dir, env);
423
+ if (pm.name === 'yarn') {
424
+ await ensureYarnReady({ dir, env, quiet });
425
+ }
296
426
  if (pm.name === 'yarn') {
297
427
  await run(pm.cmd, ['run', bin, ...args], { cwd: dir, env, stdio });
298
428
  return;
@@ -301,17 +431,41 @@ export async function pmExecBin(dirOrOpts, binArg, argsArg, optsArg) {
301
431
  }
302
432
 
303
433
  export async function pmSpawnBin(dir, label, bin, args, { env = process.env } = {}) {
304
- const pm = await getComponentPm(dir);
434
+ const usesObjectStyle = typeof dir === 'object' && dir !== null;
435
+ const componentDir = usesObjectStyle ? dir.dir : dir;
436
+ const componentLabel = usesObjectStyle ? dir.label : label;
437
+ const componentBin = usesObjectStyle ? dir.bin : bin;
438
+ const componentArgs = usesObjectStyle ? (dir.args ?? []) : (args ?? []);
439
+ const componentEnv = usesObjectStyle ? (dir.env ?? process.env) : (env ?? process.env);
440
+ const options = usesObjectStyle ? (dir.options ?? {}) : {};
441
+
442
+ const effectiveEnv = await applyStackCacheEnv(componentEnv);
443
+ const pm = await getComponentPm(componentDir, effectiveEnv);
444
+ if (pm.name === 'yarn') {
445
+ await ensureYarnReady({ dir: componentDir, env: effectiveEnv, quiet: false });
446
+ }
305
447
  if (pm.name === 'yarn') {
306
- return spawnProc(label, pm.cmd, ['run', bin, ...args], env, { cwd: dir });
448
+ return spawnProc(componentLabel, pm.cmd, ['run', componentBin, ...componentArgs], effectiveEnv, { cwd: componentDir, ...options });
307
449
  }
308
- return spawnProc(label, pm.cmd, ['exec', bin, ...args], env, { cwd: dir });
450
+ return spawnProc(componentLabel, pm.cmd, ['exec', componentBin, ...componentArgs], effectiveEnv, { cwd: componentDir, ...options });
309
451
  }
310
452
 
311
453
  export async function pmSpawnScript(dir, label, script, args, { env = process.env } = {}) {
312
- const pm = await getComponentPm(dir);
454
+ const usesObjectStyle = typeof dir === 'object' && dir !== null;
455
+ const componentDir = usesObjectStyle ? dir.dir : dir;
456
+ const componentLabel = usesObjectStyle ? dir.label : label;
457
+ const componentScript = usesObjectStyle ? dir.script : script;
458
+ const componentArgs = usesObjectStyle ? (dir.args ?? []) : (args ?? []);
459
+ const componentEnv = usesObjectStyle ? (dir.env ?? process.env) : (env ?? process.env);
460
+ const options = usesObjectStyle ? (dir.options ?? {}) : {};
461
+
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
+ }
313
467
  if (pm.name === 'yarn') {
314
- return spawnProc(label, pm.cmd, ['run', script, ...args], env, { cwd: dir });
468
+ return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], effectiveEnv, { cwd: componentDir, ...options });
315
469
  }
316
- return spawnProc(label, pm.cmd, ['run', script, ...args], env, { cwd: dir });
470
+ return spawnProc(componentLabel, pm.cmd, ['run', componentScript, ...componentArgs], effectiveEnv, { cwd: componentDir, ...options });
317
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
+