happy-stacks 0.2.0 → 0.4.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 (149) hide show
  1. package/README.md +84 -25
  2. package/bin/happys.mjs +116 -17
  3. package/docs/happy-development.md +2 -2
  4. package/docs/isolated-linux-vm.md +82 -0
  5. package/docs/mobile-ios.md +112 -54
  6. package/package.json +5 -1
  7. package/scripts/auth.mjs +59 -208
  8. package/scripts/build.mjs +58 -12
  9. package/scripts/cli-link.mjs +3 -3
  10. package/scripts/completion.mjs +5 -5
  11. package/scripts/daemon.mjs +168 -20
  12. package/scripts/dev.mjs +196 -70
  13. package/scripts/doctor.mjs +20 -36
  14. package/scripts/edison.mjs +105 -78
  15. package/scripts/happy.mjs +8 -19
  16. package/scripts/init.mjs +8 -14
  17. package/scripts/install.mjs +119 -23
  18. package/scripts/lint.mjs +31 -32
  19. package/scripts/menubar.mjs +6 -13
  20. package/scripts/migrate.mjs +11 -21
  21. package/scripts/mobile.mjs +93 -108
  22. package/scripts/mobile_dev_client.mjs +83 -0
  23. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  24. package/scripts/review.mjs +217 -0
  25. package/scripts/review_pr.mjs +368 -0
  26. package/scripts/run.mjs +95 -21
  27. package/scripts/self.mjs +11 -29
  28. package/scripts/server_flavor.mjs +4 -4
  29. package/scripts/service.mjs +19 -29
  30. package/scripts/setup.mjs +63 -160
  31. package/scripts/setup_pr.mjs +592 -52
  32. package/scripts/stack.mjs +608 -200
  33. package/scripts/stop.mjs +3 -3
  34. package/scripts/tailscale.mjs +44 -11
  35. package/scripts/test.mjs +52 -36
  36. package/scripts/tui.mjs +314 -74
  37. package/scripts/typecheck.mjs +31 -32
  38. package/scripts/ui_gateway.mjs +1 -1
  39. package/scripts/uninstall.mjs +6 -6
  40. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  41. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  42. package/scripts/utils/auth/dev_key.mjs +163 -0
  43. package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
  44. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  45. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  46. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  47. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  48. package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +32 -13
  49. package/scripts/utils/auth/sources.mjs +38 -0
  50. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  51. package/scripts/utils/cli/cli_registry.mjs +24 -0
  52. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  53. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  54. package/scripts/utils/cli/flags.mjs +17 -0
  55. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  56. package/scripts/utils/cli/normalize.mjs +16 -0
  57. package/scripts/utils/cli/prereqs.mjs +72 -0
  58. package/scripts/utils/cli/progress.mjs +126 -0
  59. package/scripts/utils/cli/smoke_help.mjs +2 -2
  60. package/scripts/utils/cli/verbosity.mjs +12 -0
  61. package/scripts/utils/cli/wizard.mjs +1 -1
  62. package/scripts/utils/crypto/tokens.mjs +14 -0
  63. package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +51 -7
  64. package/scripts/utils/dev/expo_dev.mjs +246 -0
  65. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  66. package/scripts/utils/{dev_server.mjs → dev/server.mjs} +22 -32
  67. package/scripts/utils/dev_auth_key.mjs +1 -1
  68. package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
  69. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  70. package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
  71. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
  72. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  73. package/scripts/utils/env/read.mjs +30 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/expo/command.mjs +52 -0
  76. package/scripts/utils/{expo.mjs → expo/expo.mjs} +23 -10
  77. package/scripts/utils/expo/metro_ports.mjs +114 -0
  78. package/scripts/utils/fs/json.mjs +25 -0
  79. package/scripts/utils/fs/ops.mjs +29 -0
  80. package/scripts/utils/fs/package_json.mjs +8 -0
  81. package/scripts/utils/fs/tail.mjs +12 -0
  82. package/scripts/utils/git/git.mjs +67 -0
  83. package/scripts/utils/git/refs.mjs +26 -0
  84. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +27 -23
  85. package/scripts/utils/handy_master_secret.mjs +2 -2
  86. package/scripts/utils/mobile/config.mjs +31 -0
  87. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  88. package/scripts/utils/mobile/identifiers.mjs +47 -0
  89. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  90. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  91. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  92. package/scripts/utils/net/dns.mjs +10 -0
  93. package/scripts/utils/net/lan_ip.mjs +24 -0
  94. package/scripts/utils/{ports.mjs → net/ports.mjs} +12 -6
  95. package/scripts/utils/net/url.mjs +30 -0
  96. package/scripts/utils/net/url.test.mjs +20 -0
  97. package/scripts/utils/paths/localhost_host.mjs +56 -0
  98. package/scripts/utils/{paths.mjs → paths/paths.mjs} +52 -45
  99. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
  100. package/scripts/utils/proc/commands.mjs +34 -0
  101. package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
  102. package/scripts/utils/proc/package_scripts.mjs +31 -0
  103. package/scripts/utils/proc/parallel.mjs +25 -0
  104. package/scripts/utils/proc/pids.mjs +11 -0
  105. package/scripts/utils/{pm.mjs → proc/pm.mjs} +128 -158
  106. package/scripts/utils/{proc.mjs → proc/proc.mjs} +77 -2
  107. package/scripts/utils/review/base_ref.mjs +74 -0
  108. package/scripts/utils/review/base_ref.test.mjs +54 -0
  109. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  110. package/scripts/utils/review/runners/codex.mjs +51 -0
  111. package/scripts/utils/review/targets.mjs +24 -0
  112. package/scripts/utils/review/targets.test.mjs +36 -0
  113. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  114. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
  115. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  116. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  117. package/scripts/utils/server/port.mjs +68 -0
  118. package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
  119. package/scripts/utils/server/urls.mjs +101 -0
  120. package/scripts/utils/server/validate.mjs +88 -0
  121. package/scripts/utils/service/autostart_darwin.mjs +182 -0
  122. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  123. package/scripts/utils/stack/context.mjs +23 -0
  124. package/scripts/utils/stack/dirs.mjs +27 -0
  125. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  126. package/scripts/utils/stack/names.mjs +12 -0
  127. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  128. package/scripts/utils/stack/runtime_state.mjs +88 -0
  129. package/scripts/utils/stack/stacks.mjs +45 -0
  130. package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +9 -2
  131. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +24 -19
  132. package/scripts/utils/stack_context.mjs +3 -3
  133. package/scripts/utils/stack_runtime_state.mjs +1 -1
  134. package/scripts/utils/stacks.mjs +2 -2
  135. package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
  136. package/scripts/utils/ui/qr.mjs +17 -0
  137. package/scripts/utils/ui/text.mjs +16 -0
  138. package/scripts/utils/validate.mjs +1 -1
  139. package/scripts/where.mjs +6 -6
  140. package/scripts/worktrees.mjs +171 -113
  141. package/scripts/utils/auth_sources.mjs +0 -12
  142. package/scripts/utils/dev_expo_web.mjs +0 -112
  143. package/scripts/utils/localhost_host.mjs +0 -17
  144. package/scripts/utils/server_port.mjs +0 -9
  145. package/scripts/utils/server_urls.mjs +0 -54
  146. /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
  147. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
  148. /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
  149. /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
