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/stack.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import './utils/env/env.mjs';
2
2
  import { spawn } from 'node:child_process';
3
- import { chmod, copyFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
3
+ import { chmod, copyFile, mkdir, open, readFile, readdir, writeFile } from 'node:fs/promises';
4
4
  import { dirname, isAbsolute, join, resolve } from 'node:path';
5
5
  import { existsSync } from 'node:fs';
6
6
  // NOTE: random bytes usage centralized in scripts/utils/crypto/tokens.mjs
@@ -9,8 +9,17 @@ import { ensureDir, readTextIfExists, readTextOrEmpty } from './utils/fs/ops.mjs
9
9
 
10
10
  import { parseArgs } from './utils/cli/args.mjs';
11
11
  import { killProcessTree, run, runCapture } from './utils/proc/proc.mjs';
12
- import { getComponentDir, getComponentsDir, getHappyStacksHomeDir, getLegacyStorageRoot, getRootDir, getStacksStorageRoot, resolveStackEnvPath } from './utils/paths/paths.mjs';
13
- import { isTcpPortFree, pickNextFreeTcpPort } from './utils/net/ports.mjs';
12
+ import {
13
+ componentDirEnvKey,
14
+ getComponentDir,
15
+ getComponentsDir,
16
+ getHappyStacksHomeDir,
17
+ getLegacyStorageRoot,
18
+ getRootDir,
19
+ getStacksStorageRoot,
20
+ resolveStackEnvPath,
21
+ } from './utils/paths/paths.mjs';
22
+ import { isTcpPortFree, listListenPids, pickNextFreeTcpPort } from './utils/net/ports.mjs';
14
23
  import {
15
24
  createWorktree,
16
25
  createWorktreeFromBaseWorktree,
@@ -27,10 +36,10 @@ import { listAllStackNames, stackExistsSync } from './utils/stack/stacks.mjs';
27
36
  import { stopStackWithEnv } from './utils/stack/stop.mjs';
28
37
  import { writeDevAuthKey } from './utils/auth/dev_key.mjs';
29
38
  import { startDevServer } from './utils/dev/server.mjs';
30
- import { startDevExpoWebUi } from './utils/dev/expo_web.mjs';
39
+ import { ensureDevExpoServer } from './utils/dev/expo_dev.mjs';
31
40
  import { requireDir } from './utils/proc/pm.mjs';
32
41
  import { waitForHttpOk } from './utils/server/server.mjs';
33
- import { resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
42
+ import { resolveLocalhostHost, preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
34
43
  import { openUrlInBrowser } from './utils/ui/browser.mjs';
35
44
  import { copyFileIfMissing, linkFileIfMissing, writeSecretFileIfMissing } from './utils/auth/files.mjs';
36
45
  import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './utils/auth/sources.mjs';
@@ -52,23 +61,30 @@ import {
52
61
  import { killPid } from './utils/expo/expo.mjs';
53
62
  import { getCliHomeDirFromEnvOrDefault, getServerLightDataDirFromEnvOrDefault } from './utils/stack/dirs.mjs';
54
63
  import { randomToken } from './utils/crypto/tokens.mjs';
55
- import { killPidOwnedByStack } from './utils/proc/ownership.mjs';
64
+ import { killPidOwnedByStack, killProcessGroupOwnedByStack } from './utils/proc/ownership.mjs';
56
65
  import { sanitizeSlugPart } from './utils/git/refs.mjs';
57
66
  import { isCursorInstalled, openWorkspaceInEditor, writeStackCodeWorkspace } from './utils/stack/editor_workspace.mjs';
67
+ import { readLastLines } from './utils/fs/tail.mjs';
68
+ import { defaultStackReleaseIdentity } from './utils/mobile/identifiers.mjs';
58
69
 
59
70
  function stackNameFromArg(positionals, idx) {
60
71
  const name = positionals[idx]?.trim() ? positionals[idx].trim() : '';
61
72
  return name;
62
73
  }
63
74
 
64
- function getDefaultPortStart() {
75
+ function getDefaultPortStart(stackName = null) {
65
76
  const raw = process.env.HAPPY_STACKS_STACK_PORT_START?.trim()
66
77
  ? process.env.HAPPY_STACKS_STACK_PORT_START.trim()
67
78
  : process.env.HAPPY_LOCAL_STACK_PORT_START?.trim()
68
79
  ? process.env.HAPPY_LOCAL_STACK_PORT_START.trim()
69
80
  : '';
70
- const n = raw ? Number(raw) : 3005;
71
- return Number.isFinite(n) ? n : 3005;
81
+ // Default port strategy:
82
+ // - main historically lives at 3005
83
+ // - non-main stacks should avoid 3005 to reduce accidental collisions/confusion
84
+ const target = (stackName ?? '').toString().trim() || (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
85
+ const fallback = target === 'main' ? 3005 : 3009;
86
+ const n = raw ? Number(raw) : fallback;
87
+ return Number.isFinite(n) ? n : fallback;
72
88
  }
73
89
 
74
90
  async function isPortFree(port) {
@@ -231,7 +247,11 @@ function resolveDefaultComponentDirs({ rootDir }) {
231
247
  for (const name of componentNames) {
232
248
  const embedded = join(rootDir, 'components', name);
233
249
  const workspace = join(getComponentsDir(rootDir), name);
234
- const dir = existsSync(embedded) ? embedded : workspace;
250
+ // CRITICAL:
251
+ // In sandbox mode, never point stacks at the repo's embedded `components/*` checkouts.
252
+ // Sandboxes must use the sandbox workspace clones (HAPPY_STACKS_WORKSPACE_DIR/components/*),
253
+ // otherwise worktrees/branches collide with the user's real machine state.
254
+ const dir = !isSandboxed() && existsSync(embedded) ? embedded : workspace;
235
255
  out[`HAPPY_STACKS_COMPONENT_DIR_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`] = dir;
236
256
  }
237
257
  return out;
@@ -264,9 +284,46 @@ async function withStackEnv({ stackName, fn, extraEnv = {} }) {
264
284
  // exported in their shell, it would otherwise "win" because utils/env.mjs only sets
265
285
  // env vars if they are missing/empty.
266
286
  const cleaned = { ...process.env };
287
+ const keepPrefixed = new Set([
288
+ // Stack/env pointers:
289
+ 'HAPPY_LOCAL_ENV_FILE',
290
+ 'HAPPY_STACKS_ENV_FILE',
291
+ 'HAPPY_LOCAL_STACK',
292
+ 'HAPPY_STACKS_STACK',
293
+
294
+ // Sandbox detection + policy (must propagate to child processes).
295
+ 'HAPPY_STACKS_SANDBOX_DIR',
296
+ 'HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL',
297
+
298
+ // Sandbox-enforced dirs (without these, sandbox isolation breaks).
299
+ 'HAPPY_STACKS_CLI_ROOT_DISABLE',
300
+ 'HAPPY_STACKS_CANONICAL_HOME_DIR',
301
+ 'HAPPY_STACKS_HOME_DIR',
302
+ 'HAPPY_STACKS_WORKSPACE_DIR',
303
+ 'HAPPY_STACKS_RUNTIME_DIR',
304
+ 'HAPPY_STACKS_STORAGE_DIR',
305
+ // Legacy prefix mirrors:
306
+ 'HAPPY_LOCAL_CANONICAL_HOME_DIR',
307
+ 'HAPPY_LOCAL_HOME_DIR',
308
+ 'HAPPY_LOCAL_WORKSPACE_DIR',
309
+ 'HAPPY_LOCAL_RUNTIME_DIR',
310
+ 'HAPPY_LOCAL_STORAGE_DIR',
311
+
312
+ // Sandbox-safe UX knobs (keep consistent through stack wrappers).
313
+ 'HAPPY_STACKS_VERBOSE',
314
+ 'HAPPY_STACKS_UPDATE_CHECK',
315
+ 'HAPPY_STACKS_UPDATE_CHECK_INTERVAL_MS',
316
+ 'HAPPY_STACKS_UPDATE_NOTIFY_INTERVAL_MS',
317
+
318
+ // Guided auth flow coordination across wrappers.
319
+ // These are intentionally passed through even though most HAPPY_STACKS_* vars are scrubbed.
320
+ 'HAPPY_STACKS_DAEMON_WAIT_FOR_AUTH',
321
+ 'HAPPY_LOCAL_DAEMON_WAIT_FOR_AUTH',
322
+ 'HAPPY_STACKS_AUTH_FLOW',
323
+ 'HAPPY_LOCAL_AUTH_FLOW',
324
+ ]);
267
325
  for (const k of Object.keys(cleaned)) {
268
- if (k === 'HAPPY_LOCAL_ENV_FILE' || k === 'HAPPY_STACKS_ENV_FILE') continue;
269
- if (k === 'HAPPY_LOCAL_STACK' || k === 'HAPPY_STACKS_STACK') continue;
326
+ if (keepPrefixed.has(k)) continue;
270
327
  if (k.startsWith('HAPPY_LOCAL_') || k.startsWith('HAPPY_STACKS_')) {
271
328
  delete cleaned[k];
272
329
  }
@@ -851,7 +908,7 @@ async function cmdEdit({ rootDir, argv }) {
851
908
  printResult({ json, data: { stackName, envPath: wrote, port, serverComponent }, text: `[stack] updated ${stackName}\n[stack] env: ${wrote}` });
852
909
  }
853
910
 
854
- async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {} }) {
911
+ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {}, background = false }) {
855
912
  await withStackEnv({
856
913
  stackName,
857
914
  extraEnv,
@@ -882,6 +939,29 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
882
939
  // True restart = there was an active runner for this stack. If the stack is not running,
883
940
  // `--restart` should behave like a normal start (allocate new ephemeral ports if needed).
884
941
  const isTrueRestart = wantsRestart && wasRunning;
942
+
943
+ // Restart semantics (stack mode):
944
+ // - Stop stack-owned processes first (runner, daemon, Expo, etc.)
945
+ // - Never kill arbitrary port listeners
946
+ // - Preserve previous runtime ports in memory so a true restart can reuse them
947
+ if (wantsRestart && !wantsJson) {
948
+ const baseDir = resolveStackEnvPath(stackName).baseDir;
949
+ try {
950
+ await stopStackWithEnv({
951
+ rootDir,
952
+ stackName,
953
+ baseDir,
954
+ env,
955
+ json: false,
956
+ noDocker: false,
957
+ aggressive: false,
958
+ sweepOwned: true,
959
+ });
960
+ } catch {
961
+ // ignore (fail-closed below on port checks)
962
+ }
963
+ await deleteStackRuntimeStateFile(runtimeStatePath).catch(() => {});
964
+ }
885
965
  if (wasRunning) {
886
966
  if (!wantsRestart) {
887
967
  const serverPart = Number.isFinite(existingPort) && existingPort > 0 ? ` server=${existingPort}` : '';
@@ -913,12 +993,16 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
913
993
  } else if (scriptPath === 'dev.mjs') {
914
994
  console.log(`[stack] ${stackName}: ui: unknown (missing expo.webPort in stack.runtime.json)`);
915
995
  }
996
+
997
+ // Opt-in: allow starting mobile Metro alongside an already-running stack without restarting the runner.
998
+ // This is important for workflows like re-running `setup-pr` with --mobile after the stack is already up.
999
+ const wantsMobile = args.includes('--mobile') || args.includes('--with-mobile');
1000
+ if (wantsMobile) {
1001
+ await run(process.execPath, [join(rootDir, 'scripts', 'mobile.mjs'), '--metro'], { cwd: rootDir, env });
1002
+ }
916
1003
  return;
917
1004
  }
918
- // Restart: stop the existing runner first.
919
- await killPidOwnedByStack(existingOwnerPid, { stackName, envPath, cliHomeDir: (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').toString(), label: 'runner', json: false });
920
- // Clear runtime state so we don't keep stale process PIDs; we'll re-create it for the new run below.
921
- await deleteStackRuntimeStateFile(runtimeStatePath);
1005
+ // Restart: already handled above (stopStackWithEnv is ownership-gated).
922
1006
  }
923
1007
 
924
1008
  // Ephemeral ports: allocate at start time, store only in runtime state (not in stack env).
@@ -941,7 +1025,7 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
941
1025
  }
942
1026
  }
943
1027
 
944
- const startPort = getDefaultPortStart();
1028
+ const startPort = getDefaultPortStart(stackName);
945
1029
  const ports = {};
946
1030
 
947
1031
  const parsePortOrNull = (v) => {
@@ -986,6 +1070,42 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
986
1070
  for (const p of toCheck) {
987
1071
  // eslint-disable-next-line no-await-in-loop
988
1072
  if (!(await isTcpPortFree(p))) {
1073
+ if (isTrueRestart && !wantsJson) {
1074
+ // Try one more safe cleanup of stack-owned processes and re-check.
1075
+ const baseDir = resolveStackEnvPath(stackName).baseDir;
1076
+ try {
1077
+ await stopStackWithEnv({
1078
+ rootDir,
1079
+ stackName,
1080
+ baseDir,
1081
+ env,
1082
+ json: false,
1083
+ noDocker: false,
1084
+ aggressive: false,
1085
+ sweepOwned: true,
1086
+ });
1087
+ } catch {
1088
+ // ignore
1089
+ }
1090
+ // eslint-disable-next-line no-await-in-loop
1091
+ if (await isTcpPortFree(p)) {
1092
+ continue;
1093
+ }
1094
+
1095
+ // Last resort: if we can prove the listener is stack-owned, kill it.
1096
+ // eslint-disable-next-line no-await-in-loop
1097
+ const pids = await listListenPids(p);
1098
+ const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
1099
+ const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir, env });
1100
+ for (const pid of pids) {
1101
+ // eslint-disable-next-line no-await-in-loop
1102
+ await killProcessGroupOwnedByStack(pid, { stackName, envPath, cliHomeDir, label: `port:${p}`, json: false });
1103
+ }
1104
+ // eslint-disable-next-line no-await-in-loop
1105
+ if (await isTcpPortFree(p)) {
1106
+ continue;
1107
+ }
1108
+ }
989
1109
  throw new Error(
990
1110
  `[stack] ${stackName}: cannot reuse port ${p} on restart (port is not free).\n` +
991
1111
  `[stack] Fix: stop the process using it, or re-run without --restart to allocate new ports.`
@@ -1043,13 +1163,61 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
1043
1163
  : {}),
1044
1164
  };
1045
1165
 
1166
+ // Background dev auth flow (automatic):
1167
+ // If we're starting `dev.mjs` in background and the stack is not authenticated yet,
1168
+ // keep the stack alive for guided login by marking this as an auth-flow so URL resolution
1169
+ // fails closed (never opens server port as "UI").
1170
+ //
1171
+ // IMPORTANT:
1172
+ // We must NOT start the daemon before credentials exist in orchestrated flows (setup-pr/review-pr),
1173
+ // because the daemon can enter its own auth flow and become stranded (lock held, no machine registration).
1174
+ if (background && scriptPath === 'dev.mjs') {
1175
+ const startUi = !args.includes('--no-ui') && (env.HAPPY_LOCAL_UI ?? '1').toString().trim() !== '0';
1176
+ const startDaemon = !args.includes('--no-daemon') && (env.HAPPY_LOCAL_DAEMON ?? '1').toString().trim() !== '0';
1177
+ if (startUi && startDaemon) {
1178
+ try {
1179
+ const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
1180
+ const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir, env });
1181
+ const hasCreds = existsSync(join(cliHomeDir, 'access.key'));
1182
+ if (!hasCreds) {
1183
+ childEnv.HAPPY_STACKS_AUTH_FLOW = '1';
1184
+ childEnv.HAPPY_LOCAL_AUTH_FLOW = '1';
1185
+ }
1186
+ } catch {
1187
+ // If we can't resolve CLI home dir, skip auto auth-flow markers (best-effort).
1188
+ }
1189
+ }
1190
+ }
1191
+
1192
+ // Background mode: send runner output to a stack-scoped log file so quiet flows can
1193
+ // remain clean while still providing actionable error logs.
1194
+ const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
1195
+ const logsDir = join(stackBaseDir, 'logs');
1196
+ const logPath = join(logsDir, `${scriptPath.replace(/\.mjs$/, '')}.${Date.now()}.log`);
1197
+ if (background) {
1198
+ await ensureDir(logsDir);
1199
+ }
1200
+
1201
+ let logHandle = null;
1202
+ let outFd = null;
1203
+ if (background) {
1204
+ logHandle = await open(logPath, 'a');
1205
+ outFd = logHandle.fd;
1206
+ }
1207
+
1046
1208
  // Spawn the runner (long-lived) and record its pid + ports for other stack-scoped commands.
1047
1209
  const child = spawn(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], {
1048
1210
  cwd: rootDir,
1049
1211
  env: childEnv,
1050
- stdio: 'inherit',
1212
+ stdio: background ? ['ignore', outFd ?? 'ignore', outFd ?? 'ignore'] : 'inherit',
1051
1213
  shell: false,
1214
+ detached: background && process.platform !== 'win32',
1052
1215
  });
1216
+ try {
1217
+ await logHandle?.close();
1218
+ } catch {
1219
+ // ignore
1220
+ }
1053
1221
 
1054
1222
  // Record the chosen ports immediately (before the runner finishes booting), so other stack commands
1055
1223
  // can resolve the correct endpoints and `--restart` can reliably reuse the same ports.
@@ -1059,8 +1227,104 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
1059
1227
  ephemeral: true,
1060
1228
  ownerPid: child.pid,
1061
1229
  ports,
1230
+ ...(background ? { logs: { runner: logPath } } : {}),
1062
1231
  }).catch(() => {});
1063
1232
 
1233
+ if (background) {
1234
+ // Keep stack.runtime.json so stack-scoped stop/restart can manage this runner.
1235
+ // This mode is used by higher-level commands that want to run guided auth steps
1236
+ // without mixing them into server logs.
1237
+ const internalServerUrl = `http://127.0.0.1:${ports.server}`;
1238
+
1239
+ // Fail fast if the runner dies immediately or never exposes HTTP.
1240
+ // IMPORTANT: do not treat "some process answered /health" as success unless our runner
1241
+ // is still alive. Otherwise, if the chosen port is already in use, the runner can exit
1242
+ // and a different stack/process could satisfy the health check (leading to confusing
1243
+ // follow-on behavior like auth using the wrong port).
1244
+ try {
1245
+ let exited = null;
1246
+ const exitPromise = new Promise((resolvePromise) => {
1247
+ child.once('exit', (code, sig) => {
1248
+ exited = { kind: 'exit', code: code ?? 0, sig: sig ?? null };
1249
+ resolvePromise(exited);
1250
+ });
1251
+ child.once('error', (err) => {
1252
+ exited = { kind: 'error', error: err instanceof Error ? err.message : String(err) };
1253
+ resolvePromise(exited);
1254
+ });
1255
+ });
1256
+ const readyPromise = (async () => {
1257
+ const timeoutMsRaw =
1258
+ (process.env.HAPPY_STACKS_STACK_BACKGROUND_READY_TIMEOUT_MS ??
1259
+ process.env.HAPPY_LOCAL_STACK_BACKGROUND_READY_TIMEOUT_MS ??
1260
+ '180000')
1261
+ .toString()
1262
+ .trim();
1263
+ const timeoutMs = timeoutMsRaw ? Number(timeoutMsRaw) : 180_000;
1264
+ await waitForHttpOk(`${internalServerUrl}/health`, {
1265
+ timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 180_000,
1266
+ intervalMs: 300,
1267
+ });
1268
+ return { kind: 'ready' };
1269
+ })();
1270
+
1271
+ const first = await Promise.race([exitPromise, readyPromise]);
1272
+ if (first.kind !== 'ready') {
1273
+ throw new Error(`[stack] ${stackName}: runner exited before becoming ready. log: ${logPath}`);
1274
+ }
1275
+ // Even if /health responded, ensure our runner is still alive.
1276
+ // (Prevents false positives when another process owns the port.)
1277
+ if (exited && exited.kind !== 'ready') {
1278
+ throw new Error(`[stack] ${stackName}: runner reported ready but exited immediately. log: ${logPath}`);
1279
+ }
1280
+ if (!isPidAlive(child.pid)) {
1281
+ throw new Error(
1282
+ `[stack] ${stackName}: runner health check passed, but runner is not running.\n` +
1283
+ `[stack] This usually means the chosen port (${ports.server}) is already in use by another process.\n` +
1284
+ `[stack] log: ${logPath}`
1285
+ );
1286
+ }
1287
+ } catch (e) {
1288
+ // Attach some log context so failures are debuggable even when a higher-level
1289
+ // command cleans up the sandbox directory afterwards.
1290
+ try {
1291
+ const tail = await readLastLines(logPath, 160);
1292
+ if (tail && e instanceof Error) {
1293
+ e.message = `${e.message}\n\n[stack] last runner log lines:\n${tail}`;
1294
+ }
1295
+ } catch {
1296
+ // ignore
1297
+ }
1298
+ // Best-effort cleanup on boot failure.
1299
+ try {
1300
+ // We spawned this runner process, so we can safely terminate it without relying
1301
+ // on ownership heuristics (which can be unreliable on some platforms due to `ps` truncation).
1302
+ if (background && process.platform !== 'win32') {
1303
+ try {
1304
+ process.kill(-child.pid, 'SIGTERM');
1305
+ } catch {
1306
+ // ignore
1307
+ }
1308
+ }
1309
+ try {
1310
+ child.kill('SIGTERM');
1311
+ } catch {
1312
+ // ignore
1313
+ }
1314
+ } catch {
1315
+ // ignore
1316
+ }
1317
+ await deleteStackRuntimeStateFile(runtimeStatePath).catch(() => {});
1318
+ throw e;
1319
+ }
1320
+
1321
+ if (!wantsJson) {
1322
+ console.log(`[stack] ${stackName}: logs: ${logPath}`);
1323
+ }
1324
+ try { child.unref(); } catch { /* ignore */ }
1325
+ return;
1326
+ }
1327
+
1064
1328
  try {
1065
1329
  await new Promise((resolvePromise, rejectPromise) => {
1066
1330
  child.on('error', rejectPromise);
@@ -1079,6 +1343,28 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
1079
1343
  }
1080
1344
 
1081
1345
  // Pinned port stack: run normally under the pinned env.
1346
+ if (background) {
1347
+ throw new Error('[stack] --background is only supported for ephemeral-port stacks');
1348
+ }
1349
+ if (wantsRestart && !wantsJson) {
1350
+ const pinnedPort = coercePort(env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT);
1351
+ if (pinnedPort && !(await isTcpPortFree(pinnedPort))) {
1352
+ // Last resort: kill listener only if it is stack-owned.
1353
+ const pids = await listListenPids(pinnedPort);
1354
+ const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
1355
+ const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir, env });
1356
+ for (const pid of pids) {
1357
+ // eslint-disable-next-line no-await-in-loop
1358
+ await killProcessGroupOwnedByStack(pid, { stackName, envPath, cliHomeDir, label: `port:${pinnedPort}`, json: false });
1359
+ }
1360
+ if (!(await isTcpPortFree(pinnedPort))) {
1361
+ throw new Error(
1362
+ `[stack] ${stackName}: server port ${pinnedPort} is not free on restart.\n` +
1363
+ `[stack] Refusing to kill unknown listeners. Stop the process using it, or change the pinned port.`
1364
+ );
1365
+ }
1366
+ }
1367
+ }
1082
1368
  await run(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], { cwd: rootDir, env });
1083
1369
  },
1084
1370
  });
@@ -1122,9 +1408,25 @@ async function cmdService({ rootDir, stackName, svcCmd }) {
1122
1408
  });
1123
1409
  }
