happy-stacks 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +93 -40
  2. package/bin/happys.mjs +158 -16
  3. package/docs/codex-mcp-resume.md +130 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
  6. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
  8. package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
  9. package/docs/happy-development.md +3 -4
  10. package/docs/isolated-linux-vm.md +82 -0
  11. package/docs/mobile-ios.md +112 -54
  12. package/docs/monorepo-migration.md +286 -0
  13. package/docs/server-flavors.md +19 -3
  14. package/docs/stacks.md +35 -0
  15. package/package.json +5 -1
  16. package/scripts/auth.mjs +32 -10
  17. package/scripts/build.mjs +55 -8
  18. package/scripts/daemon.mjs +166 -10
  19. package/scripts/dev.mjs +198 -50
  20. package/scripts/doctor.mjs +0 -4
  21. package/scripts/edison.mjs +6 -4
  22. package/scripts/env.mjs +150 -0
  23. package/scripts/env_cmd.test.mjs +128 -0
  24. package/scripts/init.mjs +8 -3
  25. package/scripts/install.mjs +207 -69
  26. package/scripts/lint.mjs +24 -4
  27. package/scripts/migrate.mjs +3 -12
  28. package/scripts/mobile.mjs +88 -104
  29. package/scripts/mobile_dev_client.mjs +83 -0
  30. package/scripts/monorepo.mjs +1096 -0
  31. package/scripts/monorepo_port.test.mjs +1470 -0
  32. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  33. package/scripts/review.mjs +908 -0
  34. package/scripts/review_pr.mjs +353 -0
  35. package/scripts/run.mjs +101 -21
  36. package/scripts/service.mjs +2 -2
  37. package/scripts/setup.mjs +189 -68
  38. package/scripts/setup_pr.mjs +586 -38
  39. package/scripts/stack.mjs +990 -196
  40. package/scripts/stack_archive_cmd.test.mjs +91 -0
  41. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  42. package/scripts/stack_env_cmd.test.mjs +87 -0
  43. package/scripts/stack_happy_cmd.test.mjs +126 -0
  44. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  45. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  46. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  47. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  48. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  49. package/scripts/stack_wt_list.test.mjs +128 -0
  50. package/scripts/tailscale.mjs +37 -1
  51. package/scripts/test.mjs +45 -8
  52. package/scripts/tui.mjs +395 -39
  53. package/scripts/typecheck.mjs +24 -4
  54. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  55. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  56. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  57. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  58. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  59. package/scripts/utils/auth/login_ux.mjs +32 -13
  60. package/scripts/utils/auth/sources.mjs +26 -0
  61. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  62. package/scripts/utils/cli/cli_registry.mjs +43 -4
  63. package/scripts/utils/cli/cwd_scope.mjs +136 -0
  64. package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
  65. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  66. package/scripts/utils/cli/prereqs.mjs +75 -0
  67. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  68. package/scripts/utils/cli/progress.mjs +126 -0
  69. package/scripts/utils/cli/verbosity.mjs +12 -0
  70. package/scripts/utils/cli/wizard.mjs +17 -9
  71. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  72. package/scripts/utils/dev/daemon.mjs +61 -4
  73. package/scripts/utils/dev/expo_dev.mjs +430 -0
  74. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  75. package/scripts/utils/dev/server.mjs +36 -42
  76. package/scripts/utils/dev_auth_key.mjs +169 -0
  77. package/scripts/utils/edison/git_roots.mjs +29 -0
  78. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  79. package/scripts/utils/env/env.mjs +7 -3
  80. package/scripts/utils/env/env_file.mjs +4 -2
  81. package/scripts/utils/env/env_file.test.mjs +44 -0
  82. package/scripts/utils/expo/command.mjs +52 -0
  83. package/scripts/utils/expo/expo.mjs +20 -1
  84. package/scripts/utils/expo/metro_ports.mjs +114 -0
  85. package/scripts/utils/git/git.mjs +67 -0
  86. package/scripts/utils/git/worktrees.mjs +80 -25
  87. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  88. package/scripts/utils/handy_master_secret.mjs +94 -0
  89. package/scripts/utils/mobile/config.mjs +31 -0
  90. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  91. package/scripts/utils/mobile/identifiers.mjs +47 -0
  92. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  93. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  94. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  95. package/scripts/utils/net/lan_ip.mjs +24 -0
  96. package/scripts/utils/net/ports.mjs +9 -1
  97. package/scripts/utils/net/tcp_forward.mjs +162 -0
  98. package/scripts/utils/net/url.mjs +30 -0
  99. package/scripts/utils/net/url.test.mjs +20 -0
  100. package/scripts/utils/paths/localhost_host.mjs +50 -3
  101. package/scripts/utils/paths/paths.mjs +159 -40
  102. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  103. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  104. package/scripts/utils/proc/commands.mjs +2 -3
  105. package/scripts/utils/proc/parallel.mjs +25 -0
  106. package/scripts/utils/proc/pm.mjs +176 -22
  107. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  108. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  109. package/scripts/utils/proc/proc.mjs +136 -4
  110. package/scripts/utils/proc/proc.test.mjs +77 -0
  111. package/scripts/utils/review/base_ref.mjs +74 -0
  112. package/scripts/utils/review/base_ref.test.mjs +54 -0
  113. package/scripts/utils/review/chunks.mjs +55 -0
  114. package/scripts/utils/review/chunks.test.mjs +51 -0
  115. package/scripts/utils/review/findings.mjs +165 -0
  116. package/scripts/utils/review/findings.test.mjs +85 -0
  117. package/scripts/utils/review/head_slice.mjs +153 -0
  118. package/scripts/utils/review/head_slice.test.mjs +91 -0
  119. package/scripts/utils/review/instructions/deep.md +20 -0
  120. package/scripts/utils/review/runners/coderabbit.mjs +61 -0
  121. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  122. package/scripts/utils/review/runners/codex.mjs +61 -0
  123. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  124. package/scripts/utils/review/slices.mjs +140 -0
  125. package/scripts/utils/review/slices.test.mjs +32 -0
  126. package/scripts/utils/review/targets.mjs +24 -0
  127. package/scripts/utils/review/targets.test.mjs +36 -0
  128. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  129. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  130. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  131. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  132. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  133. package/scripts/utils/server/prisma_import.mjs +37 -0
  134. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  135. package/scripts/utils/server/ui_env.mjs +14 -0
  136. package/scripts/utils/server/ui_env.test.mjs +46 -0
  137. package/scripts/utils/server/urls.mjs +14 -4
  138. package/scripts/utils/server/validate.mjs +53 -16
  139. package/scripts/utils/server/validate.test.mjs +89 -0
  140. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  141. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  142. package/scripts/utils/stack/context.mjs +2 -2
  143. package/scripts/utils/stack/editor_workspace.mjs +6 -6
  144. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  145. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  146. package/scripts/utils/stack/runtime_state.mjs +2 -1
  147. package/scripts/utils/stack/startup.mjs +120 -13
  148. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  149. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  150. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  151. package/scripts/utils/stack/stop.mjs +15 -4
  152. package/scripts/utils/stack_context.mjs +23 -0
  153. package/scripts/utils/stack_runtime_state.mjs +104 -0
  154. package/scripts/utils/stacks.mjs +38 -0
  155. package/scripts/utils/tailscale/ip.mjs +116 -0
  156. package/scripts/utils/ui/ansi.mjs +39 -0
  157. package/scripts/utils/ui/qr.mjs +17 -0
  158. package/scripts/utils/validate.mjs +88 -0
  159. package/scripts/where.mjs +2 -2
  160. package/scripts/worktrees.mjs +755 -179
  161. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  162. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  163. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  164. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
  165. package/scripts/utils/dev/expo_web.mjs +0 -112
package/scripts/stack.mjs CHANGED
@@ -1,7 +1,7 @@
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';
4
- import { dirname, isAbsolute, join, resolve } from 'node:path';
3
+ import { chmod, copyFile, mkdir, open, readFile, readdir, rename, writeFile } from 'node:fs/promises';
4
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
5
  import { existsSync } from 'node:fs';
6
6
  // NOTE: random bytes usage centralized in scripts/utils/crypto/tokens.mjs
7
7
  import { homedir } from 'node:os';
