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
package/scripts/dev.mjs CHANGED
@@ -1,36 +1,31 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
- import { killProcessTree } from './utils/proc.mjs';
4
- import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths.mjs';
5
- import { killPortListeners } from './utils/ports.mjs';
6
- import { getServerComponentName, isHappyServerRunning } from './utils/server.mjs';
7
- import { requireDir } from './utils/pm.mjs';
3
+ import { killProcessTree } from './utils/proc/proc.mjs';
4
+ import { componentDirEnvKey, getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
5
+ import { killPortListeners } from './utils/net/ports.mjs';
6
+ import { getServerComponentName, isHappyServerRunning } from './utils/server/server.mjs';
7
+ import { requireDir } from './utils/proc/pm.mjs';
8
8
  import { join } from 'node:path';
9
9
  import { setTimeout as delay } from 'node:timers/promises';
10
10
  import { homedir } from 'node:os';
11
11
  import { isDaemonRunning, stopLocalDaemon } from './daemon.mjs';
12
12
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
13
- import { assertServerComponentDirMatches, assertServerPrismaProviderMatches } from './utils/validate.mjs';
14
- import { getExpoStatePaths, isStateProcessRunning } from './utils/expo.mjs';
15
- import { isPidAlive, readStackRuntimeStateFile, recordStackRuntimeStart } from './utils/stack_runtime_state.mjs';
16
- import { resolveStackContext } from './utils/stack_context.mjs';
17
- import { resolveServerPortFromEnv, resolveServerUrls } from './utils/server_urls.mjs';
18
- import { ensureDevCliReady, prepareDaemonAuthSeed, startDevDaemon, watchHappyCliAndRestartDaemon } from './utils/dev_daemon.mjs';
19
- import { startDevServer, watchDevServerAndRestart } from './utils/dev_server.mjs';
20
- import { startDevExpoWebUi } from './utils/dev_expo_web.mjs';
21
- import { resolveLocalhostHost } from './utils/localhost_host.mjs';
22
- import { openUrlInBrowser } from './utils/browser.mjs';
23
- import { waitForHttpOk } from './utils/server.mjs';
24
-
25
- function sanitizeDnsLabel(raw, { fallback = 'stack' } = {}) {
26
- const s = String(raw ?? '')
27
- .toLowerCase()
28
- .replace(/[^a-z0-9-]+/g, '-')
29
- .replace(/-+/g, '-')
30
- .replace(/^-+/, '')
31
- .replace(/-+$/, '');
32
- return s || fallback;
33
- }
13
+ import { assertServerComponentDirMatches, assertServerPrismaProviderMatches } from './utils/server/validate.mjs';
14
+ import { getExpoStatePaths, isStateProcessRunning } from './utils/expo/expo.mjs';
15
+ import { isPidAlive, readStackRuntimeStateFile, recordStackRuntimeStart } from './utils/stack/runtime_state.mjs';
16
+ import { resolveStackContext } from './utils/stack/context.mjs';
17
+ import { resolveServerPortFromEnv, resolveServerUrls } from './utils/server/urls.mjs';
18
+ import { ensureDevCliReady, prepareDaemonAuthSeed, startDevDaemon, watchHappyCliAndRestartDaemon } from './utils/dev/daemon.mjs';
19
+ import { startDevServer, watchDevServerAndRestart } from './utils/dev/server.mjs';
20
+ import { ensureDevExpoServer } from './utils/dev/expo_dev.mjs';
21
+ import { preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
22
+ import { openUrlInBrowser } from './utils/ui/browser.mjs';
23
+ import { waitForHttpOk } from './utils/server/server.mjs';
24
+ import { sanitizeDnsLabel } from './utils/net/dns.mjs';
25
+ import { getAccountCountForServerComponent, resolveAutoCopyFromMainEnabled } from './utils/stack/startup.mjs';
26
+ import { maybeRunInteractiveStackAuthSetup } from './utils/auth/interactive_stack_auth.mjs';
27
+ import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
28
+ import { daemonStartGate, formatDaemonAuthRequiredError } from './utils/auth/daemon_gate.mjs';
34
29
 
35
30
  /**
36
31
  * Dev mode stack:
@@ -46,20 +41,38 @@ async function main() {
46
41
  if (wantsHelp(argv, { flags })) {
47
42
  printResult({
48
43
  json,
49
- data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--watch', '--no-watch', '--no-browser'], json: true },
44
+ data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--watch', '--no-watch', '--no-browser', '--mobile'], json: true },
50
45
  text: [
51
46
  '[dev] usage:',
52
47
  ' happys dev [--server=happy-server|happy-server-light] [--restart] [--json]',
53
48
  ' happys dev --watch # rebuild/restart happy-cli daemon on file changes (TTY default)',
54
49
  ' happys dev --no-watch # disable watch mode (always disabled in non-interactive mode)',
55
50
  ' happys dev --no-browser # do not open the UI in your browser automatically',
51
+ ' happys dev --mobile # also start Expo dev-client Metro for mobile',
56
52
  ' note: --json prints the resolved config (dry-run) and exits.',
53
+ '',
54
+ 'note:',
55
+ ' If run from inside a component checkout/worktree, that checkout is used for this run (without requiring `happys wt use`).',
57
56
  ].join('\n'),
58
57
  });
59
58
  return;
60
59
  }
61
60
  const rootDir = getRootDir(import.meta.url);
62
61
 
62
+ const inferred = inferComponentFromCwd({
63
+ rootDir,
64
+ invokedCwd: getInvokedCwd(process.env),
65
+ components: ['happy', 'happy-cli', 'happy-server-light', 'happy-server'],
66
+ });
67
+ if (inferred) {
68
+ const stacksKey = componentDirEnvKey(inferred.component);
69
+ const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
70
+ // Stack env should win. Only infer from CWD when the component dir isn't already configured.
71
+ if (!(process.env[stacksKey] ?? '').toString().trim() && !(process.env[legacyKey] ?? '').toString().trim()) {
72
+ process.env[stacksKey] = inferred.repoDir;
73
+ }
74
+ }
75
+
63
76
  const serverComponentName = getServerComponentName({ kv });
64
77
  if (serverComponentName === 'both') {
65
78
  throw new Error(`[local] --server=both is not supported for dev (pick one: happy-server-light or happy-server)`);
@@ -67,6 +80,7 @@ async function main() {
67
80
 
68
81
  const startUi = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_UI ?? '1') !== '0';
69
82
  const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
83
+ const startMobile = flags.has('--mobile') || flags.has('--with-mobile');
70
84
  const noBrowser = flags.has('--no-browser') || (process.env.HAPPY_STACKS_NO_BROWSER ?? process.env.HAPPY_LOCAL_NO_BROWSER ?? '').toString().trim() === '1';
71
85
 
72
86
  const serverDir = getComponentDir(rootDir, serverComponentName);
@@ -91,7 +105,10 @@ async function main() {
91
105
  // - Only the main stack should ever auto-enable (or prefer) Tailscale Serve by default.
92
106
  // - Non-main stacks should default to localhost URLs unless the user explicitly configured a public URL
93
107
  // OR Tailscale Serve is already configured for this stack's internal URL (status matches).
94
- const allowEnableTailscale = !stackMode || stackName === 'main';
108
+ const allowEnableTailscale =
109
+ !stackMode ||
110
+ stackName === 'main' ||
111
+ (baseEnv.HAPPY_STACKS_TAILSCALE_SERVE ?? baseEnv.HAPPY_LOCAL_TAILSCALE_SERVE ?? '0').toString().trim() === '1';
95
112
  const resolvedUrls = await resolveServerUrls({ env: baseEnv, serverPort, allowEnable: allowEnableTailscale });
96
113
  const internalServerUrl = resolvedUrls.internalServerUrl;
97
114
  let publicServerUrl = resolvedUrls.publicServerUrl;
@@ -102,6 +119,8 @@ async function main() {
102
119
  publicServerUrl = resolvedUrls.defaultPublicUrl;
103
120
  }
104
121
  }
122
+ // Expo app config: this is what both web + native app use to reach the Happy server.
123
+ // LAN rewrite (for dev-client) is centralized in ensureDevExpoServer.
105
124
  const uiApiUrl = resolvedUrls.defaultPublicUrl;
106
125
  const restart = flags.has('--restart');
107
126
  const cliHomeDir = process.env.HAPPY_LOCAL_CLI_HOME_DIR?.trim()
@@ -121,6 +140,7 @@ async function main() {
121
140
  internalServerUrl,
122
141
  publicServerUrl,
123
142
  startUi,
143
+ startMobile,
124
144
  startDaemon,
125
145
  cliHomeDir,
126
146
  },
@@ -144,13 +164,25 @@ async function main() {
144
164
  const serverAlreadyRunning = await isHappyServerRunning(internalServerUrl);
145
165
  const daemonAlreadyRunning = startDaemon ? isDaemonRunning(cliHomeDir) : false;
146
166
 
147
- // UI dev server state (worktree-scoped)
148
- const uiPaths = getExpoStatePaths({ baseDir: autostart.baseDir, kind: 'ui-dev', projectDir: uiDir, stateFileName: 'ui.state.json' });
149
- const uiRunning = startUi ? await isStateProcessRunning(uiPaths.statePath) : { running: false, state: null };
150
- let uiAlreadyRunning = Boolean(uiRunning.running);
151
-
152
- if (!restart && serverAlreadyRunning && (!startDaemon || daemonAlreadyRunning) && (!startUi || uiAlreadyRunning)) {
153
- console.log(`[local] dev: stack already running (server=${internalServerUrl}${startDaemon ? ` daemon=${daemonAlreadyRunning ? 'running' : 'stopped'}` : ''}${startUi ? ` ui=${uiAlreadyRunning ? 'running' : 'stopped'}` : ''})`);
167
+ // Expo dev server state (worktree-scoped): single Expo process per stack/worktree.
168
+ const startExpo = startUi || startMobile;
169
+ const expoPaths = getExpoStatePaths({
170
+ baseDir: autostart.baseDir,
171
+ kind: 'expo-dev',
172
+ projectDir: uiDir,
173
+ stateFileName: 'expo.state.json',
174
+ });
175
+ const expoRunning = startExpo ? await isStateProcessRunning(expoPaths.statePath) : { running: false, state: null };
176
+ let expoAlreadyRunning = Boolean(expoRunning.running);
177
+
178
+ if (!restart && serverAlreadyRunning && (!startDaemon || daemonAlreadyRunning) && (!startExpo || expoAlreadyRunning)) {
179
+ console.log(
180
+ `[local] dev: stack already running (server=${internalServerUrl}` +
181
+ `${startDaemon ? ` daemon=${daemonAlreadyRunning ? 'running' : 'stopped'}` : ''}` +
182
+ `${startUi ? ` ui=${expoAlreadyRunning ? 'running' : 'stopped'}` : ''}` +
183
+ `${startMobile ? ` mobile=${expoAlreadyRunning ? 'running' : 'stopped'}` : ''}` +
184
+ `)`
185
+ );
154
186
  return;
155
187
  }
156
188
 
@@ -202,6 +234,75 @@ async function main() {
202
234
  // - Ensure schema exists (server-light: db push; happy-server: migrate deploy if tables missing)
203
235
  // - Auto-seed from main only when needed (non-main + non-interactive default, and only if missing creds or 0 accounts)
204
236
  const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
237
+ const accountProbe = await getAccountCountForServerComponent({
238
+ serverComponentName,
239
+ serverDir,
240
+ env: serverEnv,
241
+ bestEffort: true,
242
+ });
243
+ const accountCount = typeof accountProbe.accountCount === 'number' ? accountProbe.accountCount : null;
244
+ const autoSeedEnabled = resolveAutoCopyFromMainEnabled({ env: baseEnv, stackName, isInteractive });
245
+
246
+ let expoResEarly = null;
247
+ const wantsAuthFlow =
248
+ (baseEnv.HAPPY_STACKS_AUTH_FLOW ?? baseEnv.HAPPY_LOCAL_AUTH_FLOW ?? '').toString().trim() === '1' ||
249
+ (baseEnv.HAPPY_STACKS_DAEMON_WAIT_FOR_AUTH ?? baseEnv.HAPPY_LOCAL_DAEMON_WAIT_FOR_AUTH ?? '').toString().trim() === '1';
250
+
251
+ // CRITICAL (review-pr / setup-pr guided login):
252
+ // In background/non-interactive runs, the daemon may block on auth. If we wait to start Expo web
253
+ // until after the daemon is authenticated, guided login will have no UI origin and will fall back
254
+ // to the server port (wrong). Start Expo web UI early when running an auth flow.
255
+ if (wantsAuthFlow && startUi && !expoResEarly) {
256
+ expoResEarly = await ensureDevExpoServer({
257
+ startUi,
258
+ startMobile,
259
+ uiDir,
260
+ autostart,
261
+ baseEnv,
262
+ apiServerUrl: uiApiUrl,
263
+ restart,
264
+ stackMode,
265
+ runtimeStatePath,
266
+ stackName,
267
+ envPath,
268
+ children,
269
+ spawnOptions: { stdio: ['ignore', 'ignore', 'ignore'] },
270
+ });
271
+ }
272
+ await maybeRunInteractiveStackAuthSetup({
273
+ rootDir,
274
+ // In dev mode, guided login must target the Expo web UI origin (not the server port).
275
+ // Mark this as an auth-flow so URL resolution fails closed if Expo isn't ready.
276
+ env: startUi ? { ...baseEnv, HAPPY_STACKS_AUTH_FLOW: '1', HAPPY_LOCAL_AUTH_FLOW: '1' } : baseEnv,
277
+ stackName,
278
+ cliHomeDir,
279
+ accountCount,
280
+ isInteractive,
281
+ autoSeedEnabled,
282
+ beforeLogin: async () => {
283
+ if (!startUi) {
284
+ throw new Error(
285
+ `[local] auth: interactive login requires the web UI.\n` +
286
+ `Re-run without --no-ui, or set HAPPY_WEBAPP_URL to a reachable Happy UI for this stack.`
287
+ );
288
+ }
289
+ if (expoResEarly) return;
290
+ expoResEarly = await ensureDevExpoServer({
291
+ startUi,
292
+ startMobile,
293
+ uiDir,
294
+ autostart,
295
+ baseEnv,
296
+ apiServerUrl: uiApiUrl,
297
+ restart,
298
+ stackMode,
299
+ runtimeStatePath,
300
+ stackName,
301
+ envPath,
302
+ children,
303
+ });
304
+ },
305
+ });
205
306
  await prepareDaemonAuthSeed({
206
307
  rootDir,
207
308
  env: baseEnv,
@@ -215,19 +316,35 @@ async function main() {
215
316
  quiet: false,
216
317
  });
217
318
 
218
- await startDevDaemon({
219
- startDaemon,
220
- cliBin,
221
- cliHomeDir,
222
- internalServerUrl,
223
- publicServerUrl,
224
- restart,
225
- isShuttingDown: () => shuttingDown,
226
- });
319
+ if (startDaemon) {
320
+ const gate = daemonStartGate({ env: baseEnv, cliHomeDir });
321
+ if (!gate.ok) {
322
+ // In orchestrated auth flows (setup-pr/review-pr), we intentionally keep server/UI up
323
+ // for guided login and start daemon post-auth from the orchestrator.
324
+ if (gate.reason === 'auth_flow_missing_credentials') {
325
+ console.log('[local] auth flow: skipping daemon start until credentials exist');
326
+ } else {
327
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
328
+ if (!isInteractive) {
329
+ throw new Error(formatDaemonAuthRequiredError({ stackName, cliHomeDir }));
330
+ }
331
+ }
332
+ } else {
333
+ await startDevDaemon({
334
+ startDaemon,
335
+ cliBin,
336
+ cliHomeDir,
337
+ internalServerUrl,
338
+ publicServerUrl,
339
+ restart,
340
+ isShuttingDown: () => shuttingDown,
341
+ });
342
+ }
343
+ }
227
344
 
228
345
  const cliWatcher = watchHappyCliAndRestartDaemon({
229
346
  enabled: watchEnabled,
230
- startDaemon,
347
+ startDaemon: startDaemon && daemonStartGate({ env: baseEnv, cliHomeDir }).ok,
231
348
  buildCli,
232
349
  cliDir,
233
350
  cliBin,
@@ -272,43 +389,52 @@ async function main() {
272
389
  );
273
390
  }
274
391
 
275
- const uiRes = await startDevExpoWebUi({
276
- startUi,
277
- uiDir,
278
- autostart,
279
- baseEnv,
280
- apiServerUrl: uiApiUrl,
281
- restart,
282
- stackMode,
283
- runtimeStatePath,
284
- stackName,
285
- envPath,
286
- children,
287
- });
392
+ const expoRes =
393
+ expoResEarly ??
394
+ (await ensureDevExpoServer({
395
+ startUi,
396
+ startMobile,
397
+ uiDir,
398
+ autostart,
399
+ baseEnv,
400
+ apiServerUrl: uiApiUrl,
401
+ restart,
402
+ stackMode,
403
+ runtimeStatePath,
404
+ stackName,
405
+ envPath,
406
+ children,
407
+ }));
288
408
  if (startUi) {
289
- const host = resolveLocalhostHost({ stackMode, stackName });
290
- if (uiRes?.reason === 'already_running' && uiRes.port) {
291
- console.log(`[local] ui already running (pid=${uiRes.pid}, port=${uiRes.port})`);
292
- console.log(`[local] ui: open http://${host}:${uiRes.port}`);
293
- } else if (uiRes?.skipped === false && uiRes.port) {
294
- console.log(`[local] ui: open http://${host}:${uiRes.port}`);
295
- } else if (uiRes?.skipped && uiRes?.reason === 'already_running') {
409
+ const uiPort = expoRes?.port;
410
+ const uiUrlRaw = uiPort ? `http://localhost:${uiPort}` : '';
411
+ const uiUrl = uiUrlRaw ? await preferStackLocalhostUrl(uiUrlRaw, { stackName }) : '';
412
+ if (expoRes?.reason === 'already_running' && expoRes.port) {
413
+ console.log(`[local] ui already running (pid=${expoRes.pid}, port=${expoRes.port})`);
414
+ if (uiUrl) console.log(`[local] ui: open ${uiUrl}`);
415
+ } else if (expoRes?.skipped === false && expoRes.port) {
416
+ if (uiUrl) console.log(`[local] ui: open ${uiUrl}`);
417
+ } else if (expoRes?.skipped && expoRes?.reason === 'already_running') {
296
418
  console.log('[local] ui already running (skipping Expo start)');
297
419
  }
298
420
 
299
421
  const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
300
- const shouldOpen = isInteractive && !noBrowser && Boolean(uiRes?.port);
422
+ const shouldOpen = isInteractive && !noBrowser && Boolean(expoRes?.port);
301
423
  if (shouldOpen) {
302
- const url = `http://${host}:${uiRes.port}`;
303
424
  // Prefer localhost for readiness checks (faster/more reliable), but open the stack-scoped hostname.
304
- await waitForHttpOk(`http://localhost:${uiRes.port}`, { timeoutMs: 30_000 }).catch(() => {});
305
- const res = await openUrlInBrowser(url);
425
+ await waitForHttpOk(`http://localhost:${expoRes.port}`, { timeoutMs: 30_000 }).catch(() => {});
426
+ const res = await openUrlInBrowser(uiUrl);
306
427
  if (!res.ok) {
307
428
  console.warn(`[local] ui: failed to open browser automatically (${res.error}).`);
308
429
  }
309
430
  }
310
431
  }
311
432
 
433
+ if (startMobile && expoRes?.port) {
434
+ const metroUrl = await preferStackLocalhostUrl(`http://localhost:${expoRes.port}`, { stackName });
435
+ console.log(`[local] mobile: metro ${metroUrl}`);
436
+ }
437
+
312
438
  const shutdown = async () => {
313
439
  if (shuttingDown) {
314
440
  return;
@@ -1,21 +1,24 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
- import { pathExists } from './utils/fs.mjs';
4
- import { runCapture } from './utils/proc.mjs';
5
- import { getComponentDir, getDefaultAutostartPaths, getHappyStacksHomeDir, getRootDir, getWorkspaceDir, resolveStackEnvPath } from './utils/paths.mjs';
6
- import { killPortListeners } from './utils/ports.mjs';
7
- import { getServerComponentName } from './utils/server.mjs';
3
+ import { pathExists } from './utils/fs/fs.mjs';
4
+ import { runCapture } from './utils/proc/proc.mjs';
5
+ import { resolveCommandPath } from './utils/proc/commands.mjs';
6
+ import { getComponentDir, getDefaultAutostartPaths, getHappyStacksHomeDir, getRootDir, getWorkspaceDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
7
+ import { killPortListeners } from './utils/net/ports.mjs';
8
+ import { getServerComponentName } from './utils/server/server.mjs';
9
+ import { fetchHappyHealth } from './utils/server/server.mjs';
8
10
  import { daemonStatusSummary } from './daemon.mjs';
9
11
  import { tailscaleServeStatus } from './tailscale.mjs';
10
12
  import { homedir } from 'node:os';
11
13
  import { join } from 'node:path';
12
14
  import { existsSync } from 'node:fs';
13
- import { readFile } from 'node:fs/promises';
14
15
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
15
- import { getRuntimeDir } from './utils/runtime.mjs';
16
- import { assertServerComponentDirMatches } from './utils/validate.mjs';
17
- import { resolveServerPortFromEnv, resolveServerUrls } from './utils/server_urls.mjs';
18
- import { resolveStackContext } from './utils/stack_context.mjs';
16
+ import { getRuntimeDir } from './utils/paths/runtime.mjs';
17
+ import { assertServerComponentDirMatches } from './utils/server/validate.mjs';
18
+ import { resolveServerPortFromEnv, resolveServerUrls } from './utils/server/urls.mjs';
19
+ import { resolveStackContext } from './utils/stack/context.mjs';
20
+ import { readJsonIfExists } from './utils/fs/json.mjs';
21
+ import { readPackageJsonVersion } from './utils/fs/package_json.mjs';
19
22
 
20
23
  /**
21
24
  * Doctor script for common happy-stacks failure modes.
@@ -43,7 +46,8 @@ async function fetchHealth(url) {
43
46
  };
44
47
 
45
48
  // Prefer /health when available, but fall back to / (matches waitForServerReady).
46
- const health = await tryGet('/health');
49
+ const healthRaw = await fetchHappyHealth(url);
50
+ const health = { ok: healthRaw.ok, status: healthRaw.status, body: healthRaw.text ? healthRaw.text.trim() : null };
47
51
  if (health.ok) {
48
52
  return health;
49
53
  }
@@ -54,26 +58,6 @@ async function fetchHealth(url) {
54
58
  return health.ok ? health : root;
55
59
  }
56
60
 
57
- async function readJsonSafe(path) {
58
- try {
59
- const raw = await readFile(path, 'utf-8');
60
- return JSON.parse(raw);
61
- } catch {
62
- return null;
63
- }
64
- }
65
-
66
- async function readPkgVersion(path) {
67
- try {
68
- const raw = await readFile(path, 'utf-8');
69
- const pkg = JSON.parse(raw);
70
- const v = String(pkg.version ?? '').trim();
71
- return v || null;
72
- } catch {
73
- return null;
74
- }
75
- }
76
-
77
61
  async function resolveSwiftbarPluginsDir() {
78
62
  if (process.platform !== 'darwin') {
79
63
  return null;
@@ -114,8 +98,8 @@ async function main() {
114
98
  const workspaceDir = getWorkspaceDir(rootDir);
115
99
  const updateCachePath = join(homeDir, 'cache', 'update.json');
116
100
  const runtimePkgJson = join(runtimeDir, 'node_modules', 'happy-stacks', 'package.json');
117
- const runtimeVersion = await readPkgVersion(runtimePkgJson);
118
- const updateCache = existsSync(updateCachePath) ? await readJsonSafe(updateCachePath) : null;
101
+ const runtimeVersion = await readPackageJsonVersion(runtimePkgJson);
102
+ const updateCache = await readJsonIfExists(updateCachePath, { defaultValue: null });
119
103
 
120
104
  const autostart = getDefaultAutostartPaths();
121
105
  const stackCtx = resolveStackContext({ env: process.env, autostart });
@@ -302,7 +286,7 @@ async function main() {
302
286
 
303
287
  // happy wrapper
304
288
  try {
305
- const happyPath = (await runCapture('sh', ['-lc', 'command -v happy'])).trim();
289
+ const happyPath = await resolveCommandPath('happy');
306
290
  if (happyPath) {
307
291
  report.checks.happyOnPath = { ok: true, path: happyPath };
308
292
  if (!json) console.log(`✅ happy on PATH: ${happyPath}`);
@@ -314,7 +298,7 @@ async function main() {
314
298
 
315
299
  // happys on PATH
316
300
  try {
317
- const happysPath = (await runCapture('sh', ['-lc', 'command -v happys'])).trim();
301
+ const happysPath = await resolveCommandPath('happys');
318
302
  if (happysPath) {
319
303
  report.checks.happysOnPath = { ok: true, path: happysPath };
320
304
  if (!json) console.log(`✅ happys on PATH: ${happysPath}`);