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,4 +1,5 @@
1
- import { resolve } from 'node:path';
1
+ import { join, resolve } from 'node:path';
2
+ import { existsSync } from 'node:fs';
2
3
 
3
4
  import { ensureCliBuilt, ensureDepsInstalled } from '../proc/pm.mjs';
4
5
  import { watchDebounced } from '../proc/watch.mjs';
@@ -7,7 +8,26 @@ import { startLocalDaemonWithAuth } from '../../daemon.mjs';
7
8
 
8
9
  export async function ensureDevCliReady({ cliDir, buildCli }) {
9
10
  await ensureDepsInstalled(cliDir, 'happy-cli');
10
- return await ensureCliBuilt(cliDir, { buildCli });
11
+ const res = await ensureCliBuilt(cliDir, { buildCli });
12
+
13
+ // Fail closed: dev mode must never start the daemon without a usable happy-cli build output.
14
+ // Even if the user disabled CLI builds globally (or build mode is "never"), missing dist will
15
+ // cause an immediate MODULE_NOT_FOUND crash when spawning the daemon.
16
+ const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
17
+ if (!existsSync(distEntrypoint)) {
18
+ // Last-chance recovery: force a build once.
19
+ await ensureCliBuilt(cliDir, { buildCli: true });
20
+ if (!existsSync(distEntrypoint)) {
21
+ throw new Error(
22
+ `[local] happy-cli build output is missing.\n` +
23
+ `Expected: ${distEntrypoint}\n` +
24
+ `Fix: run the component build directly and inspect its output:\n` +
25
+ ` cd "${cliDir}" && yarn build`
26
+ );
27
+ }
28
+ }
29
+
30
+ return res;
11
31
  }
12
32
 
