happy-stacks 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/README.md +84 -25
  2. package/bin/happys.mjs +116 -17
  3. package/docs/happy-development.md +2 -2
  4. package/docs/isolated-linux-vm.md +82 -0
  5. package/docs/mobile-ios.md +112 -54
  6. package/package.json +5 -1
  7. package/scripts/auth.mjs +59 -208
  8. package/scripts/build.mjs +58 -12
  9. package/scripts/cli-link.mjs +3 -3
  10. package/scripts/completion.mjs +5 -5
  11. package/scripts/daemon.mjs +168 -20
  12. package/scripts/dev.mjs +196 -70
  13. package/scripts/doctor.mjs +20 -36
  14. package/scripts/edison.mjs +105 -78
  15. package/scripts/happy.mjs +8 -19
  16. package/scripts/init.mjs +8 -14
  17. package/scripts/install.mjs +119 -23
  18. package/scripts/lint.mjs +31 -32
  19. package/scripts/menubar.mjs +6 -13
  20. package/scripts/migrate.mjs +11 -21
  21. package/scripts/mobile.mjs +93 -108
  22. package/scripts/mobile_dev_client.mjs +83 -0
  23. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  24. package/scripts/review.mjs +217 -0
  25. package/scripts/review_pr.mjs +368 -0
  26. package/scripts/run.mjs +95 -21
  27. package/scripts/self.mjs +11 -29
  28. package/scripts/server_flavor.mjs +4 -4
  29. package/scripts/service.mjs +19 -29
  30. package/scripts/setup.mjs +63 -160
  31. package/scripts/setup_pr.mjs +592 -52
  32. package/scripts/stack.mjs +608 -200
  33. package/scripts/stop.mjs +3 -3
  34. package/scripts/tailscale.mjs +44 -11
  35. package/scripts/test.mjs +52 -36
  36. package/scripts/tui.mjs +314 -74
  37. package/scripts/typecheck.mjs +31 -32
  38. package/scripts/ui_gateway.mjs +1 -1
  39. package/scripts/uninstall.mjs +6 -6
  40. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  41. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  42. package/scripts/utils/auth/dev_key.mjs +163 -0
  43. package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
  44. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  45. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  46. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  47. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  48. package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +32 -13
  49. package/scripts/utils/auth/sources.mjs +38 -0
  50. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  51. package/scripts/utils/cli/cli_registry.mjs +24 -0
  52. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  53. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  54. package/scripts/utils/cli/flags.mjs +17 -0
  55. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  56. package/scripts/utils/cli/normalize.mjs +16 -0
  57. package/scripts/utils/cli/prereqs.mjs +72 -0
  58. package/scripts/utils/cli/progress.mjs +126 -0
  59. package/scripts/utils/cli/smoke_help.mjs +2 -2
  60. package/scripts/utils/cli/verbosity.mjs +12 -0
  61. package/scripts/utils/cli/wizard.mjs +1 -1
  62. package/scripts/utils/crypto/tokens.mjs +14 -0
  63. package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +51 -7
  64. package/scripts/utils/dev/expo_dev.mjs +246 -0
  65. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  66. package/scripts/utils/{dev_server.mjs → dev/server.mjs} +22 -32
  67. package/scripts/utils/dev_auth_key.mjs +1 -1
  68. package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
  69. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  70. package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
  71. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
  72. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  73. package/scripts/utils/env/read.mjs +30 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/expo/command.mjs +52 -0
  76. package/scripts/utils/{expo.mjs → expo/expo.mjs} +23 -10
  77. package/scripts/utils/expo/metro_ports.mjs +114 -0
  78. package/scripts/utils/fs/json.mjs +25 -0
  79. package/scripts/utils/fs/ops.mjs +29 -0
  80. package/scripts/utils/fs/package_json.mjs +8 -0
  81. package/scripts/utils/fs/tail.mjs +12 -0
  82. package/scripts/utils/git/git.mjs +67 -0
  83. package/scripts/utils/git/refs.mjs +26 -0
  84. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +27 -23
  85. package/scripts/utils/handy_master_secret.mjs +2 -2
  86. package/scripts/utils/mobile/config.mjs +31 -0
  87. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  88. package/scripts/utils/mobile/identifiers.mjs +47 -0
  89. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  90. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  91. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  92. package/scripts/utils/net/dns.mjs +10 -0
  93. package/scripts/utils/net/lan_ip.mjs +24 -0
  94. package/scripts/utils/{ports.mjs → net/ports.mjs} +12 -6
  95. package/scripts/utils/net/url.mjs +30 -0
  96. package/scripts/utils/net/url.test.mjs +20 -0
  97. package/scripts/utils/paths/localhost_host.mjs +56 -0
  98. package/scripts/utils/{paths.mjs → paths/paths.mjs} +52 -45
  99. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
  100. package/scripts/utils/proc/commands.mjs +34 -0
  101. package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
  102. package/scripts/utils/proc/package_scripts.mjs +31 -0
  103. package/scripts/utils/proc/parallel.mjs +25 -0
  104. package/scripts/utils/proc/pids.mjs +11 -0
  105. package/scripts/utils/{pm.mjs → proc/pm.mjs} +128 -158
  106. package/scripts/utils/{proc.mjs → proc/proc.mjs} +77 -2
  107. package/scripts/utils/review/base_ref.mjs +74 -0
  108. package/scripts/utils/review/base_ref.test.mjs +54 -0
  109. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  110. package/scripts/utils/review/runners/codex.mjs +51 -0
  111. package/scripts/utils/review/targets.mjs +24 -0
  112. package/scripts/utils/review/targets.test.mjs +36 -0
  113. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  114. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
  115. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  116. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  117. package/scripts/utils/server/port.mjs +68 -0
  118. package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
  119. package/scripts/utils/server/urls.mjs +101 -0
  120. package/scripts/utils/server/validate.mjs +88 -0
  121. package/scripts/utils/service/autostart_darwin.mjs +182 -0
  122. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  123. package/scripts/utils/stack/context.mjs +23 -0
  124. package/scripts/utils/stack/dirs.mjs +27 -0
  125. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  126. package/scripts/utils/stack/names.mjs +12 -0
  127. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  128. package/scripts/utils/stack/runtime_state.mjs +88 -0
  129. package/scripts/utils/stack/stacks.mjs +45 -0
  130. package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +9 -2
  131. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +24 -19
  132. package/scripts/utils/stack_context.mjs +3 -3
  133. package/scripts/utils/stack_runtime_state.mjs +1 -1
  134. package/scripts/utils/stacks.mjs +2 -2
  135. package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
  136. package/scripts/utils/ui/qr.mjs +17 -0
  137. package/scripts/utils/ui/text.mjs +16 -0
  138. package/scripts/utils/validate.mjs +1 -1
  139. package/scripts/where.mjs +6 -6
  140. package/scripts/worktrees.mjs +171 -113
  141. package/scripts/utils/auth_sources.mjs +0 -12
  142. package/scripts/utils/dev_expo_web.mjs +0 -112
  143. package/scripts/utils/localhost_host.mjs +0 -17
  144. package/scripts/utils/server_port.mjs +0 -9
  145. package/scripts/utils/server_urls.mjs +0 -54
  146. /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
  147. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
  148. /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
  149. /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
