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,13 +1,17 @@
1
1
  import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
- import { pickNextFreeTcpPort } from './utils/net/ports.mjs';
4
3
  import { run, runCapture, spawnProc } from './utils/proc/proc.mjs';
5
4
  import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
6
5
  import { ensureDepsInstalled, pmExecBin, pmSpawnBin, requireDir } from './utils/proc/pm.mjs';
7
6
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
8
- import { ensureExpoIsolationEnv, getExpoStatePaths, isStateProcessRunning, killPid, wantsExpoClearCache, writePidState } from './utils/expo/expo.mjs';
9
- import { killProcessGroupOwnedByStack } from './utils/proc/ownership.mjs';
10
- import { getPublicServerUrlEnvOverride, resolveServerPortFromEnv } from './utils/server/urls.mjs';
7
+ import { ensureExpoIsolationEnv, getExpoStatePaths } from './utils/expo/expo.mjs';
8
+ import { resolveServerPortFromEnv, resolveServerUrls } from './utils/server/urls.mjs';
9
+ import { resolveMobileExpoConfig } from './utils/mobile/config.mjs';
10
+ import { resolveStackContext } from './utils/stack/context.mjs';
11
+ import { expoExec } from './utils/expo/command.mjs';
12
+ import { ensureDevExpoServer } from './utils/dev/expo_dev.mjs';
13
+ import { resolveMobileReachableServerUrl } from './utils/server/mobile_api_url.mjs';
14
+ import { patchIosXcodeProjectsForSigningAndIdentity, resolveIosAppXcodeProjects } from './utils/mobile/ios_xcodeproj_patch.mjs';
11
15
 
