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
@@ -1,59 +1,207 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
4
- import { getRootDir, resolveStackEnvPath } from './utils/paths.mjs';
5
- import { run } from './utils/proc.mjs';
6
- import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
4
+ import { isTty } from './utils/cli/wizard.mjs';
5
+ import { getVerbosityLevel } from './utils/cli/verbosity.mjs';
6
+ import { createStepPrinter, runCommandLogged } from './utils/cli/progress.mjs';
7
+ import { createFileLogForwarder } from './utils/cli/log_forwarder.mjs';
8
+ import { assertCliPrereqs } from './utils/cli/prereqs.mjs';
9
+ import { decidePrAuthPlan } from './utils/auth/guided_pr_auth.mjs';
10
+ import { assertExpoWebappBundlesOrThrow, guidedStackAuthLoginNow, resolveStackWebappUrlForAuth } from './utils/auth/stack_guided_login.mjs';
11
+ import { preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
12
+ import { getRootDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
13
+ import { run } from './utils/proc/proc.mjs';
14
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/env/sandbox.mjs';
15
+ import { sanitizeStackName } from './utils/stack/names.mjs';
16
+ import { getStackRuntimeStatePath, readStackRuntimeStateFile } from './utils/stack/runtime_state.mjs';
17
+ import { readEnvObjectFromFile } from './utils/env/read.mjs';
18
+ import { checkDaemonState, startLocalDaemonWithAuth } from './daemon.mjs';
7
19
  import { existsSync } from 'node:fs';
8
- import { homedir } from 'node:os';
9
20
  import { join } from 'node:path';
21
+ import { homedir } from 'node:os';
22
+ import { resolveMobileQrPayload } from './utils/mobile/dev_client_links.mjs';
23
+ import { renderQrAscii } from './utils/ui/qr.mjs';
24
+ import { inferPrStackBaseName } from './utils/stack/pr_stack_name.mjs';
25
+
26
+ function supportsAnsi() {
27
+ if (!process.stdout.isTTY) return false;
28
+ if (process.env.NO_COLOR) return false;
29
+ if ((process.env.TERM ?? '').toLowerCase() === 'dumb') return false;
30
+ return true;
31
+ }
32
+
33
+ function bold(s) {
34
+ return supportsAnsi() ? `\x1b[1m${s}\x1b[0m` : String(s);
35
+ }
10
36
 
11
- function sanitizeStackName(raw) {
12
- const s = String(raw ?? '')
13
- .trim()
14
- .toLowerCase()
15
- .replace(/[^a-z0-9-]+/g, '-')
16
- .replace(/-+/g, '-')
17
- .replace(/^-+/, '')
18
- .replace(/-+$/, '');
19
- return s || 'pr';
37
+ function dim(s) {
38
+ return supportsAnsi() ? `\x1b[2m${s}\x1b[0m` : String(s);
20
39
  }
21
40
 
22
- function parseGithubPullRequestNumber(input) {
23
- const raw = String(input ?? '').trim();
24
- if (!raw) return null;
25
- if (/^\d+$/.test(raw)) return Number(raw);
26
- const m = raw.match(/github\.com\/[^/]+\/[^/]+\/pull\/(?<num>\d+)/);
27
- return m?.groups?.num ? Number(m.groups.num) : null;
41
+ function cyan(s) {
42
+ return supportsAnsi() ? `\x1b[36m${s}\x1b[0m` : String(s);
43
+ }
44
+
45
+ function green(s) {
46
+ return supportsAnsi() ? `\x1b[32m${s}\x1b[0m` : String(s);
47
+ }
48
+
49
+ function pickReviewerMobileSchemeEnv(env) {
50
+ // For review-pr flows, reviewers typically have the standard Happy dev build on their phone,
51
+ // so default to the canonical `happy://` scheme unless the user explicitly configured one.
52
+ // If the user explicitly set a review-specific override, honor it.
53
+ const reviewOverride = (env.HAPPY_STACKS_REVIEW_MOBILE_SCHEME ?? env.HAPPY_LOCAL_REVIEW_MOBILE_SCHEME ?? '').toString().trim();
54
+ if (reviewOverride) {
55
+ return { ...env, HAPPY_STACKS_MOBILE_SCHEME: reviewOverride, HAPPY_LOCAL_MOBILE_SCHEME: reviewOverride };
56
+ }
57
+
58
+ // In sandbox review flows, prefer the standard Happy dev build scheme even if the user's global
59
+ // dev-client scheme is configured for Happy Stacks.
60
+ if (isSandboxed()) {
61
+ return { ...env, HAPPY_STACKS_MOBILE_SCHEME: 'happy', HAPPY_LOCAL_MOBILE_SCHEME: 'happy' };
62
+ }
63
+
64
+ // Non-sandbox: keep existing behavior unless nothing is configured at all.
65
+ const explicit =
66
+ (env.HAPPY_STACKS_MOBILE_SCHEME ??
67
+ env.HAPPY_LOCAL_MOBILE_SCHEME ??
68
+ env.HAPPY_STACKS_DEV_CLIENT_SCHEME ??
69
+ env.HAPPY_LOCAL_DEV_CLIENT_SCHEME ??
70
+ '')
71
+ .toString()
72
+ .trim();
73
+ if (explicit) return env;
74
+ return { ...env, HAPPY_STACKS_MOBILE_SCHEME: 'happy', HAPPY_LOCAL_MOBILE_SCHEME: 'happy' };
28
75
  }
29
76
 
30
- function inferStackNameFromPrArgs({ happy, happyCli, server, serverLight }) {
31
- const parts = [];
32
- const hn = parseGithubPullRequestNumber(happy);
33
- const cn = parseGithubPullRequestNumber(happyCli);
34
- const sn = parseGithubPullRequestNumber(server);
35
- const sln = parseGithubPullRequestNumber(serverLight);
36
- if (hn) parts.push(`happy${hn}`);
37
- if (cn) parts.push(`cli${cn}`);
38
- if (sn) parts.push(`server${sn}`);
39
- if (sln) parts.push(`light${sln}`);
40
- return sanitizeStackName(parts.length ? `pr-${parts.join('-')}` : 'pr');
77
+ async function printReviewerStackSummary({ rootDir, stackName, env, wantsMobile }) {
78
+ try {
79
+ const runtimeStatePath = getStackRuntimeStatePath(stackName);
80
+ // Wait briefly for Expo metadata to land in stack.runtime.json (it can be published slightly
81
+ // after the server /health check passes, especially after a restart).
82
+ const deadline = Date.now() + 20_000;
83
+ let st = await readStackRuntimeStateFile(runtimeStatePath);
84
+ while (Date.now() < deadline) {
85
+ const hasExpo = Boolean(st?.expo && typeof st.expo === 'object' && Number(st.expo.port) > 0);
86
+ if (hasExpo) break;
87
+ // eslint-disable-next-line no-await-in-loop
88
+ await new Promise((r) => setTimeout(r, 250));
89
+ // eslint-disable-next-line no-await-in-loop
90
+ st = await readStackRuntimeStateFile(runtimeStatePath);
91
+ }
92
+ const baseDir = resolveStackEnvPath(stackName, env).baseDir;
93
+ const envPath = resolveStackEnvPath(stackName, env).envPath;
94
+
95
+ const serverPort = Number(st?.ports?.server);
96
+ const backendPort = Number(st?.ports?.backend);
97
+ const uiPort = Number(st?.expo?.webPort ?? st?.expo?.port);
98
+ const mobilePort = Number(st?.expo?.mobilePort ?? st?.expo?.port);
99
+ const runnerLog = String(st?.logs?.runner ?? '').trim();
100
+ const runnerPid = Number(st?.ownerPid);
101
+ const serverPid = Number(st?.processes?.serverPid);
102
+ const expoPid = Number(st?.processes?.expoPid);
103
+
104
+ const internalServerUrl = Number.isFinite(serverPort) && serverPort > 0 ? `http://127.0.0.1:${serverPort}` : '';
105
+ const uiUrlRaw = Number.isFinite(uiPort) && uiPort > 0 ? `http://localhost:${uiPort}` : '';
106
+ const uiUrl = uiUrlRaw ? await preferStackLocalhostUrl(uiUrlRaw, { stackName, env }) : '';
107
+
108
+ // eslint-disable-next-line no-console
109
+ console.log('');
110
+ // eslint-disable-next-line no-console
111
+ console.log(bold('Review details'));
112
+ // eslint-disable-next-line no-console
113
+ console.log(`${dim('Stack:')} ${cyan(stackName)}`);
114
+ // eslint-disable-next-line no-console
115
+ console.log(`${dim('Env:')} ${envPath}`);
116
+ // eslint-disable-next-line no-console
117
+ console.log(`${dim('Dir:')} ${baseDir}`);
118
+ if (Number.isFinite(runnerPid) && runnerPid > 1) {
119
+ // eslint-disable-next-line no-console
120
+ console.log(`${dim('Runner:')} pid=${runnerPid}${Number.isFinite(serverPid) && serverPid > 1 ? ` serverPid=${serverPid}` : ''}${Number.isFinite(expoPid) && expoPid > 1 ? ` expoPid=${expoPid}` : ''}`);
121
+ }
122
+ if (runnerLog) {
123
+ // eslint-disable-next-line no-console
124
+ console.log(`${dim('Logs:')} ${runnerLog}`);
125
+ }
126
+
127
+ // eslint-disable-next-line no-console
128
+ console.log('');
129
+ // eslint-disable-next-line no-console
130
+ console.log(bold('Ports'));
131
+ if (Number.isFinite(serverPort) && serverPort > 0) {
132
+ // eslint-disable-next-line no-console
133
+ console.log(`- ${dim('server')}: ${serverPort}${internalServerUrl ? ` (${internalServerUrl})` : ''}`);
134
+ }
135
+ if (Number.isFinite(backendPort) && backendPort > 0) {
136
+ // eslint-disable-next-line no-console
137
+ console.log(`- ${dim('backend')}: ${backendPort}`);
138
+ }
139
+ if (Number.isFinite(uiPort) && uiPort > 0) {
140
+ // eslint-disable-next-line no-console
141
+ console.log(`- ${dim('web UI')}: ${uiPort}${uiUrl ? ` (${uiUrl})` : ''}`);
142
+ }
143
+ if (wantsMobile && Number.isFinite(mobilePort) && mobilePort > 0) {
144
+ // eslint-disable-next-line no-console
145
+ console.log(`- ${dim('mobile')}: ${mobilePort} (Metro)`);
146
+ }
147
+
148
+ // Prefer the Metro port recorded by Expo; fall back to the web UI port if needed.
149
+ const metroPort = Number.isFinite(mobilePort) && mobilePort > 0 ? mobilePort : Number.isFinite(uiPort) && uiPort > 0 ? uiPort : null;
150
+
151
+ if (wantsMobile && Number.isFinite(metroPort) && metroPort > 0) {
152
+ const payload = resolveMobileQrPayload({ env, port: metroPort });
153
+ const qr = await renderQrAscii(payload.payload, { small: true });
154
+
155
+ // eslint-disable-next-line no-console
156
+ console.log('');
157
+ // eslint-disable-next-line no-console
158
+ console.log(bold('Mobile (Expo dev-client)'));
159
+ if (payload.metroUrl) {
160
+ // eslint-disable-next-line no-console
161
+ console.log(`- ${dim('Metro')}: ${payload.metroUrl}`);
162
+ }
163
+ if (payload.scheme) {
164
+ // eslint-disable-next-line no-console
165
+ console.log(`- ${dim('Scheme')}: ${payload.scheme}://`);
166
+ }
167
+ if (payload.deepLink) {
168
+ // eslint-disable-next-line no-console
169
+ console.log(`- ${dim('Link')}: ${payload.deepLink}`);
170
+ }
171
+ if (qr.ok && qr.lines.length) {
172
+ // eslint-disable-next-line no-console
173
+ console.log('');
174
+ // eslint-disable-next-line no-console
175
+ console.log(bold('Scan this QR code with your Happy dev build:'));
176
+ // eslint-disable-next-line no-console
177
+ console.log(qr.lines.join('\n'));
178
+ } else if (!qr.ok) {
179
+ // eslint-disable-next-line no-console
180
+ console.log(dim(`(QR unavailable: ${qr.error || 'unknown error'})`));
181
+ }
182
+ }
183
+
184
+ // eslint-disable-next-line no-console
185
+ console.log('');
186
+ // eslint-disable-next-line no-console
187
+ console.log(green('✓ Ready'));
188
+ // eslint-disable-next-line no-console
189
+ console.log(dim('Tip: press Ctrl+C when you’re done to stop the stack and clean up the sandbox.'));
190
+ } catch {
191
+ // best-effort
192
+ }
41
193
  }
42
194
 
43
195
  function detectBestAuthSource() {
44
196
  const devAuthEnvExists = existsSync(resolveStackEnvPath('dev-auth').envPath);
45
197
  const devAuthAccessKey = join(resolveStackEnvPath('dev-auth').baseDir, 'cli', 'access.key');
46
198
  const mainAccessKey = join(resolveStackEnvPath('main').baseDir, 'cli', 'access.key');
47
- const allowGlobal = sandboxAllowsGlobalSideEffects();
48
- const legacyAccessKey = join(homedir(), '.happy', 'cli', 'access.key');
49
199
 
50
200
  const hasDevAuth = devAuthEnvExists && existsSync(devAuthAccessKey);
51
201
  const hasMain = existsSync(mainAccessKey);
52
- const hasLegacy = (!isSandboxed() || allowGlobal) && existsSync(legacyAccessKey);
53
202
 
54
203
  if (hasDevAuth) return { from: 'dev-auth', hasAny: true };
55
204
  if (hasMain) return { from: 'main', hasAny: true };
56
- if (hasLegacy) return { from: 'legacy', hasAny: true };
57
205
  return { from: 'main', hasAny: false };
58
206
  }
59
207
 
@@ -79,13 +227,16 @@ async function main() {
79
227
 
80
228
  const { flags, kv } = parseArgs(argv);
81
229
  const json = wantsJson(argv, { flags });
230
+ const interactive = isTty() && !json;
231
+ const verbosity = getVerbosityLevel(process.env);
232
+ const quietUi = interactive && verbosity === 0;
82
233
 
83
234
  if (wantsHelp(argv, { flags })) {
84
235
  printResult({
85
236
  json,
86
237
  data: {
87
238
  usage:
88
- 'happys setup-pr --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--happy-server=<pr-url|number>|--happy-server-light=<pr-url|number>] [--name=<stack>] [--dev|--start] [--seed-auth|--no-seed-auth] [--copy-auth-from=<stack|legacy>] [--link-auth|--copy-auth] [--update] [--force] [--json] [-- <stack dev/start args...>]',
239
+ 'happys setup-pr --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--happy-server=<pr-url|number>|--happy-server-light=<pr-url|number>] [--name=<stack>] [--dev|--start] [--mobile] [--deps=none|link|install|link-or-install] [--forks|--upstream] [--seed-auth|--no-seed-auth] [--copy-auth-from=<stack>] [--link-auth|--copy-auth] [--update] [--force] [--json] [-- <stack dev/start args...>]',
89
240
  },
90
241
  text: [
91
242
  '[setup-pr] usage:',
@@ -96,7 +247,7 @@ async function main() {
96
247
  '- ensures happy-stacks home exists (init)',
97
248
  '- bootstraps/clones missing components (upstream by default)',
98
249
  '- creates or reuses a PR stack and checks out PR worktrees',
99
- '- optionally seeds auth (best available source: dev-auth → main → legacy)',
250
+ '- optionally seeds auth (best available source: dev-auth → main)',
100
251
  '- starts the stack (dev by default)',
101
252
  '',
102
253
  'Updating when the PR changes:',
@@ -112,6 +263,8 @@ async function main() {
112
263
  return;
113
264
  }
114
265
 
266
+ await assertCliPrereqs({ git: true, pnpm: true });
267
+
115
268
  const prHappy = (kv.get('--happy') ?? '').trim();
116
269
  const prCli = (kv.get('--happy-cli') ?? '').trim();
117
270
  const prServer = (kv.get('--happy-server') ?? '').trim();
@@ -128,33 +281,190 @@ async function main() {
128
281
  if (wantsDev && wantsStart) {
129
282
  throw new Error('[setup-pr] choose either --dev or --start (not both)');
130
283
  }
284
+ const repoSourceFlag = flags.has('--upstream') ? '--upstream' : flags.has('--forks') ? '--forks' : null;
285
+ const wantsMobile = (flags.has('--mobile') || flags.has('--with-mobile')) && !flags.has('--no-mobile');
286
+ // Worktree dependency strategy:
287
+ // - For dev flows (review-pr/setup-pr), prefer reusing base checkout node_modules to avoid reinstalling in worktrees.
288
+ // - Allow override via --deps=none|link|install|link-or-install.
289
+ const depsModeArg = (kv.get('--deps') ?? '').trim();
290
+ const depsMode = depsModeArg || (wantsDev ? 'link-or-install' : 'none');
131
291
 
132
292
  const stackNameRaw = (kv.get('--name') ?? '').trim();
133
- const stackName = stackNameRaw ? sanitizeStackName(stackNameRaw) : inferStackNameFromPrArgs({ happy: prHappy, happyCli: prCli, server: prServer, serverLight: prServerLight });
293
+ const stackName = stackNameRaw
294
+ ? sanitizeStackName(stackNameRaw)
295
+ : inferPrStackBaseName({ happy: prHappy, happyCli: prCli, server: prServer, serverLight: prServerLight, fallback: 'pr' });
134
296
 
135
297
  // Determine server flavor for bootstrap and stack creation.
136
298
  const serverComponent = (kv.get('--server') ?? '').trim() || (prServer ? 'happy-server' : 'happy-server-light');
137
299
  const bootstrapServer = prServer || serverComponent === 'happy-server' ? 'both' : 'happy-server-light';
138
300
 
139
301
  // Auth defaults (avoid prompts; setup-pr should be low-friction).
140
- const seedAuthFlag = flags.has('--seed-auth') ? true : flags.has('--no-seed-auth') ? false : null;
141
- const authFrom = (kv.get('--copy-auth-from') ?? '').trim();
142
- const linkAuth = flags.has('--link-auth') ? true : flags.has('--copy-auth') ? false : null;
302
+ // Note: these may be updated below (sandbox prompt), so keep them mutable.
303
+ let seedAuthFlag = flags.has('--seed-auth') ? true : flags.has('--no-seed-auth') ? false : null;
304
+ let authFrom = (kv.get('--copy-auth-from') ?? '').trim();
305
+ let linkAuth = flags.has('--link-auth') ? true : flags.has('--copy-auth') ? false : null;
306
+
307
+ // Disallow "legacy" auth seeding in setup-pr flows:
308
+ // We can't reliably seed local DB Account rows from a remote/production Happy install,
309
+ // so this leads to broken stacks. Use guided login instead.
310
+ if (authFrom && authFrom.toLowerCase() === 'legacy') {
311
+ throw new Error('[setup-pr] --copy-auth-from=legacy is not supported. Use guided login (no seeding) instead.');
312
+ }
313
+
314
+ // Re-read flags after optional prompt mutation.
315
+ seedAuthFlag = flags.has('--seed-auth') ? true : flags.has('--no-seed-auth') ? false : null;
316
+ authFrom = (kv.get('--copy-auth-from') ?? '').trim();
317
+ linkAuth = flags.has('--link-auth') ? true : flags.has('--copy-auth') ? false : null;
318
+
319
+ // If this PR stack already has credentials, do not prompt or override it.
320
+ const stackAlreadyAuthed = (() => {
321
+ try {
322
+ const { baseDir, envPath } = resolveStackEnvPath(stackName);
323
+ if (!existsSync(envPath)) return false;
324
+ return existsSync(join(baseDir, 'cli', 'access.key'));
325
+ } catch {
326
+ return false;
327
+ }
328
+ })();
329
+
330
+ // Centralized guided auth decision (prompt early, before noisy install logs).
331
+ // In non-sandbox mode we still guide: offer reusing dev-auth/main first, otherwise guided login.
332
+ const sandboxNoGlobal = isSandboxed() && !sandboxAllowsGlobalSideEffects();
333
+ if (sandboxNoGlobal && (seedAuthFlag === true || authFrom)) {
334
+ throw new Error(
335
+ '[setup-pr] auth seeding is disabled in sandbox mode.\n' +
336
+ 'Reason: it reuses global machine state (other stacks) and breaks sandbox isolation.\n' +
337
+ 'Use guided login instead, or set: HAPPY_STACKS_SANDBOX_ALLOW_GLOBAL=1'
338
+ );
339
+ }
340
+
341
+ let plan = stackAlreadyAuthed
342
+ ? { mode: 'existing' }
343
+ : await decidePrAuthPlan({
344
+ interactive,
345
+ seedAuthFlag,
346
+ explicitFrom: authFrom,
347
+ defaultLoginNow: true,
348
+ });
349
+ if (sandboxNoGlobal && plan?.mode === 'seed') {
350
+ // Keep sandbox runs isolated by default.
351
+ plan = { mode: 'login', loginNow: true, reason: 'sandbox_no_global' };
352
+ }
143
353
 
144
354
  const best = detectBestAuthSource();
145
- const effectiveSeedAuth = seedAuthFlag != null ? seedAuthFlag : best.hasAny;
146
- const effectiveAuthFrom = authFrom || best.from;
147
- const effectiveLinkAuth = linkAuth != null ? linkAuth : detectLinkDefault();
355
+ const effectiveSeedAuth =
356
+ plan.mode === 'existing'
357
+ ? false
358
+ : plan.mode === 'seed'
359
+ ? true
360
+ : plan.mode === 'login'
361
+ ? false
362
+ : seedAuthFlag != null
363
+ ? seedAuthFlag
364
+ : best.hasAny;
365
+ const effectiveAuthFrom = plan.mode === 'seed' ? plan.from : authFrom || best.from;
366
+ const effectiveLinkAuth = plan.mode === 'seed' ? Boolean(plan.link) : linkAuth != null ? linkAuth : detectLinkDefault();
148
367
 
149
- // 1) Ensure happy-stacks home is initialized (idempotent).
150
- await runNodeScript({ rootDir, rel: 'scripts/init.mjs', args: ['--no-bootstrap'] });
368
+ // Sandbox default: no cross-stack auth reuse unless explicitly allowed.
369
+ const sandboxEffectiveSeedAuth = sandboxNoGlobal ? false : effectiveSeedAuth;
151
370
 
371
+ // If we're going to guide the user through login, start in background first (even in verbose mode)
372
+ // so auth prompts aren't buried in runner logs.
373
+ const needsAuthFlow = interactive && !stackAlreadyAuthed && !sandboxEffectiveSeedAuth && plan.mode === 'login' && plan.loginNow;
374
+ let stackStartEnv = needsAuthFlow
375
+ ? {
376
+ ...process.env,
377
+ // Hint to the dev runner that it should start the Expo web UI early (before daemon auth),
378
+ // so guided login can open the correct UI origin (not the server port).
379
+ HAPPY_STACKS_AUTH_FLOW: '1',
380
+ HAPPY_LOCAL_AUTH_FLOW: '1',
381
+ }
382
+ : process.env;
383
+ if (wantsMobile) {
384
+ stackStartEnv = pickReviewerMobileSchemeEnv(stackStartEnv);
385
+ }
386
+ // (No extra messaging here; review-pr prints the up-front explanation + enter-to-proceed gate.)
387
+
388
+ // 1) Ensure happy-stacks home is initialized (idempotent).
152
389
  // 2) Bootstrap component repos and deps (idempotent; clones only if missing).
153
- await runNodeScript({ rootDir, rel: 'scripts/install.mjs', args: ['--upstream', '--clone', `--server=${bootstrapServer}`] });
390
+ if (quietUi) {
391
+ const baseLogDir = join(process.env.HAPPY_STACKS_HOME_DIR ?? join(homedir(), '.happy-stacks'), 'logs', 'setup-pr');
392
+ const initLog = join(baseLogDir, `init.${Date.now()}.log`);
393
+ const installLog = join(baseLogDir, `install.${Date.now()}.log`);
394
+ try {
395
+ await runCommandLogged({
396
+ label: `init happy-stacks home${isSandboxed() ? ' (sandbox)' : ''}`,
397
+ cmd: process.execPath,
398
+ args: [join(rootDir, 'scripts', 'init.mjs'), '--no-bootstrap'],
399
+ cwd: rootDir,
400
+ env: process.env,
401
+ logPath: initLog,
402
+ quiet: true,
403
+ showSteps: true,
404
+ });
405
+ await runCommandLogged({
406
+ label: `install/clone components${isSandboxed() ? ' (sandbox)' : ''}`,
407
+ cmd: process.execPath,
408
+ args: [
409
+ join(rootDir, 'scripts', 'install.mjs'),
410
+ ...(repoSourceFlag ? [repoSourceFlag] : []),
411
+ '--clone',
412
+ `--server=${bootstrapServer}`,
413
+ ...(wantsDev ? ['--no-ui-build'] : []),
414
+ // Sandbox dev: avoid wasting time installing base deps we won't run directly.
415
+ ...(isSandboxed() && wantsDev ? ['--no-ui-deps'] : []),
416
+ // If the caller provided a happy-cli PR, the PR stack is guaranteed (fail-closed) to pin
417
+ // HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI to that worktree before starting dev, so building the
418
+ // base checkout is wasted work.
419
+ ...(isSandboxed() && wantsDev && prCli ? ['--no-cli-deps', '--no-cli-build'] : []),
420
+ ],
421
+ cwd: rootDir,
422
+ env: process.env,
423
+ logPath: installLog,
424
+ quiet: true,
425
+ showSteps: true,
426
+ });
427
+ } catch (e) {
428
+ const logPath = e?.logPath ? String(e.logPath) : null;
429
+ console.error('[setup-pr] failed during setup.');
430
+ if (logPath) {
431
+ console.error(`[setup-pr] log: ${logPath}`);
432
+ }
433
+ if (e?.stderr) {
434
+ console.error(String(e.stderr).trim());
435
+ } else if (e instanceof Error) {
436
+ console.error(e.message);
437
+ } else {
438
+ console.error(String(e));
439
+ }
440
+ process.exit(1);
441
+ }
442
+ } else {
443
+ await runNodeScript({ rootDir, rel: 'scripts/init.mjs', args: ['--no-bootstrap'] });
444
+ await runNodeScript({
445
+ rootDir,
446
+ rel: 'scripts/install.mjs',
447
+ args: [
448
+ ...(repoSourceFlag ? [repoSourceFlag] : []),
449
+ '--clone',
450
+ `--server=${bootstrapServer}`,
451
+ ...(wantsDev ? ['--no-ui-build'] : []),
452
+ ...(isSandboxed() && wantsDev ? ['--no-ui-deps'] : []),
453
+ ...(isSandboxed() && wantsDev && prCli ? ['--no-cli-deps', '--no-cli-build'] : []),
454
+ ],
455
+ });
456
+ }
154
457
 
155
458
  // 3) Create/reuse the PR stack and wire worktrees.
459
+ // Start Expo with all requested capabilities from the beginning to avoid stop/restart churn.
460
+ const startMobileNow = wantsMobile;
461
+ const userDisabledDaemon = forwarded.includes('--no-daemon');
462
+ const forwardedEffective =
463
+ needsAuthFlow && !userDisabledDaemon && !forwarded.includes('--no-daemon')
464
+ ? [...forwarded, '--no-daemon']
465
+ : forwarded;
466
+ const injectedNoDaemon = needsAuthFlow && !userDisabledDaemon && forwardedEffective.includes('--no-daemon');
156
467
  const stackArgs = [
157
- 'stack',
158
468
  'pr',
159
469
  stackName,
160
470
  ...(prHappy ? [`--happy=${prHappy}`] : []),
@@ -163,16 +473,246 @@ async function main() {
163
473
  ...(prServerLight ? [`--happy-server-light=${prServerLight}`] : []),
164
474
  `--server=${serverComponent}`,
165
475
  '--reuse',
476
+ ...(depsMode ? [`--deps=${depsMode}`] : []),
166
477
  ...(flags.has('--update') ? ['--update'] : []),
167
478
  ...(flags.has('--force') ? ['--force'] : []),
168
- ...(effectiveSeedAuth ? ['--seed-auth', `--copy-auth-from=${effectiveAuthFrom}`, ...(effectiveLinkAuth ? ['--link-auth'] : [])] : ['--no-seed-auth']),
479
+ ...(sandboxEffectiveSeedAuth
480
+ ? ['--seed-auth', `--copy-auth-from=${effectiveAuthFrom}`, ...(effectiveLinkAuth ? ['--link-auth'] : [])]
481
+ : ['--no-seed-auth']),
169
482
  ...(wantsDev ? ['--dev'] : ['--start']),
483
+ ...(startMobileNow ? ['--mobile'] : []),
484
+ ...(((quietUi && !json) || needsAuthFlow) ? ['--background'] : []),
170
485
  ...(json ? ['--json'] : []),
171
486
  ];
172
- if (forwarded.length) {
173
- stackArgs.push('--', ...forwarded);
487
+ if (forwardedEffective.length) {
488
+ stackArgs.push('--', ...forwardedEffective);
489
+ }
490
+ if (quietUi) {
491
+ const baseLogDir = join(process.env.HAPPY_STACKS_HOME_DIR ?? join(homedir(), '.happy-stacks'), 'logs', 'setup-pr');
492
+ const stackLog = join(baseLogDir, `stack-pr.${Date.now()}.log`);
493
+ await runCommandLogged({
494
+ label: `start PR stack${isSandboxed() ? ' (sandbox)' : ''}`,
495
+ cmd: process.execPath,
496
+ args: [join(rootDir, 'scripts', 'stack.mjs'), ...stackArgs],
497
+ cwd: rootDir,
498
+ env: stackStartEnv,
499
+ logPath: stackLog,
500
+ quiet: true,
501
+ showSteps: true,
502
+ }).catch((e) => {
503
+ const logPath = e?.logPath ? String(e.logPath) : stackLog;
504
+ console.error('[setup-pr] failed to start PR stack.');
505
+ console.error(`[setup-pr] log: ${logPath}`);
506
+ process.exit(1);
507
+ });
508
+ } else {
509
+ await runNodeScript({ rootDir, rel: 'scripts/stack.mjs', args: stackArgs, env: stackStartEnv });
510
+ }
511
+
512
+ // Sandbox UX: if we won't run the guided login flow, explicitly tell the user we're now in "keepalive"
513
+ // mode and how to exit/cleanup. Otherwise it can look like the command "hung".
514
+ if (isSandboxed() && interactive && !json && !needsAuthFlow) {
515
+ // eslint-disable-next-line no-console
516
+ console.log('');
517
+ // eslint-disable-next-line no-console
518
+ console.log('[setup-pr] Stack is running in the sandbox.');
519
+ // eslint-disable-next-line no-console
520
+ console.log('[setup-pr] Press Ctrl+C when you’re done to stop and delete the sandbox.');
521
+ }
522
+
523
+ // Guided auth flow:
524
+ // If the user chose "login now", we start in background (quiet mode) then perform login in the foreground.
525
+ // Sandbox: keep this process alive so review-pr can clean up on exit.
526
+ // Non-sandbox: after login, restart dev/start in the foreground so logs follow as usual.
527
+ if (needsAuthFlow) {
528
+ // eslint-disable-next-line no-console
529
+ console.log('');
530
+ if (interactive) {
531
+ // In verbose mode, tail the runner log so users can debug Expo/auth issues,
532
+ // but pause forwarding during the guided login prompts (keeps instructions readable).
533
+ let forwarder = null;
534
+ if (!json && verbosity > 0) {
535
+ try {
536
+ const runtimeStatePath = getStackRuntimeStatePath(stackName);
537
+ const deadline = Date.now() + 10_000;
538
+ let logPath = '';
539
+ while (Date.now() < deadline) {
540
+ // eslint-disable-next-line no-await-in-loop
541
+ const st = await readStackRuntimeStateFile(runtimeStatePath);
542
+ logPath = String(st?.logs?.runner ?? '').trim();
543
+ if (logPath) break;
544
+ // eslint-disable-next-line no-await-in-loop
545
+ await new Promise((r) => setTimeout(r, 200));
546
+ }
547
+ if (logPath) {
548
+ forwarder = createFileLogForwarder({
549
+ path: logPath,
550
+ enabled: true,
551
+ label: 'stack',
552
+ startFromEnd: false,
553
+ });
554
+ await forwarder.start();
555
+ }
556
+ } catch {
557
+ forwarder = null;
558
+ }
559
+ }
560
+
561
+ const steps = createStepPrinter({ enabled: Boolean(process.stdout.isTTY && !json) });
562
+ const label = 'prepare login (waiting for web UI)';
563
+ steps.start(label);
564
+ let webappUrl = '';
565
+ try {
566
+ // Use the same env overlay we used to start the stack in background (includes auth-flow markers).
567
+ webappUrl = await resolveStackWebappUrlForAuth({ rootDir, stackName, env: stackStartEnv });
568
+ // This can take a moment (first bundle compile / resolver errors).
569
+ await assertExpoWebappBundlesOrThrow({ rootDir, stackName, webappUrl });
570
+ steps.stop('✓', label);
571
+ } catch (e) {
572
+ // For guided login, failing to resolve the UI origin should fail closed (server URL fallback is misleading).
573
+ steps.stop('x', label);
574
+ try {
575
+ await forwarder?.stop();
576
+ } catch {
577
+ // ignore
578
+ }
579
+ throw e;
580
+ }
581
+
582
+ try {
583
+ forwarder?.pause();
584
+ // We've already checked the web UI bundle above; skip repeating it here.
585
+ await guidedStackAuthLoginNow({
586
+ rootDir,
587
+ stackName,
588
+ env: { ...stackStartEnv, HAPPY_STACKS_AUTH_SKIP_BUNDLE_CHECK: '1', HAPPY_LOCAL_AUTH_SKIP_BUNDLE_CHECK: '1' },
589
+ webappUrl,
590
+ });
591
+ } finally {
592
+ try {
593
+ forwarder?.resume();
594
+ } catch {
595
+ // ignore
596
+ }
597
+ try {
598
+ await forwarder?.stop();
599
+ } catch {
600
+ // ignore
601
+ }
602
+ }
603
+ }
604
+ // `guidedStackAuthLoginNow` already ran `stack auth <name> login` in interactive mode.
605
+ if (!interactive) {
606
+ await runNodeScript({ rootDir, rel: 'scripts/stack.mjs', args: ['auth', stackName, '--', 'login'] });
607
+ }
608
+
609
+ // After guided login, start daemon now (unless the user explicitly disabled it).
610
+ // This ensures the machine is registered and appears in the UI.
611
+ if (injectedNoDaemon && !userDisabledDaemon) {
612
+ const steps = createStepPrinter({ enabled: Boolean(process.stdout.isTTY && !json) });
613
+ const label = 'start daemon (post-auth)';
614
+ steps.start(label);
615
+ try {
616
+ const { envPath, baseDir } = resolveStackEnvPath(stackName, stackStartEnv);
617
+ const stackEnv = await readEnvObjectFromFile(envPath);
618
+ const mergedEnv = { ...process.env, ...stackEnv };
619
+
620
+ const cliHomeDir =
621
+ (mergedEnv.HAPPY_STACKS_CLI_HOME_DIR ?? mergedEnv.HAPPY_LOCAL_CLI_HOME_DIR ?? '').toString().trim() ||
622
+ join(baseDir, 'cli');
623
+ const cliDir =
624
+ (mergedEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI ?? mergedEnv.HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI ?? '').toString().trim();
625
+ if (!cliDir) {
626
+ throw new Error('[setup-pr] post-auth daemon start failed: HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI is not set');
627
+ }
628
+ const cliBin = join(cliDir, 'bin', 'happy.mjs');
629
+
630
+ const runtimeStatePath = getStackRuntimeStatePath(stackName);
631
+ const st = await readStackRuntimeStateFile(runtimeStatePath);
632
+ const serverPort = Number(st?.ports?.server);
633
+ if (!Number.isFinite(serverPort) || serverPort <= 0) {
634
+ throw new Error('[setup-pr] post-auth daemon start failed: could not resolve server port from stack.runtime.json');
635
+ }
636
+ const internalServerUrl = `http://127.0.0.1:${serverPort}`;
637
+ const publicServerUrl = internalServerUrl;
638
+
639
+ await startLocalDaemonWithAuth({
640
+ cliBin,
641
+ cliHomeDir,
642
+ internalServerUrl,
643
+ publicServerUrl,
644
+ isShuttingDown: () => false,
645
+ forceRestart: true,
646
+ env: mergedEnv,
647
+ stackName,
648
+ });
649
+
650
+ // Verify: daemon wrote state (best-effort wait).
651
+ const deadline = Date.now() + 10_000;
652
+ while (Date.now() < deadline) {
653
+ const s = checkDaemonState(cliHomeDir);
654
+ if (s.status === 'running') break;
655
+ // eslint-disable-next-line no-await-in-loop
656
+ await new Promise((r) => setTimeout(r, 250));
657
+ }
658
+ steps.stop('✓', label);
659
+ } catch (e) {
660
+ steps.stop('x', label);
661
+ throw e;
662
+ }
663
+ }
664
+
665
+ if (isSandboxed()) {
666
+ // Fall through to sandbox keepalive below.
667
+ }
668
+
669
+ // Re-attach logs in the foreground for the chosen mode.
670
+ const restartArgs = [
671
+ wantsDev ? 'dev' : 'start',
672
+ stackName,
673
+ '--restart',
674
+ ...(wantsMobile ? ['--mobile'] : []),
675
+ ...(forwarded.length ? ['--', ...forwarded] : []),
676
+ ];
677
+ // If the user explicitly asked for verbose, reattach; otherwise keep things quiet.
678
+ if (verbosity > 0) {
679
+ await runNodeScript({ rootDir, rel: 'scripts/stack.mjs', args: restartArgs });
680
+ }
681
+ // Mobile is started up-front (in the initial stack pr start) so we don't need to restart here.
682
+ }
683
+
684
+ // After login (and after the optional mobile Metro start), print a clear summary so reviewers
685
+ // have everything they need (URLs/ports/logs + QR) without needing verbose logs.
686
+ if (interactive && !json) {
687
+ await printReviewerStackSummary({ rootDir, stackName, env: stackStartEnv, wantsMobile });
688
+ }
689
+
690
+ // Sandbox: keep this process alive so review-pr stays running and can clean up on exit.
691
+ // The stack runner continues in the background; `review-pr` will stop it on Ctrl+C.
692
+ //
693
+ // IMPORTANT:
694
+ // Waiting on a Promise that only resolves on signals is NOT enough to keep Node alive; pending
695
+ // Promises and signal handlers do not keep the event loop open. We must keep a ref'd handle.
696
+ if (isSandboxed() && interactive && !json) {
697
+ // eslint-disable-next-line no-console
698
+ console.log('');
699
+ // eslint-disable-next-line no-console
700
+ console.log('[setup-pr] Stack is running in the sandbox.');
701
+ // eslint-disable-next-line no-console
702
+ console.log('[setup-pr] Press Ctrl+C when you’re done to stop and delete the sandbox.');
703
+
704
+ await new Promise((resolvePromise) => {
705
+ const interval = setInterval(() => {}, 1_000);
706
+ const done = () => {
707
+ clearInterval(interval);
708
+ process.off('SIGINT', done);
709
+ process.off('SIGTERM', done);
710
+ resolvePromise();
711
+ };
712
+ process.on('SIGINT', done);
713
+ process.on('SIGTERM', done);
714
+ });
174
715
  }
175
- await runNodeScript({ rootDir, rel: 'scripts/stack.mjs', args: stackArgs });
176
716
  }
177
717
 
178
718
  main().catch((err) => {