happy-stacks 0.3.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 (94) hide show
  1. package/README.md +29 -7
  2. package/bin/happys.mjs +114 -15
  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 +11 -7
  8. package/scripts/build.mjs +54 -7
  9. package/scripts/daemon.mjs +166 -10
  10. package/scripts/dev.mjs +181 -46
  11. package/scripts/edison.mjs +4 -2
  12. package/scripts/init.mjs +3 -1
  13. package/scripts/install.mjs +112 -16
  14. package/scripts/lint.mjs +24 -4
  15. package/scripts/mobile.mjs +88 -104
  16. package/scripts/mobile_dev_client.mjs +83 -0
  17. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  18. package/scripts/review.mjs +217 -0
  19. package/scripts/review_pr.mjs +368 -0
  20. package/scripts/run.mjs +83 -9
  21. package/scripts/service.mjs +2 -2
  22. package/scripts/setup.mjs +42 -43
  23. package/scripts/setup_pr.mjs +591 -34
  24. package/scripts/stack.mjs +503 -45
  25. package/scripts/tailscale.mjs +37 -1
  26. package/scripts/test.mjs +45 -8
  27. package/scripts/tui.mjs +309 -39
  28. package/scripts/typecheck.mjs +24 -4
  29. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  30. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  31. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  32. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  33. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  34. package/scripts/utils/auth/login_ux.mjs +32 -13
  35. package/scripts/utils/auth/sources.mjs +26 -0
  36. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  37. package/scripts/utils/cli/cli_registry.mjs +24 -0
  38. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  39. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  40. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  41. package/scripts/utils/cli/prereqs.mjs +72 -0
  42. package/scripts/utils/cli/progress.mjs +126 -0
  43. package/scripts/utils/cli/verbosity.mjs +12 -0
  44. package/scripts/utils/dev/daemon.mjs +47 -3
  45. package/scripts/utils/dev/expo_dev.mjs +246 -0
  46. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  47. package/scripts/utils/dev/server.mjs +15 -25
  48. package/scripts/utils/dev_auth_key.mjs +169 -0
  49. package/scripts/utils/expo/command.mjs +52 -0
  50. package/scripts/utils/expo/expo.mjs +20 -1
  51. package/scripts/utils/expo/metro_ports.mjs +114 -0
  52. package/scripts/utils/git/git.mjs +67 -0
  53. package/scripts/utils/git/worktrees.mjs +24 -20
  54. package/scripts/utils/handy_master_secret.mjs +94 -0
  55. package/scripts/utils/mobile/config.mjs +31 -0
  56. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  57. package/scripts/utils/mobile/identifiers.mjs +47 -0
  58. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  59. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  60. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  61. package/scripts/utils/net/lan_ip.mjs +24 -0
  62. package/scripts/utils/net/ports.mjs +9 -1
  63. package/scripts/utils/net/url.mjs +30 -0
  64. package/scripts/utils/net/url.test.mjs +20 -0
  65. package/scripts/utils/paths/localhost_host.mjs +50 -3
  66. package/scripts/utils/paths/paths.mjs +42 -38
  67. package/scripts/utils/proc/parallel.mjs +25 -0
  68. package/scripts/utils/proc/pm.mjs +69 -12
  69. package/scripts/utils/proc/proc.mjs +76 -2
  70. package/scripts/utils/review/base_ref.mjs +74 -0
  71. package/scripts/utils/review/base_ref.test.mjs +54 -0
  72. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  73. package/scripts/utils/review/runners/codex.mjs +51 -0
  74. package/scripts/utils/review/targets.mjs +24 -0
  75. package/scripts/utils/review/targets.test.mjs +36 -0
  76. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  77. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  78. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  79. package/scripts/utils/server/urls.mjs +14 -4
  80. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  81. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  82. package/scripts/utils/stack/context.mjs +2 -2
  83. package/scripts/utils/stack/editor_workspace.mjs +2 -2
  84. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  85. package/scripts/utils/stack/runtime_state.mjs +2 -1
  86. package/scripts/utils/stack/startup.mjs +7 -0
  87. package/scripts/utils/stack/stop.mjs +15 -4
  88. package/scripts/utils/stack_context.mjs +23 -0
  89. package/scripts/utils/stack_runtime_state.mjs +104 -0
  90. package/scripts/utils/stacks.mjs +38 -0
  91. package/scripts/utils/ui/qr.mjs +17 -0
  92. package/scripts/utils/validate.mjs +88 -0
  93. package/scripts/worktrees.mjs +141 -55
  94. package/scripts/utils/dev/expo_web.mjs +0 -112