@@ -0,0 +1,246 @@
1
+ import {
2
+ ensureExpoIsolationEnv,
3
+ getExpoStatePaths,
4
+ isStateProcessRunning,
5
+ wantsExpoClearCache,
6
+ writePidState,
7
+ } from '../expo/expo.mjs';
8
+ import { pickExpoDevMetroPort } from '../expo/metro_ports.mjs';
9
+ import { recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
10
+ import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
11
+ import { expoSpawn } from '../expo/command.mjs';
12
+ import { resolveMobileExpoConfig } from '../mobile/config.mjs';
13
+ import { resolveMobileReachableServerUrl } from '../server/mobile_api_url.mjs';
14
+
15
+ function normalizeExpoHost(raw) {
16
+ const v = String(raw ?? '').trim().toLowerCase();
17
+ if (v === 'localhost' || v === 'lan' || v === 'tunnel') return v;
18
+ return 'lan';
19
+ }
20
+
21
+ export function resolveExpoDevHost({ env = process.env } = {}) {
22
+ // Always prefer LAN by default so phones can reach Metro.
23
+ const raw = (env.HAPPY_STACKS_EXPO_HOST ?? env.HAPPY_LOCAL_EXPO_HOST ?? '').toString();
24
+ return normalizeExpoHost(raw || 'lan');
25
+ }
26
+
27
+ export function buildExpoStartArgs({ port, host, wantWeb, wantDevClient, scheme, clearCache }) {
28
+ const metroPort = Number(port);
29
+ if (!Number.isFinite(metroPort) || metroPort <= 0) {
30
+ throw new Error(`[expo] invalid Metro port: ${String(port)}`);
31
+ }
32
+ if (!wantWeb && !wantDevClient) {
33
+ throw new Error('[expo] cannot build Expo args: neither web nor dev-client requested');
34
+ }
35
+
36
+ // IMPORTANT:
37
+ // - We must only run one Expo per stack.
38
+ // - Expo dev-client mode is known to still serve web when accessed locally, so when mobile is
39
+ // requested we prefer `--dev-client` as the single shared process (no second `--web` process).
40
+ const args = wantDevClient
41
+ ? ['start', '--dev-client', '--host', host, '--port', String(metroPort)]
42
+ : ['start', '--web', '--host', host, '--port', String(metroPort)];
43
+
44
+ if (wantDevClient) {
45
+ const s = String(scheme ?? '').trim();
46
+ if (s) {
47
+ args.push('--scheme', s);
48
+ }
49
+ }
50
+
51
+ if (clearCache && !args.includes('--clear')) {
52
+ args.push('--clear');
53
+ }
54
+
55
+ return args;
56
+ }
57
+
58
+ function expoModeLabel({ wantWeb, wantDevClient }) {
59
+ if (wantWeb && wantDevClient) return 'dev-client+web';
60
+ if (wantDevClient) return 'dev-client';
61
+ if (wantWeb) return 'web';
62
+ return 'disabled';
63
+ }
64
+
65
+ export async function ensureDevExpoServer({
66
+ startUi,
67
+ startMobile,
68
+ uiDir,
69
+ autostart,
70
+ baseEnv,
71
+ apiServerUrl,
72
+ restart,
73
+ stackMode,
74
+ runtimeStatePath,
75
+ stackName,
76
+ envPath,
77
+ children,
78
+ spawnOptions = {},
79
+ } = {}) {
80
+ const wantWeb = Boolean(startUi);
81
+ const wantDevClient = Boolean(startMobile);
82
+ if (!wantWeb && !wantDevClient) {
83
+ return { ok: true, skipped: true, reason: 'disabled' };
84
+ }
85
+
86
+ const env = { ...(baseEnv || process.env) };
87
+ delete env.CI;
88
+ // Expo app config: this is what both web + native app use to reach the Happy server.
89
+ // When dev-client is enabled, `localhost` / `*.localhost` are not reachable from the phone,
90
+ // so rewrite to LAN IP here (centralized) to avoid relying on call sites.
91
+ const serverPortFromEnvRaw = (env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim();
92
+ const serverPortFromEnv = serverPortFromEnvRaw ? Number(serverPortFromEnvRaw) : null;
93
+ const effectiveApiServerUrl = wantDevClient
94
+ ? resolveMobileReachableServerUrl({
95
+ env,
96
+ serverUrl: apiServerUrl,
97
+ serverPort: Number.isFinite(serverPortFromEnv) ? serverPortFromEnv : null,
98
+ })
99
+ : apiServerUrl;
100
+ env.EXPO_PUBLIC_HAPPY_SERVER_URL = effectiveApiServerUrl;
101
+ env.EXPO_PUBLIC_DEBUG = env.EXPO_PUBLIC_DEBUG ?? '1';
102
+
103
+ // Optional: allow per-stack storage isolation inside a single dev-client build by
104
+ // scoping app persistence (MMKV / SecureStore) to a stack-specific namespace.
105
+ //
106
+ // This stays upstream-safe because the app behavior is unchanged unless the Expo public
107
+ // env var is explicitly set. Happy Stacks sets it automatically for stack-mode dev-client.
108
+ if (wantDevClient) {
109
+ const explicitScope = (
110
+ env.HAPPY_STACKS_STORAGE_SCOPE ??
111
+ env.HAPPY_LOCAL_STORAGE_SCOPE ??
112
+ env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE ??
113
+ ''
114
+ )
115
+ .toString()
116
+ .trim();
117
+ const defaultScope = stackMode && stackName ? String(stackName).trim() : '';
118
+ const scope = explicitScope || defaultScope;
119
+ if (scope && !env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE) {
120
+ env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE = scope;
121
+ }
122
+ }
123
+
124
+ // We own the browser opening behavior in Happy Stacks so we can reliably open the correct origin.
125
+ env.EXPO_NO_BROWSER = '1';
126
+ env.BROWSER = 'none';
127
+
128
+ // Mobile config is needed for `--scheme` and for the app's environment.
129
+ let scheme = '';
130
+ if (wantDevClient) {
131
+ const cfg = resolveMobileExpoConfig({ env });
132
+ env.APP_ENV = cfg.appEnv;
133
+ scheme = cfg.scheme;
134
+ }
135
+
136
+ const paths = getExpoStatePaths({
137
+ baseDir: autostart.baseDir,
138
+ kind: 'expo-dev',
139
+ projectDir: uiDir,
140
+ stateFileName: 'expo.state.json',
141
+ });
142
+ await ensureExpoIsolationEnv({ env, stateDir: paths.stateDir, expoHomeDir: paths.expoHomeDir, tmpDir: paths.tmpDir });
143
+
144
+ const running = await isStateProcessRunning(paths.statePath);
145
+ const alreadyRunning = Boolean(running.running);
146
+
147
+ // Always publish runtime metadata when we can.
148
+ const publishRuntime = async ({ pid, port }) => {
149
+ if (!stackMode || !runtimeStatePath) return;
150
+ const nPid = Number(pid);
151
+ const nPort = Number(port);
152
+ await recordStackRuntimeUpdate(runtimeStatePath, {
153
+ processes: { expoPid: Number.isFinite(nPid) && nPid > 1 ? nPid : null },
154
+ expo: {
155
+ port: Number.isFinite(nPort) && nPort > 0 ? nPort : null,
156
+ // For now keep these populated for callers that still expect webPort/mobilePort.
157
+ webPort: wantWeb && Number.isFinite(nPort) && nPort > 0 ? nPort : null,
158
+ mobilePort: wantDevClient && Number.isFinite(nPort) && nPort > 0 ? nPort : null,
159
+ webEnabled: wantWeb,
160
+ devClientEnabled: wantDevClient,
161
+ host: resolveExpoDevHost({ env }),
162
+ scheme: wantDevClient ? scheme : null,
163
+ },
164
+ }).catch(() => {});
165
+ };
166
+
167
+ if (alreadyRunning && !restart) {
168
+ const pid = Number(running.state?.pid);
169
+ const port = Number(running.state?.port);
170
+
171
+ // Capability check: refuse to spawn a second Expo, so if the existing process doesn't match the
172
+ // requested capabilities we fail closed and instruct a restart with the superset.
173
+ const stateWeb = Boolean(running.state?.webEnabled);
174
+ const stateDevClient = Boolean(running.state?.devClientEnabled);
175
+ const stateHasCaps = 'webEnabled' in (running.state ?? {}) || 'devClientEnabled' in (running.state ?? {});
176
+ const missingWeb = wantWeb && stateHasCaps && !stateWeb;
177
+ const missingDevClient = wantDevClient && stateHasCaps && !stateDevClient;
178
+ if (missingWeb || missingDevClient) {
179
+ throw new Error(
180
+ `[expo] Expo already running for stack=${stackName}, but it does not match the requested mode.\n` +
181
+ `- running: ${expoModeLabel({ wantWeb: stateWeb, wantDevClient: stateDevClient })}\n` +
182
+ `- wanted: ${expoModeLabel({ wantWeb, wantDevClient })}\n` +
183
+ `Fix: re-run with --restart (and include --mobile if you need dev-client).`
184
+ );
185
+ }
186
+
187
+ await publishRuntime({ pid, port });
188
+ return {
189
+ ok: true,
190
+ skipped: true,
191
+ reason: 'already_running',
192
+ pid: Number.isFinite(pid) ? pid : null,
193
+ port: Number.isFinite(port) ? port : null,
194
+ mode: expoModeLabel({ wantWeb, wantDevClient }),
195
+ };
196
+ }
197
+
198
+ const metroPort = await pickExpoDevMetroPort({ env: baseEnv, stackMode, stackName });
199
+ env.RCT_METRO_PORT = String(metroPort);
200
+ const host = resolveExpoDevHost({ env });
201
+ const args = buildExpoStartArgs({
202
+ port: metroPort,
203
+ host,
204
+ wantWeb,
205
+ wantDevClient,
206
+ scheme,
207
+ clearCache: wantsExpoClearCache({ env: baseEnv || process.env }),
208
+ });
209
+
210
+ if (restart && running.state?.pid) {
211
+ const prevPid = Number(running.state.pid);
212
+ const res = await killProcessGroupOwnedByStack(prevPid, { stackName, envPath, label: 'expo', json: true });
213
+ if (!res.killed) {
214
+ // eslint-disable-next-line no-console
215
+ console.warn(
216
+ `[local] expo: not stopping existing Expo pid=${prevPid} because it does not look stack-owned.\n` +
217
+ `[local] expo: continuing by starting a new Expo process on a free port.`
218
+ );
219
+ }
220
+ }
221
+
222
+ // eslint-disable-next-line no-console
223
+ console.log(`[local] expo: starting Expo (${expoModeLabel({ wantWeb, wantDevClient })}, metro port=${metroPort}, host=${host})`);
224
+ const proc = await expoSpawn({ label: 'expo', dir: uiDir, args, env, options: spawnOptions });
225
+ children.push(proc);
226
+
227
+ await publishRuntime({ pid: proc.pid, port: metroPort });
228
+
229
+ try {
230
+ await writePidState(paths.statePath, {
231
+ pid: proc.pid,
232
+ port: metroPort,
233
+ uiDir,
234
+ startedAt: new Date().toISOString(),
235
+ webEnabled: wantWeb,
236
+ devClientEnabled: wantDevClient,
237
+ host,
238
+ scheme: wantDevClient ? scheme : null,
239
+ });
240
+ } catch {
241
+ // ignore
242
+ }
243
+
244
+ return { ok: true, skipped: false, pid: proc.pid, port: metroPort, proc, mode: expoModeLabel({ wantWeb, wantDevClient }) };
245
+ }
246
+
@@ -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
+
@@ -1,42 +1,32 @@
1
1
  import { join, resolve } from 'node:path';
2
2
 
3
- import { ensureDepsInstalled, pmSpawnScript } from './pm.mjs';
4
- import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from './happy_server_infra.mjs';
5
- import { waitForServerReady } from './server.mjs';
6
- import { isTcpPortFree, pickNextFreeTcpPort } from './ports.mjs';
7
- import { readStackRuntimeStateFile, recordStackRuntimeUpdate } from './stack_runtime_state.mjs';
8
- import { killProcessGroupOwnedByStack } from './ownership.mjs';
9
- import { watchDebounced } from './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
- }
3
+ import { ensureDepsInstalled, pmSpawnScript } from '../proc/pm.mjs';
4
+ import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from '../server/infra/happy_server_infra.mjs';
5
+ import { waitForServerReady } from '../server/server.mjs';
6
+ import { isTcpPortFree, pickNextFreeTcpPort } from '../net/ports.mjs';
7
+ import { readStackRuntimeStateFile, recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
8
+ import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
9
+ import { watchDebounced } from '../proc/watch.mjs';
10
+ import { pickMetroPort, resolveStablePortStart } from '../expo/metro_ports.mjs';
19
11
 
20
12
  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);
13
+ return resolveStablePortStart({
14
+ env: {
15
+ ...env,
16
+ HAPPY_STACKS_UI_DEV_PORT_BASE: (env.HAPPY_STACKS_UI_DEV_PORT_BASE ?? env.HAPPY_LOCAL_UI_DEV_PORT_BASE ?? '8081').toString(),
17
+ HAPPY_STACKS_UI_DEV_PORT_RANGE: (env.HAPPY_STACKS_UI_DEV_PORT_RANGE ?? env.HAPPY_LOCAL_UI_DEV_PORT_RANGE ?? '1000').toString(),
18
+ },
19
+ stackName,
20
+ baseKey: 'HAPPY_STACKS_UI_DEV_PORT_BASE',
21
+ rangeKey: 'HAPPY_STACKS_UI_DEV_PORT_RANGE',
22
+ defaultBase: 8081,
23
+ defaultRange: 1000,
24
+ });
28
25
  }