@@ -9,8 +9,19 @@ 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
+ coerceHappyMonorepoRootFromPath,
15
+ getComponentDir,
16
+ getComponentsDir,
17
+ getHappyStacksHomeDir,
18
+ getLegacyStorageRoot,
19
+ getRootDir,
20
+ getStacksStorageRoot,
21
+ happyMonorepoSubdirForComponent,
22
+ resolveStackEnvPath,
23
+ } from './utils/paths/paths.mjs';
24
+ import { isTcpPortFree, listListenPids, pickNextFreeTcpPort } from './utils/net/ports.mjs';
14
25
  import {
15
26
  createWorktree,
16
27
  createWorktreeFromBaseWorktree,
@@ -19,7 +30,7 @@ import {
19
30
  resolveComponentSpecToDir,
20
31
  worktreeSpecFromDir,
21
32
  } from './utils/git/worktrees.mjs';
22
- import { isTty, prompt, promptWorktreeSource, withRl } from './utils/cli/wizard.mjs';
33
+ import { isTty, prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
23
34
  import { parseEnvToObject } from './utils/env/dotenv.mjs';
24
35
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
25
36
  import { ensureEnvFilePruned, ensureEnvFileUpdated } from './utils/env/env_file.mjs';
@@ -27,10 +38,10 @@ import { listAllStackNames, stackExistsSync } from './utils/stack/stacks.mjs';
27
38
  import { stopStackWithEnv } from './utils/stack/stop.mjs';
28
39
  import { writeDevAuthKey } from './utils/auth/dev_key.mjs';
29
40
  import { startDevServer } from './utils/dev/server.mjs';
30
- import { startDevExpoWebUi } from './utils/dev/expo_web.mjs';
41
+ import { ensureDevExpoServer } from './utils/dev/expo_dev.mjs';
31
42
  import { requireDir } from './utils/proc/pm.mjs';
32
43
  import { waitForHttpOk } from './utils/server/server.mjs';
33
- import { resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
44
+ import { resolveLocalhostHost, preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
34
45
  import { openUrlInBrowser } from './utils/ui/browser.mjs';
35
46
  import { copyFileIfMissing, linkFileIfMissing, writeSecretFileIfMissing } from './utils/auth/files.mjs';
36
47
  import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './utils/auth/sources.mjs';
@@ -52,23 +63,31 @@ import {
52
63
  import { killPid } from './utils/expo/expo.mjs';
53
64
  import { getCliHomeDirFromEnvOrDefault, getServerLightDataDirFromEnvOrDefault } from './utils/stack/dirs.mjs';
54
65
  import { randomToken } from './utils/crypto/tokens.mjs';
55
- import { killPidOwnedByStack } from './utils/proc/ownership.mjs';
66
+ import { killPidOwnedByStack, killProcessGroupOwnedByStack } from './utils/proc/ownership.mjs';
56
67
  import { sanitizeSlugPart } from './utils/git/refs.mjs';
57
68
  import { isCursorInstalled, openWorkspaceInEditor, writeStackCodeWorkspace } from './utils/stack/editor_workspace.mjs';
69
+ import { readLastLines } from './utils/fs/tail.mjs';
70
+ import { defaultStackReleaseIdentity } from './utils/mobile/identifiers.mjs';
71
+ import { interactiveEdit, interactiveNew } from './utils/stack/interactive_stack_config.mjs';
58
72
 
59
73
  function stackNameFromArg(positionals, idx) {
60
74
  const name = positionals[idx]?.trim() ? positionals[idx].trim() : '';
61
75
  return name;
62
76
  }
63
77
 
64
- function getDefaultPortStart() {
78
+ function getDefaultPortStart(stackName = null) {
65
79
  const raw = process.env.HAPPY_STACKS_STACK_PORT_START?.trim()
66
80
  ? process.env.HAPPY_STACKS_STACK_PORT_START.trim()
67
81
  : process.env.HAPPY_LOCAL_STACK_PORT_START?.trim()
68
82
  ? process.env.HAPPY_LOCAL_STACK_PORT_START.trim()
69
83
  : '';
70
- const n = raw ? Number(raw) : 3005;
71
- return Number.isFinite(n) ? n : 3005;
84
+ // Default port strategy:
85
+ // - main historically lives at 3005
86
+ // - non-main stacks should avoid 3005 to reduce accidental collisions/confusion
87
+ const target = (stackName ?? '').toString().trim() || (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
88
+ const fallback = target === 'main' ? 3005 : 3009;
89
+ const n = raw ? Number(raw) : fallback;
90
+ return Number.isFinite(n) ? n : fallback;
72
91
  }
73
92
 
74
93
  async function isPortFree(port) {
@@ -226,14 +245,48 @@ function stringifyEnv(env) {
226
245
  const readExistingEnv = readTextOrEmpty;
227
246
 
228
247
  function resolveDefaultComponentDirs({ rootDir }) {
229
- const componentNames = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
230
- const out = {};
231
- for (const name of componentNames) {
248
+ function hasUnifiedLightSchema(serverDir) {
249
+ return (
250
+ existsSync(join(serverDir, 'prisma', 'schema.sqlite.prisma')) ||
251
+ existsSync(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'))
252
+ );
253
+ }
254
+
255
+ function pickDefaultDir(name) {
232
256
  const embedded = join(rootDir, 'components', name);
233
257
  const workspace = join(getComponentsDir(rootDir), name);
234
- const dir = existsSync(embedded) ? embedded : workspace;
235
- out[`HAPPY_STACKS_COMPONENT_DIR_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`] = dir;
258
+ // CRITICAL:
259
+ // In sandbox mode, never point stacks at the repo's embedded `components/*` checkouts.
260
+ // Sandboxes must use the sandbox workspace clones (HAPPY_STACKS_WORKSPACE_DIR/components/*),
261
+ // otherwise worktrees/branches collide with the user's real machine state.
262
+ return !isSandboxed() && existsSync(embedded) ? embedded : workspace;
236
263
  }
264
+
265
+ const out = {};
266
+
267
+ const happyRoot = pickDefaultDir('happy');
268
+ const monoRoot = existsSync(happyRoot) ? coerceHappyMonorepoRootFromPath(happyRoot) : null;
269
+
270
+ if (monoRoot) {
271
+ const subdir = (component) => happyMonorepoSubdirForComponent(component);
272
+ out.HAPPY_STACKS_COMPONENT_DIR_HAPPY = join(monoRoot, subdir('happy'));
273
+ out.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = join(monoRoot, subdir('happy-cli'));
274
+ const serverDir = join(monoRoot, subdir('happy-server'));
275
+ out.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = serverDir;
276
+ out.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT = hasUnifiedLightSchema(serverDir)
277
+ ? serverDir
278
+ : pickDefaultDir('happy-server-light');
279
+ return out;
280
+ }
281
+
282
+ // Prefer a single unified happy-server checkout for both flavors when it includes sqlite support.
283
+ const fullServerDir = pickDefaultDir('happy-server');
284
+ const hasUnifiedLight = hasUnifiedLightSchema(fullServerDir);
285
+
286
+ out.HAPPY_STACKS_COMPONENT_DIR_HAPPY = pickDefaultDir('happy');
287
+ out.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = pickDefaultDir('happy-cli');
288
+ out.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = fullServerDir;
289
+ out.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT = hasUnifiedLight ? fullServerDir : pickDefaultDir('happy-server-light');
237
290
  return out;
238
291
  }
239
292
 
@@ -264,9 +317,46 @@ async function withStackEnv({ stackName, fn, extraEnv = {} }) {
264
317
  // exported in their shell, it would otherwise "win" because utils/env.mjs only sets
265
318
  // env vars if they are missing/empty.
266
319
  const cleaned = { ...process.env };
320
+ const keepPrefixed = new Set([
321
+ // Stack/env pointers:
322
+ 'HAPPY_LOCAL_ENV_FILE',
323
+ 'HAPPY_STACKS_ENV_FILE',
324
+ 'HAPPY_LOCAL_STACK',
325
+ 'HAPPY_STACKS_STACK',
326
+
327
+ // Sandbox detection + policy (must propagate to child processes).
328
+ 'HAPPY_STACKS_SANDBOX_DIR',
329
+ 'HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL',
330
+
331
+ // Sandbox-enforced dirs (without these, sandbox isolation breaks).
332
+ 'HAPPY_STACKS_CLI_ROOT_DISABLE',
333
+ 'HAPPY_STACKS_CANONICAL_HOME_DIR',
334
+ 'HAPPY_STACKS_HOME_DIR',
335
+ 'HAPPY_STACKS_WORKSPACE_DIR',
336
+ 'HAPPY_STACKS_RUNTIME_DIR',
337
+ 'HAPPY_STACKS_STORAGE_DIR',
338
+ // Legacy prefix mirrors:
339
+ 'HAPPY_LOCAL_CANONICAL_HOME_DIR',
340
+ 'HAPPY_LOCAL_HOME_DIR',
341
+ 'HAPPY_LOCAL_WORKSPACE_DIR',
342
+ 'HAPPY_LOCAL_RUNTIME_DIR',
343
+ 'HAPPY_LOCAL_STORAGE_DIR',
344
+
345
+ // Sandbox-safe UX knobs (keep consistent through stack wrappers).
346
+ 'HAPPY_STACKS_VERBOSE',
347
+ 'HAPPY_STACKS_UPDATE_CHECK',
348
+ 'HAPPY_STACKS_UPDATE_CHECK_INTERVAL_MS',
349
+ 'HAPPY_STACKS_UPDATE_NOTIFY_INTERVAL_MS',
350
+
351
+ // Guided auth flow coordination across wrappers.
352
+ // These are intentionally passed through even though most HAPPY_STACKS_* vars are scrubbed.
353
+ 'HAPPY_STACKS_DAEMON_WAIT_FOR_AUTH',
354
+ 'HAPPY_LOCAL_DAEMON_WAIT_FOR_AUTH',
355
+ 'HAPPY_STACKS_AUTH_FLOW',
356
+ 'HAPPY_LOCAL_AUTH_FLOW',
357
+ ]);
267
358
  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;
359
+ if (keepPrefixed.has(k)) continue;
270
360
  if (k.startsWith('HAPPY_LOCAL_') || k.startsWith('HAPPY_STACKS_')) {
271
361
  delete cleaned[k];
272
362
  }
@@ -344,110 +434,6 @@ async function withStackEnv({ stackName, fn, extraEnv = {} }) {
344
434
  return await fn({ env, envPath, stackEnv, runtimeStatePath, runtimeState });
345
435
  }
346
436
 
347
- async function interactiveNew({ rootDir, rl, defaults }) {
348
- const out = { ...defaults };
349
-
350
- if (!out.stackName) {
351
- out.stackName = (await rl.question('Stack name: ')).trim();
352
- }
353
- if (!out.stackName) {
354
- throw new Error('[stack] stack name is required');
355
- }
356
- if (out.stackName === 'main') {
357
- throw new Error('[stack] stack name \"main\" is reserved (use the default stack without creating it)');
358
- }
359
-
360
- // Server component selection
361
- if (!out.serverComponent) {
362
- const server = (await rl.question('Server component [happy-server-light|happy-server] (default: happy-server-light): ')).trim();
363
- out.serverComponent = server || 'happy-server-light';
364
- }
365
-
366
- // Port
367
- if (!out.port) {
368
- const want = (await rl.question('Port (empty = ephemeral): ')).trim();
369
- out.port = want ? Number(want) : null;
370
- }
371
-
372
- // Remote for creating new worktrees (used by all "create new worktree" choices)
373
- if (!out.createRemote) {
374
- out.createRemote = await prompt(rl, 'Git remote for creating new worktrees (default: upstream): ', { defaultValue: 'upstream' });
375
- }
376
-
377
- // Component selections
378
- for (const c of ['happy', 'happy-cli']) {
379
- if (out.components[c] != null) continue;
380
- out.components[c] = await promptWorktreeSource({
381
- rl,
382
- rootDir,
383
- component: c,
384
- stackName: out.stackName,
385
- createRemote: out.createRemote,
386
- });
387
- }
388
-
389
- // Server worktree selection (optional; only for the chosen server component)
390
- const serverComponent = out.serverComponent === 'happy-server' ? 'happy-server' : 'happy-server-light';
391
- if (out.components[serverComponent] == null) {
392
- out.components[serverComponent] = await promptWorktreeSource({
393
- rl,
394
- rootDir,
395
- component: serverComponent,
396
- stackName: out.stackName,
397
- createRemote: out.createRemote,
398
- });
399
- }
400
-
401
- return out;
402
- }
403
-
404
- async function interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults }) {
405
- const out = { ...defaults, stackName };
406
-
407
- // Server component selection
408
- const currentServer = existingEnv.HAPPY_STACKS_SERVER_COMPONENT ?? existingEnv.HAPPY_LOCAL_SERVER_COMPONENT ?? '';
409
- const server = await prompt(
410
- rl,
411
- `Server component [happy-server-light|happy-server] (default: ${currentServer || 'happy-server-light'}): `,
412
- { defaultValue: currentServer || 'happy-server-light' }
413
- );
414
- out.serverComponent = server || 'happy-server-light';
415
-
416
- // Port
417
- const currentPort = existingEnv.HAPPY_STACKS_SERVER_PORT ?? existingEnv.HAPPY_LOCAL_SERVER_PORT ?? '';
418
- const wantPort = await prompt(rl, `Port (empty = keep ${currentPort || 'ephemeral'}; type 'ephemeral' to unpin): `, { defaultValue: '' });
419
- const wantTrimmed = wantPort.trim().toLowerCase();
420
- out.port = wantTrimmed === 'ephemeral' ? null : wantPort ? Number(wantPort) : (currentPort ? Number(currentPort) : null);
421
-
422
- // Remote for creating new worktrees
423
- const currentRemote = existingEnv.HAPPY_STACKS_STACK_REMOTE ?? existingEnv.HAPPY_LOCAL_STACK_REMOTE ?? '';
424
- out.createRemote = await prompt(rl, `Git remote for creating new worktrees (default: ${currentRemote || 'upstream'}): `, {
425
- defaultValue: currentRemote || 'upstream',
426
- });
427
-
428
- // Worktree selections
429
- for (const c of ['happy', 'happy-cli']) {
430
- out.components[c] = await promptWorktreeSource({
431
- rl,
432
- rootDir,
433
- component: c,
434
- stackName,
435
- createRemote: out.createRemote,
436
- });
437
- }
438
-
439
- const serverComponent = out.serverComponent === 'happy-server' ? 'happy-server' : 'happy-server-light';
440
- out.components[serverComponent] = await promptWorktreeSource({
441
- rl,
442
- rootDir,
443
- component: serverComponent,
444
- stackName,
445
- createRemote: out.createRemote,
446
- });
447
-
448
- return out;
449
- }
450
-
451
437
  async function cmdNew({ rootDir, argv, emit = true }) {
452
438
  const { flags, kv } = parseArgs(argv);
453
439
  const positionals = argv.filter((a) => !a.startsWith('--'));
@@ -494,7 +480,7 @@ async function cmdNew({ rootDir, argv, emit = true }) {
494
480
  if (!stackName) {
495
481
  throw new Error(
496
482
  '[stack] usage: happys stack new <name> [--port=NNN] [--server=happy-server|happy-server-light] ' +
497
- '[--happy=default|<owner/...>|<path>] [--happy-cli=...] [--happy-server=...] [--happy-server-light=...] ' +
483
+ '[--happy=default|<owner/...>|<path>] [--happy-server-light=...] ' +
498
484
  '[--copy-auth-from=<stack|legacy>] [--link-auth] [--no-copy-auth] [--interactive] [--force-port]'
499
485
  );
500
486
  }
@@ -615,24 +601,88 @@ async function cmdNew({ rootDir, argv, emit = true }) {
615
601
  }
616
602
  }
617
603
 
618
- // happy
619
- const happySpec = config.components.happy;
620
- if (happySpec && typeof happySpec === 'object' && happySpec.create) {
621
- const dir = await createWorktree({ rootDir, component: 'happy', slug: happySpec.slug, remoteName: happySpec.remote || 'upstream' });
622
- stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY = dir;
623
- } else {
624
- const dir = resolveComponentSpecToDir({ rootDir, component: 'happy', spec: happySpec });
625
- if (dir) stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY = resolve(rootDir, dir);
604
+ let monorepoPinned = false;
605
+
606
+ // happy / happy-cli / happy-server can be a single monorepo (slopus/happy).
607
+ // Detect monorepo pinning by resolving the provided spec(s), rather than relying on the local default checkout.
608
+ const monoSpecs = [
609
+ { component: 'happy', spec: config.components.happy },
610
+ { component: 'happy-cli', spec: config.components['happy-cli'] },
611
+ { component: 'happy-server', spec: config.components['happy-server'] },
612
+ ].filter((x) => x.spec);
613
+
614
+ if (monoSpecs.length) {
615
+ const primary = monoSpecs[0];
616
+ const canon = (spec) => {
617
+ if (spec && typeof spec === 'object' && spec.create) {
618
+ const remote = String(spec.remote || 'upstream');
619
+ return `create:${String(spec.slug)}@${remote}`;
620
+ }
621
+ return String(spec);
622
+ };
623
+
624
+ let resolvedDir = '';
625
+ if (primary.spec && typeof primary.spec === 'object' && primary.spec.create) {
626
+ resolvedDir = await createWorktree({
627
+ rootDir,
628
+ component: primary.component,
629
+ slug: primary.spec.slug,
630
+ remoteName: primary.spec.remote || 'upstream',
631
+ });
632
+ } else {
633
+ const dir = resolveComponentSpecToDir({ rootDir, component: primary.component, spec: primary.spec });
634
+ if (dir) resolvedDir = resolve(rootDir, dir);
635
+ }
636
+
637
+ const monoRoot = resolvedDir ? coerceHappyMonorepoRootFromPath(resolvedDir) : null;
638
+ if (monoRoot) {
639
+ for (const s of monoSpecs.slice(1)) {
640
+ if (canon(s.spec) !== canon(primary.spec)) {
641
+ throw new Error(
642
+ `[stack] conflicting monorepo component specs.\n` +
643
+ `- happy: ${canon(config.components.happy)}\n` +
644
+ `- happy-cli: ${canon(config.components['happy-cli'])}\n` +
645
+ `- happy-server: ${canon(config.components['happy-server'])}\n` +
646
+ `Fix: in monorepo mode, pass only one of --happy/--happy-cli/--happy-server (or pass the same value for all).`
647
+ );
648
+ }
649
+ }
650
+
651
+ const subdir = (c) => happyMonorepoSubdirForComponent(c);
652
+ stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY = join(monoRoot, subdir('happy'));
653
+ stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = join(monoRoot, subdir('happy-cli'));
654
+ const serverDir = join(monoRoot, subdir('happy-server'));
655
+ stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = serverDir;
656
+ if (
657
+ existsSync(join(serverDir, 'prisma', 'schema.sqlite.prisma')) ||
658
+ existsSync(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'))
659
+ ) {
660
+ stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT = serverDir;
661
+ }
662
+ monorepoPinned = true;
663
+ }
626
664
  }
627
665
 
628
- // happy-cli
629
- const cliSpec = config.components['happy-cli'];
630
- if (cliSpec && typeof cliSpec === 'object' && cliSpec.create) {
631
- const dir = await createWorktree({ rootDir, component: 'happy-cli', slug: cliSpec.slug, remoteName: cliSpec.remote || 'upstream' });
632
- stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = dir;
633
- } else {
634
- const dir = resolveComponentSpecToDir({ rootDir, component: 'happy-cli', spec: cliSpec });
635
- if (dir) stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = resolve(rootDir, dir);
666
+ if (!monorepoPinned) {
667
+ // happy
668
+ const happySpec = config.components.happy;
669
+ if (happySpec && typeof happySpec === 'object' && happySpec.create) {
670
+ const dir = await createWorktree({ rootDir, component: 'happy', slug: happySpec.slug, remoteName: happySpec.remote || 'upstream' });
671
+ stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY = dir;
672
+ } else {
673
+ const dir = resolveComponentSpecToDir({ rootDir, component: 'happy', spec: happySpec });
674
+ if (dir) stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY = resolve(rootDir, dir);
675
+ }
676
+
677
+ // happy-cli
678
+ const cliSpec = config.components['happy-cli'];
679
+ if (cliSpec && typeof cliSpec === 'object' && cliSpec.create) {
680
+ const dir = await createWorktree({ rootDir, component: 'happy-cli', slug: cliSpec.slug, remoteName: cliSpec.remote || 'upstream' });
681
+ stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = dir;
682
+ } else {
683
+ const dir = resolveComponentSpecToDir({ rootDir, component: 'happy-cli', spec: cliSpec });
684
+ if (dir) stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = resolve(rootDir, dir);
685
+ }
636
686
  }
637
687
 
638
688
  // Server component directory override (optional)
@@ -651,13 +701,15 @@ async function cmdNew({ rootDir, argv, emit = true }) {
651
701
  if (dir) stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT = resolve(rootDir, dir);
652
702
  }
653
703
  } else if (serverComponent === 'happy-server') {
654
- const spec = config.components['happy-server'];
655
- if (spec && typeof spec === 'object' && spec.create) {
656
- const dir = await createWorktree({ rootDir, component: 'happy-server', slug: spec.slug, remoteName: spec.remote || 'upstream' });
657
- stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = dir;
658
- } else {
659
- const dir = resolveComponentSpecToDir({ rootDir, component: 'happy-server', spec });
660
- if (dir) stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = resolve(rootDir, dir);
704
+ if (!monorepoPinned) {
705
+ const spec = config.components['happy-server'];
706
+ if (spec && typeof spec === 'object' && spec.create) {
707
+ const dir = await createWorktree({ rootDir, component: 'happy-server', slug: spec.slug, remoteName: spec.remote || 'upstream' });
708
+ stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = dir;
709
+ } else {
710
+ const dir = resolveComponentSpecToDir({ rootDir, component: 'happy-server', spec });
711
+ if (dir) stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = resolve(rootDir, dir);
712
+ }
661
713
  }
662
714
  }
663
715
 
@@ -839,11 +891,83 @@ async function cmdEdit({ rootDir, argv }) {
839
891
  }
840
892
  };
841
893
 
842
- await applyComponent('happy', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY', config.components.happy);
843
- await applyComponent('happy-cli', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI', config.components['happy-cli']);
844
- if (serverComponent === 'happy-server') {
845
- await applyComponent('happy-server', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER', config.components['happy-server']);
894
+ const existingHappy = String(next.HAPPY_STACKS_COMPONENT_DIR_HAPPY ?? '').trim();
895
+ const happyMonorepo = Boolean(coerceHappyMonorepoRootFromPath(existingHappy)) || Boolean(coerceHappyMonorepoRootFromPath(getComponentDir(rootDir, 'happy', process.env)));
896
+
897
+ if (happyMonorepo) {
898
+ const monoSpecs = [
899
+ { component: 'happy', spec: config.components.happy },
900
+ { component: 'happy-cli', spec: config.components['happy-cli'] },
901
+ { component: 'happy-server', spec: config.components['happy-server'] },
902
+ ].filter((x) => x.spec);
903
+
904
+ if (monoSpecs.length) {
905
+ const primary = monoSpecs[0];
906
+ const canon = (spec) => {
907
+ if (spec && typeof spec === 'object' && spec.create) {
908
+ const remote = String(spec.remote || 'upstream');
909
+ return `create:${String(spec.slug)}@${remote}`;
910
+ }
911
+ return String(spec);
912
+ };
913
+
914
+ let resolvedDir = '';
915
+ if (primary.spec && typeof primary.spec === 'object' && primary.spec.create) {
916
+ resolvedDir = await createWorktree({
917
+ rootDir,
918
+ component: primary.component,
919
+ slug: primary.spec.slug,
920
+ remoteName: primary.spec.remote || next.HAPPY_STACKS_STACK_REMOTE,
921
+ });
922
+ } else {
923
+ const dir = resolveComponentSpecToDir({ rootDir, component: primary.component, spec: primary.spec });
924
+ if (dir) resolvedDir = resolve(rootDir, dir);
925
+ }
926
+
927
+ const monoRoot = resolvedDir ? coerceHappyMonorepoRootFromPath(resolvedDir) : null;
928
+ if (monoRoot) {
929
+ for (const s of monoSpecs.slice(1)) {
930
+ if (canon(s.spec) !== canon(primary.spec)) {
931
+ throw new Error(
932
+ `[stack] edit: conflicting monorepo component specs.\n` +
933
+ `- happy: ${canon(config.components.happy)}\n` +
934
+ `- happy-cli: ${canon(config.components['happy-cli'])}\n` +
935
+ `- happy-server: ${canon(config.components['happy-server'])}\n` +
936
+ `Fix: in monorepo mode, pass only one of --happy/--happy-cli/--happy-server (or pass the same value for all).`
937
+ );
938
+ }
939
+ }
940
+
941
+ const subdir = (c) => happyMonorepoSubdirForComponent(c);
942
+ next.HAPPY_STACKS_COMPONENT_DIR_HAPPY = join(monoRoot, subdir('happy'));
943
+ next.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI = join(monoRoot, subdir('happy-cli'));
944
+ const serverDir = join(monoRoot, subdir('happy-server'));
945
+ next.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER = serverDir;
946
+ if (
947
+ existsSync(join(serverDir, 'prisma', 'schema.sqlite.prisma')) ||
948
+ existsSync(join(serverDir, 'prisma', 'sqlite', 'schema.prisma'))
949
+ ) {
950
+ next.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT = serverDir;
951
+ }
952
+ } else {
953
+ await applyComponent('happy', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY', config.components.happy);
954
+ await applyComponent('happy-cli', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI', config.components['happy-cli']);
955
+ await applyComponent('happy-server', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER', config.components['happy-server']);
956
+ }
957
+ } else {
958
+ await applyComponent('happy', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY', config.components.happy);
959
+ await applyComponent('happy-cli', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI', config.components['happy-cli']);
960
+ await applyComponent('happy-server', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER', config.components['happy-server']);
961
+ }
846
962
  } else {
963
+ await applyComponent('happy', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY', config.components.happy);
964
+ await applyComponent('happy-cli', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI', config.components['happy-cli']);
965
+ if (serverComponent === 'happy-server') {
966
+ await applyComponent('happy-server', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER', config.components['happy-server']);
967
+ }
968
+ }
969
+
970
+ if (serverComponent === 'happy-server-light') {
847
971
  await applyComponent('happy-server-light', 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT', config.components['happy-server-light']);
848
972
  }
849
973
 
@@ -851,7 +975,7 @@ async function cmdEdit({ rootDir, argv }) {
851
975
  printResult({ json, data: { stackName, envPath: wrote, port, serverComponent }, text: `[stack] updated ${stackName}\n[stack] env: ${wrote}` });
852
976
  }
853
977
 
854
- async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {} }) {
978
+ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {}, background = false }) {
855
979
  await withStackEnv({
856
980
  stackName,
857
981
  extraEnv,
@@ -882,6 +1006,29 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
882
1006
  // True restart = there was an active runner for this stack. If the stack is not running,
883
1007
  // `--restart` should behave like a normal start (allocate new ephemeral ports if needed).
884
1008
  const isTrueRestart = wantsRestart && wasRunning;
1009
+
1010
+ // Restart semantics (stack mode):
1011
+ // - Stop stack-owned processes first (runner, daemon, Expo, etc.)
1012
+ // - Never kill arbitrary port listeners
1013
+ // - Preserve previous runtime ports in memory so a true restart can reuse them
1014
+ if (wantsRestart && !wantsJson) {
1015
+ const baseDir = resolveStackEnvPath(stackName).baseDir;
1016
+ try {
1017
+ await stopStackWithEnv({
1018
+ rootDir,
1019
+ stackName,
1020
+ baseDir,
1021
+ env,
1022
+ json: false,
1023
+ noDocker: false,
1024
+ aggressive: false,
1025
+ sweepOwned: true,
1026
+ });
1027
+ } catch {
1028
+ // ignore (fail-closed below on port checks)
1029
+ }
1030
+ await deleteStackRuntimeStateFile(runtimeStatePath).catch(() => {});
1031
+ }
885
1032
  if (wasRunning) {
886
1033
  if (!wantsRestart) {
887
1034
  const serverPart = Number.isFinite(existingPort) && existingPort > 0 ? ` server=${existingPort}` : '';
@@ -913,12 +1060,16 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
913
1060
  } else if (scriptPath === 'dev.mjs') {
914
1061
  console.log(`[stack] ${stackName}: ui: unknown (missing expo.webPort in stack.runtime.json)`);
915
1062
  }
1063
+
1064
+ // Opt-in: allow starting mobile Metro alongside an already-running stack without restarting the runner.
1065
+ // This is important for workflows like re-running `setup-pr` with --mobile after the stack is already up.
1066
+ const wantsMobile = args.includes('--mobile') || args.includes('--with-mobile');
1067
+ if (wantsMobile) {
1068
+ await run(process.execPath, [join(rootDir, 'scripts', 'mobile.mjs'), '--metro'], { cwd: rootDir, env });
1069
+ }
916
1070
  return;
917
1071
  }
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);
1072
+ // Restart: already handled above (stopStackWithEnv is ownership-gated).
922
1073
  }
923
1074
 
924
1075
  // Ephemeral ports: allocate at start time, store only in runtime state (not in stack env).
@@ -941,7 +1092,7 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
941
1092
  }
942
1093
  }
943
1094
 
944
- const startPort = getDefaultPortStart();
1095
+ const startPort = getDefaultPortStart(stackName);
945
1096
  const ports = {};
946
1097
 
947
1098
  const parsePortOrNull = (v) => {
@@ -986,6 +1137,42 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
986
1137
  for (const p of toCheck) {
987
1138
  // eslint-disable-next-line no-await-in-loop
988
1139
  if (!(await isTcpPortFree(p))) {
1140
+ if (isTrueRestart && !wantsJson) {
1141
+ // Try one more safe cleanup of stack-owned processes and re-check.
1142
+ const baseDir = resolveStackEnvPath(stackName).baseDir;
1143
+ try {
1144
+ await stopStackWithEnv({
1145
+ rootDir,
1146
+ stackName,
1147
+ baseDir,
1148
+ env,
1149
+ json: false,
1150
+ noDocker: false,
1151
+ aggressive: false,
1152
+ sweepOwned: true,
1153
+ });
1154
+ } catch {
1155
+ // ignore
1156
+ }
1157
+ // eslint-disable-next-line no-await-in-loop
1158
+ if (await isTcpPortFree(p)) {
1159
+ continue;
1160
+ }
1161
+
1162
+ // Last resort: if we can prove the listener is stack-owned, kill it.
1163
+ // eslint-disable-next-line no-await-in-loop
1164
+ const pids = await listListenPids(p);
1165
+ const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
1166
+ const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir, env });
1167
+ for (const pid of pids) {
1168
+ // eslint-disable-next-line no-await-in-loop
1169
+ await killProcessGroupOwnedByStack(pid, { stackName, envPath, cliHomeDir, label: `port:${p}`, json: false });
1170
+ }
1171
+ // eslint-disable-next-line no-await-in-loop
1172
+ if (await isTcpPortFree(p)) {
1173
+ continue;
1174
+ }
1175
+ }
989
1176
  throw new Error(
990
1177
  `[stack] ${stackName}: cannot reuse port ${p} on restart (port is not free).\n` +
991
1178
  `[stack] Fix: stop the process using it, or re-run without --restart to allocate new ports.`
@@ -1043,13 +1230,61 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
1043
1230
  : {}),
1044
1231
  };
1045
1232
 
1233
+ // Background dev auth flow (automatic):
1234
+ // If we're starting `dev.mjs` in background and the stack is not authenticated yet,
1235
+ // keep the stack alive for guided login by marking this as an auth-flow so URL resolution
1236
+ // fails closed (never opens server port as "UI").
1237
+ //
1238
+ // IMPORTANT:
1239
+ // We must NOT start the daemon before credentials exist in orchestrated flows (setup-pr/review-pr),
1240
+ // because the daemon can enter its own auth flow and become stranded (lock held, no machine registration).
1241
+ if (background && scriptPath === 'dev.mjs') {
1242
+ const startUi = !args.includes('--no-ui') && (env.HAPPY_LOCAL_UI ?? '1').toString().trim() !== '0';
1243
+ const startDaemon = !args.includes('--no-daemon') && (env.HAPPY_LOCAL_DAEMON ?? '1').toString().trim() !== '0';
1244
+ if (startUi && startDaemon) {
1245
+ try {
1246
+ const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
1247
+ const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir, env });
1248
+ const hasCreds = existsSync(join(cliHomeDir, 'access.key'));
1249
+ if (!hasCreds) {
1250
+ childEnv.HAPPY_STACKS_AUTH_FLOW = '1';
1251
+ childEnv.HAPPY_LOCAL_AUTH_FLOW = '1';
1252
+ }
1253
+ } catch {
1254
+ // If we can't resolve CLI home dir, skip auto auth-flow markers (best-effort).
1255
+ }
1256
+ }
1257
+ }
1258
+
1259
+ // Background mode: send runner output to a stack-scoped log file so quiet flows can
1260
+ // remain clean while still providing actionable error logs.
1261
+ const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
1262
+ const logsDir = join(stackBaseDir, 'logs');
1263
+ const logPath = join(logsDir, `${scriptPath.replace(/\.mjs$/, '')}.${Date.now()}.log`);
1264
+ if (background) {
1265
+ await ensureDir(logsDir);
1266
+ }
1267
+
1268
+ let logHandle = null;
1269
+ let outFd = null;
1270
+ if (background) {
1271
+ logHandle = await open(logPath, 'a');
1272
+ outFd = logHandle.fd;
1273
+ }
1274
+
1046
1275
  // Spawn the runner (long-lived) and record its pid + ports for other stack-scoped commands.
1047
1276
  const child = spawn(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], {
1048
1277
  cwd: rootDir,
1049
1278
  env: childEnv,
1050
- stdio: 'inherit',
1279
+ stdio: background ? ['ignore', outFd ?? 'ignore', outFd ?? 'ignore'] : 'inherit',
1051
1280
  shell: false,
1281
+ detached: background && process.platform !== 'win32',
1052
1282
  });
1283
+ try {
1284
+ await logHandle?.close();
1285
+ } catch {
1286
+ // ignore
1287
+ }
1053
1288
 
1054
1289
  // Record the chosen ports immediately (before the runner finishes booting), so other stack commands
1055
1290
  // can resolve the correct endpoints and `--restart` can reliably reuse the same ports.
@@ -1059,8 +1294,104 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
1059
1294
  ephemeral: true,
1060
1295
  ownerPid: child.pid,
1061
1296
  ports,
1297
+ ...(background ? { logs: { runner: logPath } } : {}),
1062
1298
  }).catch(() => {});
1063
1299
 
1300
+ if (background) {
1301
+ // Keep stack.runtime.json so stack-scoped stop/restart can manage this runner.
1302
+ // This mode is used by higher-level commands that want to run guided auth steps
1303
+ // without mixing them into server logs.
1304
+ const internalServerUrl = `http://127.0.0.1:${ports.server}`;
1305
+
1306
+ // Fail fast if the runner dies immediately or never exposes HTTP.
1307
+ // IMPORTANT: do not treat "some process answered /health" as success unless our runner
1308
+ // is still alive. Otherwise, if the chosen port is already in use, the runner can exit
1309
+ // and a different stack/process could satisfy the health check (leading to confusing
1310
+ // follow-on behavior like auth using the wrong port).
1311
+ try {
1312
+ let exited = null;
1313
+ const exitPromise = new Promise((resolvePromise) => {
1314
+ child.once('exit', (code, sig) => {
1315
+ exited = { kind: 'exit', code: code ?? 0, sig: sig ?? null };
1316
+ resolvePromise(exited);
1317
+ });
1318
+ child.once('error', (err) => {
1319
+ exited = { kind: 'error', error: err instanceof Error ? err.message : String(err) };
1320
+ resolvePromise(exited);
1321
+ });
1322
+ });
1323
+ const readyPromise = (async () => {
1324
+ const timeoutMsRaw =
1325
+ (process.env.HAPPY_STACKS_STACK_BACKGROUND_READY_TIMEOUT_MS ??
1326
+ process.env.HAPPY_LOCAL_STACK_BACKGROUND_READY_TIMEOUT_MS ??
1327
+ '180000')
1328
+ .toString()
1329
+ .trim();
1330
+ const timeoutMs = timeoutMsRaw ? Number(timeoutMsRaw) : 180_000;
1331
+ await waitForHttpOk(`${internalServerUrl}/health`, {
1332
+ timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 180_000,
1333
+ intervalMs: 300,
1334
+ });
1335
+ return { kind: 'ready' };
1336
+ })();
1337
+
1338
+ const first = await Promise.race([exitPromise, readyPromise]);
1339
+ if (first.kind !== 'ready') {
1340
+ throw new Error(`[stack] ${stackName}: runner exited before becoming ready. log: ${logPath}`);
1341
+ }
1342
+ // Even if /health responded, ensure our runner is still alive.
1343
+ // (Prevents false positives when another process owns the port.)
1344
+ if (exited && exited.kind !== 'ready') {
1345
+ throw new Error(`[stack] ${stackName}: runner reported ready but exited immediately. log: ${logPath}`);
1346
+ }
1347
+ if (!isPidAlive(child.pid)) {
1348
+ throw new Error(
1349
+ `[stack] ${stackName}: runner health check passed, but runner is not running.\n` +
1350
+ `[stack] This usually means the chosen port (${ports.server}) is already in use by another process.\n` +
1351
+ `[stack] log: ${logPath}`
1352
+ );
1353
+ }
1354
+ } catch (e) {
1355
+ // Attach some log context so failures are debuggable even when a higher-level
1356
+ // command cleans up the sandbox directory afterwards.
1357
+ try {
1358
+ const tail = await readLastLines(logPath, 160);
1359
+ if (tail && e instanceof Error) {
1360
+ e.message = `${e.message}\n\n[stack] last runner log lines:\n${tail}`;
1361
+ }
1362
+ } catch {
1363
+ // ignore
1364
+ }
1365
+ // Best-effort cleanup on boot failure.
1366
+ try {
1367
+ // We spawned this runner process, so we can safely terminate it without relying
1368
+ // on ownership heuristics (which can be unreliable on some platforms due to `ps` truncation).
1369
+ if (background && process.platform !== 'win32') {
1370
+ try {
1371
+ process.kill(-child.pid, 'SIGTERM');
1372
+ } catch {
1373
+ // ignore
1374
+ }
1375
+ }
1376
+ try {
1377
+ child.kill('SIGTERM');
1378
+ } catch {
1379
+ // ignore
1380
+ }
1381
+ } catch {
1382
+ // ignore
1383
+ }
1384
+ await deleteStackRuntimeStateFile(runtimeStatePath).catch(() => {});
1385
+ throw e;
1386
+ }
1387
+
1388
+ if (!wantsJson) {
1389
+ console.log(`[stack] ${stackName}: logs: ${logPath}`);
1390
+ }
1391
+ try { child.unref(); } catch { /* ignore */ }
1392
+ return;
1393
+ }
1394
+
1064
1395
  try {
1065
1396
  await new Promise((resolvePromise, rejectPromise) => {
1066
1397
  child.on('error', rejectPromise);
@@ -1079,6 +1410,28 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
1079
1410
  }
1080
1411
 
1081
1412
  // Pinned port stack: run normally under the pinned env.
1413
+ if (background) {
1414
+ throw new Error('[stack] --background is only supported for ephemeral-port stacks');
1415
+ }
1416
+ if (wantsRestart && !wantsJson) {
1417
+ const pinnedPort = coercePort(env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT);
1418
+ if (pinnedPort && !(await isTcpPortFree(pinnedPort))) {
1419
+ // Last resort: kill listener only if it is stack-owned.
1420
+ const pids = await listListenPids(pinnedPort);
1421
+ const stackBaseDir = resolveStackEnvPath(stackName).baseDir;
1422
+ const cliHomeDir = getCliHomeDirFromEnvOrDefault({ stackBaseDir, env });
1423
+ for (const pid of pids) {
1424
+ // eslint-disable-next-line no-await-in-loop
1425
+ await killProcessGroupOwnedByStack(pid, { stackName, envPath, cliHomeDir, label: `port:${pinnedPort}`, json: false });
1426
+ }
1427
+ if (!(await isTcpPortFree(pinnedPort))) {
1428
+ throw new Error(
1429
+ `[stack] ${stackName}: server port ${pinnedPort} is not free on restart.\n` +
1430
+ `[stack] Refusing to kill unknown listeners. Stop the process using it, or change the pinned port.`
1431
+ );
1432
+ }
1433
+ }
1434
+ }
1082
1435
  await run(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], { cwd: rootDir, env });
1083
1436
  },
1084
1437
  });
@@ -1122,9 +1475,25 @@ async function cmdService({ rootDir, stackName, svcCmd }) {
1122
1475
  });
1123
1476
  }
1124
1477
 
1478
+ async function getRuntimePortExtraEnv(stackName) {
1479
+ const runtimeStatePath = getStackRuntimeStatePath(stackName);
1480
+ const runtimeState = await readStackRuntimeStateFile(runtimeStatePath);
1481
+ const runtimePort = Number(runtimeState?.ports?.server);
1482
+ return Number.isFinite(runtimePort) && runtimePort > 0
1483
+ ? {
1484
+ // Ephemeral stacks (PR stacks) store their chosen ports in stack.runtime.json, not the env file.
1485
+ // Ensure stack-scoped commands that compute URLs don't fall back to 3005 (main default).
1486
+ HAPPY_STACKS_SERVER_PORT: String(runtimePort),
1487
+ HAPPY_LOCAL_SERVER_PORT: String(runtimePort),
1488
+ }
1489
+ : null;
1490
+ }
1491
+
1125
1492
  async function cmdTailscale({ rootDir, stackName, subcmd, args }) {
1493
+ const extraEnv = await getRuntimePortExtraEnv(stackName);
1126
1494
  await withStackEnv({
1127
1495
  stackName,
1496
+ ...(extraEnv ? { extraEnv } : {}),
1128
1497
  fn: async ({ env }) => {
1129
1498
  await run(process.execPath, [join(rootDir, 'scripts', 'tailscale.mjs'), subcmd, ...args], { cwd: rootDir, env });
1130
1499
  },
@@ -1146,7 +1515,21 @@ async function cmdWt({ rootDir, stackName, args }) {
1146
1515
  // Forward to scripts/worktrees.mjs under the stack env.
1147
1516
  // This makes `happys stack wt <name> -- ...` behave exactly like `happys wt ...`,
1148
1517
  // but read/write the stack env file (HAPPY_STACKS_ENV_FILE / legacy: HAPPY_LOCAL_ENV_FILE) instead of repo env.local.
1149
- const forwarded = args[0] === '--' ? args.slice(1) : args;
1518
+ let forwarded = args[0] === '--' ? args.slice(1) : args;
1519
+
1520
+ // Stack users usually want to see what *this stack* is using (active checkout),
1521
+ // not an exhaustive enumeration of every worktree on disk.
1522
+ //
1523
+ // `happys wt list` defaults to showing all worktrees. In stack mode, default to
1524
+ // an active-only view unless the caller opts into `--all`.
1525
+ if (forwarded[0] === 'list') {
1526
+ const wantsAll = forwarded.includes('--all') || forwarded.includes('--all-worktrees');
1527
+ const wantsActive = forwarded.includes('--active') || forwarded.includes('--active-only');
1528
+ if (!wantsAll && !wantsActive) {
1529
+ forwarded = [...forwarded, '--active'];
1530
+ }
1531
+ }
1532
+
1150
1533
  await withStackEnv({
1151
1534
  stackName,
1152
1535
  fn: async ({ env }) => {
@@ -1159,8 +1542,10 @@ async function cmdAuth({ rootDir, stackName, args }) {
1159
1542
  // Forward to scripts/auth.mjs under the stack env.
1160
1543
  // This makes `happys stack auth <name> ...` resolve CLI home/urls for that stack.
1161
1544
  const forwarded = args[0] === '--' ? args.slice(1) : args;
1545
+ const extraEnv = await getRuntimePortExtraEnv(stackName);
1162
1546
  await withStackEnv({
1163
1547
  stackName,
1548
+ ...(extraEnv ? { extraEnv } : {}),
1164
1549
  fn: async ({ env }) => {
1165
1550
  await run(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...forwarded], { cwd: rootDir, env });
1166
1551
  },
@@ -1795,7 +2180,7 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
1795
2180
 
1796
2181
  const serverPort = await pickNextFreeTcpPort(3005, { host: '127.0.0.1' });
1797
2182
  const internalServerUrl = `http://127.0.0.1:${serverPort}`;
1798
- const publicServerUrl = `http://localhost:${serverPort}`;
2183
+ const publicServerUrl = await preferStackLocalhostUrl(`http://localhost:${serverPort}`, { stackName: name });
1799
2184
 
1800
2185
  const autostart = { stackName: name, baseDir: resolveStackEnvPath(name).baseDir };
1801
2186
  const children = [];
@@ -1815,9 +2200,14 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
1815
2200
  serverComponent === 'happy-server'
1816
2201
  ? env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER
1817
2202
  : 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');
2203
+ const resolvedServerDir =
2204
+ (serverDir ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER_LIGHT ?? '').toString().trim() ||
2205
+ getComponentDir(rootDir, serverComponent);
2206
+ const resolvedCliDir =
2207
+ (env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI ?? '').toString().trim() ||
2208
+ getComponentDir(rootDir, 'happy-cli');
2209
+ const resolvedUiDir =
2210
+ (env.HAPPY_STACKS_COMPONENT_DIR_HAPPY ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY ?? '').toString().trim() || getComponentDir(rootDir, 'happy');
1821
2211
 
1822
2212
  await requireDir(serverComponent, resolvedServerDir);
1823
2213
  await requireDir('happy-cli', resolvedCliDir);
@@ -1844,9 +2234,10 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
1844
2234
  });
1845
2235
  serverProc = started.serverProc;
1846
2236
 
1847
- // Start Expo web UI so /terminal/connect exists for happy-cli web auth.
1848
- const uiRes = await startDevExpoWebUi({
2237
+ // Start Expo (web) so /terminal/connect exists for happy-cli web auth.
2238
+ const uiRes = await ensureDevExpoServer({
1849
2239
  startUi: true,
2240
+ startMobile: false,
1850
2241
  uiDir: resolvedUiDir,
1851
2242
  autostart,
1852
2243
  baseEnv: env,
@@ -1865,10 +2256,9 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
1865
2256
  }
1866
2257
 
1867
2258
  console.log('');
1868
- const uiHost = resolveLocalhostHost({ stackMode: true, stackName: name });
1869
2259
  const uiPort = uiRes?.port;
1870
- const uiRoot = Number.isFinite(uiPort) && uiPort > 0 ? `http://${uiHost}:${uiPort}` : null;
1871
2260
  const uiRootLocalhost = Number.isFinite(uiPort) && uiPort > 0 ? `http://localhost:${uiPort}` : null;
2261
+ const uiRoot = uiRootLocalhost ? await preferStackLocalhostUrl(uiRootLocalhost, { stackName: name }) : null;
1872
2262
  const uiSettings = uiRoot ? `${uiRoot}/settings/account` : null;
1873
2263
 
1874
2264
  console.log('[stack] step 1/3: create a dev-auth account in the UI (this generates the dev key)');
@@ -2001,6 +2391,150 @@ async function readStackEnvObject(stackName) {
2001
2391
  return { envPath, env };
2002
2392
  }
2003
2393
 
2394
+ function getTodayYmd() {
2395
+ const now = new Date();
2396
+ const y = String(now.getFullYear());
2397
+ const m = String(now.getMonth() + 1).padStart(2, '0');
2398
+ const d = String(now.getDate()).padStart(2, '0');
2399
+ return `${y}-${m}-${d}`;
2400
+ }
2401
+
2402
+ async function cmdArchiveStack({ rootDir, argv, stackName }) {
2403
+ const { flags, kv } = parseArgs(argv);
2404
+ const json = wantsJson(argv, { flags });
2405
+ const dryRun = flags.has('--dry-run');
2406
+ const date = (kv.get('--date') ?? '').toString().trim() || getTodayYmd();
2407
+
2408
+ if (!stackExistsSync(stackName)) {
2409
+ throw new Error(`[stack] archive: stack does not exist: ${stackName}`);
2410
+ }
2411
+
2412
+ const { env } = await readStackEnvObject(stackName);
2413
+ const serverComponent = parseServerComponentFromEnv(env);
2414
+ const components = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
2415
+
2416
+ const componentsDir = getComponentsDir(rootDir);
2417
+ const workspaceDir = dirname(componentsDir);
2418
+ const worktreesRoot = join(componentsDir, '.worktrees');
2419
+
2420
+ // Collect unique git worktree roots referenced by this stack.
2421
+ const byRoot = new Map();
2422
+ for (const component of components) {
2423
+ const key = envKeyForComponentDir({ serverComponent, component });
2424
+ const legacyKey = key.replace('HAPPY_STACKS_', 'HAPPY_LOCAL_');
2425
+ const raw = (env[key] ?? env[legacyKey] ?? '').toString().trim();
2426
+ if (!raw) continue;
2427
+ const abs = isAbsolute(raw) ? raw : resolve(workspaceDir, raw);
2428
+ // Only archive paths that live under components/.worktrees/.
2429
+ const rel = relative(worktreesRoot, abs);
2430
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) continue;
2431
+ try {
2432
+ // eslint-disable-next-line no-await-in-loop
2433
+ const top = (await runCapture('git', ['rev-parse', '--show-toplevel'], { cwd: abs })).trim();
2434
+ if (!top) continue;
2435
+ if (!byRoot.has(top)) {
2436
+ byRoot.set(top, { component, dir: top });
2437
+ }
2438
+ } catch {
2439
+ // ignore invalid git dirs
2440
+ }
2441
+ }
2442
+
2443
+ const { baseDir } = resolveStackEnvPath(stackName);
2444
+ const destStackDir = join(dirname(baseDir), '.archived', date, stackName);
2445
+
2446
+ // Safety: avoid archiving a worktree that is still actively referenced by other stacks.
2447
+ // If we did, we'd break those stacks by moving their active checkout.
2448
+ if (!dryRun && byRoot.size) {
2449
+ const otherStacks = new Map(); // envPath -> Set(keys)
2450
+ const otherNames = new Set();
2451
+
2452
+ for (const wt of byRoot.values()) {
2453
+ // eslint-disable-next-line no-await-in-loop
2454
+ const out = await runCapture(
2455
+ process.execPath,
2456
+ [join(rootDir, 'scripts', 'worktrees.mjs'), 'archive', wt.component, wt.dir, '--dry-run', `--date=${date}`, '--json'],
2457
+ { cwd: rootDir, env: process.env }
2458
+ );
2459
+ const info = JSON.parse(out);
2460
+ const linked = Array.isArray(info.linkedStacks) ? info.linkedStacks : [];
2461
+ for (const s of linked) {
2462
+ if (!s?.name || s.name === stackName) continue;
2463
+ otherNames.add(s.name);
2464
+ const envPath = String(s.envPath ?? '').trim();
2465
+ if (!envPath) continue;
2466
+ const set = otherStacks.get(envPath) ?? new Set();
2467
+ for (const k of Array.isArray(s.keys) ? s.keys : []) {
2468
+ if (k) set.add(String(k));
2469
+ }
2470
+ otherStacks.set(envPath, set);
2471
+ }
2472
+ }
2473
+
2474
+ if (otherNames.size) {
2475
+ const names = Array.from(otherNames).sort().join(', ');
2476
+ if (json || !isTty()) {
2477
+ throw new Error(`[stack] archive: worktree(s) are still referenced by other stacks: ${names}. Resolve first (detach or archive those stacks).`);
2478
+ }
2479
+
2480
+ const action = await withRl(async (rl) => {
2481
+ return await promptSelect(rl, {
2482
+ title: `Worktree(s) referenced by "${stackName}" are still in use by other stacks: ${names}`,
2483
+ options: [
2484
+ { label: 'abort (recommended)', value: 'abort' },
2485
+ { label: 'detach those stacks from the shared worktree(s)', value: 'detach' },
2486
+ { label: 'archive the linked stacks as well', value: 'archive-stacks' },
2487
+ ],
2488
+ defaultIndex: 0,
2489
+ });
2490
+ });
2491
+
2492
+ if (action === 'abort') {
2493
+ throw new Error('[stack] archive aborted');
2494
+ }
2495
+ if (action === 'archive-stacks') {
2496
+ for (const name of Array.from(otherNames).sort()) {
2497
+ // eslint-disable-next-line no-await-in-loop
2498
+ await run(process.execPath, [join(rootDir, 'scripts', 'stack.mjs'), 'archive', name, `--date=${date}`], { cwd: rootDir, env: process.env });
2499
+ }
2500
+ } else {
2501
+ for (const [envPath, keys] of otherStacks.entries()) {
2502
+ // eslint-disable-next-line no-await-in-loop
2503
+ await ensureEnvFilePruned({ envPath, removeKeys: Array.from(keys) });
2504
+ }
2505
+ }
2506
+ }
2507
+ }
2508
+
2509
+ if (dryRun) {
2510
+ return {
2511
+ ok: true,
2512
+ dryRun: true,
2513
+ stackName,
2514
+ date,
2515
+ stackBaseDir: baseDir,
2516
+ archivedStackDir: destStackDir,
2517
+ worktrees: Array.from(byRoot.values()),
2518
+ };
2519
+ }
2520
+
2521
+ await mkdir(dirname(destStackDir), { recursive: true });
2522
+ await rename(baseDir, destStackDir);
2523
+
2524
+ const archivedWorktrees = [];
2525
+ for (const wt of byRoot.values()) {
2526
+ if (!existsSync(wt.dir)) continue;
2527
+ // eslint-disable-next-line no-await-in-loop
2528
+ const out = await runCapture(process.execPath, [join(rootDir, 'scripts', 'worktrees.mjs'), 'archive', wt.component, wt.dir, `--date=${date}`, '--json'], {
2529
+ cwd: rootDir,
2530
+ env: process.env,
2531
+ });
2532
+ archivedWorktrees.push(JSON.parse(out));
2533
+ }
2534
+
2535
+ return { ok: true, dryRun: false, stackName, date, archivedStackDir: destStackDir, archivedWorktrees };
2536
+ }
2537
+
2004
2538
  function envKeyForComponentDir({ serverComponent, component }) {
2005
2539
  if (component === 'happy') return 'HAPPY_STACKS_COMPONENT_DIR_HAPPY';
2006
2540
  if (component === 'happy-cli') return 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI';
@@ -2058,14 +2592,14 @@ async function cmdDuplicate({ rootDir, argv }) {
2058
2592
  if (!rawDir) continue;
2059
2593
 
2060
2594
  let nextDir = rawDir;
2061
- if (duplicateWorktrees && isComponentWorktreePath({ rootDir, component, dir: rawDir })) {
2062
- const spec = worktreeSpecFromDir({ rootDir, component, dir: rawDir });
2595
+ if (duplicateWorktrees && isComponentWorktreePath({ rootDir, component, dir: rawDir, env: fromEnv })) {
2596
+ const spec = worktreeSpecFromDir({ rootDir, component, dir: rawDir, env: fromEnv });
2063
2597
  if (spec) {
2064
2598
  const [owner, ...restParts] = spec.split('/').filter(Boolean);
2065
2599
  const rest = restParts.join('/');
2066
2600
  const slug = `dup/${sanitizeSlugPart(toStack)}/${rest}`;
2067
2601
 
2068
- const repoDir = join(getComponentsDir(rootDir), component);
2602
+ const repoDir = join(getComponentsDir(rootDir, fromEnv), component);
2069
2603
  const remoteName = await inferRemoteNameForOwner({ repoDir, owner });
2070
2604
  // Base on the existing worktree's HEAD/branch so we get the same commit.
2071
2605
  nextDir = await createWorktreeFromBaseWorktree({
@@ -2075,6 +2609,7 @@ async function cmdDuplicate({ rootDir, argv }) {
2075
2609
  baseWorktreeSpec: spec,
2076
2610
  remoteName,
2077
2611
  depsMode,
2612
+ env: fromEnv,
2078
2613
  });
2079
2614
  }
2080
2615
  }
@@ -2160,28 +2695,29 @@ async function cmdPrStack({ rootDir, argv }) {
2160
2695
  json,
2161
2696
  data: {
2162
2697
  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...>]',
2698
+ 'happys stack pr <name> --happy=<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] [--expo-tailscale] [--json] [-- <stack dev/start args...>]',
2164
2699
  },
2165
2700
  text: [
2166
2701
  '[stack] usage:',
2167
- ' 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]',
2702
+ ' happys stack pr <name> --happy=<pr-url|number> [--happy-server-light=<pr-url|number>] [--dev|--start]',
2703
+ ' [--seed-auth] [--copy-auth-from=<stack>] [--link-auth] [--with-infra] [--auth-force]',
2704
+ ' [--remote=upstream] [--deps=none|link|install|link-or-install] [--update] [--force] [--background]',
2705
+ ' [--mobile] # also start Expo dev-client Metro for mobile',
2706
+ ' [--expo-tailscale] # forward Expo to Tailscale interface for remote access',
2170
2707
  ' [--json] [-- <stack dev/start args...>]',
2171
2708
  '',
2172
2709
  'examples:',
2173
2710
  ' # Create stack + check out PRs + start dev UI',
2174
2711
  ' happys stack pr pr123 \\',
2175
2712
  ' --happy=https://github.com/slopus/happy/pull/123 \\',
2176
- ' --happy-cli=https://github.com/slopus/happy-cli/pull/456 \\',
2177
2713
  ' --seed-auth --copy-auth-from=dev-auth \\',
2178
2714
  ' --dev',
2179
2715
  '',
2180
2716
  ' # Use numeric PR refs (remote defaults to upstream)',
2181
- ' happys stack pr pr123 --happy=123 --happy-cli=456 --seed-auth --copy-auth-from=dev-auth --dev',
2717
+ ' happys stack pr pr123 --happy=123 --seed-auth --copy-auth-from=dev-auth --dev',
2182
2718
  '',
2183
2719
  ' # 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',
2720
+ ' (deprecated) legacy ~/.happy is not supported for reliable seeding',
2185
2721
  '',
2186
2722
  'notes:',
2187
2723
  ' - This composes existing commands: `happys stack new`, `happys stack wt ...`, and `happys stack auth ...`',
@@ -2209,7 +2745,7 @@ async function cmdPrStack({ rootDir, argv }) {
2209
2745
  );
2210
2746
  }
2211
2747
 
2212
- const remoteName = (kv.get('--remote') ?? '').trim() || 'upstream';
2748
+ const remoteNameFromArg = (kv.get('--remote') ?? '').trim();
2213
2749
  const depsMode = (kv.get('--deps') ?? '').trim();
2214
2750
 
2215
2751
  const prHappy = (kv.get('--happy') ?? '').trim();
@@ -2226,6 +2762,22 @@ async function cmdPrStack({ rootDir, argv }) {
2226
2762
  throw new Error('[stack] pr: cannot specify both --happy-server and --happy-server-light');
2227
2763
  }
2228
2764
 
2765
+ const happyMonorepoActive = Boolean(coerceHappyMonorepoRootFromPath(getComponentDir(rootDir, 'happy', process.env)));
2766
+ if (happyMonorepoActive) {
2767
+ if (prCli) {
2768
+ throw new Error(
2769
+ '[stack] pr: --happy-cli is not supported when using the slopus/happy monorepo.\n' +
2770
+ 'Fix: use --happy=<pr> to pin the monorepo (UI + CLI + server) in one worktree.'
2771
+ );
2772
+ }
2773
+ if (prServer) {
2774
+ throw new Error(
2775
+ '[stack] pr: --happy-server is not supported when using the slopus/happy monorepo.\n' +
2776
+ 'Fix: use --happy=<pr> to pin the monorepo (UI + CLI + server) in one worktree.'
2777
+ );
2778
+ }
2779
+ }
2780
+
2229
2781
  const serverFromArg = (kv.get('--server') ?? '').trim();
2230
2782
  const inferredServer = prServer ? 'happy-server' : prServerLight ? 'happy-server-light' : '';
2231
2783
  const serverComponent = (serverFromArg || inferredServer || 'happy-server-light').trim();
@@ -2239,6 +2791,10 @@ async function cmdPrStack({ rootDir, argv }) {
2239
2791
  throw new Error('[stack] pr: choose either --dev or --start (not both)');
2240
2792
  }
2241
2793
 
2794
+ const wantsMobile = flags.has('--mobile') || flags.has('--with-mobile');
2795
+ const wantsExpoTailscale = flags.has('--expo-tailscale');
2796
+ const background = flags.has('--background') || flags.has('--bg') || (kv.get('--background') ?? '').trim() === '1';
2797
+
2242
2798
  const seedAuthFlag = flags.has('--seed-auth') ? true : flags.has('--no-seed-auth') ? false : null;
2243
2799
  const authFromFlag = (kv.get('--copy-auth-from') ?? '').trim();
2244
2800
  const withInfra = flags.has('--with-infra') || flags.has('--ensure-infra') || flags.has('--infra');
@@ -2367,14 +2923,22 @@ async function cmdPrStack({ rootDir, argv }) {
2367
2923
  }
2368
2924
 
2369
2925
  // 2) Checkout PR worktrees and pin them to the stack env file.
2370
- const prSpecs = [
2371
- { component: 'happy', pr: prHappy },
2372
- { component: 'happy-cli', pr: prCli },
2373
- ...(serverComponent === 'happy-server' ? [{ component: 'happy-server', pr: prServer }] : []),
2374
- ...(serverComponent === 'happy-server-light' ? [{ component: 'happy-server-light', pr: prServerLight }] : []),
2375
- ].filter((x) => x.pr);
2926
+ const prSpecs = (
2927
+ happyMonorepoActive
2928
+ ? [
2929
+ ...(prHappy ? [{ component: 'happy', pr: prHappy }] : []),
2930
+ ...(serverComponent === 'happy-server-light' && prServerLight ? [{ component: 'happy-server-light', pr: prServerLight }] : []),
2931
+ ]
2932
+ : [
2933
+ ...(prHappy ? [{ component: 'happy', pr: prHappy }] : []),
2934
+ ...(prCli ? [{ component: 'happy-cli', pr: prCli }] : []),
2935
+ ...(serverComponent === 'happy-server' && prServer ? [{ component: 'happy-server', pr: prServer }] : []),
2936
+ ...(serverComponent === 'happy-server-light' && prServerLight ? [{ component: 'happy-server-light', pr: prServerLight }] : []),
2937
+ ]
2938
+ ).filter((x) => x.pr);
2376
2939
 
2377
2940
  const worktrees = [];
2941
+ const stackEnvPath = resolveStackEnvPath(stackName).envPath;
2378
2942
  for (const { component, pr } of prSpecs) {
2379
2943
  progress(`[stack] pr: ${stackName}: fetching PR for ${component} (${pr})...`);
2380
2944
  const out = await withStackEnv({
@@ -2385,7 +2949,7 @@ async function cmdPrStack({ rootDir, argv }) {
2385
2949
  'pr',
2386
2950
  component,
2387
2951
  pr,
2388
- `--remote=${remoteName}`,
2952
+ ...(remoteNameFromArg ? [`--remote=${remoteNameFromArg}`] : []),
2389
2953
  '--use',
2390
2954
  ...(depsMode ? [`--deps=${depsMode}`] : []),
2391
2955
  ...(doUpdate ? ['--update'] : []),
@@ -2393,11 +2957,36 @@ async function cmdPrStack({ rootDir, argv }) {
2393
2957
  '--json',
2394
2958
  ];
2395
2959
  const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'worktrees.mjs'), ...args], { cwd: rootDir, env });
2396
- return stdout.trim() ? JSON.parse(stdout.trim()) : null;
2960
+ const parsed = stdout.trim() ? JSON.parse(stdout.trim()) : null;
2961
+
2962
+ // Fail-closed invariant for PR stacks:
2963
+ // If you asked to pin a component to a PR checkout, it MUST be a worktree path under
2964
+ // the active workspace components dir (including sandbox workspace).
2965
+ if (parsed?.path && !isComponentWorktreePath({ rootDir, component, dir: parsed.path, env })) {
2966
+ const expectedRepoKey = parsed?.repoKey ? String(parsed.repoKey) : component;
2967
+ throw new Error(
2968
+ `[stack] pr: refusing to pin ${component} because the checked out path is not a worktree.\n` +
2969
+ `- expected under: ${join(getComponentsDir(rootDir, env), '.worktrees', expectedRepoKey)}/...\n` +
2970
+ `- actual: ${String(parsed.path ?? '').trim()}\n` +
2971
+ `Fix: this is a bug. Please re-run with --force, or delete/recreate the stack (${stackName}).`
2972
+ );
2973
+ }
2974
+
2975
+ return parsed;
2397
2976
  },
2398
2977
  });
2399
- if (json) {
2978
+ if (out) {
2400
2979
  worktrees.push(out);
2980
+ // Fail-closed invariant for PR stacks:
2981
+ // - if you asked to pin a component to a PR checkout, the stack env file MUST point at that exact worktree dir
2982
+ // before we start dev/start. Otherwise the stack can accidentally run the base checkout.
2983
+ //
2984
+ // We intentionally do NOT rely solely on `wt pr --use` for this; we make it explicit here.
2985
+ const key = componentDirEnvKey(component);
2986
+ await ensureEnvFileUpdated({ envPath: stackEnvPath, updates: [{ key, value: out.path }] });
2987
+ }
2988
+ if (json) {
2989
+ // collected above
2401
2990
  } else if (out) {
2402
2991
  const short = (sha) => (sha ? String(sha).slice(0, 8) : '');
2403
2992
  const changed = Boolean(out.updated && out.oldHead && out.newHead && out.oldHead !== out.newHead);
@@ -2414,6 +3003,70 @@ async function cmdPrStack({ rootDir, argv }) {
2414
3003
  }
2415
3004
  }
2416
3005
 
3006
+ // Monorepo shortcut:
3007
+ // If `--happy=<pr>` was provided and the local checkout is the slopus/happy monorepo, pin
3008
+ // happy-cli and (optionally) happy-server to that same worktree without fetching separate PRs.
3009
+ if (happyMonorepoActive && prHappy) {
3010
+ const happyWt = worktrees.find((w) => w?.component === 'happy');
3011
+ const happyPath = String(happyWt?.path ?? '').trim();
3012
+ const happyRoot = happyWt?.worktreeRoot ? resolve(String(happyWt.worktreeRoot)) : happyPath ? coerceHappyMonorepoRootFromPath(happyPath) : null;
3013
+ if (!happyRoot) {
3014
+ throw new Error('[stack] pr: expected happy monorepo worktree root but could not resolve it from the checked out path.');
3015
+ }
3016
+
3017
+ const derive = (component) => {
3018
+ const sub = happyMonorepoSubdirForComponent(component);
3019
+ if (!sub) return null;
3020
+ return join(happyRoot, sub);
3021
+ };
3022
+
3023
+ const derivedComponents = [
3024
+ 'happy-cli',
3025
+ ...(serverComponent === 'happy-server' ? ['happy-server'] : []),
3026
+ ];
3027
+
3028
+ for (const c of derivedComponents) {
3029
+ const p = derive(c);
3030
+ if (!p) continue;
3031
+ if (!isComponentWorktreePath({ rootDir, component: c, dir: p, env: process.env })) {
3032
+ throw new Error(`[stack] pr: refusing to pin ${c} because the derived path is not a worktree: ${p}`);
3033
+ }
3034
+ const key = componentDirEnvKey(c);
3035
+ await ensureEnvFileUpdated({ envPath: stackEnvPath, updates: [{ key, value: p }] });
3036
+ worktrees.push({ component: c, path: p });
3037
+ }
3038
+ }
3039
+
3040
+ // Validate that all PR components are pinned correctly before starting.
3041
+ // This prevents "wrong daemon" / "wrong UI" errors that are otherwise extremely confusing in review-pr.
3042
+ if (prSpecs.length) {
3043
+ const afterRaw = await readExistingEnv(stackEnvPath);
3044
+ const afterEnv = parseEnvToObject(afterRaw);
3045
+ for (const wt of worktrees) {
3046
+ const key = componentDirEnvKey(wt.component);
3047
+ const val = (afterEnv[key] ?? '').toString().trim();
3048
+ const expected = resolve(String(wt.path ?? '').trim());
3049
+ const actual = val ? resolve(val) : '';
3050
+ if (!actual) {
3051
+ throw new Error(
3052
+ `[stack] pr: failed to pin ${wt.component} to the PR checkout.\n` +
3053
+ `- missing env key: ${key}\n` +
3054
+ `- expected: ${expected}\n` +
3055
+ `Fix: re-run with --force, or delete/recreate the stack (${stackName}).`
3056
+ );
3057
+ }
3058
+ if (expected && actual !== expected) {
3059
+ throw new Error(
3060
+ `[stack] pr: stack is pinned to the wrong checkout for ${wt.component}.\n` +
3061
+ `- env key: ${key}\n` +
3062
+ `- expected: ${expected}\n` +
3063
+ `- actual: ${actual}\n` +
3064
+ `Fix: re-run with --force, or delete/recreate the stack (${stackName}).`
3065
+ );
3066
+ }
3067
+ }
3068
+ }
3069
+
2417
3070
  // 3) Optional: seed auth (copies cli creds + master secret + DB Account rows).
2418
3071
  let auth = null;
2419
3072
  if (seedAuth) {
@@ -2426,8 +3079,10 @@ async function cmdPrStack({ rootDir, argv }) {
2426
3079
  ...(authLink ? ['--link'] : []),
2427
3080
  ];
2428
3081
  if (json) {
3082
+ const extraEnv = await getRuntimePortExtraEnv(stackName);
2429
3083
  auth = await withStackEnv({
2430
3084
  stackName,
3085
+ ...(extraEnv ? { extraEnv } : {}),
2431
3086
  fn: async ({ env }) => {
2432
3087
  const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...args, '--json'], { cwd: rootDir, env });
2433
3088
  return stdout.trim() ? JSON.parse(stdout.trim()) : null;
@@ -2442,12 +3097,20 @@ async function cmdPrStack({ rootDir, argv }) {
2442
3097
  // 4) Optional: start dev / start.
2443
3098
  if (wantsDev) {
2444
3099
  progress(`[stack] pr: ${stackName}: starting dev...`);
2445
- const args = passthrough.length ? ['--', ...passthrough] : [];
2446
- await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args });
3100
+ const args = [
3101
+ ...(wantsMobile ? ['--mobile'] : []),
3102
+ ...(wantsExpoTailscale ? ['--expo-tailscale'] : []),
3103
+ ...(passthrough.length ? ['--', ...passthrough] : []),
3104
+ ];
3105
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args, background });
2447
3106
  } else if (wantsStart) {
2448
3107
  progress(`[stack] pr: ${stackName}: starting...`);
2449
- const args = passthrough.length ? ['--', ...passthrough] : [];
2450
- await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args });
3108
+ const args = [
3109
+ ...(wantsMobile ? ['--mobile'] : []),
3110
+ ...(wantsExpoTailscale ? ['--expo-tailscale'] : []),
3111
+ ...(passthrough.length ? ['--', ...passthrough] : []),
3112
+ ];
3113
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args, background });
2451
3114
  }
2452
3115
 
2453
3116
  const info = await cmdInfoInternal({ rootDir, stackName });
@@ -2500,10 +3163,15 @@ async function cmdInfoInternal({ rootDir, stackName }) {
2500
3163
  runtimeState?.expo && typeof runtimeState.expo === 'object' && Number(runtimeState.expo.webPort) > 0
2501
3164
  ? Number(runtimeState.expo.webPort)
2502
3165
  : null;
3166
+ const mobilePort =
3167
+ runtimeState?.expo && typeof runtimeState.expo === 'object' && Number(runtimeState.expo.mobilePort) > 0
3168
+ ? Number(runtimeState.expo.mobilePort)
3169
+ : null;
2503
3170
 
2504
3171
  const host = resolveLocalhostHost({ stackMode: true, stackName });
2505
3172
  const internalServerUrl = serverPort ? `http://127.0.0.1:${serverPort}` : null;
2506
3173
  const uiUrl = uiPort ? `http://${host}:${uiPort}` : null;
3174
+ const mobileUrl = mobilePort ? await preferStackLocalhostUrl(`http://localhost:${mobilePort}`, { stackName }) : null;
2507
3175
 
2508
3176
  const componentSpecs = [
2509
3177
  { component: 'happy', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY'] },
@@ -2546,11 +3214,13 @@ async function cmdInfoInternal({ rootDir, stackName }) {
2546
3214
  host,
2547
3215
  internalServerUrl,
2548
3216
  uiUrl,
3217
+ mobileUrl,
2549
3218
  },
2550
3219
  ports: {
2551
3220
  server: serverPort,
2552
3221
  backend: backendPort,
2553
3222
  ui: uiPort,
3223
+ mobile: mobilePort,
2554
3224
  },
2555
3225
  components,
2556
3226
  };
@@ -2609,19 +3279,25 @@ async function main() {
2609
3279
  'list',
2610
3280
  'migrate',
2611
3281
  'audit',
2612
- 'duplicate',
3282
+ 'archive',
3283
+ 'duplicate',
2613
3284
  'info',
2614
3285
  'pr',
2615
3286
  'create-dev-auth-seed',
3287
+ 'happy',
3288
+ 'env',
2616
3289
  'auth',
2617
3290
  'dev',
2618
3291
  'start',
2619
3292
  'build',
3293
+ 'review',
2620
3294
  'typecheck',
2621
3295
  'lint',
2622
3296
  'test',
2623
3297
  'doctor',
2624
3298
  'mobile',
3299
+ 'mobile:install',
3300
+ 'mobile-dev-client',
2625
3301
  'resume',
2626
3302
  'stop',
2627
3303
  'code',
@@ -2635,24 +3311,30 @@ async function main() {
2635
3311
  },
2636
3312
  text: [
2637
3313
  '[stack] usage:',
2638
- ' happys stack new <name> [--port=NNN] [--server=happy-server|happy-server-light] [--happy=default|<owner/...>|<path>] [--happy-cli=...] [--interactive] [--copy-auth-from=<stack>] [--no-copy-auth] [--force-port] [--json]',
3314
+ ' happys stack new <name> [--port=NNN] [--server=happy-server|happy-server-light] [--happy=default|<owner/...>|<path>] [--interactive] [--copy-auth-from=<stack>] [--no-copy-auth] [--force-port] [--json]',
2639
3315
  ' happys stack edit <name> --interactive [--json]',
2640
3316
  ' happys stack list [--json]',
2641
3317
  ' happys stack migrate [--json] # copy legacy env files from ~/.happy/local/stacks/* -> ~/.happy/stacks/*',
2642
3318
  ' happys stack audit [--fix] [--fix-main] [--fix-ports] [--fix-workspace] [--fix-paths] [--unpin-ports] [--unpin-ports-except=stack1,stack2] [--json]',
3319
+ ' happys stack archive <name> [--dry-run] [--date=YYYY-MM-DD] [--json]',
2643
3320
  ' happys stack duplicate <from> <to> [--duplicate-worktrees] [--deps=none|link|install|link-or-install] [--json]',
2644
3321
  ' happys stack info <name> [--json]',
2645
- ' happys stack pr <name> --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--dev|--start] [--json] [-- ...]',
3322
+ ' happys stack pr <name> --happy=<pr-url|number> [--happy-server-light=<pr-url|number>] [--dev|--start] [--json] [-- ...]',
2646
3323
  ' happys stack create-dev-auth-seed [name] [--server=happy-server|happy-server-light] [--non-interactive] [--json]',
3324
+ ' happys stack happy <name> [-- ...]',
3325
+ ' happys stack env <name> set KEY=VALUE [KEY2=VALUE2...] | unset KEY [KEY2...] | get KEY | list | path [--json]',
2647
3326
  ' happys stack auth <name> status|login|copy-from [--json]',
2648
3327
  ' happys stack dev <name> [-- ...]',
2649
3328
  ' happys stack start <name> [-- ...]',
2650
3329
  ' happys stack build <name> [-- ...]',
3330
+ ' happys stack review <name> [component...] [--reviewers=coderabbit,codex] [--base-remote=<remote>] [--base-branch=<branch>] [--base-ref=<ref>] [--chunks|--no-chunks] [--chunking=auto|head-slice|commit-window] [--chunk-max-files=N] [--json]',
2651
3331
  ' happys stack typecheck <name> [component...] [--json]',
2652
3332
  ' happys stack lint <name> [component...] [--json]',
2653
3333
  ' happys stack test <name> [component...] [--json]',
2654
3334
  ' happys stack doctor <name> [-- ...]',
2655
3335
  ' happys stack mobile <name> [-- ...]',
3336
+ ' happys stack mobile:install <name> [--name="Happy (exp1)"] [--device=...] [--json]',
3337
+ ' happys stack mobile-dev-client <name> --install [--device=...] [--clean] [--configuration=Debug|Release] [--json]',
2656
3338
  ' happys stack resume <name> <sessionId...> [--json]',
2657
3339
  ' happys stack stop <name> [--aggressive] [--sweep-owned] [--no-docker] [--json]',
2658
3340
  ' happys stack code <name> [--no-stack-dir] [--include-all-components] [--include-cli-home] [--json]',
@@ -2765,6 +3447,15 @@ async function main() {
2765
3447
  'example:',
2766
3448
  ' happys stack srv exp1 -- status',
2767
3449
  ]
3450
+ : cmd === 'env'
3451
+ ? [
3452
+ '[stack] usage:',
3453
+ ' happys stack env <name> set KEY=VALUE [KEY2=VALUE2...]',
3454
+ ' happys stack env <name> unset KEY [KEY2...]',
3455
+ ' happys stack env <name> get KEY',
3456
+ ' happys stack env <name> list',
3457
+ ' happys stack env <name> path',
3458
+ ]
2768
3459
  : cmd.startsWith('tailscale:')
2769
3460
  ? [
2770
3461
  '[stack] usage:',
@@ -2785,12 +3476,51 @@ async function main() {
2785
3476
  // Remaining args after "<cmd> <name>"
2786
3477
  const passthrough = argv.slice(2);
2787
3478
 
3479
+ if (cmd === 'archive') {
3480
+ const res = await cmdArchiveStack({ rootDir, argv, stackName });
3481
+ if (json) {
3482
+ printResult({ json, data: res });
3483
+ } else if (res.dryRun) {
3484
+ console.log(`[stack] would archive "${stackName}" -> ${res.archivedStackDir} (dry-run)`);
3485
+ } else {
3486
+ console.log(`[stack] archived "${stackName}" -> ${res.archivedStackDir}`);
3487
+ }
3488
+ return;
3489
+ }
3490
+
3491
+ if (cmd === 'env') {
3492
+ const hasPositional = passthrough.some((a) => !a.startsWith('-'));
3493
+ const envArgv = hasPositional ? passthrough : ['list', ...passthrough];
3494
+ // Forward to scripts/env.mjs under the stack env.
3495
+ // This keeps stack env editing behavior unified with `happys env ...`.
3496
+ await withStackEnv({
3497
+ stackName,
3498
+ fn: async ({ env }) => {
3499
+ await run(process.execPath, [join(rootDir, 'scripts', 'env.mjs'), ...envArgv], { cwd: rootDir, env });
3500
+ },
3501
+ });
3502
+ return;
3503
+ }
3504
+ if (cmd === 'happy') {
3505
+ const args = passthrough[0] === '--' ? passthrough.slice(1) : passthrough;
3506
+ await withStackEnv({
3507
+ stackName,
3508
+ fn: async ({ env }) => {
3509
+ await run(process.execPath, [join(rootDir, 'scripts', 'happy.mjs'), ...args], { cwd: rootDir, env });
3510
+ },
3511
+ });
3512
+ return;
3513
+ }
2788
3514
  if (cmd === 'dev') {
2789
- await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args: passthrough });
3515
+ const background = passthrough.includes('--background') || passthrough.includes('--bg');
3516
+ const args = background ? passthrough.filter((a) => a !== '--background' && a !== '--bg') : passthrough;
3517
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'dev.mjs', args, background });
2790
3518
  return;
2791
3519
  }
2792
3520
  if (cmd === 'start') {
2793
- await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args: passthrough });
3521
+ const background = passthrough.includes('--background') || passthrough.includes('--bg');
3522
+ const args = background ? passthrough.filter((a) => a !== '--background' && a !== '--bg') : passthrough;
3523
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'run.mjs', args, background });
2794
3524
  return;
2795
3525
  }
2796
3526
  if (cmd === 'build') {
@@ -2817,6 +3547,12 @@ async function main() {
2817
3547
  await cmdRunScript({ rootDir, stackName, scriptPath: 'test.mjs', args: passthrough, extraEnv: overrides });
2818
3548
  return;
2819
3549
  }
3550
+ if (cmd === 'review') {
3551
+ const { kv } = parseArgs(passthrough);
3552
+ const overrides = resolveTransientComponentOverrides({ rootDir, kv });
3553
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'review.mjs', args: passthrough, extraEnv: overrides });
3554
+ return;
3555
+ }
2820
3556
  if (cmd === 'doctor') {
2821
3557
  await cmdRunScript({ rootDir, stackName, scriptPath: 'doctor.mjs', args: passthrough });
2822
3558
  return;
@@ -2825,6 +3561,62 @@ async function main() {
2825
3561
  await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile.mjs', args: passthrough });
2826
3562
  return;
2827
3563
  }
3564
+ if (cmd === 'mobile-dev-client') {
3565
+ // Stack-scoped wrapper so the dev-client can be built from the stack's active happy checkout/worktree.
3566
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile_dev_client.mjs', args: passthrough });
3567
+ return;
3568
+ }
3569
+ if (cmd === 'mobile:install') {
3570
+ const { flags: mFlags, kv: mKv } = parseArgs(passthrough);
3571
+ const device = (mKv.get('--device') ?? '').toString();
3572
+ const name = (mKv.get('--name') ?? mKv.get('--app-name') ?? '').toString().trim();
3573
+ const jsonOut = wantsJson(passthrough, { flags: mFlags }) || json;
3574
+
3575
+ const envPath = resolveStackEnvPath(stackName).envPath;
3576
+ const existingRaw = await readExistingEnv(envPath);
3577
+ const existing = parseEnvToObject(existingRaw);
3578
+
3579
+ const priorName =
3580
+ (existing.HAPPY_STACKS_MOBILE_RELEASE_IOS_APP_NAME ?? existing.HAPPY_LOCAL_MOBILE_RELEASE_IOS_APP_NAME ?? '').toString().trim();
3581
+ const identity = defaultStackReleaseIdentity({
3582
+ stackName,
3583
+ user: process.env.USER ?? process.env.USERNAME ?? 'user',
3584
+ appName: name || priorName || null,
3585
+ });
3586
+
3587
+ // Persist the chosen identity so re-installs are stable and user-friendly.
3588
+ await ensureEnvFileUpdated({
3589
+ envPath,
3590
+ updates: [
3591
+ { key: 'HAPPY_STACKS_MOBILE_RELEASE_IOS_APP_NAME', value: identity.iosAppName },
3592
+ { key: 'HAPPY_STACKS_MOBILE_RELEASE_IOS_BUNDLE_ID', value: identity.iosBundleId },
3593
+ { key: 'HAPPY_STACKS_MOBILE_RELEASE_SCHEME', value: identity.scheme },
3594
+ ],
3595
+ });
3596
+
3597
+ // Install a per-stack release-configured app (isolated container) without starting Metro.
3598
+ const args = [
3599
+ `--app-env=production`,
3600
+ `--ios-app-name=${identity.iosAppName}`,
3601
+ `--ios-bundle-id=${identity.iosBundleId}`,
3602
+ `--scheme=${identity.scheme}`,
3603
+ '--prebuild',
3604
+ '--run-ios',
3605
+ '--configuration=Release',
3606
+ '--no-metro',
3607
+ ...(device ? [`--device=${device}`] : []),
3608
+ ];
3609
+
3610
+ await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile.mjs', args });
3611
+
3612
+ if (jsonOut) {
3613
+ printResult({
3614
+ json: true,
3615
+ data: { ok: true, stackName, installed: true, identity },
3616
+ });
3617
+ }
3618
+ return;
3619
+ }
2828
3620
  if (cmd === 'resume') {
2829
3621
  const sessionIds = passthrough.filter((a) => a && a !== '--' && !a.startsWith('--'));
2830
3622
  if (sessionIds.length === 0) {
@@ -2841,7 +3633,9 @@ async function main() {
2841
3633
  const out = await withStackEnv({
2842
3634
  stackName,
2843
3635
  fn: async ({ env }) => {
2844
- const cliDir = getComponentDir(rootDir, 'happy-cli');
3636
+ // IMPORTANT: use the stack's pinned happy-cli checkout if set.
3637
+ // Do not read component dirs from this process's `process.env` (withStackEnv does not mutate it).
3638
+ const cliDir = (env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI ?? env.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI ?? '').toString().trim() || getComponentDir(rootDir, 'happy-cli');
2845
3639
  const happyBin = join(cliDir, 'bin', 'happy.mjs');
2846
3640
  // Run stack-scoped happy-cli and ask the stack daemon to resume these sessions.
2847
3641
  return await run(process.execPath, [happyBin, 'daemon', 'resume', ...sessionIds], { cwd: rootDir, env });