package/scripts/dev.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { killProcessTree } from './utils/proc/proc.mjs';
4
- import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
4
+ import { componentDirEnvKey, getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
5
5
  import { killPortListeners } from './utils/net/ports.mjs';
6
6
  import { getServerComponentName, isHappyServerRunning } from './utils/server/server.mjs';
7
7
  import { requireDir } from './utils/proc/pm.mjs';
@@ -17,11 +17,15 @@ import { resolveStackContext } from './utils/stack/context.mjs';
17
17
  import { resolveServerPortFromEnv, resolveServerUrls } from './utils/server/urls.mjs';
18
18
  import { ensureDevCliReady, prepareDaemonAuthSeed, startDevDaemon, watchHappyCliAndRestartDaemon } from './utils/dev/daemon.mjs';
19
19
  import { startDevServer, watchDevServerAndRestart } from './utils/dev/server.mjs';
20
- import { startDevExpoWebUi } from './utils/dev/expo_web.mjs';
21
- import { resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
20
+ import { ensureDevExpoServer } from './utils/dev/expo_dev.mjs';
21
+ import { preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
22
22
  import { openUrlInBrowser } from './utils/ui/browser.mjs';
23
23
  import { waitForHttpOk } from './utils/server/server.mjs';
24
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';
25
29
 
26
30
  /**
27
31
  * Dev mode stack:
@@ -37,20 +41,38 @@ async function main() {
37
41
  if (wantsHelp(argv, { flags })) {
38
42
  printResult({
39
43
  json,
40
- 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 },
41
45
  text: [
42
46
  '[dev] usage:',
43
47
  ' happys dev [--server=happy-server|happy-server-light] [--restart] [--json]',
44
48
  ' happys dev --watch # rebuild/restart happy-cli daemon on file changes (TTY default)',
45
49
  ' happys dev --no-watch # disable watch mode (always disabled in non-interactive mode)',
46
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',
47
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`).',
48
56
  ].join('\n'),
49
57
  });
50
58
  return;
51
59
  }
52
60
  const rootDir = getRootDir(import.meta.url);
53
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
+
54
76
  const serverComponentName = getServerComponentName({ kv });
55
77
  if (serverComponentName === 'both') {
56
78
  throw new Error(`[local] --server=both is not supported for dev (pick one: happy-server-light or happy-server)`);
@@ -58,6 +80,7 @@ async function main() {
58
80
 
59
81
  const startUi = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_UI ?? '1') !== '0';
60
82
  const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
83
+ const startMobile = flags.has('--mobile') || flags.has('--with-mobile');
61
84
  const noBrowser = flags.has('--no-browser') || (process.env.HAPPY_STACKS_NO_BROWSER ?? process.env.HAPPY_LOCAL_NO_BROWSER ?? '').toString().trim() === '1';
62
85
 
63
86
  const serverDir = getComponentDir(rootDir, serverComponentName);
@@ -82,7 +105,10 @@ async function main() {
82
105
  // - Only the main stack should ever auto-enable (or prefer) Tailscale Serve by default.
83
106
  // - Non-main stacks should default to localhost URLs unless the user explicitly configured a public URL
84
107
  // OR Tailscale Serve is already configured for this stack's internal URL (status matches).
85
- 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';
86
112
  const resolvedUrls = await resolveServerUrls({ env: baseEnv, serverPort, allowEnable: allowEnableTailscale });
87
113
  const internalServerUrl = resolvedUrls.internalServerUrl;
88
114
  let publicServerUrl = resolvedUrls.publicServerUrl;
@@ -93,6 +119,8 @@ async function main() {
93
119
  publicServerUrl = resolvedUrls.defaultPublicUrl;
94
120
  }
95
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.
96
124
  const uiApiUrl = resolvedUrls.defaultPublicUrl;
97
125
  const restart = flags.has('--restart');
98
126
  const cliHomeDir = process.env.HAPPY_LOCAL_CLI_HOME_DIR?.trim()
@@ -112,6 +140,7 @@ async function main() {
112
140
  internalServerUrl,
113
141
  publicServerUrl,
114
142
  startUi,
143
+ startMobile,
115
144
  startDaemon,
116
145
  cliHomeDir,
117
146
  },
@@ -135,13 +164,25 @@ async function main() {
135
164
  const serverAlreadyRunning = await isHappyServerRunning(internalServerUrl);
136
165
  const daemonAlreadyRunning = startDaemon ? isDaemonRunning(cliHomeDir) : false;
137
166
 
138
- // UI dev server state (worktree-scoped)
139
- const uiPaths = getExpoStatePaths({ baseDir: autostart.baseDir, kind: 'ui-dev', projectDir: uiDir, stateFileName: 'ui.state.json' });
140
- const uiRunning = startUi ? await isStateProcessRunning(uiPaths.statePath) : { running: false, state: null };
141
- let uiAlreadyRunning = Boolean(uiRunning.running);
142
-
143
- if (!restart && serverAlreadyRunning && (!startDaemon || daemonAlreadyRunning) && (!startUi || uiAlreadyRunning)) {
144
- 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
+ );
145
186
  return;
146
187
  }
147
188
 
@@ -193,6 +234,75 @@ async function main() {
193
234
  // - Ensure schema exists (server-light: db push; happy-server: migrate deploy if tables missing)
194
235
  // - Auto-seed from main only when needed (non-main + non-interactive default, and only if missing creds or 0 accounts)
195
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
+ });
196
306
  await prepareDaemonAuthSeed({
197
307
  rootDir,
198
308
  env: baseEnv,
@@ -206,19 +316,35 @@ async function main() {
206
316
  quiet: false,
207
317
  });
208
318
 
209
- await startDevDaemon({
210
- startDaemon,
211
- cliBin,
212
- cliHomeDir,
213
- internalServerUrl,
214
- publicServerUrl,
215
- restart,
216
- isShuttingDown: () => shuttingDown,
217
- });
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
+ }
218
344
 
219
345
  const cliWatcher = watchHappyCliAndRestartDaemon({
220
346
  enabled: watchEnabled,
221
- startDaemon,
347
+ startDaemon: startDaemon && daemonStartGate({ env: baseEnv, cliHomeDir }).ok,
222
348
  buildCli,
223
349
  cliDir,
224
350
  cliBin,
@@ -263,43 +389,52 @@ async function main() {
263
389
  );
264
390
  }
265
391
 
266
- const uiRes = await startDevExpoWebUi({
267
- startUi,
268
- uiDir,
269
- autostart,
270
- baseEnv,
271
- apiServerUrl: uiApiUrl,
272
- restart,
273
- stackMode,
274
- runtimeStatePath,
275
- stackName,
276
- envPath,
277
- children,
278
- });
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
+ }));
279
408
  if (startUi) {
280
- const host = resolveLocalhostHost({ stackMode, stackName });
281
- if (uiRes?.reason === 'already_running' && uiRes.port) {
282
- console.log(`[local] ui already running (pid=${uiRes.pid}, port=${uiRes.port})`);
283
- console.log(`[local] ui: open http://${host}:${uiRes.port}`);
284
- } else if (uiRes?.skipped === false && uiRes.port) {
285
- console.log(`[local] ui: open http://${host}:${uiRes.port}`);
286
- } 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') {
287
418
  console.log('[local] ui already running (skipping Expo start)');
288
419
  }
289
420
 
290
421
  const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
291
- const shouldOpen = isInteractive && !noBrowser && Boolean(uiRes?.port);
422
+ const shouldOpen = isInteractive && !noBrowser && Boolean(expoRes?.port);
292
423
  if (shouldOpen) {
293
- const url = `http://${host}:${uiRes.port}`;
294
424
  // Prefer localhost for readiness checks (faster/more reliable), but open the stack-scoped hostname.
295
- await waitForHttpOk(`http://localhost:${uiRes.port}`, { timeoutMs: 30_000 }).catch(() => {});
296
- const res = await openUrlInBrowser(url);
425
+ await waitForHttpOk(`http://localhost:${expoRes.port}`, { timeoutMs: 30_000 }).catch(() => {});
426
+ const res = await openUrlInBrowser(uiUrl);
297
427
  if (!res.ok) {
298
428
  console.warn(`[local] ui: failed to open browser automatically (${res.error}).`);
299
429
  }
300
430
  }
301
431
  }
302
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
+
303
438
  const shutdown = async () => {
304
439
  if (shuttingDown) {
305
440
  return;
@@ -8,7 +8,7 @@ import { readTextOrEmpty } from './utils/fs/ops.mjs';
8
8
  import { readJsonIfExists } from './utils/fs/json.mjs';
9
9
  import { isPidAlive } from './utils/proc/pids.mjs';
10
10
  import { run, runCapture } from './utils/proc/proc.mjs';
11
- import { resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
11
+ import { preferStackLocalhostHost, resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
12
12
  import { sanitizeStackName } from './utils/stack/names.mjs';
13
13
  import { join } from 'node:path';
14
14
  import { spawn } from 'node:child_process';
@@ -1781,7 +1781,9 @@ async function main() {
1781
1781
  env.HAPPY_STACKS_EDISON_WRAPPER = '1';
1782
1782
  // Provide a stack-scoped localhost hostname for validators and browser flows.
1783
1783
  // This ensures origin isolation even if ports are reused later (common with ephemeral ports).
1784
- const localhostHost = resolveLocalhostHost({ stackMode: Boolean(stackName), stackName: stackName || 'main' });
1784
+ const localhostHost = Boolean(stackName)
1785
+ ? await preferStackLocalhostHost({ stackName })
1786
+ : resolveLocalhostHost({ stackMode: false, stackName: 'main' });
1785
1787
  env.HAPPY_STACKS_LOCALHOST_HOST = localhostHost;
1786
1788
  env.HAPPY_LOCAL_LOCALHOST_HOST = localhostHost;
1787
1789
 
package/scripts/init.mjs CHANGED
@@ -199,7 +199,9 @@ async function main() {
199
199
  const storageDirRaw = parseArgValue(argv, 'storage-dir');
200
200
  const storageDirOverride = expandHome((storageDirRaw ?? '').trim());
201
201
  if (storageDirOverride) {
202
- process.env.HAPPY_STACKS_STORAGE_DIR = process.env.HAPPY_STACKS_STORAGE_DIR ?? storageDirOverride;
202
+ // In sandbox mode, storage dir MUST be isolated and must override any pre-existing env.
203
+ process.env.HAPPY_STACKS_STORAGE_DIR = isSandboxed() ? storageDirOverride : (process.env.HAPPY_STACKS_STORAGE_DIR ?? storageDirOverride);
204
+ process.env.HAPPY_LOCAL_STORAGE_DIR = process.env.HAPPY_LOCAL_STORAGE_DIR ?? process.env.HAPPY_STACKS_STORAGE_DIR;
203
205
  }
204
206
 
205
207
  const cliRootDirRaw = parseArgValue(argv, 'cli-root-dir');
@@ -1,7 +1,7 @@
1
1
  import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { pathExists } from './utils/fs/fs.mjs';
4
- import { run } from './utils/proc/proc.mjs';
4
+ import { run, runCapture } from './utils/proc/proc.mjs';
5
5
  import { getComponentDir, getRootDir } from './utils/paths/paths.mjs';
6
6
  import { getServerComponentName } from './utils/server/server.mjs';
7
7
  import { ensureCliBuilt, ensureDepsInstalled, ensureHappyCliLocalNpmLinked } from './utils/proc/pm.mjs';
@@ -24,8 +24,10 @@ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox
24
24
 
25
25
  const DEFAULT_FORK_REPOS = {
26
26
  serverLight: 'https://github.com/leeroybrun/happy-server-light.git',
27
- // We don't currently maintain a separate fork for full happy-server; default to upstream.
28
- serverFull: 'https://github.com/slopus/happy-server.git',
27
+ // Both server flavors live as branches in the same fork repo:
28
+ // - happy-server-light (sqlite)
29
+ // - happy-server (full)
30
+ serverFull: 'https://github.com/leeroybrun/happy-server-light.git',
29
31
  cli: 'https://github.com/leeroybrun/happy-cli.git',
30
32
  ui: 'https://github.com/leeroybrun/happy.git',
31
33
  };
@@ -44,7 +46,8 @@ function repoUrlsFromOwners({ forkOwner, upstreamOwner }) {
44
46
  return {
45
47
  forks: {
46
48
  serverLight: fork('happy-server-light'),
47
- serverFull: fork('happy-server') /* best-effort; user can override */,
49
+ // Fork convention: server full is a branch in happy-server-light repo (not a separate repo).
50
+ serverFull: fork('happy-server-light'),
48
51
  cli: fork('happy-cli'),
49
52
  ui: fork('happy'),
50
53
  },
@@ -86,6 +89,51 @@ function getRepoUrls({ repoSource }) {
86
89
  };
87
90
  }
88
91
 
92
+ async function ensureGitBranchCheckedOut({ repoDir, branch, label }) {
93
+ if (!(await pathExists(join(repoDir, '.git')))) return;
94
+ const b = String(branch ?? '').trim();
95
+ if (!b) return;
96
+
97
+ try {
98
+ const head = (await runCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoDir })).trim();
99
+ if (head && head === b) return;
100
+ } catch {
101
+ // ignore
102
+ }
103
+
104
+ // Ensure branch exists locally, otherwise fetch it from origin.
105
+ let hasLocal = true;
106
+ try {
107
+ await run('git', ['show-ref', '--verify', '--quiet', `refs/heads/${b}`], { cwd: repoDir });
108
+ } catch {
109
+ hasLocal = false;
110
+ }
111
+ if (!hasLocal) {
112
+ try {
113
+ await run('git', ['fetch', '--quiet', 'origin', b], { cwd: repoDir });
114
+ } catch {
115
+ throw new Error(
116
+ `[local] ${label}: expected branch "${b}" to exist in ${repoDir}.\n` +
117
+ `[local] Fix: use --forks for happy-server-light (sqlite), or use --server=happy-server with --upstream.`
118
+ );
119
+ }
120
+ }
121
+
122
+ try {
123
+ await run('git', ['checkout', '-q', b], { cwd: repoDir });
124
+ } catch {
125
+ // If remote-tracking branch exists but local doesn't, create it.
126
+ try {
127
+ await run('git', ['checkout', '-q', '-B', b, `origin/${b}`], { cwd: repoDir });
128
+ } catch {
129
+ throw new Error(
130
+ `[local] ${label}: failed to checkout branch "${b}" in ${repoDir}.\n` +
131
+ `[local] Fix: re-run with --force in worktree flows, or delete the checkout and re-run install/bootstrap.`
132
+ );
133
+ }
134
+ }
135
+ }
136
+
89
137
  async function ensureComponentPresent({ dir, label, repoUrl, allowClone }) {
90
138
  if (await pathExists(dir)) {
91
139
  return;
@@ -201,7 +249,22 @@ async function main() {
201
249
  if (wantsHelp(argv, { flags })) {
202
250
  printResult({
203
251
  json,
204
- data: { flags: ['--forks', '--upstream', '--clone', '--no-clone', '--autostart', '--no-autostart', '--server=...'], json: true },
252
+ data: {
253
+ flags: [
254
+ '--forks',
255
+ '--upstream',
256
+ '--clone',
257
+ '--no-clone',
258
+ '--autostart',
259
+ '--no-autostart',
260
+ '--server=...',
261
+ '--no-ui-build',
262
+ '--no-ui-deps',
263
+ '--no-cli-deps',
264
+ '--no-cli-build',
265
+ ],
266
+ json: true,
267
+ },
205
268
  text: [
206
269
  '[bootstrap] usage:',
207
270
  ' happys bootstrap [--forks|--upstream] [--server=happy-server|happy-server-light|both] [--json]',
@@ -270,6 +333,17 @@ async function main() {
270
333
  const disableAutostart = flags.has('--no-autostart');
271
334
 
272
335
  const serverComponentName = (wizard?.serverComponentName ?? getServerComponentName({ kv })).trim();
336
+ // Safety: upstream server-light is not a separate upstream repo/branch today.
337
+ // Upstream slopus/happy-server is Postgres-only, while happy-server-light requires sqlite.
338
+ if (repoSource === 'upstream' && (serverComponentName === 'happy-server-light' || serverComponentName === 'both')) {
339
+ throw new Error(
340
+ `[bootstrap] --upstream is not supported for happy-server-light (sqlite).\n` +
341
+ `Reason: upstream ${DEFAULT_UPSTREAM_REPOS.serverLight} does not provide a happy-server-light branch.\n` +
342
+ `Fix:\n` +
343
+ `- use --forks (recommended), OR\n` +
344
+ `- use --server=happy-server with --upstream`
345
+ );
346
+ }
273
347
  const serverLightDir = getComponentDir(rootDir, 'happy-server-light');
274
348
  const serverFullDir = getComponentDir(rootDir, 'happy-server');
275
349
  const cliDir = getComponentDir(rootDir, 'happy-cli');
@@ -305,35 +379,57 @@ async function main() {
305
379
  allowClone,
306
380
  });
307
381
 
382
+ // Ensure expected branches are checked out for server flavors (avoids "server-light directory contains full server" mistakes).
383
+ if (serverComponentName === 'both' || serverComponentName === 'happy-server-light') {
384
+ await ensureGitBranchCheckedOut({ repoDir: serverLightDir, branch: 'happy-server-light', label: 'SERVER' });
385
+ }
386
+ if (serverComponentName === 'both' || serverComponentName === 'happy-server') {
387
+ // In fork mode, full server is a branch in the fork server repo. In upstream mode, use upstream main.
388
+ const serverFullBranch = repoSource === 'upstream' ? 'main' : 'happy-server';
389
+ await ensureGitBranchCheckedOut({ repoDir: serverFullDir, branch: serverFullBranch, label: 'SERVER_FULL' });
390
+ }
391
+
308
392
  const cliDirFinal = cliDir;
309
393
  const uiDirFinal = uiDir;
310
394
 
311
395
  // Install deps
396
+ const skipUiDeps = flags.has('--no-ui-deps') || (process.env.HAPPY_STACKS_INSTALL_NO_UI_DEPS ?? '').trim() === '1';
397
+ const skipCliDeps = flags.has('--no-cli-deps') || (process.env.HAPPY_STACKS_INSTALL_NO_CLI_DEPS ?? '').trim() === '1';
312
398
  if (serverComponentName === 'both' || serverComponentName === 'happy-server-light') {
313
399
  await ensureDepsInstalled(serverLightDir, 'happy-server-light');
314
400
  }
315
401
  if (serverComponentName === 'both' || serverComponentName === 'happy-server') {
316
402
  await ensureDepsInstalled(serverFullDir, 'happy-server');
317
403
  }
318
- await ensureDepsInstalled(uiDirFinal, 'happy');
319
- await ensureDepsInstalled(cliDirFinal, 'happy-cli');
404
+ if (!skipUiDeps) {
405
+ await ensureDepsInstalled(uiDirFinal, 'happy');
406
+ }
407
+ if (!skipCliDeps) {
408
+ await ensureDepsInstalled(cliDirFinal, 'happy-cli');
409
+ }
320
410
 
321
411
  // CLI build + link
322
- const buildCli = (process.env.HAPPY_LOCAL_CLI_BUILD ?? '1') !== '0';
323
- const npmLinkCli = (process.env.HAPPY_LOCAL_NPM_LINK ?? '1') !== '0';
324
- await ensureCliBuilt(cliDirFinal, { buildCli });
325
- await ensureHappyCliLocalNpmLinked(rootDir, { npmLinkCli });
412
+ const skipCliBuild = flags.has('--no-cli-build') || (process.env.HAPPY_STACKS_INSTALL_NO_CLI_BUILD ?? '').trim() === '1';
413
+ if (!skipCliBuild) {
414
+ const buildCli = (process.env.HAPPY_LOCAL_CLI_BUILD ?? '1') !== '0';
415
+ const npmLinkCli = (process.env.HAPPY_LOCAL_NPM_LINK ?? '1') !== '0';
416
+ await ensureCliBuilt(cliDirFinal, { buildCli });
417
+ await ensureHappyCliLocalNpmLinked(rootDir, { npmLinkCli });
418
+ }
326
419
 
327
420
  // Build UI (so run works without expo dev server)
421
+ const skipUiBuild = flags.has('--no-ui-build') || (process.env.HAPPY_STACKS_INSTALL_NO_UI_BUILD ?? '').trim() === '1';
328
422
  const buildArgs = [join(rootDir, 'scripts', 'build.mjs')];
329
423
  // Tauri builds are opt-in (slow + requires additional toolchain).
330
424
  const buildTauri = wizard?.buildTauri ?? (flags.has('--tauri') && !flags.has('--no-tauri'));
331
- if (buildTauri) {
332
- buildArgs.push('--tauri');
333
- } else if (flags.has('--no-tauri')) {
334
- buildArgs.push('--no-tauri');
425
+ if (!skipUiBuild) {
426
+ if (buildTauri) {
427
+ buildArgs.push('--tauri');
428
+ } else if (flags.has('--no-tauri')) {
429
+ buildArgs.push('--no-tauri');
430
+ }
431
+ await run(process.execPath, buildArgs, { cwd: rootDir });
335
432
  }
336
- await run(process.execPath, buildArgs, { cwd: rootDir });
337
433
 
338
434
  // Optional autostart (macOS)
339
435
  if (disableAutostart) {
package/scripts/lint.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
4
- import { getComponentDir, getRootDir } from './utils/paths/paths.mjs';
4
+ import { componentDirEnvKey, getComponentDir, getRootDir } from './utils/paths/paths.mjs';
5
5
  import { ensureDepsInstalled } from './utils/proc/pm.mjs';
6
6
  import { pathExists } from './utils/fs/fs.mjs';
7
7
  import { run } from './utils/proc/proc.mjs';
8
8
  import { detectPackageManagerCmd, pickFirstScript, readPackageJsonScripts } from './utils/proc/package_scripts.mjs';
9
+ import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
9
10
 
10
11
  const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
11
12
 
@@ -40,18 +41,37 @@ async function main() {
40
41
  'examples:',
41
42
  ' happys lint',
42
43
  ' happys lint happy happy-cli',
44
+ '',
45
+ 'note:',
46
+ ' If run from inside a component checkout/worktree and no components are provided, defaults to that component.',
43
47
  ].join('\n'),
44
48
  });
45
49
  return;
46
50
  }
47
51
 
52
+ const rootDir = getRootDir(import.meta.url);
53
+
48
54
  const positionals = argv.filter((a) => !a.startsWith('--'));
49
- const requested = positionals.length ? positionals : ['all'];
55
+ const inferred =
56
+ positionals.length === 0
57
+ ? inferComponentFromCwd({
58
+ rootDir,
59
+ invokedCwd: getInvokedCwd(process.env),
60
+ components: DEFAULT_COMPONENTS,
61
+ })
62
+ : null;
63
+ if (inferred) {
64
+ const stacksKey = componentDirEnvKey(inferred.component);
65
+ const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
66
+ if (!(process.env[stacksKey] ?? '').toString().trim() && !(process.env[legacyKey] ?? '').toString().trim()) {
67
+ process.env[stacksKey] = inferred.repoDir;
68
+ }
69
+ }
70
+
71
+ const requested = positionals.length ? positionals : inferred ? [inferred.component] : ['all'];
50
72
  const wantAll = requested.includes('all');
51
73
  const components = wantAll ? DEFAULT_COMPONENTS : requested;
52
74
 
53
- const rootDir = getRootDir(import.meta.url);
54
-
55
75
  const results = [];
56
76
  for (const component of components) {
57
77
  if (!DEFAULT_COMPONENTS.includes(component)) {