13
33
  export async function prepareDaemonAuthSeed({
@@ -77,8 +97,25 @@ export function watchHappyCliAndRestartDaemon({
77
97
  if (!enabled || !startDaemon) return null;
78
98
 
79
99
  let inFlight = false;
100
+
101
+ // IMPORTANT:
102
+ // Watch only source/config paths, not build outputs. Watching the whole repo can
103
+ // trigger rebuild loops because `yarn build` writes to `dist/` (and may touch other
104
+ // generated files), which then retriggers the watcher.
105
+ const watchPaths = [
106
+ join(cliDir, 'src'),
107
+ join(cliDir, 'bin'),
108
+ join(cliDir, 'codex'),
109
+ join(cliDir, 'package.json'),
110
+ join(cliDir, 'tsconfig.json'),
111
+ join(cliDir, 'tsconfig.build.json'),
112
+ join(cliDir, 'pkgroll.config.mjs'),
113
+ join(cliDir, 'yarn.lock'),
114
+ join(cliDir, 'pnpm-lock.yaml'),
115
+ ].filter((p) => existsSync(p));
116
+
80
117
  return watchDebounced({
81
- paths: [resolve(cliDir)],
118
+ paths: (watchPaths.length ? watchPaths : [cliDir]).map((p) => resolve(p)),
82
119
  debounceMs: 500,
83
120
  onChange: async () => {
84
121
  if (isShuttingDown?.()) return;
@@ -87,7 +124,27 @@ export function watchHappyCliAndRestartDaemon({
87
124
  try {
88
125
  // eslint-disable-next-line no-console
89
126
  console.log('[local] watch: happy-cli changed → rebuilding + restarting daemon...');
90
- 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
+ }
141
+ const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
142
+ if (!existsSync(distEntrypoint)) {
143
+ console.warn(
144
+ `[local] watch: happy-cli build did not produce ${distEntrypoint}; refusing to restart daemon to avoid downtime.`
145
+ );
146
+ return;
147
+ }
91
148
  await startLocalDaemonWithAuth({
92
149
  cliBin,
93
150
  cliHomeDir,
@@ -0,0 +1,430 @@
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';
5
+ import {
6
+ ensureExpoIsolationEnv,
7
+ getExpoStatePaths,
8
+ isStateProcessRunning,
9
+ wantsExpoClearCache,
10
+ writePidState,
11
+ } from '../expo/expo.mjs';
12
+ import { pickExpoDevMetroPort } from '../expo/metro_ports.mjs';
13
+ import { recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
14
+ import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
15
+ import { expoSpawn } from '../expo/command.mjs';
16
+ import { resolveMobileExpoConfig } from '../mobile/config.mjs';
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);
23
+
24
+ function normalizeExpoHost(raw) {
25
+ const v = String(raw ?? '').trim().toLowerCase();
26
+ if (v === 'localhost' || v === 'lan' || v === 'tunnel') return v;
27
+ return 'lan';
28
+ }
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
+
164
+ export function resolveExpoDevHost({ env = process.env } = {}) {
165
+ // Always prefer LAN by default so phones can reach Metro.
166
+ const raw = (env.HAPPY_STACKS_EXPO_HOST ?? env.HAPPY_LOCAL_EXPO_HOST ?? '').toString();
167
+ return normalizeExpoHost(raw || 'lan');
168
+ }
169
+
170
+ export function buildExpoStartArgs({ port, host, wantWeb, wantDevClient, scheme, clearCache }) {
171
+ const metroPort = Number(port);
172
+ if (!Number.isFinite(metroPort) || metroPort <= 0) {
173
+ throw new Error(`[expo] invalid Metro port: ${String(port)}`);
174
+ }
175
+ if (!wantWeb && !wantDevClient) {
176
+ throw new Error('[expo] cannot build Expo args: neither web nor dev-client requested');
177
+ }
178
+
179
+ // IMPORTANT:
180
+ // - We must only run one Expo per stack.
181
+ // - Expo dev-client mode is known to still serve web when accessed locally, so when mobile is
182
+ // requested we prefer `--dev-client` as the single shared process (no second `--web` process).
183
+ const args = wantDevClient
184
+ ? ['start', '--dev-client', '--host', host, '--port', String(metroPort)]
185
+ : ['start', '--web', '--host', host, '--port', String(metroPort)];
186
+
187
+ if (wantDevClient) {
188
+ const s = String(scheme ?? '').trim();
189
+ if (s) {
190
+ args.push('--scheme', s);
191
+ }
192
+ }
193
+
194
+ if (clearCache && !args.includes('--clear')) {
195
+ args.push('--clear');
196
+ }
197
+
198
+ return args;
199
+ }
200
+
201
+ function expoModeLabel({ wantWeb, wantDevClient }) {
202
+ if (wantWeb && wantDevClient) return 'dev-client+web';
203
+ if (wantDevClient) return 'dev-client';
204
+ if (wantWeb) return 'web';
205
+ return 'disabled';
206
+ }
207
+
208
+ export async function ensureDevExpoServer({
209
+ startUi,
210
+ startMobile,
211
+ uiDir,
212
+ autostart,
213
+ baseEnv,
214
+ apiServerUrl,
215
+ restart,
216
+ stackMode,
217
+ runtimeStatePath,
218
+ stackName,
219
+ envPath,
220
+ children,
221
+ spawnOptions = {},
222
+ expoTailscale = false,
223
+ } = {}) {
224
+ const wantWeb = Boolean(startUi);
225
+ const wantDevClient = Boolean(startMobile);
226
+ if (!wantWeb && !wantDevClient) {
227
+ return { ok: true, skipped: true, reason: 'disabled' };
228
+ }
229
+
230
+ const env = { ...(baseEnv || process.env) };
231
+ delete env.CI;
232
+ // Expo app config: this is what both web + native app use to reach the Happy server.
233
+ // When dev-client is enabled, `localhost` / `*.localhost` are not reachable from the phone,
234
+ // so rewrite to LAN IP here (centralized) to avoid relying on call sites.
235
+ const serverPortFromEnvRaw = (env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim();
236
+ const serverPortFromEnv = serverPortFromEnvRaw ? Number(serverPortFromEnvRaw) : null;
237
+ const effectiveApiServerUrl = wantDevClient
238
+ ? resolveMobileReachableServerUrl({
239
+ env,
240
+ serverUrl: apiServerUrl,
241
+ serverPort: Number.isFinite(serverPortFromEnv) ? serverPortFromEnv : null,
242
+ })
243
+ : apiServerUrl;
244
+ env.EXPO_PUBLIC_HAPPY_SERVER_URL = effectiveApiServerUrl;
245
+ env.EXPO_PUBLIC_DEBUG = env.EXPO_PUBLIC_DEBUG ?? '1';
246
+
247
+ // Optional: allow per-stack storage isolation inside a single dev-client build by
248
+ // scoping app persistence (MMKV / SecureStore) to a stack-specific namespace.
249
+ //
250
+ // This stays upstream-safe because the app behavior is unchanged unless the Expo public
251
+ // env var is explicitly set. Happy Stacks sets it automatically for stack-mode dev-client.
252
+ if (wantDevClient) {
253
+ const explicitScope = (
254
+ env.HAPPY_STACKS_STORAGE_SCOPE ??
255
+ env.HAPPY_LOCAL_STORAGE_SCOPE ??
256
+ env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE ??
257
+ ''
258
+ )
259
+ .toString()
260
+ .trim();
261
+ const defaultScope = stackMode && stackName ? String(stackName).trim() : '';
262
+ const scope = explicitScope || defaultScope;
263
+ if (scope && !env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE) {
264
+ env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE = scope;
265
+ }
266
+ }
267
+
268
+ // We own the browser opening behavior in Happy Stacks so we can reliably open the correct origin.
269
+ env.EXPO_NO_BROWSER = '1';
270
+ env.BROWSER = 'none';
271
+
272
+ // Mobile config is needed for `--scheme` and for the app's environment.
273
+ let scheme = '';
274
+ if (wantDevClient) {
275
+ const cfg = resolveMobileExpoConfig({ env });
276
+ env.APP_ENV = cfg.appEnv;
277
+ scheme = cfg.scheme;
278
+ }
279
+
280
+ const paths = getExpoStatePaths({
281
+ baseDir: autostart.baseDir,
282
+ kind: 'expo-dev',
283
+ projectDir: uiDir,
284
+ stateFileName: 'expo.state.json',
285
+ });
286
+ await ensureExpoIsolationEnv({ env, stateDir: paths.stateDir, expoHomeDir: paths.expoHomeDir, tmpDir: paths.tmpDir });
287
+
288
+ const running = await isStateProcessRunning(paths.statePath);
289
+ const alreadyRunning = Boolean(running.running);
290
+
291
+ // Resolve Tailscale forwarding preference
292
+ const wantTailscale = resolveExpoTailscaleEnabled({ env: baseEnv, expoTailscale });
293
+
294
+ // Always publish runtime metadata when we can.
295
+ const publishRuntime = async ({ pid, port, tailscaleForwarderPid = null, tailscaleIp = null }) => {
296
+ if (!stackMode || !runtimeStatePath) return;
297
+ const nPid = Number(pid);
298
+ const nPort = Number(port);
299
+ const nTsPid = Number(tailscaleForwarderPid);
300
+ await recordStackRuntimeUpdate(runtimeStatePath, {
301
+ processes: {
302
+ expoPid: Number.isFinite(nPid) && nPid > 1 ? nPid : null,
303
+ expoTailscaleForwarderPid: Number.isFinite(nTsPid) && nTsPid > 1 ? nTsPid : null,
304
+ },
305
+ expo: {
306
+ port: Number.isFinite(nPort) && nPort > 0 ? nPort : null,
307
+ // For now keep these populated for callers that still expect webPort/mobilePort.
308
+ webPort: wantWeb && Number.isFinite(nPort) && nPort > 0 ? nPort : null,
309
+ mobilePort: wantDevClient && Number.isFinite(nPort) && nPort > 0 ? nPort : null,
310
+ webEnabled: wantWeb,
311
+ devClientEnabled: wantDevClient,
312
+ host: resolveExpoDevHost({ env }),
313
+ scheme: wantDevClient ? scheme : null,
314
+ tailscaleEnabled: wantTailscale,
315
+ tailscaleIp: tailscaleIp ?? null,
316
+ },
317
+ }).catch(() => {});
318
+ };
319
+
320
+ if (alreadyRunning && !restart) {
321
+ const pid = Number(running.state?.pid);
322
+ const port = Number(running.state?.port);
323
+
324
+ // Capability check: refuse to spawn a second Expo, so if the existing process doesn't match the
325
+ // requested capabilities we fail closed and instruct a restart with the superset.
326
+ const stateWeb = Boolean(running.state?.webEnabled);
327
+ const stateDevClient = Boolean(running.state?.devClientEnabled);
328
+ const stateHasCaps = 'webEnabled' in (running.state ?? {}) || 'devClientEnabled' in (running.state ?? {});
329
+ const missingWeb = wantWeb && stateHasCaps && !stateWeb;
330
+ const missingDevClient = wantDevClient && stateHasCaps && !stateDevClient;
331
+ if (missingWeb || missingDevClient) {
332
+ throw new Error(
333
+ `[expo] Expo already running for stack=${stackName}, but it does not match the requested mode.\n` +
334
+ `- running: ${expoModeLabel({ wantWeb: stateWeb, wantDevClient: stateDevClient })}\n` +
335
+ `- wanted: ${expoModeLabel({ wantWeb, wantDevClient })}\n` +
336
+ `Fix: re-run with --restart (and include --mobile if you need dev-client).`
337
+ );
338
+ }
339
+
340
+ await publishRuntime({ pid, port });
341
+ return {
342
+ ok: true,
343
+ skipped: true,
344
+ reason: 'already_running',
345
+ pid: Number.isFinite(pid) ? pid : null,
346
+ port: Number.isFinite(port) ? port : null,
347
+ mode: expoModeLabel({ wantWeb, wantDevClient }),
348
+ };
349
+ }
350
+
351
+ const metroPort = await pickExpoDevMetroPort({ env: baseEnv, stackMode, stackName });
352
+ env.RCT_METRO_PORT = String(metroPort);
353
+ const host = resolveExpoDevHost({ env });
354
+ const args = buildExpoStartArgs({
355
+ port: metroPort,
356
+ host,
357
+ wantWeb,
358
+ wantDevClient,
359
+ scheme,
360
+ clearCache: wantsExpoClearCache({ env: baseEnv || process.env }),
361
+ });
362
+
363
+ if (restart && running.state?.pid) {
364
+ const prevPid = Number(running.state.pid);
365
+ const res = await killProcessGroupOwnedByStack(prevPid, { stackName, envPath, label: 'expo', json: true });
366
+ if (!res.killed) {
367
+ // eslint-disable-next-line no-console
368
+ console.warn(
369
+ `[local] expo: not stopping existing Expo pid=${prevPid} because it does not look stack-owned.\n` +
370
+ `[local] expo: continuing by starting a new Expo process on a free port.`
371
+ );
372
+ }
373
+ }
374
+
375
+ // eslint-disable-next-line no-console
376
+ console.log(`[local] expo: starting Expo (${expoModeLabel({ wantWeb, wantDevClient })}, metro port=${metroPort}, host=${host})`);
377
+ const proc = await expoSpawn({ label: 'expo', dir: uiDir, args, env, options: spawnOptions });
378
+ children.push(proc);
379
+
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
+ });
401
+
402
+ try {
403
+ await writePidState(paths.statePath, {
404
+ pid: proc.pid,
405
+ port: metroPort,
406
+ uiDir,
407
+ startedAt: new Date().toISOString(),
408
+ webEnabled: wantWeb,
409
+ devClientEnabled: wantDevClient,
410
+ host,
411
+ scheme: wantDevClient ? scheme : null,
412
+ tailscaleEnabled: wantTailscale,
413
+ tailscaleForwarderPid: tailscaleResult?.pid ?? null,
414
+ tailscaleIp: tailscaleResult?.tailscaleIp ?? null,
415
+ });
416
+ } catch {
417
+ // ignore
418
+ }
419
+
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
+ };
429
+ }
430
+
@@ -0,0 +1,76 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { buildExpoStartArgs, resolveExpoDevHost } from './expo_dev.mjs';
5
+
6
+ test('resolveExpoDevHost defaults to lan and normalizes values', () => {
7
+ assert.equal(resolveExpoDevHost({ env: {} }), 'lan');
8
+ assert.equal(resolveExpoDevHost({ env: { HAPPY_STACKS_EXPO_HOST: 'LAN' } }), 'lan');
9
+ assert.equal(resolveExpoDevHost({ env: { HAPPY_STACKS_EXPO_HOST: 'localhost' } }), 'localhost');
10
+ assert.equal(resolveExpoDevHost({ env: { HAPPY_STACKS_EXPO_HOST: 'tunnel' } }), 'tunnel');
11
+ assert.equal(resolveExpoDevHost({ env: { HAPPY_STACKS_EXPO_HOST: 'nope' } }), 'lan');
12
+ });
13
+
14
+ test('buildExpoStartArgs builds dev-client args (preferred when mobile enabled)', () => {
15
+ const args = buildExpoStartArgs({
16
+ port: 8081,
17
+ host: 'lan',
18
+ wantWeb: true,
19
+ wantDevClient: true,
20
+ scheme: 'happy',
21
+ clearCache: true,
22
+ });
23
+ assert.deepEqual(args, ['start', '--dev-client', '--host', 'lan', '--port', '8081', '--scheme', 'happy', '--clear']);
24
+ });
25
+
26
+ test('buildExpoStartArgs builds web args when dev-client is not requested', () => {
27
+ const args = buildExpoStartArgs({
28
+ port: 8081,
29
+ host: 'lan',
30
+ wantWeb: true,
31
+ wantDevClient: false,
32
+ scheme: '',
33
+ clearCache: false,
34
+ });
35
+ assert.deepEqual(args, ['start', '--web', '--host', 'lan', '--port', '8081']);
36
+ });
37
+
38
+ test('buildExpoStartArgs omits --scheme when empty', () => {
39
+ const args = buildExpoStartArgs({
40
+ port: 8081,
41
+ host: 'lan',
42
+ wantWeb: false,
43
+ wantDevClient: true,
44
+ scheme: '',
45
+ clearCache: false,
46
+ });
47
+ assert.deepEqual(args, ['start', '--dev-client', '--host', 'lan', '--port', '8081']);
48
+ });
49
+
50
+ test('buildExpoStartArgs throws on invalid requests', () => {
51
+ assert.throws(
52
+ () =>
53
+ buildExpoStartArgs({
54
+ port: 0,
55
+ host: 'lan',
56
+ wantWeb: true,
57
+ wantDevClient: false,
58
+ scheme: '',
59
+ clearCache: false,
60
+ }),
61
+ /invalid Metro port/i
62
+ );
63
+ assert.throws(
64
+ () =>
65
+ buildExpoStartArgs({
66
+ port: 8081,
67
+ host: 'lan',
68
+ wantWeb: false,
69
+ wantDevClient: false,
70
+ scheme: '',
71
+ clearCache: false,
72
+ }),
73
+ /neither web nor dev-client requested/i
74
+ );
75
+ });
76
+