package/scripts/stack.mjs CHANGED
@@ -1,15 +1,25 @@
1
- import './utils/env.mjs';
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
- import { randomBytes } from 'node:crypto';
6
+ // NOTE: random bytes usage centralized in scripts/utils/crypto/tokens.mjs
7
7
  import { homedir } from 'node:os';
8
+ import { ensureDir, readTextIfExists, readTextOrEmpty } from './utils/fs/ops.mjs';
8
9
 
9
10
  import { parseArgs } from './utils/cli/args.mjs';
10
- import { killProcessTree, run, runCapture } from './utils/proc.mjs';
11
- import { getComponentDir, getComponentsDir, getHappyStacksHomeDir, getLegacyStorageRoot, getRootDir, getStacksStorageRoot, resolveStackEnvPath } from './utils/paths.mjs';
12
- import { isTcpPortFree, pickNextFreeTcpPort } from './utils/ports.mjs';
11
+ import { killProcessTree, run, runCapture } from './utils/proc/proc.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';
13
23
  import {
14
24
  createWorktree,
15
25
  createWorktreeFromBaseWorktree,
@@ -17,70 +27,64 @@ import {
17
27
  isComponentWorktreePath,
18
28
  resolveComponentSpecToDir,
19
29
  worktreeSpecFromDir,
20
- } from './utils/worktrees.mjs';
30
+ } from './utils/git/worktrees.mjs';
21
31
  import { isTty, prompt, promptWorktreeSource, withRl } from './utils/cli/wizard.mjs';
22
- import { parseDotenv } from './utils/dotenv.mjs';
32
+ import { parseEnvToObject } from './utils/env/dotenv.mjs';
23
33
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
24
- import { ensureEnvFilePruned, ensureEnvFileUpdated } from './utils/env_file.mjs';
25
- import { listAllStackNames } from './utils/stacks.mjs';
26
- import { stopStackWithEnv } from './utils/stack_stop.mjs';
27
- import { writeDevAuthKey } from './utils/dev_auth_key.mjs';
28
- import { startDevServer } from './utils/dev_server.mjs';
29
- import { startDevExpoWebUi } from './utils/dev_expo_web.mjs';
30
- import { requireDir } from './utils/pm.mjs';
31
- import { waitForHttpOk } from './utils/server.mjs';
32
- import { resolveLocalhostHost } from './utils/localhost_host.mjs';
33
- import { openUrlInBrowser } from './utils/browser.mjs';
34
- import { copyFileIfMissing, linkFileIfMissing, writeSecretFileIfMissing } from './utils/auth_files.mjs';
35
- import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './utils/auth_sources.mjs';
36
- import { resolveAuthSeedFromEnv } from './utils/stack_startup.mjs';
37
- import { getHomeEnvLocalPath } from './utils/config.mjs';
38
- import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
39
- import { resolveHandyMasterSecretFromStack } from './utils/handy_master_secret.mjs';
34
+ import { ensureEnvFilePruned, ensureEnvFileUpdated } from './utils/env/env_file.mjs';
35
+ import { listAllStackNames, stackExistsSync } from './utils/stack/stacks.mjs';
36
+ import { stopStackWithEnv } from './utils/stack/stop.mjs';
37
+ import { writeDevAuthKey } from './utils/auth/dev_key.mjs';
38
+ import { startDevServer } from './utils/dev/server.mjs';
39
+ import { ensureDevExpoServer } from './utils/dev/expo_dev.mjs';
40
+ import { requireDir } from './utils/proc/pm.mjs';
41
+ import { waitForHttpOk } from './utils/server/server.mjs';
42
+ import { resolveLocalhostHost, preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
43
+ import { openUrlInBrowser } from './utils/ui/browser.mjs';
44
+ import { copyFileIfMissing, linkFileIfMissing, writeSecretFileIfMissing } from './utils/auth/files.mjs';
45
+ import { getLegacyHappyBaseDir, isLegacyAuthSourceName } from './utils/auth/sources.mjs';
46
+ import { resolveAuthSeedFromEnv } from './utils/stack/startup.mjs';
47
+ import { getHomeEnvLocalPath } from './utils/env/config.mjs';
48
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
49
+ import { resolveHandyMasterSecretFromStack } from './utils/auth/handy_master_secret.mjs';
50
+ import { readPinnedServerPortFromEnvFile } from './utils/server/port.mjs';
51
+ import { getEnvValue, getEnvValueAny } from './utils/env/values.mjs';
52
+ import { sanitizeDnsLabel } from './utils/net/dns.mjs';
53
+ import { coercePort, listPortsFromEnvObject, STACK_RESERVED_PORT_KEYS } from './utils/server/port.mjs';
40
54
  import {
41
55
  deleteStackRuntimeStateFile,
42
56
  getStackRuntimeStatePath,
43
57
  isPidAlive,
44
58
  recordStackRuntimeStart,
45
59
  readStackRuntimeStateFile,
46
- } from './utils/stack_runtime_state.mjs';
47
- import { killPid } from './utils/expo.mjs';
48
- import { killPidOwnedByStack } from './utils/ownership.mjs';
49
-
50
- function getEnvValue(obj, key) {
51
- const v = (obj?.[key] ?? '').toString().trim();
52
- return v || '';
53
- }
54
-
55
- function getEnvValueAny(obj, keys) {
56
- for (const k of keys) {
57
- const v = getEnvValue(obj, k);
58
- if (v) return v;
59
- }
60
- return '';
61
- }
60
+ } from './utils/stack/runtime_state.mjs';
61
+ import { killPid } from './utils/expo/expo.mjs';
62
+ import { getCliHomeDirFromEnvOrDefault, getServerLightDataDirFromEnvOrDefault } from './utils/stack/dirs.mjs';
63
+ import { randomToken } from './utils/crypto/tokens.mjs';
64
+ import { killPidOwnedByStack, killProcessGroupOwnedByStack } from './utils/proc/ownership.mjs';
65
+ import { sanitizeSlugPart } from './utils/git/refs.mjs';
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';
62
69
 
63
70
  function stackNameFromArg(positionals, idx) {
64
71
  const name = positionals[idx]?.trim() ? positionals[idx].trim() : '';
65
72
  return name;
66
73
  }
