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
@@ -2,41 +2,32 @@ 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';
8
9
  import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
9
10
  import { watchDebounced } from '../proc/watch.mjs';
10
-
11
- function hashStringToInt(s) {
12
- let h = 0;
13
- const str = String(s ?? '');
14
- for (let i = 0; i < str.length; i++) {
15
- h = (h * 31 + str.charCodeAt(i)) >>> 0;
16
- }
17
- return h >>> 0;
18
- }
11
+ import { pickMetroPort, resolveStablePortStart } from '../expo/metro_ports.mjs';
19
12
 
20
13
  export function resolveStackUiDevPortStart({ env = process.env, stackName }) {
21
- const baseRaw = (env.HAPPY_STACKS_UI_DEV_PORT_BASE ?? env.HAPPY_LOCAL_UI_DEV_PORT_BASE ?? '8081').toString().trim();
22
- const rangeRaw = (env.HAPPY_STACKS_UI_DEV_PORT_RANGE ?? env.HAPPY_LOCAL_UI_DEV_PORT_RANGE ?? '1000').toString().trim();
23
- const base = Number(baseRaw);
24
- const range = Number(rangeRaw);
25
- const b = Number.isFinite(base) ? base : 8081;
26
- const r = Number.isFinite(range) && range > 0 ? range : 1000;
27
- return b + (hashStringToInt(stackName) % r);
14
+ return resolveStablePortStart({
15
+ env: {
16
+ ...env,
17
+ HAPPY_STACKS_UI_DEV_PORT_BASE: (env.HAPPY_STACKS_UI_DEV_PORT_BASE ?? env.HAPPY_LOCAL_UI_DEV_PORT_BASE ?? '8081').toString(),
18
+ HAPPY_STACKS_UI_DEV_PORT_RANGE: (env.HAPPY_STACKS_UI_DEV_PORT_RANGE ?? env.HAPPY_LOCAL_UI_DEV_PORT_RANGE ?? '1000').toString(),
19
+ },
20
+ stackName,
21
+ baseKey: 'HAPPY_STACKS_UI_DEV_PORT_BASE',
22
+ rangeKey: 'HAPPY_STACKS_UI_DEV_PORT_RANGE',
23
+ defaultBase: 8081,
24
+ defaultRange: 1000,
25
+ });
28
26
  }
29
27
 
30
28
  export async function pickDevMetroPort({ startPort, reservedPorts = new Set(), host = '127.0.0.1' } = {}) {
31
- const forcedRaw = (process.env.HAPPY_STACKS_UI_DEV_PORT ?? process.env.HAPPY_LOCAL_UI_DEV_PORT ?? '').toString().trim();
32
- if (forcedRaw) {
33
- const forced = Number(forcedRaw);
34
- if (Number.isFinite(forced) && forced > 0) {
35
- const ok = await isTcpPortFree(forced, { host });
36
- if (ok) return forced;
37
- }
38
- }
39
- return await pickNextFreeTcpPort(startPort, { reservedPorts, host });
29
+ const forcedPort = (process.env.HAPPY_STACKS_UI_DEV_PORT ?? process.env.HAPPY_LOCAL_UI_DEV_PORT ?? '').toString().trim();
30
+ return await pickMetroPort({ startPort, forcedPort, reservedPorts, host });
40
31
  }
41
32
 
42
33
  export async function startDevServer({
@@ -97,12 +88,7 @@ export async function startDevServer({
97
88
  await ensureDepsInstalled(serverDir, serverComponentName);
98
89
 
99
90
  const prismaPush = (baseEnv.HAPPY_STACKS_PRISMA_PUSH ?? baseEnv.HAPPY_LOCAL_PRISMA_PUSH ?? '1').toString().trim() !== '0';
100
- const serverScript =
101
- serverComponentName === 'happy-server'
102
- ? 'start'
103
- : serverComponentName === 'happy-server-light' && !prismaPush
104
- ? 'start'
105
- : 'dev';
91
+ const serverScript = resolveServerDevScript({ serverComponentName, serverDir, prismaPush });
106
92
 
107
93
  // Restart behavior (stack-safe): only kill when we can prove ownership via runtime state.
108
94
  if (restart && stackMode && runtimeStatePath && serverAlreadyRunning) {
@@ -165,19 +151,27 @@ export function watchDevServerAndRestart({
165
151
  const pid = Number(serverProcRef?.current?.pid);
166
152
  if (!Number.isFinite(pid) || pid <= 1) return;
167
153
 
168
- // eslint-disable-next-line no-console
169
- console.log('[local] watch: server changed → restarting...');
170
- 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 });
171
158
 
172
- const next = await pmSpawnScript({ label: 'server', dir: serverDir, script: serverScript, env: serverEnv });
173
- children.push(next);
174
- serverProcRef.current = next;
175
- if (stackMode && runtimeStatePath) {
176
- 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);
177
174
  }
178
- await waitForServerReady(internalServerUrl);
179
- // eslint-disable-next-line no-console
180
- console.log(`[local] watch: server restarted (pid=${next.pid}, port=${serverPort})`);
181
175
  },
182
176
  });
183
177
  }
