happy-stacks 0.3.0 → 0.5.0

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