67
74
 
68
- function getStackDir(stackName) {
69
- return resolveStackEnvPath(stackName).baseDir;
70
- }
71
-
72
- function getStackEnvPath(stackName) {
73
- return resolveStackEnvPath(stackName).envPath;
74
- }
75
-
76
- function getDefaultPortStart() {
75
+ function getDefaultPortStart(stackName = null) {
77
76
  const raw = process.env.HAPPY_STACKS_STACK_PORT_START?.trim()
78
77
  ? process.env.HAPPY_STACKS_STACK_PORT_START.trim()
79
78
  : process.env.HAPPY_LOCAL_STACK_PORT_START?.trim()
80
79
  ? process.env.HAPPY_LOCAL_STACK_PORT_START.trim()
81
80
  : '';
82
- const n = raw ? Number(raw) : 3005;
83
- 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;
84
88
  }
85
89
 
86
90
  async function isPortFree(port) {
@@ -97,34 +101,14 @@ async function pickNextFreePort(startPort, { reservedPorts = new Set() } = {}) {
97
101
  }
98
102
 
99
103
  async function readPortFromEnvFile(envPath) {
100
- const raw = await readExistingEnv(envPath);
101
- if (!raw.trim()) return null;
102
- const parsed = parseEnvToObject(raw);
103
- const portRaw = (parsed.HAPPY_STACKS_SERVER_PORT ?? parsed.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim();
104
- const n = portRaw ? Number(portRaw) : NaN;
105
- return Number.isFinite(n) && n > 0 ? n : null;
104
+ return await readPinnedServerPortFromEnvFile(envPath);
106
105
  }
107
106
 
108
107
  async function readPortsFromEnvFile(envPath) {
109
108
  const raw = await readExistingEnv(envPath);
110
109
  if (!raw.trim()) return [];
111
110
  const parsed = parseEnvToObject(raw);
112
- const keys = [
113
- 'HAPPY_STACKS_SERVER_PORT',
114
- 'HAPPY_LOCAL_SERVER_PORT',
115
- 'HAPPY_STACKS_HAPPY_SERVER_BACKEND_PORT',
116
- 'HAPPY_STACKS_PG_PORT',
117
- 'HAPPY_STACKS_REDIS_PORT',
118
- 'HAPPY_STACKS_MINIO_PORT',
119
- 'HAPPY_STACKS_MINIO_CONSOLE_PORT',
120
- ];
121
- const ports = [];
122
- for (const k of keys) {
123
- const rawV = (parsed[k] ?? '').toString().trim();
124
- const n = rawV ? Number(rawV) : NaN;
125
- if (Number.isFinite(n) && n > 0) ports.push(n);
126
- }
127
- return ports;
111
+ return listPortsFromEnvObject(parsed, STACK_RESERVED_PORT_KEYS);
128
112
  }
129
113
 
130
114
  async function collectReservedStackPorts({ excludeStackName = null } = {}) {
@@ -159,54 +143,7 @@ async function collectReservedStackPorts({ excludeStackName = null } = {}) {
159
143
  return reserved;
160
144
  }
161
145
 
162
- function base64Url(buf) {
163
- return Buffer.from(buf)
164
- .toString('base64')
165
- .replaceAll('+', '-')
166
- .replaceAll('/', '_')
167
- .replaceAll('=', '');
168
- }
169
-
170
- function randomToken(lenBytes = 24) {
171
- return base64Url(randomBytes(lenBytes));
172
- }
173
-
174
- function sanitizeDnsLabel(raw, { fallback = 'happy' } = {}) {
175
- const s = String(raw ?? '')
176
- .toLowerCase()
177
- .replace(/[^a-z0-9-]+/g, '-')
178
- .replace(/-+/g, '-')
179
- .replace(/^-+/, '')
180
- .replace(/-+$/, '');
181
- return s || fallback;
182
- }
183
-
184
- async function ensureDir(p) {
185
- await mkdir(p, { recursive: true });
186
- }
187
-
188
- async function readTextIfExists(path) {
189
- try {
190
- if (!existsSync(path)) return null;
191
- const raw = await readFile(path, 'utf-8');
192
- const t = raw.trim();
193
- return t ? t : null;
194
- } catch {
195
- return null;
196
- }
197
- }
198
-
199
- // auth file copy/link helpers live in scripts/utils/auth_files.mjs
200
-
201
- function getCliHomeDirFromEnvOrDefault({ stackBaseDir, env }) {
202
- const fromEnv = (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').trim();
203
- return fromEnv || join(stackBaseDir, 'cli');
204
- }
205
-
206
- function getServerLightDataDirFromEnvOrDefault({ stackBaseDir, env }) {
207
- const fromEnv = (env.HAPPY_SERVER_LIGHT_DATA_DIR ?? '').trim();
208
- return fromEnv || join(stackBaseDir, 'server-light');
209
- }
146
+ // auth file copy/link helpers live in scripts/utils/auth/files.mjs
210
147
 
211
148
  async function copyAuthFromStackIntoNewStack({
212
149
  fromStackName,
@@ -255,8 +192,8 @@ async function copyAuthFromStackIntoNewStack({
255
192
  'If you really want this, set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
256
193
  );
257
194
  }
258
- const sourceBaseDir = legacy ? getLegacyHappyBaseDir() : getStackDir(fromStackName);
259
- const sourceEnvRaw = legacy ? '' : await readExistingEnv(getStackEnvPath(fromStackName));
195
+ const sourceBaseDir = legacy ? getLegacyHappyBaseDir() : resolveStackEnvPath(fromStackName).baseDir;
196
+ const sourceEnvRaw = legacy ? '' : await readExistingEnv(resolveStackEnvPath(fromStackName).envPath);
260
197
  const sourceEnv = parseEnvToObject(sourceEnvRaw);
261
198
  const sourceCli = legacy ? join(sourceBaseDir, 'cli') : getCliHomeDirFromEnvOrDefault({ stackBaseDir: sourceBaseDir, env: sourceEnv });
262
199
  const targetCli = stackEnv.HAPPY_STACKS_CLI_HOME_DIR;
@@ -302,25 +239,7 @@ function stringifyEnv(env) {
302
239
  return lines.join('\n') + '\n';
303
240
  }
304
241
 
305
- async function readExistingEnv(path) {
306
- try {
307
- const raw = await readFile(path, 'utf-8');
308
- return raw;
309
- } catch {
310
- return '';
311
- }
312
- }
313
-
314
- function parseEnvToObject(raw) {
315
- const parsed = parseDotenv(raw);
316
- return Object.fromEntries(parsed.entries());
317
- }
318
-
319
- function stackExistsSync(stackName) {
320
- if (stackName === 'main') return true;
321
- const envPath = getStackEnvPath(stackName);
322
- return existsSync(envPath);
323
- }
242
+ const readExistingEnv = readTextOrEmpty;
324
243
 
325
244
  function resolveDefaultComponentDirs({ rootDir }) {
326
245
  const componentNames = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
@@ -328,16 +247,20 @@ function resolveDefaultComponentDirs({ rootDir }) {
328
247
  for (const name of componentNames) {
329
248
  const embedded = join(rootDir, 'components', name);
330
249
  const workspace = join(getComponentsDir(rootDir), name);
331
- 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;
332
255
  out[`HAPPY_STACKS_COMPONENT_DIR_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`] = dir;
333
256
  }
334
257
  return out;
335
258
  }
336
259
 
337
260
  async function writeStackEnv({ stackName, env }) {
338
- const stackDir = getStackDir(stackName);
261
+ const stackDir = resolveStackEnvPath(stackName).baseDir;
339
262
  await ensureDir(stackDir);
340
- const envPath = getStackEnvPath(stackName);
263
+ const envPath = resolveStackEnvPath(stackName).envPath;
341
264
  const next = stringifyEnv(env);
342
265
  const existing = await readExistingEnv(envPath);
343
266
  if (existing !== next) {
@@ -347,7 +270,7 @@ async function writeStackEnv({ stackName, env }) {
347
270
  }
348
271
 
349
272
  async function withStackEnv({ stackName, fn, extraEnv = {} }) {
350
- const envPath = getStackEnvPath(stackName);
273
+ const envPath = resolveStackEnvPath(stackName).envPath;
351
274
  if (!stackExistsSync(stackName)) {
352
275
  throw new Error(
353
276
  `[stack] stack "${stackName}" does not exist yet.\n` +
@@ -361,9 +284,46 @@ async function withStackEnv({ stackName, fn, extraEnv = {} }) {
361
284
  // exported in their shell, it would otherwise "win" because utils/env.mjs only sets
362
285
  // env vars if they are missing/empty.
363
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
+ ]);
364
325
  for (const k of Object.keys(cleaned)) {
365
- if (k === 'HAPPY_LOCAL_ENV_FILE' || k === 'HAPPY_STACKS_ENV_FILE') continue;
366
- if (k === 'HAPPY_LOCAL_STACK' || k === 'HAPPY_STACKS_STACK') continue;
326
+ if (keepPrefixed.has(k)) continue;
367
327
  if (k.startsWith('HAPPY_LOCAL_') || k.startsWith('HAPPY_STACKS_')) {
368
328
  delete cleaned[k];
369
329
  }
@@ -604,7 +564,7 @@ async function cmdNew({ rootDir, argv, emit = true }) {
604
564
  throw new Error(`[stack] invalid server component: ${serverComponent}`);
605
565
  }
606
566
 
607
- const baseDir = getStackDir(stackName);
567
+ const baseDir = resolveStackEnvPath(stackName).baseDir;
608
568
  const uiBuildDir = join(baseDir, 'ui');
609
569
  const cliHomeDir = join(baseDir, 'cli');
610
570
 
@@ -804,7 +764,7 @@ async function cmdEdit({ rootDir, argv }) {
804
764
  throw new Error('[stack] usage: happys stack edit <name> [--interactive]');
805
765
  }
806
766
 
807
- const envPath = getStackEnvPath(stackName);
767
+ const envPath = resolveStackEnvPath(stackName).envPath;
808
768
  const raw = await readExistingEnv(envPath);
809
769
  const existingEnv = parseEnvToObject(raw);
810
770
 
@@ -829,7 +789,7 @@ async function cmdEdit({ rootDir, argv }) {
829
789
  const config = await withRl((rl) => interactiveEdit({ rootDir, rl, stackName, existingEnv, defaults }));
830
790
 
831
791
  // Build next env, starting from existing env but enforcing stack-scoped invariants.
832
- const baseDir = getStackDir(stackName);
792
+ const baseDir = resolveStackEnvPath(stackName).baseDir;
833
793
  const uiBuildDir = join(baseDir, 'ui');
834
794
  const cliHomeDir = join(baseDir, 'cli');
835
795
 
@@ -948,7 +908,7 @@ async function cmdEdit({ rootDir, argv }) {
948
908
  printResult({ json, data: { stackName, envPath: wrote, port, serverComponent }, text: `[stack] updated ${stackName}\n[stack] env: ${wrote}` });
949
909
  }
950
910
 
951
- async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {} }) {
911
+ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {}, background = false }) {
952
912
  await withStackEnv({
953
913
  stackName,
954
914
  extraEnv,
@@ -979,6 +939,29 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
979
939
  // True restart = there was an active runner for this stack. If the stack is not running,
980
940
  // `--restart` should behave like a normal start (allocate new ephemeral ports if needed).
981
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
+ }
982
965
  if (wasRunning) {
983
966
  if (!wantsRestart) {
984
967
  const serverPart = Number.isFinite(existingPort) && existingPort > 0 ? ` server=${existingPort}` : '';
@@ -1010,12 +993,16 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
1010
993
  } else if (scriptPath === 'dev.mjs') {
1011
994
  console.log(`[stack] ${stackName}: ui: unknown (missing expo.webPort in stack.runtime.json)`);
1012
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
+ }
1013
1003
  return;
1014
1004
  }
1015
- // Restart: stop the existing runner first.
1016
- await killPidOwnedByStack(existingOwnerPid, { stackName, envPath, cliHomeDir: (env.HAPPY_STACKS_CLI_HOME_DIR ?? env.HAPPY_LOCAL_CLI_HOME_DIR ?? '').toString(), label: 'runner', json: false });
1017
- // Clear runtime state so we don't keep stale process PIDs; we'll re-create it for the new run below.
1018
- await deleteStackRuntimeStateFile(runtimeStatePath);
1005
+ // Restart: already handled above (stopStackWithEnv is ownership-gated).
1019
1006
  }
1020
1007
 
1021
1008
  // Ephemeral ports: allocate at start time, store only in runtime state (not in stack env).
@@ -1038,7 +1025,7 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
1038
1025
  }
1039
1026
  }
1040
1027
 
1041
- const startPort = getDefaultPortStart();
1028
+ const startPort = getDefaultPortStart(stackName);
1042
1029
  const ports = {};
1043
1030
 
1044
1031
  const parsePortOrNull = (v) => {
@@ -1083,6 +1070,42 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
1083
1070
  for (const p of toCheck) {
1084
1071
  // eslint-disable-next-line no-await-in-loop
1085
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
+ }
1086
1109
  throw new Error(
1087
1110
  `[stack] ${stackName}: cannot reuse port ${p} on restart (port is not free).\n` +
1088
1111
  `[stack] Fix: stop the process using it, or re-run without --restart to allocate new ports.`
@@ -1140,13 +1163,61 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
1140
1163
  : {}),
1141
1164
  };
1142
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
+
1143
1208
  // Spawn the runner (long-lived) and record its pid + ports for other stack-scoped commands.
1144
1209
  const child = spawn(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], {
1145
1210
  cwd: rootDir,
1146
1211
  env: childEnv,
1147
- stdio: 'inherit',
1212
+ stdio: background ? ['ignore', outFd ?? 'ignore', outFd ?? 'ignore'] : 'inherit',
1148
1213
  shell: false,
1214
+ detached: background && process.platform !== 'win32',
1149
1215
  });
1216
+ try {
1217
+ await logHandle?.close();
1218
+ } catch {
1219
+ // ignore
1220
+ }
1150
1221
 
1151
1222
  // Record the chosen ports immediately (before the runner finishes booting), so other stack commands
1152
1223
  // can resolve the correct endpoints and `--restart` can reliably reuse the same ports.
@@ -1156,8 +1227,104 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
1156
1227
  ephemeral: true,
1157
1228
  ownerPid: child.pid,
1158
1229
  ports,
1230
+ ...(background ? { logs: { runner: logPath } } : {}),
1159
1231
  }).catch(() => {});
1160
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
+
1161
1328
  try {
1162
1329
  await new Promise((resolvePromise, rejectPromise) => {
1163
1330
  child.on('error', rejectPromise);
@@ -1176,6 +1343,28 @@ async function cmdRunScript({ rootDir, stackName, scriptPath, args, extraEnv = {
1176
1343
  }
1177
1344
 
1178
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
+ }
1179
1368
  await run(process.execPath, [join(rootDir, 'scripts', scriptPath), ...args], { cwd: rootDir, env });
1180
1369
  },
1181
1370
  });
@@ -1219,9 +1408,25 @@ async function cmdService({ rootDir, stackName, svcCmd }) {
1219
1408
  });
1220
1409
  }
1221
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
+
1222
1425
  async function cmdTailscale({ rootDir, stackName, subcmd, args }) {
1426
+ const extraEnv = await getRuntimePortExtraEnv(stackName);
1223
1427
  await withStackEnv({
1224
1428
  stackName,
1429
+ ...(extraEnv ? { extraEnv } : {}),
1225
1430
  fn: async ({ env }) => {
1226
1431
  await run(process.execPath, [join(rootDir, 'scripts', 'tailscale.mjs'), subcmd, ...args], { cwd: rootDir, env });
1227
1432
  },
@@ -1256,8 +1461,10 @@ async function cmdAuth({ rootDir, stackName, args }) {
1256
1461
  // Forward to scripts/auth.mjs under the stack env.
1257
1462
  // This makes `happys stack auth <name> ...` resolve CLI home/urls for that stack.
1258
1463
  const forwarded = args[0] === '--' ? args.slice(1) : args;
1464
+ const extraEnv = await getRuntimePortExtraEnv(stackName);
1259
1465
  await withStackEnv({
1260
1466
  stackName,
1467
+ ...(extraEnv ? { extraEnv } : {}),
1261
1468
  fn: async ({ env }) => {
1262
1469
  await run(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...forwarded], { cwd: rootDir, env });
1263
1470
  },
@@ -1892,9 +2099,9 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
1892
2099
 
1893
2100
  const serverPort = await pickNextFreeTcpPort(3005, { host: '127.0.0.1' });
1894
2101
  const internalServerUrl = `http://127.0.0.1:${serverPort}`;
1895
- const publicServerUrl = `http://localhost:${serverPort}`;
2102
+ const publicServerUrl = await preferStackLocalhostUrl(`http://localhost:${serverPort}`, { stackName: name });
1896
2103
 
1897
- const autostart = { stackName: name, baseDir: getStackDir(name) };
2104
+ const autostart = { stackName: name, baseDir: resolveStackEnvPath(name).baseDir };
1898
2105
  const children = [];
1899
2106
 
1900
2107
  await withStackEnv({
@@ -1912,9 +2119,14 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
1912
2119
  serverComponent === 'happy-server'
1913
2120
  ? env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER
1914
2121
  : env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT;
1915
- const resolvedServerDir = serverDir || getComponentDir(rootDir, serverComponent);
1916
- const resolvedCliDir = env.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI || getComponentDir(rootDir, 'happy-cli');
1917
- 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');
1918
2130
 
1919
2131
  await requireDir(serverComponent, resolvedServerDir);
1920
2132
  await requireDir('happy-cli', resolvedCliDir);
@@ -1941,9 +2153,10 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
1941
2153
  });
1942
2154
  serverProc = started.serverProc;
1943
2155
 
1944
- // Start Expo web UI so /terminal/connect exists for happy-cli web auth.
1945
- const uiRes = await startDevExpoWebUi({
2156
+ // Start Expo (web) so /terminal/connect exists for happy-cli web auth.
2157
+ const uiRes = await ensureDevExpoServer({
1946
2158
  startUi: true,
2159
+ startMobile: false,
1947
2160
  uiDir: resolvedUiDir,
1948
2161
  autostart,
1949
2162
  baseEnv: env,
@@ -1962,10 +2175,9 @@ async function cmdCreateDevAuthSeed({ rootDir, argv }) {
1962
2175
  }
1963
2176
 
1964
2177
  console.log('');
1965
- const uiHost = `happy-${sanitizeDnsLabel(name)}.localhost`;
1966
2178
  const uiPort = uiRes?.port;
1967
- const uiRoot = Number.isFinite(uiPort) && uiPort > 0 ? `http://${uiHost}:${uiPort}` : null;
1968
2179
  const uiRootLocalhost = Number.isFinite(uiPort) && uiPort > 0 ? `http://localhost:${uiPort}` : null;
2180
+ const uiRoot = uiRootLocalhost ? await preferStackLocalhostUrl(uiRootLocalhost, { stackName: name }) : null;
1969
2181
  const uiSettings = uiRoot ? `${uiRoot}/settings/account` : null;
1970
2182
 
1971
2183
  console.log('[stack] step 1/3: create a dev-auth account in the UI (this generates the dev key)');
@@ -2092,7 +2304,7 @@ function parseServerComponentFromEnv(env) {
2092
2304
  }
2093
2305
 
2094
2306
  async function readStackEnvObject(stackName) {
2095
- const envPath = getStackEnvPath(stackName);
2307
+ const envPath = resolveStackEnvPath(stackName).envPath;
2096
2308
  const raw = await readExistingEnv(envPath);
2097
2309
  const env = raw ? parseEnvToObject(raw) : {};
2098
2310
  return { envPath, env };
@@ -2107,16 +2319,6 @@ function envKeyForComponentDir({ serverComponent, component }) {
2107
2319
  return `HAPPY_STACKS_COMPONENT_DIR_${component.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`;
2108
2320
  }
2109
2321
 
2110
- function sanitizeSlugPart(s) {
2111
- return String(s ?? '')
2112
- .trim()
2113
- .toLowerCase()
2114
- .replace(/[^a-z0-9._/-]+/g, '-')
2115
- .replace(/-+/g, '-')
2116
- .replace(/^-+/, '')
2117
- .replace(/-+$/, '');
2118
- }
2119
-
2120
2322
  async function cmdDuplicate({ rootDir, argv }) {
2121
2323
  const { flags, kv } = parseArgs(argv);
2122
2324
  const json = wantsJson(argv, { flags });
@@ -2165,14 +2367,14 @@ async function cmdDuplicate({ rootDir, argv }) {
2165
2367
  if (!rawDir) continue;
2166
2368
 
2167
2369
  let nextDir = rawDir;
2168
- if (duplicateWorktrees && isComponentWorktreePath({ rootDir, component, dir: rawDir })) {
2169
- 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 });
2170
2372
  if (spec) {
2171
2373
  const [owner, ...restParts] = spec.split('/').filter(Boolean);
2172
2374
  const rest = restParts.join('/');
2173
2375
  const slug = `dup/${sanitizeSlugPart(toStack)}/${rest}`;
2174
2376
 
2175
- const repoDir = join(getComponentsDir(rootDir), component);
2377
+ const repoDir = join(getComponentsDir(rootDir, fromEnv), component);
2176
2378
  const remoteName = await inferRemoteNameForOwner({ repoDir, owner });
2177
2379
  // Base on the existing worktree's HEAD/branch so we get the same commit.
2178
2380
  nextDir = await createWorktreeFromBaseWorktree({
@@ -2182,6 +2384,7 @@ async function cmdDuplicate({ rootDir, argv }) {
2182
2384
  baseWorktreeSpec: spec,
2183
2385
  remoteName,
2184
2386
  depsMode,
2387
+ env: fromEnv,
2185
2388
  });
2186
2389
  }
2187
2390
  }
@@ -2190,7 +2393,7 @@ async function cmdDuplicate({ rootDir, argv }) {
2190
2393
  }
2191
2394
 
2192
2395
  // Apply component dir overrides to the destination stack env file.
2193
- const toEnvPath = getStackEnvPath(toStack);
2396
+ const toEnvPath = resolveStackEnvPath(toStack).envPath;
2194
2397
  if (updates.length) {
2195
2398
  await ensureEnvFileUpdated({ envPath: toEnvPath, updates });
2196
2399
  }
@@ -2267,13 +2470,14 @@ async function cmdPrStack({ rootDir, argv }) {
2267
2470
  json,
2268
2471
  data: {
2269
2472
  usage:
2270
- '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...>]',
2271
2474
  },
2272
2475
  text: [
2273
2476
  '[stack] usage:',
2274
2477
  ' happys stack pr <name> --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--dev|--start]',
2275
- ' [--seed-auth] [--copy-auth-from=<stack|legacy>] [--link-auth] [--with-infra] [--auth-force]',
2276
- ' [--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',
2277
2481
  ' [--json] [-- <stack dev/start args...>]',
2278
2482
  '',
2279
2483
  'examples:',
@@ -2288,7 +2492,7 @@ async function cmdPrStack({ rootDir, argv }) {
2288
2492
  ' happys stack pr pr123 --happy=123 --happy-cli=456 --seed-auth --copy-auth-from=dev-auth --dev',
2289
2493
  '',
2290
2494
  ' # Reuse an existing non-stacks Happy install for auth seeding',
2291
- ' 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',
2292
2496
  '',
2293
2497
  'notes:',
2294
2498
  ' - This composes existing commands: `happys stack new`, `happys stack wt ...`, and `happys stack auth ...`',
@@ -2316,7 +2520,7 @@ async function cmdPrStack({ rootDir, argv }) {
2316
2520
  );
2317
2521
  }
2318
2522
 
2319
- const remoteName = (kv.get('--remote') ?? '').trim() || 'upstream';
2523
+ const remoteNameFromArg = (kv.get('--remote') ?? '').trim();
2320
2524
  const depsMode = (kv.get('--deps') ?? '').trim();
2321
2525
 
2322
2526
  const prHappy = (kv.get('--happy') ?? '').trim();
@@ -2346,6 +2550,9 @@ async function cmdPrStack({ rootDir, argv }) {
2346
2550
  throw new Error('[stack] pr: choose either --dev or --start (not both)');
2347
2551
  }
2348
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
+
2349
2556
  const seedAuthFlag = flags.has('--seed-auth') ? true : flags.has('--no-seed-auth') ? false : null;
2350
2557
  const authFromFlag = (kv.get('--copy-auth-from') ?? '').trim();
2351
2558
  const withInfra = flags.has('--with-infra') || flags.has('--ensure-infra') || flags.has('--infra');
@@ -2482,6 +2689,7 @@ async function cmdPrStack({ rootDir, argv }) {
2482
2689
  ].filter((x) => x.pr);
2483
2690
 
2484
2691
  const worktrees = [];
2692
+ const stackEnvPath = resolveStackEnvPath(stackName).envPath;
2485
2693
  for (const { component, pr } of prSpecs) {
2486
2694
  progress(`[stack] pr: ${stackName}: fetching PR for ${component} (${pr})...`);
2487
2695
  const out = await withStackEnv({
@@ -2492,7 +2700,7 @@ async function cmdPrStack({ rootDir, argv }) {
2492
2700
  'pr',
2493
2701
  component,
2494
2702
  pr,
2495
- `--remote=${remoteName}`,
2703
+ ...(remoteNameFromArg ? [`--remote=${remoteNameFromArg}`] : []),
2496
2704
  '--use',
2497
2705
  ...(depsMode ? [`--deps=${depsMode}`] : []),
2498
2706
  ...(doUpdate ? ['--update'] : []),
@@ -2500,11 +2708,35 @@ async function cmdPrStack({ rootDir, argv }) {
2500
2708
  '--json',
2501
2709
  ];
2502
2710
  const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'worktrees.mjs'), ...args], { cwd: rootDir, env });
2503
- 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;
2504
2726
  },
2505
2727
  });
2506
- if (json) {
2728
+ if (out) {
2507
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
2508
2740
  } else if (out) {
2509
2741
  const short = (sha) => (sha ? String(sha).slice(0, 8) : '');
2510
2742
  const changed = Boolean(out.updated && out.oldHead && out.newHead && out.oldHead !== out.newHead);
@@ -2521,6 +2753,36 @@ async function cmdPrStack({ rootDir, argv }) {
2521
2753
  }
2522
2754
  }
2523
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
+
2524
2786
  // 3) Optional: seed auth (copies cli creds + master secret + DB Account rows).
2525
2787
  let auth = null;
2526
2788
  if (seedAuth) {
@@ -2533,8 +2795,10 @@ async function cmdPrStack({ rootDir, argv }) {
2533
2795
  ...(authLink ? ['--link'] : []),
2534
2796
  ];
2535
2797
  if (json) {
2798
+ const extraEnv = await getRuntimePortExtraEnv(stackName);
2536
2799
  auth = await withStackEnv({
2537
2800
  stackName,
2801
+ ...(extraEnv ? { extraEnv } : {}),
2538
2802
  fn: async ({ env }) => {
2539
2803
  const stdout = await runCapture(process.execPath, [join(rootDir, 'scripts', 'auth.mjs'), ...args, '--json'], { cwd: rootDir, env });
2540
2804
  return stdout.trim() ? JSON.parse(stdout.trim()) : null;
@@ -2549,12 +2813,18 @@ async function cmdPrStack({ rootDir, argv }) {
2549
2813
  // 4) Optional: start dev / start.
2550
2814
  if (wantsDev) {
2551
2815
  progress(`[stack] pr: ${stackName}: starting dev...`);
2552
- const args = passthrough.length ? ['--', ...passthrough] : [];
2553
- 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 });
2554
2821
  } else if (wantsStart) {
2555
2822
  progress(`[stack] pr: ${stackName}: starting...`);
2556
- const args = passthrough.length ? ['--', ...passthrough] : [];
2557
- 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 });
2558
2828
  }
2559
2829
 
2560
2830
  const info = await cmdInfoInternal({ rootDir, stackName });
@@ -2577,8 +2847,8 @@ async function cmdPrStack({ rootDir, argv }) {
2577
2847
 
2578
2848
  async function cmdInfoInternal({ rootDir, stackName }) {
2579
2849
  // Minimal extraction from cmdInfo to avoid re-parsing argv/printing. Used by cmdPrStack.
2580
- const baseDir = getStackDir(stackName);
2581
- const envPath = getStackEnvPath(stackName);
2850
+ const baseDir = resolveStackEnvPath(stackName).baseDir;
2851
+ const envPath = resolveStackEnvPath(stackName).envPath;
2582
2852
  const envRaw = await readExistingEnv(envPath);
2583
2853
  const stackEnv = envRaw ? parseEnvToObject(envRaw) : {};
2584
2854
  const runtimeStatePath = getStackRuntimeStatePath(stackName);
@@ -2587,6 +2857,9 @@ async function cmdInfoInternal({ rootDir, stackName }) {
2587
2857
  const serverComponent =
2588
2858
  getEnvValueAny(stackEnv, ['HAPPY_STACKS_SERVER_COMPONENT', 'HAPPY_LOCAL_SERVER_COMPONENT']) || 'happy-server-light';
2589
2859
 
2860
+ const stackRemote =
2861
+ getEnvValueAny(stackEnv, ['HAPPY_STACKS_STACK_REMOTE', 'HAPPY_LOCAL_STACK_REMOTE']) || 'upstream';
2862
+
2590
2863
  const pinnedServerPortRaw = getEnvValueAny(stackEnv, ['HAPPY_STACKS_SERVER_PORT', 'HAPPY_LOCAL_SERVER_PORT']);
2591
2864
  const pinnedServerPort = pinnedServerPortRaw ? Number(pinnedServerPortRaw) : null;
2592
2865
 
@@ -2604,10 +2877,15 @@ async function cmdInfoInternal({ rootDir, stackName }) {
2604
2877
  runtimeState?.expo && typeof runtimeState.expo === 'object' && Number(runtimeState.expo.webPort) > 0
2605
2878
  ? Number(runtimeState.expo.webPort)
2606
2879
  : null;
2880
+ const mobilePort =
2881
+ runtimeState?.expo && typeof runtimeState.expo === 'object' && Number(runtimeState.expo.mobilePort) > 0
2882
+ ? Number(runtimeState.expo.mobilePort)
2883
+ : null;
2607
2884
 
2608
2885
  const host = resolveLocalhostHost({ stackMode: true, stackName });
2609
2886
  const internalServerUrl = serverPort ? `http://127.0.0.1:${serverPort}` : null;
2610
2887
  const uiUrl = uiPort ? `http://${host}:${uiPort}` : null;
2888
+ const mobileUrl = mobilePort ? await preferStackLocalhostUrl(`http://localhost:${mobilePort}`, { stackName }) : null;
2611
2889
 
2612
2890
  const componentSpecs = [
2613
2891
  { component: 'happy', keys: ['HAPPY_STACKS_COMPONENT_DIR_HAPPY', 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY'] },
@@ -2632,6 +2910,7 @@ async function cmdInfoInternal({ rootDir, stackName }) {
2632
2910
  envPath,
2633
2911
  runtimeStatePath,
2634
2912
  serverComponent,
2913
+ stackRemote,
2635
2914
  pinned: {
2636
2915
  serverPort: Number.isFinite(pinnedServerPort) && pinnedServerPort > 0 ? pinnedServerPort : null,
2637
2916
  },
@@ -2649,16 +2928,43 @@ async function cmdInfoInternal({ rootDir, stackName }) {
2649
2928
  host,
2650
2929
  internalServerUrl,
2651
2930
  uiUrl,
2931
+ mobileUrl,
2652
2932
  },
2653
2933
  ports: {
2654
2934
  server: serverPort,
2655
2935
  backend: backendPort,
2656
2936
  ui: uiPort,
2937
+ mobile: mobilePort,
2657
2938
  },
2658
2939
  components,
2659
2940
  };
2660
2941
  }
2661
2942
 
2943
+ async function cmdStackCodeOrCursor({ rootDir, stackName, json, editor, includeStackDir, includeAllComponents, includeCliHome }) {
2944
+ const ws = await writeStackCodeWorkspace({ rootDir, stackName, includeStackDir, includeAllComponents, includeCliHome });
2945
+
2946
+ if (json) {
2947
+ printResult({
2948
+ json,
2949
+ data: {
2950
+ ok: true,
2951
+ stackName,
2952
+ editor,
2953
+ ...ws,
2954
+ },
2955
+ });
2956
+ return;
2957
+ }
2958
+
2959
+ await openWorkspaceInEditor({ rootDir, editor, workspacePath: ws.workspacePath });
2960
+ console.log(`[stack] opened ${editor === 'code' ? 'VS Code' : 'Cursor'} workspace for "${stackName}": ${ws.workspacePath}`);
2961
+ }
2962
+
2963
+ async function cmdStackOpen({ rootDir, stackName, json, includeStackDir, includeAllComponents, includeCliHome }) {
2964
+ const editor = (await isCursorInstalled({ cwd: rootDir, env: process.env })) ? 'cursor' : 'code';
2965
+ await cmdStackCodeOrCursor({ rootDir, stackName, json, editor, includeStackDir, includeAllComponents, includeCliHome });
2966
+ }
2967
+
2662
2968
  async function main() {
2663
2969
  const rootDir = getRootDir(import.meta.url);
2664
2970
  // pnpm (legacy) passes an extra leading `--` when forwarding args into scripts. Normalize it away so
@@ -2695,13 +3001,19 @@ async function main() {
2695
3001
  'dev',
2696
3002
  'start',
2697
3003
  'build',
3004
+ 'review',
2698
3005
  'typecheck',
2699
3006
  'lint',
2700
3007
  'test',
2701
3008
  'doctor',
2702
3009
  'mobile',
3010
+ 'mobile:install',
3011
+ 'mobile-dev-client',
2703
3012
  'resume',
2704
3013
  'stop',
3014
+ 'code',
3015
+ 'cursor',
3016
+ 'open',
2705
3017
  'srv',
2706
3018
  'wt',
2707
3019
  'tailscale:*',
@@ -2723,13 +3035,19 @@ async function main() {
2723
3035
  ' happys stack dev <name> [-- ...]',
2724
3036
  ' happys stack start <name> [-- ...]',
2725
3037
  ' happys stack build <name> [-- ...]',
3038
+ ' happys stack review <name> [component...] [--reviewers=coderabbit,codex] [--base-remote=<remote>] [--base-branch=<branch>] [--base-ref=<ref>] [--json]',
2726
3039
  ' happys stack typecheck <name> [component...] [--json]',
2727
3040
  ' happys stack lint <name> [component...] [--json]',
2728
3041
  ' happys stack test <name> [component...] [--json]',
2729
3042
  ' happys stack doctor <name> [-- ...]',
2730
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]',
2731
3046
  ' happys stack resume <name> <sessionId...> [--json]',
2732
3047
  ' happys stack stop <name> [--aggressive] [--sweep-owned] [--no-docker] [--json]',
3048
+ ' happys stack code <name> [--no-stack-dir] [--include-all-components] [--include-cli-home] [--json]',
3049
+ ' happys stack cursor <name> [--no-stack-dir] [--include-all-components] [--include-cli-home] [--json]',
3050
+ ' happys stack open <name> [--no-stack-dir] [--include-all-components] [--include-cli-home] [--json] # prefer Cursor, else VS Code',
2733
3051
  ' happys stack srv <name> -- status|use ...',
2734
3052
  ' happys stack wt <name> -- <wt args...>',
2735
3053
  ' happys stack tailscale:status|enable|disable|url <name> [-- ...]',
@@ -2858,11 +3176,15 @@ async function main() {
2858
3176
  const passthrough = argv.slice(2);
2859
3177
 
2860
3178
  if (cmd === 'dev') {
2861
- 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 });
2862
3182
  return;
2863
3183
  }
2864
3184
  if (cmd === 'start') {
2865
- 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 });
2866
3188
  return;
2867
3189
  }
2868
3190
  if (cmd === 'build') {
@@ -2889,6 +3211,12 @@ async function main() {
2889
3211
  await cmdRunScript({ rootDir, stackName, scriptPath: 'test.mjs', args: passthrough, extraEnv: overrides });
2890
3212
  return;
2891
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
+ }
2892
3220
  if (cmd === 'doctor') {
2893
3221
  await cmdRunScript({ rootDir, stackName, scriptPath: 'doctor.mjs', args: passthrough });
2894
3222
  return;
@@ -2897,6 +3225,62 @@ async function main() {
2897
3225
  await cmdRunScript({ rootDir, stackName, scriptPath: 'mobile.mjs', args: passthrough });
2898
3226
  return;
2899
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
+ }
2900
3284
  if (cmd === 'resume') {
2901
3285
  const sessionIds = passthrough.filter((a) => a && a !== '--' && !a.startsWith('--'));
2902
3286
  if (sessionIds.length === 0) {
@@ -2913,7 +3297,9 @@ async function main() {
2913
3297
  const out = await withStackEnv({
2914
3298
  stackName,
2915
3299
  fn: async ({ env }) => {
2916
- 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');
2917
3303
  const happyBin = join(cliDir, 'bin', 'happy.mjs');
2918
3304
  // Run stack-scoped happy-cli and ask the stack daemon to resume these sessions.
2919
3305
  return await run(process.execPath, [happyBin, 'daemon', 'resume', ...sessionIds], { cwd: rootDir, env });
@@ -2928,7 +3314,7 @@ async function main() {
2928
3314
  const noDocker = stopFlags.has('--no-docker');
2929
3315
  const aggressive = stopFlags.has('--aggressive');
2930
3316
  const sweepOwned = stopFlags.has('--sweep-owned');
2931
- const baseDir = getStackDir(stackName);
3317
+ const baseDir = resolveStackEnvPath(stackName).baseDir;
2932
3318
  const out = await withStackEnv({
2933
3319
  stackName,
2934
3320
  fn: async ({ env }) => {
@@ -2939,6 +3325,28 @@ async function main() {
2939
3325
  return;
2940
3326
  }
2941
3327
 
3328
+ if (cmd === 'code') {
3329
+ const includeStackDir = !flags.has('--no-stack-dir');
3330
+ const includeAllComponents = flags.has('--include-all-components');
3331
+ const includeCliHome = flags.has('--include-cli-home');
3332
+ await cmdStackCodeOrCursor({ rootDir, stackName, json, editor: 'code', includeStackDir, includeAllComponents, includeCliHome });
3333
+ return;
3334
+ }
3335
+ if (cmd === 'cursor') {
3336
+ const includeStackDir = !flags.has('--no-stack-dir');
3337
+ const includeAllComponents = flags.has('--include-all-components');
3338
+ const includeCliHome = flags.has('--include-cli-home');
3339
+ await cmdStackCodeOrCursor({ rootDir, stackName, json, editor: 'cursor', includeStackDir, includeAllComponents, includeCliHome });
3340
+ return;
3341
+ }
3342
+ if (cmd === 'open') {
3343
+ const includeStackDir = !flags.has('--no-stack-dir');
3344
+ const includeAllComponents = flags.has('--include-all-components');
3345
+ const includeCliHome = flags.has('--include-cli-home');
3346
+ await cmdStackOpen({ rootDir, stackName, json, includeStackDir, includeAllComponents, includeCliHome });
3347
+ return;
3348
+ }
3349
+
2942
3350
  if (cmd === 'srv') {
2943
3351
  await cmdSrv({ rootDir, stackName, args: passthrough });
2944
3352
  return;