@@ -0,0 +1,169 @@
1
+ import { chmod, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+
6
+ import { expandHome } from './paths/canonical_home.mjs';
7
+
8
+ export function resolveHappyStacksHomeDir(env = process.env) {
9
+ const fromEnv = (env.HAPPY_STACKS_HOME_DIR ?? env.HAPPY_LOCAL_HOME_DIR ?? '').toString().trim();
10
+ return fromEnv ? expandHome(fromEnv) : join(homedir(), '.happy-stacks');
11
+ }
12
+
13
+ export function getDevAuthKeyPath(env = process.env) {
14
+ return join(resolveHappyStacksHomeDir(env), 'keys', 'dev-auth.json');
15
+ }
16
+
17
+ function base64UrlToBytes(s) {
18
+ try {
19
+ const raw = String(s ?? '').trim();
20
+ if (!raw) return null;
21
+ const b64 = raw.replace(/-/g, '+').replace(/_/g, '/');
22
+ const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4));
23
+ return new Uint8Array(Buffer.from(b64 + pad, 'base64'));
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function bytesToBase64Url(bytes) {
30
+ const b64 = Buffer.from(bytes).toString('base64');
31
+ return b64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
32
+ }
33
+
34
+ // Base32 alphabet (RFC 4648)
35
+ const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
36
+
37
+ function bytesToBase32(bytes) {
38
+ let result = '';
39
+ let buffer = 0;
40
+ let bufferLength = 0;
41
+
42
+ for (const byte of bytes) {
43
+ buffer = (buffer << 8) | byte;
44
+ bufferLength += 8;
45
+ while (bufferLength >= 5) {
46
+ bufferLength -= 5;
47
+ result += BASE32_ALPHABET[(buffer >> bufferLength) & 0x1f];
48
+ }
49
+ }
50
+ if (bufferLength > 0) {
51
+ result += BASE32_ALPHABET[(buffer << (5 - bufferLength)) & 0x1f];
52
+ }
53
+ return result;
54
+ }
55
+
56
+ function base32ToBytes(base32) {
57
+ let normalized = String(base32 ?? '')
58
+ .toUpperCase()
59
+ .replace(/0/g, 'O')
60
+ .replace(/1/g, 'I')
61
+ .replace(/8/g, 'B')
62
+ .replace(/9/g, 'G');
63
+ const cleaned = normalized.replace(/[^A-Z2-7]/g, '');
64
+ if (!cleaned) throw new Error('no valid base32 characters');
65
+
66
+ const bytes = [];
67
+ let buffer = 0;
68
+ let bufferLength = 0;
69
+ for (const char of cleaned) {
70
+ const value = BASE32_ALPHABET.indexOf(char);
71
+ if (value === -1) throw new Error('invalid base32 character');
72
+ buffer = (buffer << 5) | value;
73
+ bufferLength += 5;
74
+ if (bufferLength >= 8) {
75
+ bufferLength -= 8;
76
+ bytes.push((buffer >> bufferLength) & 0xff);
77
+ }
78
+ }
79
+ return new Uint8Array(bytes);
80
+ }
81
+
82
+ export function normalizeDevAuthKeyInputToBytes(input) {
83
+ const raw = String(input ?? '').trim();
84
+ if (!raw) return null;
85
+
86
+ // Match Happy UI behavior:
87
+ // - backup format is base32 and is long (usually grouped with '-' / spaces)
88
+ // - base64url is short (~43 chars) and may contain '-' / '_' legitimately
89
+ //
90
+ // Key point: avoid mis-parsing backup base32 as base64.
91
+ if (raw.length > 50) {
92
+ try {
93
+ const b32 = base32ToBytes(raw);
94
+ return b32.length === 32 ? b32 : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ const b64 = base64UrlToBytes(raw);
101
+ if (b64 && b64.length === 32) return b64;
102
+ try {
103
+ const b32 = base32ToBytes(raw);
104
+ return b32.length === 32 ? b32 : null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ export function formatDevAuthKeyBackup(secretKeyBase64Url) {
111
+ const bytes = base64UrlToBytes(secretKeyBase64Url);
112
+ if (!bytes || bytes.length !== 32) throw new Error('invalid secret key (expected base64url 32 bytes)');
113
+ const base32 = bytesToBase32(bytes);
114
+ const groups = [];
115
+ for (let i = 0; i < base32.length; i += 5) groups.push(base32.slice(i, i + 5));
116
+ return groups.join('-');
117
+ }
118
+
119
+ export async function readDevAuthKey({ env = process.env } = {}) {
120
+ if ((env.HAPPY_STACKS_DEV_AUTH_SECRET_KEY ?? '').toString().trim()) {
121
+ const bytes = normalizeDevAuthKeyInputToBytes(env.HAPPY_STACKS_DEV_AUTH_SECRET_KEY);
122
+ if (!bytes) return { ok: false, error: 'invalid_env_key', source: 'env', secretKeyBase64Url: null, backup: null };
123
+ const base64url = bytesToBase64Url(bytes);
124
+ return { ok: true, source: 'env:HAPPY_STACKS_DEV_AUTH_SECRET_KEY', secretKeyBase64Url: base64url, backup: formatDevAuthKeyBackup(base64url) };
125
+ }
126
+
127
+ const path = getDevAuthKeyPath(env);
128
+ try {
129
+ if (!existsSync(path)) return { ok: true, source: `file:${path}`, secretKeyBase64Url: null, backup: null, path };
130
+ const raw = await readFile(path, 'utf-8');
131
+ const parsed = JSON.parse(raw);
132
+ const input = parsed?.secretKeyBase64Url ?? parsed?.secretKey ?? parsed?.key ?? null;
133
+ const bytes = normalizeDevAuthKeyInputToBytes(input);
134
+ if (!bytes) return { ok: false, error: 'invalid_file_key', source: `file:${path}`, secretKeyBase64Url: null, backup: null, path };
135
+ const base64url = bytesToBase64Url(bytes);
136
+ return { ok: true, source: `file:${path}`, secretKeyBase64Url: base64url, backup: formatDevAuthKeyBackup(base64url), path };
137
+ } catch (e) {
138
+ return { ok: false, error: 'failed_to_read', source: `file:${path}`, secretKeyBase64Url: null, backup: null, path, details: e instanceof Error ? e.message : String(e) };
139
+ }
140
+ }
141
+
142
+ export async function writeDevAuthKey({ env = process.env, input } = {}) {
143
+ const bytes = normalizeDevAuthKeyInputToBytes(input);
144
+ if (!bytes || bytes.length !== 32) {
145
+ throw new Error('invalid secret key (expected 32 bytes; accept base64url or backup format)');
146
+ }
147
+ const secretKeyBase64Url = bytesToBase64Url(bytes);
148
+ const path = getDevAuthKeyPath(env);
149
+ await mkdir(dirname(path), { recursive: true });
150
+ const payload = {
151
+ v: 1,
152
+ createdAt: new Date().toISOString(),
153
+ secretKeyBase64Url,
154
+ };
155
+ await writeFile(path, JSON.stringify(payload, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
156
+ await chmod(path, 0o600).catch(() => {});
157
+ return { ok: true, path, secretKeyBase64Url, backup: formatDevAuthKeyBackup(secretKeyBase64Url) };
158
+ }
159
+
160
+ export async function clearDevAuthKey({ env = process.env } = {}) {
161
+ const path = getDevAuthKeyPath(env);
162
+ try {
163
+ if (!existsSync(path)) return { ok: true, deleted: false, path };
164
+ await unlink(path);
165
+ return { ok: true, deleted: true, path };
166
+ } catch (e) {
167
+ return { ok: false, deleted: false, path, error: e instanceof Error ? e.message : String(e) };
168
+ }
169
+ }
@@ -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
+
@@ -0,0 +1,52 @@
1
+ import { ensureDepsInstalled, pmExecBin, pmSpawnBin } from '../proc/pm.mjs';
2
+ import { ensureExpoIsolationEnv, getExpoStatePaths, wantsExpoClearCache } from './expo.mjs';
3
+
4
+ export async function prepareExpoCommandEnv({
5
+ baseDir,
6
+ kind,
7
+ projectDir,
8
+ baseEnv,
9
+ stateFileName,
10
+ }) {
11
+ const env = { ...(baseEnv ?? process.env) };
12
+ const paths = getExpoStatePaths({ baseDir, kind, projectDir, stateFileName });
13
+ await ensureExpoIsolationEnv({ env, stateDir: paths.stateDir, expoHomeDir: paths.expoHomeDir, tmpDir: paths.tmpDir });
14
+ return { env, paths };
15
+ }
16
+
17
+ export function maybeAddExpoClear({ args, env }) {
18
+ const next = [...(args ?? [])];
19
+ if (wantsExpoClearCache({ env: env ?? process.env })) {
20
+ // Expo supports `--clear` for start, and `-c` for export.
21
+ // Callers should pass the right flag for their subcommand; we only add when missing.
22
+ if (!next.includes('--clear') && !next.includes('-c')) {
23
+ // Prefer `--clear` as a safe default; callers can override per-command.
24
+ next.push('--clear');
25
+ }
26
+ }
27
+ return next;
28
+ }
29
+
30
+ export async function expoExec({
31
+ dir,
32
+ args,
33
+ env,
34
+ ensureDepsLabel = 'happy',
35
+ quiet = false,
36
+ }) {
37
+ await ensureDepsInstalled(dir, ensureDepsLabel, { quiet });
38
+ await pmExecBin({ dir, bin: 'expo', args, env, quiet });
39
+ }
40
+
41
+ export async function expoSpawn({
42
+ label,
43
+ dir,
44
+ args,
45
+ env,
46
+ ensureDepsLabel = 'happy',
47
+ options,
48
+ }) {
49
+ await ensureDepsInstalled(dir, ensureDepsLabel);
50
+ return await pmSpawnBin({ label, dir, bin: 'expo', args, env, options });
51
+ }
52
+
@@ -4,6 +4,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { setTimeout as delay } from 'node:timers/promises';
6
6
  import { isPidAlive } from '../proc/pids.mjs';
7
+ import { isTcpPortFree } from '../net/ports.mjs';
7
8
 
8
9
  export { isPidAlive };
9
10
 
@@ -63,7 +64,25 @@ export async function isStateProcessRunning(statePath) {
63
64
  const state = await readPidState(statePath);
64
65
  if (!state) return { running: false, state: null };
65
66
  const pid = Number(state.pid);
66
- return { running: isPidAlive(pid), state };
67
+ if (isPidAlive(pid)) {
68
+ return { running: true, state, reason: 'pid' };
69
+ }
70
+
71
+ // Expo/Metro can sometimes be “up” even if the original wrapper pid exited (pm/yarn layers).
72
+ // If we have a port and something is listening on it, treat it as running.
73
+ const port = Number(state?.port);
74
+ if (Number.isFinite(port) && port > 0) {
75
+ try {
76
+ const free = await isTcpPortFree(port, { host: '127.0.0.1' });
77
+ if (!free) {
78
+ return { running: true, state, reason: 'port' };
79
+ }
80
+ } catch {
81
+ // ignore
82
+ }
83
+ }
84
+
85
+ return { running: false, state };
67
86
  }
68
87
 
69
88
  export async function writePidState(statePath, state) {
@@ -0,0 +1,114 @@
1
+ import { isTcpPortFree, pickNextFreeTcpPort } from '../net/ports.mjs';
2
+
3
+ function hashStringToInt(s) {
4
+ let h = 0;
5
+ const str = String(s ?? '');
6
+ for (let i = 0; i < str.length; i++) {
7
+ h = (h * 31 + str.charCodeAt(i)) >>> 0;
8
+ }
9
+ return h >>> 0;
10
+ }
11
+
12
+ function coercePositiveInt(v) {
13
+ const n = Number(v);
14
+ return Number.isFinite(n) && n > 0 ? n : null;
15
+ }
16
+
17
+ export function resolveStablePortStart({
18
+ env = process.env,
19
+ stackName,
20
+ baseKey,
21
+ rangeKey,
22
+ defaultBase,
23
+ defaultRange,
24
+ }) {
25
+ const baseRaw = (env[baseKey] ?? '').toString().trim();
26
+ const rangeRaw = (env[rangeKey] ?? '').toString().trim();
27
+ const base = coercePositiveInt(baseRaw) ?? defaultBase;
28
+ const range = coercePositiveInt(rangeRaw) ?? defaultRange;
29
+ return base + (hashStringToInt(stackName) % range);
30
+ }
31
+
32
+ export async function pickMetroPort({
33
+ startPort,
34
+ forcedPort,
35
+ reservedPorts = new Set(),
36
+ host = '127.0.0.1',
37
+ } = {}) {
38
+ const forced = coercePositiveInt(forcedPort);
39
+ if (forced) {
40
+ const ok = await isTcpPortFree(forced, { host });
41
+ if (ok) return forced;
42
+ }
43
+ return await pickNextFreeTcpPort(startPort, { reservedPorts, host });
44
+ }
45
+
46
+ export function wantsStablePortStrategy({ env = process.env, strategyKey, legacyStrategyKey } = {}) {
47
+ const raw = (env[strategyKey] ?? env[legacyStrategyKey] ?? 'ephemeral').toString().trim() || 'ephemeral';
48
+ return raw === 'stable';
49
+ }
50
+
51
+ export async function pickUiDevMetroPort({
52
+ env = process.env,
53
+ stackMode,
54
+ stackName,
55
+ reservedPorts = new Set(),
56
+ host = '127.0.0.1',
57
+ } = {}) {
58
+ // Legacy alias: UI dev Metro is now the unified Expo dev server port.
59
+ return await pickExpoDevMetroPort({ env, stackMode, stackName, reservedPorts, host });
60
+ }
61
+
62
+ export async function pickMobileDevMetroPort({
63
+ env = process.env,
64
+ stackMode,
65
+ stackName,
66
+ reservedPorts = new Set(),
67
+ host = '127.0.0.1',
68
+ } = {}) {
69
+ // Legacy alias: mobile dev Metro is now the unified Expo dev server port.
70
+ return await pickExpoDevMetroPort({ env, stackMode, stackName, reservedPorts, host });
71
+ }
72
+
73
+ export async function pickExpoDevMetroPort({
74
+ env = process.env,
75
+ stackMode,
76
+ stackName,
77
+ reservedPorts = new Set(),
78
+ host = '127.0.0.1',
79
+ } = {}) {
80
+ const forcedPort =
81
+ (env.HAPPY_STACKS_EXPO_DEV_PORT ??
82
+ env.HAPPY_LOCAL_EXPO_DEV_PORT ??
83
+ // Back-compat: older knobs.
84
+ env.HAPPY_STACKS_UI_DEV_PORT ??
85
+ env.HAPPY_LOCAL_UI_DEV_PORT ??
86
+ env.HAPPY_STACKS_MOBILE_DEV_PORT ??
87
+ env.HAPPY_LOCAL_MOBILE_DEV_PORT ??
88
+ env.HAPPY_STACKS_MOBILE_PORT ??
89
+ env.HAPPY_LOCAL_MOBILE_PORT ??
90
+ '')
91
+ .toString()
92
+ .trim() || '';
93
+
94
+ const stable =
95
+ stackMode &&
96
+ wantsStablePortStrategy({
97
+ env,
98
+ strategyKey: 'HAPPY_STACKS_EXPO_DEV_PORT_STRATEGY',
99
+ legacyStrategyKey: 'HAPPY_LOCAL_EXPO_DEV_PORT_STRATEGY',
100
+ });
101
+ const startPort = stable
102
+ ? resolveStablePortStart({
103
+ env,
104
+ stackName,
105
+ baseKey: 'HAPPY_STACKS_EXPO_DEV_PORT_BASE',
106
+ rangeKey: 'HAPPY_STACKS_EXPO_DEV_PORT_RANGE',
107
+ defaultBase: 8081,
108
+ defaultRange: 1000,
109
+ })
110
+ : 8081;
111
+
112
+ return await pickMetroPort({ startPort, forcedPort, reservedPorts, host });
113
+ }
114
+