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
@@ -0,0 +1,60 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { promptWorktreeSource } from './wizard.mjs';
5
+
6
+ test('promptWorktreeSource does not list worktrees unless user selects "pick"', async () => {
7
+ let listed = 0;
8
+ const listWorktreeSpecs = async () => {
9
+ listed++;
10
+ return ['slopus/pr/123'];
11
+ };
12
+
13
+ const promptSelect = async () => 'default';
14
+ const prompt = async () => '';
15
+
16
+ const res = await promptWorktreeSource({
17
+ rl: {},
18
+ rootDir: '/tmp',
19
+ component: 'happy',
20
+ stackName: 'exp1',
21
+ createRemote: 'upstream',
22
+ deps: { listWorktreeSpecs, promptSelect, prompt },
23
+ });
24
+
25
+ assert.equal(res, 'default');
26
+ assert.equal(listed, 0);
27
+ });
28
+
29
+ test('promptWorktreeSource lists worktrees when user selects "pick"', async () => {
30
+ let listed = 0;
31
+ const listWorktreeSpecs = async () => {
32
+ listed++;
33
+ return ['slopus/pr/123', 'slopus/pr/456'];
34
+ };
35
+
36
+ let selectCount = 0;
37
+ const promptSelect = async (_rl, { title }) => {
38
+ selectCount++;
39
+ if (selectCount === 1) {
40
+ assert.ok(title.startsWith('Select '));
41
+ return 'pick';
42
+ }
43
+ assert.ok(title.startsWith('Available '));
44
+ return 'slopus/pr/456';
45
+ };
46
+ const prompt = async () => '';
47
+
48
+ const res = await promptWorktreeSource({
49
+ rl: {},
50
+ rootDir: '/tmp',
51
+ component: 'happy',
52
+ stackName: 'exp1',
53
+ createRemote: 'upstream',
54
+ deps: { listWorktreeSpecs, promptSelect, prompt },
55
+ });
56
+
57
+ assert.equal(res, 'slopus/pr/456');
58
+ assert.equal(listed, 1);
59
+ });
60
+
@@ -124,7 +124,20 @@ export function watchHappyCliAndRestartDaemon({
124
124
  try {
125
125
  // eslint-disable-next-line no-console
126
126
  console.log('[local] watch: happy-cli changed → rebuilding + restarting daemon...');
127
- await ensureCliBuilt(cliDir, { buildCli });
127
+ try {
128
+ await ensureCliBuilt(cliDir, { buildCli });
129
+ } catch (e) {
130
+ // IMPORTANT:
131
+ // - A rebuild can legitimately fail while an agent is mid-edit (e.g. TS errors).
132
+ // - In that case we must NOT restart the daemon (we'd just restart into a broken build),
133
+ // and we must NOT crash the parent dev process. Keep watching for the next change.
134
+ const msg = e instanceof Error ? e.stack || e.message : String(e);
135
+ // eslint-disable-next-line no-console
136
+ console.error('[local] watch: happy-cli rebuild failed; keeping daemon running (will retry on next change).');
137
+ // eslint-disable-next-line no-console
138
+ console.error(msg);
139
+ return;
140
+ }
128
141
  const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
129
142
  if (!existsSync(distEntrypoint)) {
130
143
  console.warn(
@@ -1,3 +1,7 @@
1
+ import { fork } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+ import net from 'node:net';
1
5
  import {
2
6
  ensureExpoIsolationEnv,
3
7
  getExpoStatePaths,
@@ -11,6 +15,11 @@ import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
11
15
  import { expoSpawn } from '../expo/command.mjs';
12
16
  import { resolveMobileExpoConfig } from '../mobile/config.mjs';
13
17
  import { resolveMobileReachableServerUrl } from '../server/mobile_api_url.mjs';
18
+ import { getTailscaleStatus } from '../tailscale/ip.mjs';
19
+ import { pickLanIpv4 } from '../net/lan_ip.mjs';
20
+
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = dirname(__filename);
14
23
 
15
24
  function normalizeExpoHost(raw) {
16
25
  const v = String(raw ?? '').trim().toLowerCase();
@@ -18,6 +27,140 @@ function normalizeExpoHost(raw) {
18
27
  return 'lan';
19
28
  }
20
29
 
30
+ /**
31
+ * Resolve whether Tailscale forwarding for Expo is enabled.
32
+ *
33
+ * Can be enabled via:
34
+ * - --expo-tailscale flag (passed as expoTailscale option)
35
+ * - HAPPY_STACKS_EXPO_TAILSCALE=1 env var
36
+ * - HAPPY_LOCAL_EXPO_TAILSCALE=1 env var (legacy)
37
+ */
38
+ export function resolveExpoTailscaleEnabled({ env = process.env, expoTailscale = false } = {}) {
39
+ if (expoTailscale) return true;
40
+ const envVal = (env.HAPPY_STACKS_EXPO_TAILSCALE ?? env.HAPPY_LOCAL_EXPO_TAILSCALE ?? '').toString().trim();
41
+ return envVal === '1' || envVal.toLowerCase() === 'true';
42
+ }
43
+
44
+ /**
45
+ * Start a TCP forwarder process for Expo Tailscale access.
46
+ *
47
+ * Forwards from Tailscale IP:port to the LAN IP:port where Expo actually binds.
48
+ *
49
+ * @param {Object} options
50
+ * @param {number} options.metroPort - The Metro bundler port
51
+ * @param {Object} options.baseEnv - Base environment variables
52
+ * @param {string} options.stackName - Stack name for logging
53
+ * @param {Array} options.children - Array to track child processes
54
+ * @returns {Promise<{ ok: boolean, pid?: number, tailscaleIp?: string, lanIp?: string, error?: string }>}
55
+ */
56
+ export async function startExpoTailscaleForwarder({ metroPort, baseEnv, stackName, children }) {
57
+ const ts = await getTailscaleStatus();
58
+ if (!ts.available || !ts.ip) {
59
+ // Common case: Tailscale app installed but toggle is off / not connected.
60
+ // This must never fail stack startup; just skip with a clear message.
61
+ return { ok: false, error: ts.error || 'Tailscale is not connected' };
62
+ }
63
+ const tailscaleIp = ts.ip;
64
+
65
+ // Some platforms / Tailscale variants report an IP but do not allow binding to it (EADDRNOTAVAIL).
66
+ // If we can't bind *at all*, don't spawn the forwarder process (it will just error noisily).
67
+ const canBind = await new Promise((resolve) => {
68
+ const srv = net.createServer();
69
+ const done = (ok, err) => {
70
+ try {
71
+ srv.close(() => resolve({ ok, err }));
72
+ } catch {
73
+ resolve({ ok, err });
74
+ }
75
+ };
76
+ srv.once('error', (err) => done(false, err));
77
+ srv.listen(0, tailscaleIp, () => done(true, null));
78
+ });
79
+ if (!canBind.ok) {
80
+ const code = canBind.err && typeof canBind.err === 'object' ? canBind.err.code : '';
81
+ const msg = canBind.err instanceof Error ? canBind.err.message : String(canBind.err ?? '');
82
+ const hint =
83
+ code === 'EADDRNOTAVAIL'
84
+ ? `Tailscale IP ${tailscaleIp} is not bindable on this machine (EADDRNOTAVAIL).`
85
+ : `Tailscale IP ${tailscaleIp} is not bindable (${code || 'error'}).`;
86
+ return { ok: false, error: `${hint}${msg ? ` ${msg}` : ''}`.trim() };
87
+ }
88
+
89
+ // Determine where Expo binds (LAN IP when host=lan, localhost otherwise)
90
+ const host = resolveExpoDevHost({ env: baseEnv });
91
+ let targetHost = '127.0.0.1';
92
+ if (host === 'lan') {
93
+ const lanIp = pickLanIpv4();
94
+ if (lanIp) targetHost = lanIp;
95
+ }
96
+
97
+ const label = `expo-ts-fwd${stackName ? `-${stackName}` : ''}`;
98
+ const forwarderScript = join(__dirname, '..', 'net', 'tcp_forward.mjs');
99
+
100
+ // Fork the forwarder as a child process
101
+ // Note: fork() requires 'ipc' in stdio array
102
+ const forwarderProc = fork(forwarderScript, [
103
+ `--listen-host=${tailscaleIp}`,
104
+ `--listen-port=${metroPort}`,
105
+ `--target-host=${targetHost}`,
106
+ `--target-port=${metroPort}`,
107
+ `--label=${label}`,
108
+ ], {
109
+ env: { ...baseEnv },
110
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
111
+ detached: process.platform !== 'win32',
112
+ });
113
+
114
+ // Prefix forwarder output
115
+ const outPrefix = `[${label}] `;
116
+ forwarderProc.stdout?.on('data', (d) => process.stdout.write(outPrefix + d.toString()));
117
+ forwarderProc.stderr?.on('data', (d) => process.stderr.write(outPrefix + d.toString()));
118
+
119
+ // Wait until the forwarder actually starts listening (or fails) before declaring success.
120
+ const ready = await new Promise((resolve) => {
121
+ const t = setTimeout(() => resolve({ ok: false, error: 'forwarder startup timed out' }), 2000);
122
+ const done = (res) => {
123
+ clearTimeout(t);
124
+ resolve(res);
125
+ };
126
+ forwarderProc.once('message', (m) => {
127
+ if (m && typeof m === 'object' && m.type === 'ready') {
128
+ done({ ok: true });
129
+ } else if (m && typeof m === 'object' && m.type === 'error') {
130
+ done({ ok: false, error: m.message ? String(m.message) : 'failed to start' });
131
+ }
132
+ });
133
+ forwarderProc.once('exit', (code, sig) => {
134
+ done({ ok: false, error: `exited (code=${code}, sig=${sig})` });
135
+ });
136
+ forwarderProc.once('error', (e) => {
137
+ done({ ok: false, error: e instanceof Error ? e.message : String(e) });
138
+ });
139
+ });
140
+
141
+ if (!ready.ok) {
142
+ try {
143
+ forwarderProc.kill('SIGKILL');
144
+ } catch {
145
+ // ignore
146
+ }
147
+ return { ok: false, error: ready.error || 'failed to start forwarder' };
148
+ }
149
+
150
+ children.push(forwarderProc);
151
+
152
+ // eslint-disable-next-line no-console
153
+ console.log(`[local] expo: Tailscale forwarder started (${tailscaleIp}:${metroPort} -> ${targetHost}:${metroPort})`);
154
+
155
+ return {
156
+ ok: true,
157
+ pid: forwarderProc.pid,
158
+ tailscaleIp,
159
+ lanIp: targetHost,
160
+ proc: forwarderProc,
161
+ };
162
+ }
163
+
21
164
  export function resolveExpoDevHost({ env = process.env } = {}) {
22
165
  // Always prefer LAN by default so phones can reach Metro.
23
166
  const raw = (env.HAPPY_STACKS_EXPO_HOST ?? env.HAPPY_LOCAL_EXPO_HOST ?? '').toString();
@@ -76,6 +219,7 @@ export async function ensureDevExpoServer({
76
219
  envPath,
77
220
  children,
78
221
  spawnOptions = {},
222
+ expoTailscale = false,
79
223
  } = {}) {
80
224
  const wantWeb = Boolean(startUi);
81
225
  const wantDevClient = Boolean(startMobile);
@@ -144,13 +288,20 @@ export async function ensureDevExpoServer({
144
288
  const running = await isStateProcessRunning(paths.statePath);
145
289
  const alreadyRunning = Boolean(running.running);
146
290
 
291
+ // Resolve Tailscale forwarding preference
292
+ const wantTailscale = resolveExpoTailscaleEnabled({ env: baseEnv, expoTailscale });
293
+
147
294
  // Always publish runtime metadata when we can.
148
- const publishRuntime = async ({ pid, port }) => {
295
+ const publishRuntime = async ({ pid, port, tailscaleForwarderPid = null, tailscaleIp = null }) => {
149
296
  if (!stackMode || !runtimeStatePath) return;
150
297
  const nPid = Number(pid);
151
298
  const nPort = Number(port);
299
+ const nTsPid = Number(tailscaleForwarderPid);
152
300
  await recordStackRuntimeUpdate(runtimeStatePath, {
153
- processes: { expoPid: Number.isFinite(nPid) && nPid > 1 ? nPid : null },
301
+ processes: {
302
+ expoPid: Number.isFinite(nPid) && nPid > 1 ? nPid : null,
303
+ expoTailscaleForwarderPid: Number.isFinite(nTsPid) && nTsPid > 1 ? nTsPid : null,
304
+ },
154
305
  expo: {
155
306
  port: Number.isFinite(nPort) && nPort > 0 ? nPort : null,
156
307
  // For now keep these populated for callers that still expect webPort/mobilePort.
@@ -160,6 +311,8 @@ export async function ensureDevExpoServer({
160
311
  devClientEnabled: wantDevClient,
161
312
  host: resolveExpoDevHost({ env }),
162
313
  scheme: wantDevClient ? scheme : null,
314
+ tailscaleEnabled: wantTailscale,
315
+ tailscaleIp: tailscaleIp ?? null,
163
316
  },
164
317
  }).catch(() => {});
165
318
  };
@@ -224,7 +377,27 @@ export async function ensureDevExpoServer({
224
377
  const proc = await expoSpawn({ label: 'expo', dir: uiDir, args, env, options: spawnOptions });
225
378
  children.push(proc);
226
379
 
227
- await publishRuntime({ pid: proc.pid, port: metroPort });
380
+ // Start Tailscale forwarder if enabled
381
+ let tailscaleResult = null;
382
+ if (wantTailscale) {
383
+ tailscaleResult = await startExpoTailscaleForwarder({
384
+ metroPort,
385
+ baseEnv,
386
+ stackName,
387
+ children,
388
+ });
389
+ if (!tailscaleResult.ok) {
390
+ // eslint-disable-next-line no-console
391
+ console.warn(`[local] expo: Tailscale forwarder not started: ${tailscaleResult.error}`);
392
+ }
393
+ }
394
+
395
+ await publishRuntime({
396
+ pid: proc.pid,
397
+ port: metroPort,
398
+ tailscaleForwarderPid: tailscaleResult?.pid ?? null,
399
+ tailscaleIp: tailscaleResult?.tailscaleIp ?? null,
400
+ });
228
401
 
229
402
  try {
230
403
  await writePidState(paths.statePath, {
@@ -236,11 +409,22 @@ export async function ensureDevExpoServer({
236
409
  devClientEnabled: wantDevClient,
237
410
  host,
238
411
  scheme: wantDevClient ? scheme : null,
412
+ tailscaleEnabled: wantTailscale,
413
+ tailscaleForwarderPid: tailscaleResult?.pid ?? null,
414
+ tailscaleIp: tailscaleResult?.tailscaleIp ?? null,
239
415
  });
240
416
  } catch {
241
417
  // ignore
242
418
  }
243
419
 
244
- return { ok: true, skipped: false, pid: proc.pid, port: metroPort, proc, mode: expoModeLabel({ wantWeb, wantDevClient }) };
420
+ return {
421
+ ok: true,
422
+ skipped: false,
423
+ pid: proc.pid,
424
+ port: metroPort,
425
+ proc,
426
+ mode: expoModeLabel({ wantWeb, wantDevClient }),
427
+ tailscale: tailscaleResult ?? null,
428
+ };
245
429
  }
246
430
 
@@ -2,6 +2,7 @@ import { join, resolve } from 'node:path';
2
2
 
3
3
  import { ensureDepsInstalled, pmSpawnScript } from '../proc/pm.mjs';
4
4
  import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from '../server/infra/happy_server_infra.mjs';
5
+ import { resolveServerDevScript } from '../server/flavor_scripts.mjs';
5
6
  import { waitForServerReady } from '../server/server.mjs';
6
7
  import { isTcpPortFree, pickNextFreeTcpPort } from '../net/ports.mjs';
7
8
  import { readStackRuntimeStateFile, recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
@@ -87,12 +88,7 @@ export async function startDevServer({
87
88
  await ensureDepsInstalled(serverDir, serverComponentName);
88
89
 
89
90
  const prismaPush = (baseEnv.HAPPY_STACKS_PRISMA_PUSH ?? baseEnv.HAPPY_LOCAL_PRISMA_PUSH ?? '1').toString().trim() !== '0';
90
- const serverScript =
91
- serverComponentName === 'happy-server'
92
- ? 'start'
93
- : serverComponentName === 'happy-server-light' && !prismaPush
94
- ? 'start'
95
- : 'dev';
91
+ const serverScript = resolveServerDevScript({ serverComponentName, serverDir, prismaPush });
96
92
 
97
93
  // Restart behavior (stack-safe): only kill when we can prove ownership via runtime state.
98
94
  if (restart && stackMode && runtimeStatePath && serverAlreadyRunning) {
@@ -155,19 +151,27 @@ export function watchDevServerAndRestart({
155
151
  const pid = Number(serverProcRef?.current?.pid);
156
152
  if (!Number.isFinite(pid) || pid <= 1) return;
157
153
 
158
- // eslint-disable-next-line no-console
159
- console.log('[local] watch: server changed → restarting...');
160
- await killProcessGroupOwnedByStack(pid, { stackName, envPath, label: 'server', json: false });
154
+ try {
155
+ // eslint-disable-next-line no-console
156
+ console.log('[local] watch: server changed restarting...');
157
+ await killProcessGroupOwnedByStack(pid, { stackName, envPath, label: 'server', json: false });
161
158
 
162
- const next = await pmSpawnScript({ label: 'server', dir: serverDir, script: serverScript, env: serverEnv });
163
- children.push(next);
164
- serverProcRef.current = next;
165
- if (stackMode && runtimeStatePath) {
166
- await recordStackRuntimeUpdate(runtimeStatePath, { processes: { serverPid: next.pid } }).catch(() => {});
159
+ const next = await pmSpawnScript({ label: 'server', dir: serverDir, script: serverScript, env: serverEnv });
160
+ children.push(next);
161
+ serverProcRef.current = next;
162
+ if (stackMode && runtimeStatePath) {
163
+ await recordStackRuntimeUpdate(runtimeStatePath, { processes: { serverPid: next.pid } }).catch(() => {});
164
+ }
165
+ await waitForServerReady(internalServerUrl);
166
+ // eslint-disable-next-line no-console
167
+ console.log(`[local] watch: server restarted (pid=${next.pid}, port=${serverPort})`);
168
+ } catch (e) {
169
+ const msg = e instanceof Error ? e.stack || e.message : String(e);
170
+ // eslint-disable-next-line no-console
171
+ console.error('[local] watch: server restart failed; keeping existing process as-is (will retry on next change).');
172
+ // eslint-disable-next-line no-console
173
+ console.error(msg);
167
174
  }
168
- await waitForServerReady(internalServerUrl);
169
- // eslint-disable-next-line no-console
170
- console.log(`[local] watch: server restarted (pid=${next.pid}, port=${serverPort})`);
171
175
  },
172
176
  });
173
177
  }
@@ -0,0 +1,29 @@
1
+ import { dirname, join, resolve } from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+
4
+ export function findGitRootForPath(dir) {
5
+ let cur = resolve(String(dir ?? '').trim());
6
+ if (!cur) return '';
7
+ while (true) {
8
+ try {
9
+ if (existsSync(join(cur, '.git'))) {
10
+ return cur;
11
+ }
12
+ } catch {
13
+ // ignore
14
+ }
15
+ const parent = dirname(cur);
16
+ if (parent === cur) return '';
17
+ cur = parent;
18
+ }
19
+ }
20
+
21
+ export function normalizeGitRoots(paths) {
22
+ const list = Array.isArray(paths) ? paths : [];
23
+ const normalized = list
24
+ .map((d) => findGitRootForPath(d) || String(d ?? '').trim())
25
+ .map((d) => resolve(d))
26
+ .filter(Boolean);
27
+ return Array.from(new Set(normalized));
28
+ }
29
+
@@ -0,0 +1,36 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ import { findGitRootForPath, normalizeGitRoots } from './git_roots.mjs';
8
+
9
+ async function withTempRoot(t) {
10
+ const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-edison-git-roots-'));
11
+ t.after(async () => {
12
+ await rm(dir, { recursive: true, force: true });
13
+ });
14
+ return dir;
15
+ }
16
+
17
+ test('findGitRootForPath returns nearest ancestor containing .git marker', async (t) => {
18
+ const root = await withTempRoot(t);
19
+ const repoRoot = join(root, 'repo');
20
+ await mkdir(join(repoRoot, 'a', 'b'), { recursive: true });
21
+ await writeFile(join(repoRoot, '.git'), 'gitdir: /tmp/fake\n', 'utf-8');
22
+
23
+ assert.equal(findGitRootForPath(join(repoRoot, 'a', 'b')), repoRoot);
24
+ });
25
+
26
+ test('normalizeGitRoots de-duplicates multiple paths inside the same repo', async (t) => {
27
+ const root = await withTempRoot(t);
28
+ const repoRoot = join(root, 'repo');
29
+ await mkdir(join(repoRoot, 'expo-app'), { recursive: true });
30
+ await mkdir(join(repoRoot, 'cli'), { recursive: true });
31
+ await writeFile(join(repoRoot, '.git'), 'gitdir: /tmp/fake\n', 'utf-8');
32
+
33
+ const roots = normalizeGitRoots([join(repoRoot, 'expo-app'), join(repoRoot, 'cli')]);
34
+ assert.deepEqual(roots, [repoRoot]);
35
+ });
36
+
@@ -162,7 +162,12 @@ if (hasHomeConfig) {
162
162
  const stacksStorageRootRaw = (process.env.HAPPY_STACKS_STORAGE_DIR ?? process.env.HAPPY_LOCAL_STORAGE_DIR ?? '').trim();
163
163
  const stacksStorageRoot = stacksStorageRootRaw ? expandHome(stacksStorageRootRaw) : join(homedir(), '.happy', 'stacks');
164
164
  const allowLegacy = !isSandboxed() || sandboxAllowsGlobalSideEffects();
165
- const legacyStacksRoot = allowLegacy ? join(homedir(), '.happy', 'local', 'stacks') : join(stacksStorageRoot, '__legacy_disabled__');
165
+ // If the user explicitly overrides the stacks storage root, do not auto-discover a legacy env file from the real home dir.
166
+ // This keeps isolated runs (tests, sandboxes, custom dirs) from accidentally loading a "real" machine stack env file.
167
+ const legacyStacksRoot =
168
+ allowLegacy && !stacksStorageRootRaw
169
+ ? join(homedir(), '.happy', 'local', 'stacks')
170
+ : join(stacksStorageRoot, '__legacy_disabled__');
166
171
 
167
172
  const candidates = [
168
173
  join(stacksStorageRoot, stackName, 'env'),
@@ -204,8 +209,7 @@ process.env.NPM_CONFIG_PACKAGE_MANAGER_STRICT = process.env.NPM_CONFIG_PACKAGE_M
204
209
  const delimiter = process.platform === 'win32' ? ';' : ':';
205
210
  const current = (process.env.PATH ?? '').split(delimiter).filter(Boolean);
206
211
  const nodeBinDir = dirname(process.execPath);
207
- const want = [nodeBinDir, '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin'];
212
+ const want = [nodeBinDir, '/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin', '/usr/bin', '/bin'];
208
213
  const next = [...want.filter((p) => p && !current.includes(p)), ...current];
209
214
  process.env.PATH = next.join(delimiter);
210
215
  })();
211
-
@@ -7,7 +7,9 @@ export async function ensureEnvFileUpdated({ envPath, updates }) {
7
7
  return;
8
8
  }
9
9
  await mkdir(dirname(envPath), { recursive: true });
10
- await writeFileIfChanged(envPath, applyEnvUpdates(await readText(envPath), updates), envPath);
10
+ const existing = await readText(envPath);
11
+ const next = applyEnvUpdates(existing, updates);
12
+ await writeFileIfChanged(existing, next, envPath);
11
13
  }
12
14
 
13
15
  export async function ensureEnvFilePruned({ envPath, removeKeys }) {
@@ -30,7 +32,7 @@ async function readText(path) {
30
32
  }
31
33
 
32
34
  function applyEnvUpdates(existing, updates) {
33
- const lines = existing.split('\n');
35
+ const lines = existing ? existing.split('\n') : [];
34
36
  const next = [...lines];
35
37
 
36
38
  const upsert = (key, value) => {
@@ -0,0 +1,44 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { setTimeout as delay } from 'node:timers/promises';
7
+
8
+ import { ensureEnvFilePruned, ensureEnvFileUpdated } from './env_file.mjs';
9
+
10
+ test('ensureEnvFileUpdated appends new key and ensures trailing newline', async () => {
11
+ const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-env-file-'));
12
+ const envPath = join(dir, 'env');
13
+
14
+ await ensureEnvFileUpdated({ envPath, updates: [{ key: 'OPENAI_API_KEY', value: 'sk-test' }] });
15
+ const next = await readFile(envPath, 'utf-8');
16
+ assert.equal(next, 'OPENAI_API_KEY=sk-test\n');
17
+ });
18
+
19
+ test('ensureEnvFileUpdated does not touch file when no content changes', async () => {
20
+ const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-env-file-'));
21
+ const envPath = join(dir, 'env');
22
+
23
+ await writeFile(envPath, 'FOO=bar\n', 'utf-8');
24
+ const before = await stat(envPath);
25
+
26
+ // Ensure filesystem mtime resolution won't hide unintended writes.
27
+ await delay(25);
28
+
29
+ await ensureEnvFileUpdated({ envPath, updates: [{ key: 'FOO', value: 'bar' }] });
30
+ const after = await stat(envPath);
31
+ assert.equal(after.mtimeMs, before.mtimeMs);
32
+ });
33
+
34
+ test('ensureEnvFilePruned removes a key but keeps comments/blank lines', async () => {
35
+ const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-env-file-'));
36
+ const envPath = join(dir, 'env');
37
+
38
+ await writeFile(envPath, '# header\nFOO=bar\n\nBAZ=qux\n', 'utf-8');
39
+ await ensureEnvFilePruned({ envPath, removeKeys: ['FOO'] });
40
+
41
+ const next = await readFile(envPath, 'utf-8');
42
+ assert.equal(next, '# header\n\nBAZ=qux\n');
43
+ });
44
+