12
16
  /**
13
17
  * Mobile dev helper for the embedded `components/happy` Expo app.
@@ -70,19 +74,6 @@ async function main() {
70
74
  await requireDir('happy', uiDir);
71
75
  await ensureDepsInstalled(uiDir, 'happy');
72
76
 
73
- const sanitizeBundleIdSegment = (s) =>
74
- (s ?? '')
75
- .toString()
76
- .trim()
77
- .toLowerCase()
78
- .replace(/[^a-z0-9-]+/g, '-')
79
- .replace(/^-+|-+$/g, '') || 'user';
80
-
81
- const defaultLocalBundleId = (() => {
82
- const user = sanitizeBundleIdSegment(process.env.USER ?? process.env.USERNAME ?? 'user');
83
- return `com.happy.local.${user}.dev`;
84
- })();
85
-
86
77
  async function readXcdeviceList() {
87
78
  if (process.platform !== 'darwin') {
88
79
  return [];
@@ -97,21 +88,6 @@ async function main() {
97
88
  // Default to the existing dev bundle identifier, which is also registered as a URL scheme
98
89
  // (Info.plist includes `com.slopus.happy.dev`), so iOS will open the dev build instead of the App Store app.
99
90
  const appEnv = process.env.APP_ENV ?? kv.get('--app-env') ?? 'development';
100
- const iosAppName =
101
- kv.get('--ios-app-name') ??
102
- process.env.HAPPY_STACKS_IOS_APP_NAME ??
103
- process.env.HAPPY_LOCAL_IOS_APP_NAME ??
104
- '';
105
- const iosBundleId =
106
- kv.get('--ios-bundle-id') ??
107
- process.env.HAPPY_STACKS_IOS_BUNDLE_ID ??
108
- process.env.HAPPY_LOCAL_IOS_BUNDLE_ID ??
109
- defaultLocalBundleId;
110
- const scheme =
111
- kv.get('--scheme') ??
112
- process.env.HAPPY_STACKS_MOBILE_SCHEME ??
113
- process.env.HAPPY_LOCAL_MOBILE_SCHEME ??
114
- iosBundleId;
115
91
  const host = kv.get('--host') ?? process.env.HAPPY_STACKS_MOBILE_HOST ?? process.env.HAPPY_LOCAL_MOBILE_HOST ?? 'lan';
116
92
  const portRaw = kv.get('--port') ?? process.env.HAPPY_STACKS_MOBILE_PORT ?? process.env.HAPPY_LOCAL_MOBILE_PORT ?? '8081';
117
93
  // Default behavior:
@@ -126,26 +102,56 @@ async function main() {
126
102
  APP_ENV: appEnv,
127
103
  };
128
104
 
105
+ const cfgBase = resolveMobileExpoConfig({ env });
106
+ const iosAppName = (kv.get('--ios-app-name') ?? cfgBase.iosAppName ?? '').toString();
107
+ const iosBundleId = (kv.get('--ios-bundle-id') ?? cfgBase.iosBundleId ?? '').toString();
108
+ const scheme = (kv.get('--scheme') ?? cfgBase.scheme ?? iosBundleId).toString();
109
+
129
110
  const autostart = getDefaultAutostartPaths();
130
- const mobilePaths = getExpoStatePaths({
111
+ const stackCtx = resolveStackContext({ env, autostart });
112
+ const { stackMode, runtimeStatePath, stackName, envPath } = stackCtx;
113
+
114
+ // Ensure the built iOS app registers the same scheme we use for dev-client QR links.
115
+ // (Happy app reads EXPO_APP_SCHEME in app.config.js; default remains unchanged when unset.)
116
+ env.EXPO_APP_SCHEME = scheme;
117
+ // Ensure the app display name + bundle id are consistent with what we install.
118
+ // (app.config.js keeps upstream defaults unless these are explicitly set.)
119
+ if (iosAppName && iosAppName.trim()) {
120
+ env.EXPO_APP_NAME = iosAppName.trim();
121
+ }
122
+ if (iosBundleId && iosBundleId.trim()) {
123
+ env.EXPO_APP_BUNDLE_ID = iosBundleId.trim();
124
+ }
125
+
126
+ // Always isolate Expo home + TMPDIR to avoid cross-worktree cache pollution (and to keep sandbox runs contained).
127
+ const expoPaths = getExpoStatePaths({
131
128
  baseDir: autostart.baseDir,
132
- kind: 'mobile-dev',
129
+ kind: 'expo-dev',
133
130
  projectDir: uiDir,
134
- stateFileName: 'mobile.state.json',
131
+ stateFileName: 'expo.state.json',
135
132
  });
136
133
  await ensureExpoIsolationEnv({
137
134
  env,
138
- stateDir: mobilePaths.stateDir,
139
- expoHomeDir: mobilePaths.expoHomeDir,
140
- tmpDir: mobilePaths.tmpDir,
135
+ stateDir: expoPaths.stateDir,
136
+ expoHomeDir: expoPaths.expoHomeDir,
137
+ tmpDir: expoPaths.tmpDir,
141
138
  });
142
139
 
143
140
  // Allow happy-stacks to define the default server URL baked into the app bundle.
144
141
  // This is read by the app via `process.env.EXPO_PUBLIC_HAPPY_SERVER_URL`.
145
- const serverPort = resolveServerPortFromEnv({ env: process.env, defaultPort: 3005 });
146
- const { envPublicUrl } = getPublicServerUrlEnvOverride({ env: process.env, serverPort });
147
- if (envPublicUrl && !env.EXPO_PUBLIC_HAPPY_SERVER_URL) {
148
- env.EXPO_PUBLIC_HAPPY_SERVER_URL = envPublicUrl;
142
+ const serverPort = resolveServerPortFromEnv({ env, defaultPort: 3005 });
143
+ const allowEnableTailscale =
144
+ !stackMode || stackName === 'main' || (env.HAPPY_STACKS_TAILSCALE_SERVE ?? env.HAPPY_LOCAL_TAILSCALE_SERVE ?? '0').toString().trim() === '1';
145
+ const resolvedUrls = await resolveServerUrls({ env, serverPort, allowEnable: allowEnableTailscale });
146
+ if (resolvedUrls.publicServerUrl && !env.EXPO_PUBLIC_HAPPY_SERVER_URL) {
147
+ env.EXPO_PUBLIC_HAPPY_SERVER_URL = resolvedUrls.publicServerUrl;
148
+ }
149
+ if (env.EXPO_PUBLIC_HAPPY_SERVER_URL) {
150
+ env.EXPO_PUBLIC_HAPPY_SERVER_URL = resolveMobileReachableServerUrl({
151
+ env,
152
+ serverUrl: env.EXPO_PUBLIC_HAPPY_SERVER_URL,
153
+ serverPort,
154
+ });
149
155
  }
150
156
 
151
157
  if (json) {
@@ -179,13 +185,12 @@ async function main() {
179
185
  if (shouldClean) {
180
186
  prebuildArgs.push('--clean');
181
187
  }
182
- await pmExecBin({ dir: uiDir, bin: 'expo', args: prebuildArgs, env });
188
+ await expoExec({ dir: uiDir, args: prebuildArgs, env, ensureDepsLabel: 'happy' });
183
189
 
184
190
  // Always patch iOS props if iOS was generated.
185
191
  if (platform === 'ios' || platform === 'all') {
186
192
  const fs = await import('node:fs/promises');
187
193
  const podPropsPath = `${uiDir}/ios/Podfile.properties.json`;
188
- const pbxprojPath = `${uiDir}/ios/Happydev.xcodeproj/project.pbxproj`;
189
194
  try {
190
195
  const raw = await fs.readFile(podPropsPath, 'utf-8');
191
196
  const json = JSON.parse(raw);
@@ -196,14 +201,17 @@ async function main() {
196
201
  // ignore if path missing (platform != ios)
197
202
  }
198
203
 
199
- try {
200
- const raw = await fs.readFile(pbxprojPath, 'utf-8');
201
- const next = raw.replaceAll('IPHONEOS_DEPLOYMENT_TARGET = 15.1;', 'IPHONEOS_DEPLOYMENT_TARGET = 16.0;');
202
- if (next !== raw) {
203
- await fs.writeFile(pbxprojPath, next, 'utf-8');
204
+ const iosProjects = await resolveIosAppXcodeProjects({ uiDir });
205
+ for (const project of iosProjects) {
206
+ try {
207
+ const raw = await fs.readFile(project.pbxprojPath, 'utf-8');
208
+ const next = raw.replaceAll('IPHONEOS_DEPLOYMENT_TARGET = 15.1;', 'IPHONEOS_DEPLOYMENT_TARGET = 16.0;');
209
+ if (next !== raw) {
210
+ await fs.writeFile(project.pbxprojPath, next, 'utf-8');
211
+ }
212
+ } catch {
213
+ // ignore missing/invalid pbxproj; Expo will surface actionable errors if needed
204
214
  }
205
- } catch {
206
- // ignore missing pbxproj (unexpected)
207
215
  }
208
216
 
209
217
  // Ensure CocoaPods doesn't crash due to locale issues.
@@ -264,25 +272,9 @@ async function main() {
264
272
  // xcodebuild fails with:
265
273
  // "Automatic signing is disabled ... pass -allowProvisioningUpdates"
266
274
  //
267
- // We force Expo CLI to go through its signing configuration path by clearing DEVELOPMENT_TEAM,
268
- // so it will re-set the team and include the provisioning flags.
269
- try {
270
- const fs = await import('node:fs/promises');
271
- const pbxprojPath = `${uiDir}/ios/Happydev.xcodeproj/project.pbxproj`;
272
- const raw = await fs.readFile(pbxprojPath, 'utf-8');
273
- let next = raw.replaceAll(/^\s*DEVELOPMENT_TEAM = ".*";\s*$/gm, '');
274
- next = next.replaceAll(/PRODUCT_BUNDLE_IDENTIFIER = [^;]+;/g, `PRODUCT_BUNDLE_IDENTIFIER = ${iosBundleId};`);
275
- if (iosAppName && iosAppName.trim()) {
276
- const name = iosAppName.trim();
277
- const quoted = name.includes(' ') || name.includes('"') ? `"${name.replaceAll('"', '\\"')}"` : name;
278
- next = next.replaceAll(/PRODUCT_NAME = [^;]+;/g, `PRODUCT_NAME = ${quoted};`);
279
- }
280
- if (next !== raw) {
281
- await fs.writeFile(pbxprojPath, next, 'utf-8');
282
- }
283
- } catch {
284
- // ignore
285
- }
275
+ // We force Expo CLI to go through its signing configuration path by clearing any pre-existing
276
+ // team/profile identifiers, so it will re-set the team and include the provisioning flags.
277
+ await patchIosXcodeProjectsForSigningAndIdentity({ uiDir, iosBundleId, iosAppName });
286
278
  }
287
279
 
288
280
  const configuration = kv.get('--configuration') ?? 'Debug';
@@ -293,47 +285,39 @@ async function main() {
293
285
  // Ensure CocoaPods doesn't crash due to locale issues.
294
286
  env.LANG = env.LANG ?? 'en_US.UTF-8';
295
287
  env.LC_ALL = env.LC_ALL ?? 'en_US.UTF-8';
296
- await pmExecBin({ dir: uiDir, bin: 'expo', args, env });
288
+ await expoExec({ dir: uiDir, args, env, ensureDepsLabel: 'happy' });
297
289
  }
298
290
 
299
291
  if (!shouldStartMetro) {
300
292
  return;
301
293
  }
302
294
 
303
- const running = await isStateProcessRunning(mobilePaths.statePath);
304
- if (!restart && running.running) {
305
- // eslint-disable-next-line no-console
306
- console.log(`[mobile] Metro already running for this stack/worktree (pid=${running.state.pid}, port=${running.state.port})`);
307
- return;
308
- }
309
- if (restart && running.state?.pid) {
310
- const prevPid = Number(running.state.pid);
311
- const stackName = (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || autostart.stackName;
312
- const envPath = (process.env.HAPPY_STACKS_ENV_FILE ?? process.env.HAPPY_LOCAL_ENV_FILE ?? '').toString();
313
- const res = await killProcessGroupOwnedByStack(prevPid, { stackName, envPath, label: 'expo-mobile', json: true });
314
- if (!res.killed) {
315
- // eslint-disable-next-line no-console
316
- console.warn(
317
- `[mobile] not stopping existing Metro pid=${prevPid} because it does not look stack-owned.\n` +
318
- `[mobile] continuing by starting a new Metro on a free port.`
319
- );
320
- }
321
- }
322
-
323
- const requestedPort = Number.parseInt(String(portRaw), 10);
324
- const startPort = Number.isFinite(requestedPort) && requestedPort > 0 ? requestedPort : 8081;
325
- const portNumber = await pickNextFreeTcpPort(startPort);
326
- env.RCT_METRO_PORT = String(portNumber);
295
+ // Unify Expo: one Expo dev server per stack/worktree. If dev mode already started Expo, we reuse it.
296
+ // If Expo is already running without dev-client enabled, we fail closed (no second Expo).
297
+ env.HAPPY_STACKS_EXPO_HOST = host;
298
+ env.HAPPY_LOCAL_EXPO_HOST = host;
299
+ env.HAPPY_STACKS_MOBILE_HOST = host;
300
+ env.HAPPY_LOCAL_MOBILE_HOST = host;
301
+ env.HAPPY_STACKS_MOBILE_SCHEME = scheme;
302
+ env.HAPPY_LOCAL_MOBILE_SCHEME = scheme;
303
+ env.HAPPY_STACKS_EXPO_DEV_PORT = String(portRaw);
304
+ env.HAPPY_LOCAL_EXPO_DEV_PORT = String(portRaw);
327
305
 
328
- // Start Metro for a dev client.
329
- // The critical part is --scheme: without it, Expo defaults to `exp+<slug>` (here `exp+happy`)
330
- // which the App Store app also registers, so iOS can open the wrong app.
331
- const args = ['start', '--dev-client', '--host', host, '--port', String(portNumber), '--scheme', scheme];
332
- if (wantsExpoClearCache({ env })) {
333
- args.push('--clear');
334
- }
335
- const child = await pmSpawnBin({ label: 'mobile', dir: uiDir, bin: 'expo', args, env });
336
- await writePidState(mobilePaths.statePath, { pid: child.pid, port: portNumber, uiDir, startedAt: new Date().toISOString() });
306
+ const children = [];
307
+ await ensureDevExpoServer({
308
+ startUi: false,
309
+ startMobile: true,
310
+ uiDir,
311
+ autostart,
312
+ baseEnv: env,
313
+ apiServerUrl: env.EXPO_PUBLIC_HAPPY_SERVER_URL ?? '',
314
+ restart,
315
+ stackMode,
316
+ runtimeStatePath,
317
+ stackName,
318
+ envPath,
319
+ children,
320
+ });
337
321
 
338
322
  await new Promise(() => {});
339
323
  }
@@ -0,0 +1,83 @@
1
+ import './utils/env/env.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
4
+ import { run } from './utils/proc/proc.mjs';
5
+ import { getRootDir } from './utils/paths/paths.mjs';
6
+ import { join } from 'node:path';
7
+
8
+ import { defaultDevClientIdentity } from './utils/mobile/identifiers.mjs';
9
+
10
+ async function main() {
11
+ const argv = process.argv.slice(2);
12
+ const { flags, kv } = parseArgs(argv);
13
+ const json = wantsJson(argv, { flags });
14
+
15
+ if (wantsHelp(argv, { flags }) || flags.has('--help') || argv.length === 0) {
16
+ printResult({
17
+ json,
18
+ data: {
19
+ flags: ['--device=<id-or-name>', '--clean', '--configuration=Debug|Release', '--json'],
20
+ },
21
+ text: [
22
+ '[mobile-dev-client] usage:',
23
+ ' happys mobile-dev-client --install [--device=...] [--clean] [--configuration=Debug|Release] [--json]',
24
+ '',
25
+ 'Notes:',
26
+ '- Installs a dedicated "Happy Stacks Dev" Expo dev-client app on your iPhone.',
27
+ '- This app is intended to be reused across stacks (no per-stack installs for dev-client).',
28
+ ].join('\n'),
29
+ });
30
+ return;
31
+ }
32
+
33
+ if (!flags.has('--install')) {
34
+ printResult({
35
+ json,
36
+ data: { ok: false, error: 'missing_install_flag' },
37
+ text: '[mobile-dev-client] missing --install. Run: happys mobile-dev-client --help',
38
+ });
39
+ process.exit(1);
40
+ }
41
+
42
+ const rootDir = getRootDir(import.meta.url);
43
+ const mobileScript = join(rootDir, 'scripts', 'mobile.mjs');
44
+
45
+ const device = kv.get('--device') ?? '';
46
+ const clean = flags.has('--clean');
47
+ const configuration = kv.get('--configuration') ?? 'Debug';
48
+
49
+ const id = defaultDevClientIdentity({ user: process.env.USER ?? process.env.USERNAME ?? 'user' });
50
+
51
+ const args = [
52
+ mobileScript,
53
+ '--app-env=development',
54
+ `--ios-app-name=${id.iosAppName}`,
55
+ `--ios-bundle-id=${id.iosBundleId}`,
56
+ `--scheme=${id.scheme}`,
57
+ '--prebuild',
58
+ ...(clean ? ['--clean'] : []),
59
+ '--run-ios',
60
+ `--configuration=${configuration}`,
61
+ '--no-metro',
62
+ ...(device ? [`--device=${device}`] : []),
63
+ ];
64
+
65
+ const env = {
66
+ ...process.env,
67
+ // Ensure Expo app config uses the dev-client scheme.
68
+ EXPO_APP_SCHEME: id.scheme,
69
+ // Ensure per-stack storage isolation is available during dev-client usage.
70
+ EXPO_PUBLIC_HAPPY_STORAGE_SCOPE: process.env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE ?? '',
71
+ };
72
+
73
+ const out = await run(process.execPath, args, { cwd: rootDir, env });
74
+ if (json) {
75
+ printResult({ json, data: { ok: true, installed: true, identity: id, out } });
76
+ }
77
+ }
78
+
79
+ main().catch((err) => {
80
+ console.error('[mobile-dev-client] failed:', err);
81
+ process.exit(1);
82
+ });
83
+