29
26
 
30
27
  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 });
28
+ const forcedPort = (process.env.HAPPY_STACKS_UI_DEV_PORT ?? process.env.HAPPY_LOCAL_UI_DEV_PORT ?? '').toString().trim();
29
+ return await pickMetroPort({ startPort, forcedPort, reservedPorts, host });
40
30
  }
41
31
 
42
32
  export async function startDevServer({
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
 
6
- import { expandHome } from './canonical_home.mjs';
6
+ import { expandHome } from './paths/canonical_home.mjs';
7
7
 
8
8
  export function resolveHappyStacksHomeDir(env = process.env) {
9
9
  const fromEnv = (env.HAPPY_STACKS_HOME_DIR ?? env.HAPPY_LOCAL_HOME_DIR ?? '').toString().trim();
@@ -1,7 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import { ensureEnvFileUpdated } from './env_file.mjs';
3
- import { getHappyStacksHomeDir, resolveStackEnvPath } from './paths.mjs';
4
- import { getCanonicalHomeDirFromEnv } from './canonical_home.mjs';
3
+ import { getHappyStacksHomeDir, resolveStackEnvPath } from '../paths/paths.mjs';
4
+ import { getCanonicalHomeDirFromEnv } from '../paths/canonical_home.mjs';
5
5
 
6
6
  export function getHomeEnvPath() {
7
7
  return join(getHappyStacksHomeDir(), '.env');
@@ -50,3 +50,4 @@ export async function ensureUserConfigEnvUpdated({ cliRootDir, updates }) {
50
50
  await ensureEnvFileUpdated({ envPath, updates });
51
51
  return envPath;
52
52
  }
53
+
@@ -28,3 +28,6 @@ export function parseDotenv(contents) {
28
28
  return out;
29
29
  }
30
30
 
31
+ export function parseEnvToObject(contents) {
32
+ return Object.fromEntries(parseDotenv(contents));
33
+ }
@@ -4,7 +4,7 @@ import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { parseDotenv } from './dotenv.mjs';
7
- import { expandHome, getCanonicalHomeEnvPathFromEnv } from './canonical_home.mjs';
7
+ import { expandHome, getCanonicalHomeEnvPathFromEnv } from '../paths/canonical_home.mjs';
8
8
  import { isSandboxed, sandboxAllowsGlobalSideEffects } from './sandbox.mjs';
9
9
 
10
10
  async function loadEnvFile(path, { override = false, overridePrefix = null } = {}) {
@@ -57,8 +57,9 @@ async function loadEnvFileIgnoringPrefixes(path, { ignorePrefixes = [] } = {}) {
57
57
  }
58
58
 
59
59
  // Load happy-stacks env (optional). This is intentionally lightweight and does not require extra deps.
60
- // This file lives under scripts/utils/, so repo root is two directories up.
61
- const __utilsDir = dirname(fileURLToPath(import.meta.url));
60
+ // This file lives under scripts/utils/env, so repo root is three directories up.
61
+ const __envDir = dirname(fileURLToPath(import.meta.url));
62
+ const __utilsDir = dirname(__envDir);
62
63
  const __scriptsDir = dirname(__utilsDir);
63
64
  const __cliRootDir = dirname(__scriptsDir);
64
65
 
@@ -207,3 +208,4 @@ process.env.NPM_CONFIG_PACKAGE_MANAGER_STRICT = process.env.NPM_CONFIG_PACKAGE_M
207
208
  const next = [...want.filter((p) => p && !current.includes(p)), ...current];
208
209
  process.env.PATH = next.join(delimiter);
209
210
  })();
211
+
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
- import { pathExists } from './fs.mjs';
3
+ import { pathExists } from '../fs/fs.mjs';
4
4
 
5
5
  export async function ensureEnvFileUpdated({ envPath, updates }) {
6
6
  if (!updates.length) {
@@ -93,3 +93,4 @@ async function writeFileIfChanged(existingContent, nextContent, path) {
93
93
  }
94
94
  await writeFile(path, normalizedNext, 'utf-8');
95
95
  }
96
+
@@ -23,3 +23,4 @@ export async function ensureEnvLocalUpdated({ rootDir, updates }) {
23
23
 
24
24
  await ensureEnvFileUpdated({ envPath: join(rootDir, 'env.local'), updates });
25
25
  }
26
+
@@ -0,0 +1,30 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+
4
+ import { parseDotenv } from './dotenv.mjs';
5
+
6
+ export async function readEnvValueFromFile(envPath, key, { defaultValue = '' } = {}) {
7
+ try {
8
+ const p = String(envPath ?? '').trim();
9
+ const k = String(key ?? '').trim();
10
+ if (!p || !k) return defaultValue;
11
+ if (!existsSync(p)) return defaultValue;
12
+ const raw = await readFile(p, 'utf-8');
13
+ const parsed = parseDotenv(raw ?? '');
14
+ return String(parsed.get(k) ?? '').trim();
15
+ } catch {
16
+ return defaultValue;
17
+ }
18
+ }
19
+
20
+ export async function readEnvObjectFromFile(envPath) {
21
+ try {
22
+ const p = String(envPath ?? '').trim();
23
+ if (!p || !existsSync(p)) return {};
24
+ const raw = await readFile(p, 'utf-8');
25
+ return Object.fromEntries(parseDotenv(raw ?? '').entries());
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
@@ -0,0 +1,13 @@
1
+ export function getEnvValue(obj, key) {
2
+ const v = (obj?.[key] ?? '').toString().trim();
3
+ return v || '';
4
+ }
5
+
6
+ export function getEnvValueAny(obj, keys) {
7
+ for (const k of keys) {
8
+ const v = getEnvValue(obj, k);
9
+ if (v) return v;
10
+ }
11
+ return '';
12
+ }
13
+
@@ -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
+
@@ -3,6 +3,10 @@ import { existsSync } from 'node:fs';
3
3
  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
+ import { isPidAlive } from '../proc/pids.mjs';
7
+ import { isTcpPortFree } from '../net/ports.mjs';
8
+
9
+ export { isPidAlive };
6
10
 
7
11
  function hashDir(dir) {
8
12
  return createHash('sha1').update(String(dir ?? '')).digest('hex').slice(0, 12);
@@ -56,20 +60,29 @@ export async function readPidState(statePath) {
56
60
  }
57
61
  }
58
62
 
59
- export function isPidAlive(pid) {
60
- try {
61
- process.kill(pid, 0);
62
- return true;
63
- } catch {
64
- return false;
65
- }
66
- }
67
-
68
63
  export async function isStateProcessRunning(statePath) {
69
64
  const state = await readPidState(statePath);
70
65
  if (!state) return { running: false, state: null };
71
66
  const pid = Number(state.pid);
72
- 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 };
73
86
  }
74
87
 
75
88
  export async function writePidState(statePath, state) {