happy-stacks 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +29 -7
  2. package/bin/happys.mjs +114 -15
  3. package/docs/happy-development.md +2 -2
  4. package/docs/isolated-linux-vm.md +82 -0
  5. package/docs/mobile-ios.md +112 -54
  6. package/package.json +5 -1
  7. package/scripts/auth.mjs +11 -7
  8. package/scripts/build.mjs +54 -7
  9. package/scripts/daemon.mjs +166 -10
  10. package/scripts/dev.mjs +181 -46
  11. package/scripts/edison.mjs +4 -2
  12. package/scripts/init.mjs +3 -1
  13. package/scripts/install.mjs +112 -16
  14. package/scripts/lint.mjs +24 -4
  15. package/scripts/mobile.mjs +88 -104
  16. package/scripts/mobile_dev_client.mjs +83 -0
  17. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  18. package/scripts/review.mjs +217 -0
  19. package/scripts/review_pr.mjs +368 -0
  20. package/scripts/run.mjs +83 -9
  21. package/scripts/service.mjs +2 -2
  22. package/scripts/setup.mjs +42 -43
  23. package/scripts/setup_pr.mjs +591 -34
  24. package/scripts/stack.mjs +503 -45
  25. package/scripts/tailscale.mjs +37 -1
  26. package/scripts/test.mjs +45 -8
  27. package/scripts/tui.mjs +309 -39
  28. package/scripts/typecheck.mjs +24 -4
  29. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  30. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  31. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  32. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  33. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  34. package/scripts/utils/auth/login_ux.mjs +32 -13
  35. package/scripts/utils/auth/sources.mjs +26 -0
  36. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  37. package/scripts/utils/cli/cli_registry.mjs +24 -0
  38. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  39. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  40. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  41. package/scripts/utils/cli/prereqs.mjs +72 -0
  42. package/scripts/utils/cli/progress.mjs +126 -0
  43. package/scripts/utils/cli/verbosity.mjs +12 -0
  44. package/scripts/utils/dev/daemon.mjs +47 -3
  45. package/scripts/utils/dev/expo_dev.mjs +246 -0
  46. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  47. package/scripts/utils/dev/server.mjs +15 -25
  48. package/scripts/utils/dev_auth_key.mjs +169 -0
  49. package/scripts/utils/expo/command.mjs +52 -0
  50. package/scripts/utils/expo/expo.mjs +20 -1
  51. package/scripts/utils/expo/metro_ports.mjs +114 -0
  52. package/scripts/utils/git/git.mjs +67 -0
  53. package/scripts/utils/git/worktrees.mjs +24 -20
  54. package/scripts/utils/handy_master_secret.mjs +94 -0
  55. package/scripts/utils/mobile/config.mjs +31 -0
  56. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  57. package/scripts/utils/mobile/identifiers.mjs +47 -0
  58. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  59. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  60. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  61. package/scripts/utils/net/lan_ip.mjs +24 -0
  62. package/scripts/utils/net/ports.mjs +9 -1
  63. package/scripts/utils/net/url.mjs +30 -0
  64. package/scripts/utils/net/url.test.mjs +20 -0
  65. package/scripts/utils/paths/localhost_host.mjs +50 -3
  66. package/scripts/utils/paths/paths.mjs +42 -38
  67. package/scripts/utils/proc/parallel.mjs +25 -0
  68. package/scripts/utils/proc/pm.mjs +69 -12
  69. package/scripts/utils/proc/proc.mjs +76 -2
  70. package/scripts/utils/review/base_ref.mjs +74 -0
  71. package/scripts/utils/review/base_ref.test.mjs +54 -0
  72. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  73. package/scripts/utils/review/runners/codex.mjs +51 -0
  74. package/scripts/utils/review/targets.mjs +24 -0
  75. package/scripts/utils/review/targets.test.mjs +36 -0
  76. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  77. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  78. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  79. package/scripts/utils/server/urls.mjs +14 -4
  80. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  81. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  82. package/scripts/utils/stack/context.mjs +2 -2
  83. package/scripts/utils/stack/editor_workspace.mjs +2 -2
  84. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  85. package/scripts/utils/stack/runtime_state.mjs +2 -1
  86. package/scripts/utils/stack/startup.mjs +7 -0
  87. package/scripts/utils/stack/stop.mjs +15 -4
  88. package/scripts/utils/stack_context.mjs +23 -0
  89. package/scripts/utils/stack_runtime_state.mjs +104 -0
  90. package/scripts/utils/stacks.mjs +38 -0
  91. package/scripts/utils/ui/qr.mjs +17 -0
  92. package/scripts/utils/validate.mjs +88 -0
  93. package/scripts/worktrees.mjs +141 -55
  94. package/scripts/utils/dev/expo_web.mjs +0 -112
@@ -1,42 +1,207 @@
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
+
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
+ }
36
+
37
+ function dim(s) {
38
+ return supportsAnsi() ? `\x1b[2m${s}\x1b[0m` : String(s);
39
+ }
40
+
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' };
75
+ }
76
+
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
+ }
12
147
 
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 });
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
+ }
24
193
  }
25
194
 