1124
1410
 
1411
+ async function getRuntimePortExtraEnv(stackName) {
1412
+ const runtimeStatePath = getStackRuntimeStatePath(stackName);
1413
+ const runtimeState = await readStackRuntimeStateFile(runtimeStatePath);
1414
+ const runtimePort = Number(runtimeState?.ports?.server);
1415
+ return Number.isFinite(runtimePort) && runtimePort > 0
1416
+ ? {
1417
+ // Ephemeral stacks (PR stacks) store their chosen ports in stack.runtime.json, not the env file.
1418
+ // Ensure stack-scoped commands that compute URLs don't fall back to 3005 (main default).
1419
+ HAPPY_STACKS_SERVER_PORT: String(runtimePort),
1420
+ HAPPY_LOCAL_SERVER_PORT: String(runtimePort),
1421
+ }
1422
+ : null;
1423
+ }
1424
+
1125
1425
  async function cmdTailscale({ rootDir, stackName, subcmd, args }) {
1426
+ const extraEnv = await getRuntimePortExtraEnv(stackName);
1126
1427
  await withStackEnv({
1127
1428
  stackName,
1429
+ ...(extraEnv ? { extraEnv } : {}),
1128
1430
  fn: async ({ env }) => {
1129
1431
  await run(process.execPath, [join(rootDir, 'scripts', 'tailscale.mjs'), subcmd, ...args], { cwd: rootDir, env });
1130
1432
  },
@@ -1159,8 +1461,10 @@ async function cmdAuth({ rootDir, stackName, args }) {
1159
1461
  // Forward to scripts/auth.mjs under the stack env.
1160
1462
  // This makes `happys stack auth <name> ...` resolve CLI home/urls for that stack.
1161
1463
  const forwarded = args[0] === '--' ? args.slice(1) : args;
1464
+ const extraEnv = await getRuntimePortExtraEnv(stackName);
1162
1465
  await withStackEnv({
1163
1466
  stackName,
1467
+ ...(extraEnv ? { extraEnv } : {}),
1164
1468
  fn: async ({ env }) => {
1165
1469
  await run(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...forwarded], { cwd: rootDir, env });
1166
1470
  },
@@ -1795,7 +2099,7 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
1795
2099
 
1796
2100
  const serverPort = await pickNextFreeTcpPort(3005, { host: '127.0.0.1' });
1797
2101
  const internalServerUrl = `http://127.0.0.1:${serverPort}`;
1798
- const publicServerUrl = `http://localhost:${serverPort}`;
2102
+ const publicServerUrl = await preferStackLocalhostUrl(`http://localhost:${serverPort}`, { stackName: name });
1799
2103
 
1800
2104
  const autostart = { stackName: name, baseDir: resolveStackEnvPath(name).baseDir };
1801
2105
  const children = [];
@@ -1815,9 +2119,14 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
1815
2119
  serverComponent === 'happy-server'
1816
2120
  ? env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER
1817
2121
  : env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT;
1818
- const resolvedServerDir = serverDir || getComponentDir(rootDir, serverComponent);
1819
- const resolvedCliDir = env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI || getComponentDir(rootDir, 'happy-cli');
1820
- const resolvedUiDir = env.HAPPY_STACKS_COMPONENT_DIR_HAPPY || getComponentDir(rootDir, 'happy');
2122
+ const resolvedServerDir =
2123
+ (serverDir ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER_LIGHT ?? '').toString().trim() ||
2124
+ getComponentDir(rootDir, serverComponent);
2125
+ const resolvedCliDir =
2126
+ (env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI ?? '').toString().trim() ||
2127
+ getComponentDir(rootDir, 'happy-cli');
2128
+ const resolvedUiDir =
2129
+ (env.HAPPY_STACKS_COMPONENT_DIR_HAPPY ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY ?? '').toString().trim() || getComponentDir(rootDir, 'happy');
1821
2130
 
1822
2131
  await requireDir(serverComponent, resolvedServerDir);
1823
2132
  await requireDir('happy-cli', resolvedCliDir);
@@ -1844,9 +2153,10 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
1844
2153
  });
1845
2154
  serverProc = started.serverProc;
1846
2155
 
1847
- // Start Expo web UI so /terminal/connect exists for happy-cli web auth.
1848
- const uiRes = await startDevExpoWebUi({
2156
+ // Start Expo (web) so /terminal/connect exists for happy-cli web auth.
2157
+ const uiRes = await ensureDevExpoServer({
1849
2158
  startUi: true,
2159
+ startMobile: false,
1850
2160
  uiDir: resolvedUiDir,
1851
2161
  autostart,
1852
2162
  baseEnv: env,
@@ -1865,10 +2175,9 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
1865
2175
  }
1866
2176
 
1867
2177
  console.log('');
1868
- const uiHost = resolveLocalhostHost({ stackMode: true, stackName: name });
1869
2178
  const uiPort = uiRes?.port;
1870
- const uiRoot = Number.isFinite(uiPort) && uiPort > 0 ? `http://${uiHost}:${uiPort}` : null;
1871
2179
  const uiRootLocalhost = Number.isFinite(uiPort) && uiPort > 0 ? `http://localhost:${uiPort}` : null;
2180
+ const uiRoot = uiRootLocalhost ? await preferStackLocalhostUrl(uiRootLocalhost, { stackName: name }) : null;
1872
2181
  const uiSettings = uiRoot ? `${uiRoot}/settings/account` : null;
1873
2182
 
1874
2183
  console.log('[stack] step 1/3: create a dev-auth account in the UI (this generates the dev key)');
@@ -2058,14 +2367,14 @@ async function cmdDuplicate({ rootDir, argv }) {
2058
2367
  if (!rawDir) continue;
2059
2368
 
2060
2369
  let nextDir = rawDir;
2061
- if (duplicateWorktrees && isComponentWorktreePath({ rootDir, component, dir: rawDir })) {
2062
- const spec = worktreeSpecFromDir({ rootDir, component, dir: rawDir });
2370
+ if (duplicateWorktrees && isComponentWorktreePath({ rootDir, component, dir: rawDir, env: fromEnv })) {
2371
+ const spec = worktreeSpecFromDir({ rootDir, component, dir: rawDir, env: fromEnv });
2063
2372
  if (spec) {
2064
2373
  const [owner, ...restParts] = spec.split('/').filter(Boolean);
2065
2374
  const rest = restParts.join('/');
2066
2375
  const slug = `dup/${sanitizeSlugPart(toStack)}/${rest}`;
2067
2376
 
2068
- const repoDir = join(getComponentsDir(rootDir), component);
2377
+ const repoDir = join(getComponentsDir(rootDir, fromEnv), component);
2069
2378
  const remoteName = await inferRemoteNameForOwner({ repoDir, owner });
2070
2379
  // Base on the existing worktree's HEAD/branch so we get the same commit.
2071
2380
  nextDir = await createWorktreeFromBaseWorktree({
@@ -2075,6 +2384,7 @@ async function cmdDuplicate({ rootDir, argv }) {
2075
2384
  baseWorktreeSpec: spec,
2076
2385
  remoteName,
2077
2386
  depsMode,
2387
+ env: fromEnv,
2078
2388
  });
2079
2389
  }
2080
2390
  }
@@ -2160,13 +2470,14 @@ async function cmdPrStack({ rootDir, argv }) {
2160
2470
  json,
2161
2471
  data: {
2162
2472
  usage:
2163
- 'happys stack pr <name> --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--happy-server=<pr-url|number>|--happy-server-light=<pr-url|number>] [--server=happy-server|happy-server-light] [--remote=upstream] [--deps=none|link|install|link-or-install] [--seed-auth] [--copy-auth-from=<stack>] [--with-infra] [--auth-force] [--dev|--start] [--json] [-- <stack dev/start args...>]',
2473
+ 'happys stack pr <name> --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--happy-server=<pr-url|number>|--happy-server-light=<pr-url|number>] [--server=happy-server|happy-server-light] [--remote=upstream] [--deps=none|link|install|link-or-install] [--seed-auth] [--copy-auth-from=<stack>] [--with-infra] [--auth-force] [--dev|--start] [--background] [--mobile] [--json] [-- <stack dev/start args...>]',
2164
2474
  },
2165
2475
  text: [
2166
2476
  '[stack] usage:',
2167
2477
  ' happys stack pr <name> --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--dev|--start]',
2168
- ' [--seed-auth] [--copy-auth-from=<stack|legacy>] [--link-auth] [--with-infra] [--auth-force]',
2169
- ' [--remote=upstream] [--deps=none|link|install|link-or-install] [--update] [--force]',
2478
+ ' [--seed-auth] [--copy-auth-from=<stack>] [--link-auth] [--with-infra] [--auth-force]',
2479
+ ' [--remote=upstream] [--deps=none|link|install|link-or-install] [--update] [--force] [--background]',
2480
+ ' [--mobile] # also start Expo dev-client Metro for mobile',
2170
2481
  ' [--json] [-- <stack dev/start args...>]',
2171
2482
  '',
2172
2483
  'examples:',
@@ -2181,7 +2492,7 @@ async function cmdPrStack({ rootDir, argv }) {
2181
2492
  ' happys stack pr pr123 --happy=123 --happy-cli=456 --seed-auth --copy-auth-from=dev-auth --dev',
2182
2493
  '',
2183
2494
  ' # Reuse an existing non-stacks Happy install for auth seeding',
2184
- ' happys stack pr pr123 --happy=123 --seed-auth --copy-auth-from=legacy --link-auth --dev',
2495
+ ' (deprecated) legacy ~/.happy is not supported for reliable seeding',
2185
2496
  '',
2186
2497
  'notes:',
2187
2498
  ' - This composes existing commands: `happys stack new`, `happys stack wt ...`, and `happys stack auth ...`',
@@ -2209,7 +2520,7 @@ async function cmdPrStack({ rootDir, argv }) {
2209
2520
  );
2210
2521
  }
2211
2522
 
2212
- const remoteName = (kv.get('--remote') ?? '').trim() || 'upstream';
2523
+ const remoteNameFromArg = (kv.get('--remote') ?? '').trim();
2213
2524
  const depsMode = (kv.get('--deps') ?? '').trim();
2214
2525
 
2215
2526
  const prHappy = (kv.get('--happy') ?? '').trim();
@@ -2239,6 +2550,9 @@ async function cmdPrStack({ rootDir, argv }) {
2239
2550
  throw new Error('[stack] pr: choose either --dev or --start (not both)');
2240
2551
  }
2241
2552
 
2553
+ const wantsMobile = flags.has('--mobile') || flags.has('--with-mobile');
2554
+ const background = flags.has('--background') || flags.has('--bg') || (kv.get('--background') ?? '').trim() === '1';
2555
+
2242
2556
  const seedAuthFlag = flags.has('--seed-auth') ? true : flags.has('--no-seed-auth') ? false : null;
2243
2557
  const authFromFlag = (kv.get('--copy-auth-from') ?? '').trim();
2244
2558
  const withInfra = flags.has('--with-infra') || flags.has('--ensure-infra') || flags.has('--infra');
@@ -2375,6 +2689,7 @@ async function cmdPrStack({ rootDir, argv }) {
2375
2689
  ].filter((x) => x.pr);
2376
2690
 
2377
2691
  const worktrees = [];
2692
+ const stackEnvPath = resolveStackEnvPath(stackName).envPath;
2378
2693
  for (const { component, pr } of prSpecs) {
2379
2694
  progress(`[stack] pr: ${stackName}: fetching PR for ${component} (${pr})...`);
2380
2695
  const out = await withStackEnv({
@@ -2385,7 +2700,7 @@ async function cmdPrStack({ rootDir, argv }) {
2385
2700
  'pr',
2386
2701
  component,
2387
2702
  pr,
2388
- `--remote=${remoteName}`,
2703
+ ...(remoteNameFromArg ? [`--remote=${remoteNameFromArg}`] : []),
2389
2704
  '--use',
2390
2705
  ...(depsMode ? [`--deps=${depsMode}`] : []),
2391
2706
  ...(doUpdate ? ['--update'] : []),
@@ -2393,11 +2708,35 @@ async function cmdPrStack({ rootDir, argv }) {
2393
2708
  '--json',
2394
2709
  ];
2395
2710
  const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'worktrees.mjs'), ...args], { cwd: rootDir, env });
2396
- return stdout.trim() ? JSON.parse(stdout.trim()) : null;
2711
+ const parsed = stdout.trim() ? JSON.parse(stdout.trim()) : null;
2712
+
2713
+ // Fail-closed invariant for PR stacks:
2714
+ // If you asked to pin a component to a PR checkout, it MUST be a worktree path under
2715
+ // the active workspace components dir (including sandbox workspace).
2716
+ if (parsed?.path && !isComponentWorktreePath({ rootDir, component, dir: parsed.path, env })) {
2717
+ throw new Error(
2718
+ `[stack] pr: refusing to pin ${component} because the checked out path is not a worktree.\n` +
2719
+ `- expected under: ${join(getComponentsDir(rootDir, env), '.worktrees', component)}/...\n` +
2720
+ `- actual: ${String(parsed.path ?? '').trim()}\n` +
2721
+ `Fix: this is a bug. Please re-run with --force, or delete/recreate the stack (${stackName}).`
2722
+ );
2723
+ }
2724
+
2725
+ return parsed;
2397
2726
  },
2398
2727
  });
2399
- if (json) {
2728
+ if (out) {
2400
2729
  worktrees.push(out);
2730
+ // Fail-closed invariant for PR stacks:
2731
+ // - if you asked to pin a component to a PR checkout, the stack env file MUST point at that exact worktree dir
2732
+ // before we start dev/start. Otherwise the stack can accidentally run the base checkout.
2733
+ //
2734
+ // We intentionally do NOT rely solely on `wt pr --use` for this; we make it explicit here.
2735
+ const key = componentDirEnvKey(component);
2736
+ await ensureEnvFileUpdated({ envPath: stackEnvPath, updates: [{ key, value: out.path }] });
2737
+ }
2738
+ if (json) {
2739
+ // collected above
2401
2740
  } else if (out) {
2402
2741
  const short = (sha) => (sha ? String(sha).slice(0, 8) : '');
2403
2742
  const changed = Boolean(out.updated && out.oldHead && out.newHead && out.oldHead !== out.newHead);
@@ -2414,6 +2753,36 @@ async function cmdPrStack({ rootDir, argv }) {
2414
2753
  }
2415
2754
  }
2416
2755
 
2756
+ // Validate that all PR components are pinned correctly before starting.
2757
+ // This prevents "wrong daemon" / "wrong UI" errors that are otherwise extremely confusing in review-pr.
2758
+ if (prSpecs.length) {
2759
+ const afterRaw = await readExistingEnv(stackEnvPath);
2760
+ const afterEnv = parseEnvToObject(afterRaw);
2761
+ for (const wt of worktrees) {
2762
+ const key = componentDirEnvKey(wt.component);
2763
+ const val = (afterEnv[key] ?? '').toString().trim();
2764
+ const expected = resolve(String(wt.path ?? '').trim());
2765
+ const actual = val ? resolve(val) : '';
2766
+ if (!actual) {
2767
+ throw new Error(
2768
+ `[stack] pr: failed to pin ${wt.component} to the PR checkout.\n` +
2769
+ `- missing env key: ${key}\n` +
2770
+ `- expected: ${expected}\n` +
2771
+ `Fix: re-run with --force, or delete/recreate the stack (${stackName}).`
2772
+ );
2773
+ }
2774
+ if (expected && actual !== expected) {
2775
+ throw new Error(
2776
+ `[stack] pr: stack is pinned to the wrong checkout for ${wt.component}.\n` +
2777
+ `- env key: ${key}\n` +
2778
+ `- expected: ${expected}\n` +
2779
+ `- actual: ${actual}\n` +
2780
+ `Fix: re-run with --force, or delete/recreate the stack (${stackName}).`
2781
+ );
2782
+ }
2783
+ }
2784
+ }
2785
+
2417
2786
  // 3) Optional: seed auth (copies cli creds + master secret + DB Account rows).
2418
2787
  let auth = null;
2419
2788
  if (seedAuth) {
@@ -2426,8 +2795,10 @@ async function cmdPrStack({ rootDir, argv }) {
2426
2795
  ...(authLink ? ['--link'] : []),
2427
2796
  ];
2428
2797
  if (json) {
2798
+ const extraEnv = await getRuntimePortExtraEnv(stackName);
2429
2799
  auth = await withStackEnv({
2430
2800
  stackName,
2801
+ ...(extraEnv ? { extraEnv } : {}),
2431
2802
  fn: async ({ env }) => {
2432
2803
  const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...args, '--json'], { cwd: rootDir, env });
2433
2804
  return stdout.trim() ? JSON.parse(stdout.trim()) : null;
@@ -2442,12 +2813,18 @@ async function cmdPrStack({ rootDir, argv }) {
2442
2813
  // 4) Optional: start dev / start.
2443
2814
  if (wantsDev) {
2444
2815
  progress(`[stack] pr: ${stackName}: starting dev...`);
2445
- const args = passthrough.length ? ['--', ...passthrough] : [];
2446
- await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args });
2816
+ const args = [
2817
+ ...(wantsMobile ? ['--mobile'] : []),
2818
+ ...(passthrough.length ? ['--', ...passthrough] : []),
2819
+ ];
2820
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args, background });
2447
2821
  } else if (wantsStart) {
2448
2822
  progress(`[stack] pr: ${stackName}: starting...`);
2449
- const args = passthrough.length ? ['--', ...passthrough] : [];
2450
- await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args });
2823
+ const args = [
2824
+ ...(wantsMobile ? ['--mobile'] : []),
2825
+ ...(passthrough.length ? ['--', ...passthrough] : []),
2826
+ ];
2827
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args, background });
2451
2828
  }
2452
2829
 
2453
2830
  const info = await cmdInfoInternal({ rootDir, stackName });
@@ -2500,10 +2877,15 @@ async function cmdInfoInternal({ rootDir, stackName }) {
2500
2877
  runtimeState?.expo && typeof runtimeState.expo === 'object' && Number(runtimeState.expo.webPort) > 0
2501
2878
  ? Number(runtimeState.expo.webPort)
2502
2879
  : null;
2880
+ const mobilePort =
2881
+ runtimeState?.expo && typeof runtimeState.expo === 'object' && Number(runtimeState.expo.mobilePort) > 0
2882
+ ? Number(runtimeState.expo.mobilePort)
2883
+ : null;
2503
2884
 
2504
2885
  const host = resolveLocalhostHost({ stackMode: true, stackName });
2505
2886
  const internalServerUrl = serverPort ? `http://127.0.0.1:${serverPort}` : null;
2506
2887
  const uiUrl = uiPort ? `http://${host}:${uiPort}` : null;
2888
+ const mobileUrl = mobilePort ? await preferStackLocalhostUrl(`http://localhost:${mobilePort}`, { stackName }) : null;
2507
2889
 
2508
2890
  const componentSpecs = [
2509
2891
  { component: 'happy', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY'] },
@@ -2546,11 +2928,13 @@ async function cmdInfoInternal({ rootDir, stackName }) {
2546
2928
  host,
2547
2929
  internalServerUrl,
2548
2930
  uiUrl,
2931
+ mobileUrl,
2549
2932
  },
2550
2933
  ports: {
2551
2934
  server: serverPort,
2552
2935
  backend: backendPort,
2553
2936
  ui: uiPort,
2937
+ mobile: mobilePort,
2554
2938
  },
2555
2939
  components,
2556
2940
  };
@@ -2617,11 +3001,14 @@ async function main() {
2617
3001
  'dev',
2618
3002
  'start',
2619
3003
  'build',
3004
+ 'review',
2620
3005
  'typecheck',
2621
3006
  'lint',
2622
3007
  'test',
2623
3008
  'doctor',
2624
3009
  'mobile',
3010
+ 'mobile:install',
3011
+ 'mobile-dev-client',
2625
3012
  'resume',
2626
3013
  'stop',
2627
3014
  'code',
@@ -2648,11 +3035,14 @@ async function main() {
2648
3035
  ' happys stack dev <name> [-- ...]',
2649
3036
  ' happys stack start <name> [-- ...]',
2650
3037
  ' happys stack build <name> [-- ...]',
3038
+ ' happys stack review <name> [component...] [--reviewers=coderabbit,codex] [--base-remote=<remote>] [--base-branch=<branch>] [--base-ref=<ref>] [--json]',
2651
3039
  ' happys stack typecheck <name> [component...] [--json]',
2652
3040
  ' happys stack lint <name> [component...] [--json]',
2653
3041
  ' happys stack test <name> [component...] [--json]',
2654
3042
  ' happys stack doctor <name> [-- ...]',
2655
3043
  ' happys stack mobile <name> [-- ...]',
3044
+ ' happys stack mobile:install <name> [--name="Happy (exp1)"] [--device=...] [--json]',
3045
+ ' happys stack mobile-dev-client <name> --install [--device=...] [--clean] [--configuration=Debug|Release] [--json]',
2656
3046
  ' happys stack resume <name> <sessionId...> [--json]',
2657
3047
  ' happys stack stop <name> [--aggressive] [--sweep-owned] [--no-docker] [--json]',
2658
3048
  ' happys stack code <name> [--no-stack-dir] [--include-all-components] [--include-cli-home] [--json]',
@@ -2786,11 +3176,15 @@ async function main() {
2786
3176
  const passthrough = argv.slice(2);
2787
3177
 
2788
3178
  if (cmd === 'dev') {
2789
- await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args: passthrough });
3179
+ const background = passthrough.includes('--background') || passthrough.includes('--bg');
3180
+ const args = background ? passthrough.filter((a) => a !== '--background' && a !== '--bg') : passthrough;
3181
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args, background });
2790
3182
  return;
2791
3183
  }
2792
3184
  if (cmd === 'start') {
2793
- await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args: passthrough });
3185
+ const background = passthrough.includes('--background') || passthrough.includes('--bg');
3186
+ const args = background ? passthrough.filter((a) => a !== '--background' && a !== '--bg') : passthrough;
3187
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args, background });
2794
3188
  return;
2795
3189
  }
2796
3190
  if (cmd === 'build') {
@@ -2817,6 +3211,12 @@ async function main() {
2817
3211
  await cmdRunScript({ rootDir, stackName, scriptPath: 'test.mjs', args: passthrough, extraEnv: overrides });
2818
3212
  return;
2819
3213
  }
3214
+ if (cmd === 'review') {
3215
+ const { kv } = parseArgs(passthrough);
3216
+ const overrides = resolveTransientComponentOverrides({ rootDir, kv });
3217
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'review.mjs', args: passthrough, extraEnv: overrides });
3218
+ return;
3219
+ }
2820
3220
  if (cmd === 'doctor') {
2821
3221
  await cmdRunScript({ rootDir, stackName, scriptPath: 'doctor.mjs', args: passthrough });
2822
3222
  return;
@@ -2825,6 +3225,62 @@ async function main() {
2825
3225
  await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile.mjs', args: passthrough });
2826
3226
  return;
2827
3227
  }
3228
+ if (cmd === 'mobile-dev-client') {
3229
+ // Stack-scoped wrapper so the dev-client can be built from the stack's active happy checkout/worktree.
3230
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile_dev_client.mjs', args: passthrough });
3231
+ return;
3232
+ }
3233
+ if (cmd === 'mobile:install') {
3234
+ const { flags: mFlags, kv: mKv } = parseArgs(passthrough);
3235
+ const device = (mKv.get('--device') ?? '').toString();
3236
+ const name = (mKv.get('--name') ?? mKv.get('--app-name') ?? '').toString().trim();
3237
+ const jsonOut = wantsJson(passthrough, { flags: mFlags }) || json;
3238
+
3239
+ const envPath = resolveStackEnvPath(stackName).envPath;
3240
+ const existingRaw = await readExistingEnv(envPath);
3241
+ const existing = parseEnvToObject(existingRaw);
3242
+
3243
+ const priorName =
3244
+ (existing.HAPPY_STACKS_MOBILE_RELEASE_IOS_APP_NAME ?? existing.HAPPY_LOCAL_MOBILE_RELEASE_IOS_APP_NAME ?? '').toString().trim();
3245
+ const identity = defaultStackReleaseIdentity({
3246
+ stackName,
3247
+ user: process.env.USER ?? process.env.USERNAME ?? 'user',
3248
+ appName: name || priorName || null,
3249
+ });
3250
+
3251
+ // Persist the chosen identity so re-installs are stable and user-friendly.
3252
+ await ensureEnvFileUpdated({
3253
+ envPath,
3254
+ updates: [
3255
+ { key: 'HAPPY_STACKS_MOBILE_RELEASE_IOS_APP_NAME', value: identity.iosAppName },
3256
+ { key: 'HAPPY_STACKS_MOBILE_RELEASE_IOS_BUNDLE_ID', value: identity.iosBundleId },
3257
+ { key: 'HAPPY_STACKS_MOBILE_RELEASE_SCHEME', value: identity.scheme },
3258
+ ],
3259
+ });
3260
+
3261
+ // Install a per-stack release-configured app (isolated container) without starting Metro.
3262
+ const args = [
3263
+ `--app-env=production`,
3264
+ `--ios-app-name=${identity.iosAppName}`,
3265
+ `--ios-bundle-id=${identity.iosBundleId}`,
3266
+ `--scheme=${identity.scheme}`,
3267
+ '--prebuild',
3268
+ '--run-ios',
3269
+ '--configuration=Release',
3270
+ '--no-metro',
3271
+ ...(device ? [`--device=${device}`] : []),
3272
+ ];
3273
+
3274
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile.mjs', args });
3275
+
3276
+ if (jsonOut) {
3277
+ printResult({
3278
+ json: true,
3279
+ data: { ok: true, stackName, installed: true, identity },
3280
+ });
3281
+ }
3282
+ return;
3283
+ }
2828
3284
  if (cmd === 'resume') {
2829
3285
  const sessionIds = passthrough.filter((a) => a && a !== '--' && !a.startsWith('--'));
2830
3286
  if (sessionIds.length === 0) {
@@ -2841,7 +3297,9 @@ async function main() {
2841
3297
  const out = await withStackEnv({
2842
3298
  stackName,
2843
3299
  fn: async ({ env }) => {
2844
- const cliDir = getComponentDir(rootDir, 'happy-cli');
3300
+ // IMPORTANT: use the stack's pinned happy-cli checkout if set.
3301
+ // Do not read component dirs from this process's `process.env` (withStackEnv does not mutate it).
3302
+ const cliDir = (env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI ?? '').toString().trim() || getComponentDir(rootDir, 'happy-cli');
2845
3303
  const happyBin = join(cliDir, 'bin', 'happy.mjs');
2846
3304
  // Run stack-scoped happy-cli and ask the stack daemon to resume these sessions.
2847
3305
  return await run(process.execPath, [happyBin, 'daemon', 'resume', ...sessionIds], { cwd: rootDir, env });