26
195
  function detectBestAuthSource() {
27
196
  const devAuthEnvExists = existsSync(resolveStackEnvPath('dev-auth').envPath);
28
197
  const devAuthAccessKey = join(resolveStackEnvPath('dev-auth').baseDir, 'cli', 'access.key');
29
198
  const mainAccessKey = join(resolveStackEnvPath('main').baseDir, 'cli', 'access.key');
30
- const allowGlobal = sandboxAllowsGlobalSideEffects();
31
- const legacyAccessKey = join(homedir(), '.happy', 'cli', 'access.key');
32
199
 
33
200
  const hasDevAuth = devAuthEnvExists && existsSync(devAuthAccessKey);
34
201
  const hasMain = existsSync(mainAccessKey);
35
- const hasLegacy = (!isSandboxed() || allowGlobal) && existsSync(legacyAccessKey);
36
202
 
37
203
  if (hasDevAuth) return { from: 'dev-auth', hasAny: true };
38
204
  if (hasMain) return { from: 'main', hasAny: true };
39
- if (hasLegacy) return { from: 'legacy', hasAny: true };
40
205
  return { from: 'main', hasAny: false };
41
206
  }
42
207
 
@@ -62,13 +227,16 @@ async function main() {
62
227
 
63
228
  const { flags, kv } = parseArgs(argv);
64
229
  const json = wantsJson(argv, { flags });
230
+ const interactive = isTty() && !json;
231
+ const verbosity = getVerbosityLevel(process.env);
232
+ const quietUi = interactive && verbosity === 0;
65
233
 
66
234
  if (wantsHelp(argv, { flags })) {
67
235
  printResult({
68
236
  json,
69
237
  data: {
70
238
  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...>]',
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...>]',
72
240
  },
73
241
  text: [
74
242
  '[setup-pr] usage:',
@@ -79,7 +247,7 @@ async function main() {
79
247
  '- ensures happy-stacks home exists (init)',
80
248
  '- bootstraps/clones missing components (upstream by default)',
81
249
  '- creates or reuses a PR stack and checks out PR worktrees',
82
- '- optionally seeds auth (best available source: dev-auth → main → legacy)',
250
+ '- optionally seeds auth (best available source: dev-auth → main)',
83
251
  '- starts the stack (dev by default)',
84
252
  '',
85
253
  'Updating when the PR changes:',
@@ -95,6 +263,8 @@ async function main() {
95
263
  return;
96
264
  }
97
265
 
266
+ await assertCliPrereqs({ git: true, pnpm: true });
267
+
98
268
  const prHappy = (kv.get('--happy') ?? '').trim();
99
269
  const prCli = (kv.get('--happy-cli') ?? '').trim();
100
270
  const prServer = (kv.get('--happy-server') ?? '').trim();
@@ -111,33 +281,190 @@ async function main() {
111
281
  if (wantsDev && wantsStart) {
112
282
  throw new Error('[setup-pr] choose either --dev or --start (not both)');
113
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');
114
291
 
115
292
  const stackNameRaw = (kv.get('--name') ?? '').trim();
116
- 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' });
117
296
 
118
297
  // Determine server flavor for bootstrap and stack creation.
119
298
  const serverComponent = (kv.get('--server') ?? '').trim() || (prServer ? 'happy-server' : 'happy-server-light');
120
299
  const bootstrapServer = prServer || serverComponent === 'happy-server' ? 'both' : 'happy-server-light';
121
300
 
122
301
  // 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;
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
+ }
126
353
 
127
354
  const best = detectBestAuthSource();
128
- const effectiveSeedAuth = seedAuthFlag != null ? seedAuthFlag : best.hasAny;
129
- const effectiveAuthFrom = authFrom || best.from;
130
- 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();
131
367
 
132
- // 1) Ensure happy-stacks home is initialized (idempotent).
133
- 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;
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.)
134
387
 
388
+ // 1) Ensure happy-stacks home is initialized (idempotent).
135
389
  // 2) Bootstrap component repos and deps (idempotent; clones only if missing).
136
- 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
+ }
137
457
 
138
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');
139
467
  const stackArgs = [
140
- 'stack',
141
468
  'pr',
142
469
  stackName,
143
470
  ...(prHappy ? [`--happy=${prHappy}`] : []),
@@ -146,16 +473,246 @@ async function main() {
146
473
  ...(prServerLight ? [`--happy-server-light=${prServerLight}`] : []),
147
474
  `--server=${serverComponent}`,
148
475
  '--reuse',
476
+ ...(depsMode ? [`--deps=${depsMode}`] : []),
149
477
  ...(flags.has('--update') ? ['--update'] : []),
150
478
  ...(flags.has('--force') ? ['--force'] : []),
151
- ...(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']),
152
482
  ...(wantsDev ? ['--dev'] : ['--start']),
483
+ ...(startMobileNow ? ['--mobile'] : []),
484
+ ...(((quietUi && !json) || needsAuthFlow) ? ['--background'] : []),
153
485
  ...(json ? ['--json'] : []),
154
486
  ];
155
- if (forwarded.length) {
156
- 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
+ });
157
715
  }
158
- await runNodeScript({ rootDir, rel: 'scripts/stack.mjs', args: stackArgs });
159
716
  }
160
717
 
161
718
  main().catch((err) => {