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
@@ -0,0 +1,217 @@
1
+ import './utils/env/env.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
4
+ import { getComponentDir, getRootDir } from './utils/paths/paths.mjs';
5
+ import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
6
+ import { assertCliPrereqs } from './utils/cli/prereqs.mjs';
7
+ import { resolveBaseRef } from './utils/review/base_ref.mjs';
8
+ import { isStackMode, resolveDefaultStackReviewComponents } from './utils/review/targets.mjs';
9
+ import { runWithConcurrencyLimit } from './utils/proc/parallel.mjs';
10
+ import { runCodeRabbitReview } from './utils/review/runners/coderabbit.mjs';
11
+ import { extractCodexReviewFromJsonl, runCodexReview } from './utils/review/runners/codex.mjs';
12
+
13
+ const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
14
+ const VALID_COMPONENTS = DEFAULT_COMPONENTS;
15
+ const VALID_REVIEWERS = ['coderabbit', 'codex'];
16
+
17
+ function parseCsv(raw) {
18
+ return String(raw ?? '')
19
+ .split(',')
20
+ .map((s) => s.trim())
21
+ .filter(Boolean);
22
+ }
23
+
24
+ function normalizeReviewers(list) {
25
+ const raw = Array.isArray(list) ? list : [];
26
+ const lower = raw.map((r) => String(r).trim().toLowerCase()).filter(Boolean);
27
+ const uniq = Array.from(new Set(lower));
28
+ return uniq.length ? uniq : ['coderabbit'];
29
+ }
30
+
31
+ function usage() {
32
+ return [
33
+ '[review] usage:',
34
+ ' happys review [component...] [--reviewers=coderabbit,codex] [--base-remote=<remote>] [--base-branch=<branch>] [--base-ref=<ref>] [--concurrency=N] [--json]',
35
+ '',
36
+ 'components:',
37
+ ` ${VALID_COMPONENTS.join(' | ')}`,
38
+ '',
39
+ 'reviewers:',
40
+ ` ${VALID_REVIEWERS.join(' | ')}`,
41
+ '',
42
+ 'notes:',
43
+ '- If run from inside a component checkout/worktree and no components are provided, defaults to that component.',
44
+ '- In stack mode (invoked via `happys stack review <stack>`), if no components are provided, defaults to stack-pinned non-default components only.',
45
+ '',
46
+ 'examples:',
47
+ ' happys review',
48
+ ' happys review happy-cli --reviewers=coderabbit,codex',
49
+ ' happys stack review exp1 --reviewers=codex',
50
+ ' happys review happy --base-remote=upstream --base-branch=main',
51
+ ].join('\n');
52
+ }
53
+
54
+ function resolveComponentFromCwdOrNull({ rootDir, invokedCwd }) {
55
+ return inferComponentFromCwd({ rootDir, invokedCwd, components: DEFAULT_COMPONENTS });
56
+ }
57
+
58
+ function stackRemoteFallbackFromEnv(env) {
59
+ return String(env.HAPPY_STACKS_STACK_REMOTE ?? env.HAPPY_LOCAL_STACK_REMOTE ?? '').trim();
60
+ }
61
+
62
+ async function main() {
63
+ const argv = process.argv.slice(2);
64
+ const { flags, kv } = parseArgs(argv);
65
+ const json = wantsJson(argv, { flags });
66
+
67
+ if (wantsHelp(argv, { flags })) {
68
+ printResult({ json, data: { usage: usage() }, text: usage() });
69
+ return;
70
+ }
71
+
72
+ const rootDir = getRootDir(import.meta.url);
73
+ const invokedCwd = getInvokedCwd(process.env);
74
+ const positionals = argv.filter((a) => !a.startsWith('--'));
75
+
76
+ const reviewers = normalizeReviewers(parseCsv(kv.get('--reviewers') ?? ''));
77
+ for (const r of reviewers) {
78
+ if (!VALID_REVIEWERS.includes(r)) {
79
+ throw new Error(`[review] unknown reviewer: ${r} (expected one of: ${VALID_REVIEWERS.join(', ')})`);
80
+ }
81
+ }
82
+
83
+ await assertCliPrereqs({
84
+ git: true,
85
+ coderabbit: reviewers.includes('coderabbit'),
86
+ codex: reviewers.includes('codex'),
87
+ });
88
+
89
+ const inferred = positionals.length === 0 ? resolveComponentFromCwdOrNull({ rootDir, invokedCwd }) : null;
90
+ if (inferred) {
91
+ // Make downstream getComponentDir() resolve to the inferred repo dir for this run.
92
+ process.env[`HAPPY_STACKS_COMPONENT_DIR_${inferred.component.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}`] = inferred.repoDir;
93
+ }
94
+
95
+ const inStackMode = isStackMode(process.env);
96
+ const requestedComponents = positionals.length ? positionals : inferred ? [inferred.component] : ['all'];
97
+ const wantAll = requestedComponents.includes('all');
98
+
99
+ let components = wantAll ? DEFAULT_COMPONENTS : requestedComponents;
100
+ if (!positionals.length && !inferred && inStackMode) {
101
+ const pinned = resolveDefaultStackReviewComponents({ rootDir, components: DEFAULT_COMPONENTS });
102
+ components = pinned.length ? pinned : [];
103
+ }
104
+
105
+ for (const c of components) {
106
+ if (!VALID_COMPONENTS.includes(c)) {
107
+ throw new Error(`[review] unknown component: ${c} (expected one of: ${VALID_COMPONENTS.join(', ')})`);
108
+ }
109
+ }
110
+
111
+ if (!components.length) {
112
+ const msg = inStackMode ? '[review] no non-default stack-pinned components to review' : '[review] no components selected';
113
+ printResult({ json, data: { ok: true, skipped: true, reason: msg }, text: msg });
114
+ return;
115
+ }
116
+
117
+ const baseRefOverride = (kv.get('--base-ref') ?? '').trim();
118
+ const baseRemoteOverride = (kv.get('--base-remote') ?? '').trim();
119
+ const baseBranchOverride = (kv.get('--base-branch') ?? '').trim();
120
+ const stackRemoteFallback = stackRemoteFallbackFromEnv(process.env);
121
+ const concurrency = (kv.get('--concurrency') ?? '').trim();
122
+ const limit = concurrency ? Number(concurrency) : 4;
123
+
124
+ const jobs = [];
125
+ for (const component of components) {
126
+ const repoDir = getComponentDir(rootDir, component);
127
+ jobs.push({ component, repoDir });
128
+ }
129
+
130
+ const jobResults = await runWithConcurrencyLimit({
131
+ items: jobs,
132
+ limit,
133
+ fn: async (job) => {
134
+ const { component, repoDir } = job;
135
+ const base = await resolveBaseRef({
136
+ cwd: repoDir,
137
+ baseRefOverride,
138
+ baseRemoteOverride,
139
+ baseBranchOverride,
140
+ stackRemoteFallback,
141
+ });
142
+
143
+ const perReviewer = await Promise.all(
144
+ reviewers.map(async (reviewer) => {
145
+ if (reviewer === 'coderabbit') {
146
+ const res = await runCodeRabbitReview({ repoDir, baseRef: base.baseRef, env: process.env });
147
+ return {
148
+ reviewer,
149
+ ok: Boolean(res.ok),
150
+ exitCode: res.exitCode,
151
+ signal: res.signal,
152
+ durationMs: res.durationMs,
153
+ stdout: res.stdout ?? '',
154
+ stderr: res.stderr ?? '',
155
+ };
156
+ }
157
+ if (reviewer === 'codex') {
158
+ const res = await runCodexReview({ repoDir, baseRef: base.baseRef, env: process.env, jsonMode: true });
159
+ const extracted = extractCodexReviewFromJsonl(res.stdout ?? '');
160
+ return {
161
+ reviewer,
162
+ ok: Boolean(res.ok),
163
+ exitCode: res.exitCode,
164
+ signal: res.signal,
165
+ durationMs: res.durationMs,
166
+ stdout: res.stdout ?? '',
167
+ stderr: res.stderr ?? '',
168
+ review_output: extracted,
169
+ };
170
+ }
171
+ return { reviewer, ok: false, exitCode: null, signal: null, durationMs: 0, stdout: '', stderr: 'unknown reviewer\n' };
172
+ })
173
+ );
174
+
175
+ return { component, repoDir, base, results: perReviewer };
176
+ },
177
+ });
178
+
179
+ const ok = jobResults.every((r) => r.results.every((x) => x.ok));
180
+ if (json) {
181
+ printResult({ json, data: { ok, reviewers, components, results: jobResults } });
182
+ if (!ok) process.exit(1);
183
+ return;
184
+ }
185
+
186
+ const lines = [];
187
+ lines.push('[review] results:');
188
+ for (const r of jobResults) {
189
+ lines.push('============================================================================');
190
+ lines.push(`component: ${r.component}`);
191
+ lines.push(`dir: ${r.repoDir}`);
192
+ lines.push(`baseRef: ${r.base.baseRef}`);
193
+ for (const rr of r.results) {
194
+ lines.push('');
195
+ const status = rr.ok ? '✅ ok' : '❌ failed';
196
+ lines.push(`[${rr.reviewer}] ${status} (exit=${rr.exitCode ?? 'null'} durMs=${rr.durationMs ?? '?'})`);
197
+ if (rr.stderr) {
198
+ lines.push('--- stderr ---');
199
+ lines.push(String(rr.stderr).trimEnd());
200
+ }
201
+ if (rr.stdout) {
202
+ lines.push('--- stdout ---');
203
+ lines.push(String(rr.stdout).trimEnd());
204
+ }
205
+ }
206
+ lines.push('');
207
+ }
208
+ lines.push(ok ? '[review] ok' : '[review] failed');
209
+ printResult({ json: false, text: lines.join('\n') });
210
+ if (!ok) process.exit(1);
211
+ }
212
+
213
+ main().catch((err) => {
214
+ console.error('[review] failed:', err);
215
+ process.exit(1);
216
+ });
217
+
@@ -0,0 +1,368 @@
1
+ import './utils/env/env.mjs';
2
+ import { spawn } from 'node:child_process';
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join, resolve } from 'node:path';
7
+ import { getRootDir } from './utils/paths/paths.mjs';
8
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
9
+ import { parseArgs } from './utils/cli/args.mjs';
10
+ import { getVerbosityLevel } from './utils/cli/verbosity.mjs';
11
+ import { createStepPrinter } from './utils/cli/progress.mjs';
12
+ import { prompt, promptSelect, withRl } from './utils/cli/wizard.mjs';
13
+ import { assertCliPrereqs } from './utils/cli/prereqs.mjs';
14
+ import { randomToken } from './utils/crypto/tokens.mjs';
15
+ import { inferPrStackBaseName } from './utils/stack/pr_stack_name.mjs';
16
+ import { sanitizeStackName } from './utils/stack/names.mjs';
17
+ import { listReviewPrSandboxes, reviewPrSandboxPrefixPath, writeReviewPrSandboxMeta } from './utils/sandbox/review_pr_sandbox.mjs';
18
+
19
+ function supportsAnsi() {
20
+ if (!process.stdout.isTTY) return false;
21
+ if (process.env.NO_COLOR) return false;
22
+ if ((process.env.TERM ?? '').toLowerCase() === 'dumb') return false;
23
+ return true;
24
+ }
25
+
26
+ function bold(s) {
27
+ return supportsAnsi() ? `\x1b[1m${s}\x1b[0m` : String(s);
28
+ }
29
+
30
+ function dim(s) {
31
+ return supportsAnsi() ? `\x1b[2m${s}\x1b[0m` : String(s);
32
+ }
33
+
34
+ function cyan(s) {
35
+ return supportsAnsi() ? `\x1b[36m${s}\x1b[0m` : String(s);
36
+ }
37
+
38
+ function usage() {
39
+ return [
40
+ '[review-pr] usage:',
41
+ ' happys review-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|--no-mobile] [--forks|--upstream] [--seed-auth|--no-seed-auth] [--copy-auth-from=<stack>] [--link-auth|--copy-auth] [--update] [--force] [--keep-sandbox] [--json] [-- <stack dev/start args...>]',
42
+ '',
43
+ 'What it does:',
44
+ '- creates a temporary sandbox dir',
45
+ '- runs `happys setup-pr ...` inside that sandbox (fully isolated state)',
46
+ '- on exit (including Ctrl+C): stops sandbox processes and deletes the sandbox dir',
47
+ ].join('\n');
48
+ }
49
+
50
+ function waitForExit(child) {
51
+ return new Promise((resolvePromise, rejectPromise) => {
52
+ child.on('error', rejectPromise);
53
+ child.on('close', (code, signal) => resolvePromise({ code: code ?? 1, signal: signal ?? null }));
54
+ });
55
+ }
56
+
57
+ async function tryStopSandbox({ rootDir, sandboxDir }) {
58
+ const bin = join(rootDir, 'bin', 'happys.mjs');
59
+ const child = spawn(process.execPath, [bin, '--sandbox-dir', sandboxDir, 'stop', '--yes', '--aggressive', '--sweep-owned', '--no-service'], {
60
+ cwd: rootDir,
61
+ env: process.env,
62
+ stdio: 'ignore',
63
+ });
64
+ await waitForExit(child);
65
+ }
66
+
67
+ function argvHasFlag(argv, names) {
68
+ for (const n of names) {
69
+ if (argv.includes(n)) return true;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ function kvValue(argv, names) {
75
+ for (const a of argv) {
76
+ for (const n of names) {
77
+ if (a === n) {
78
+ return '';
79
+ }
80
+ if (a.startsWith(`${n}=`)) {
81
+ return a.slice(`${n}=`.length);
82
+ }
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ async function main() {
89
+ const rootDir = getRootDir(import.meta.url);
90
+ const argv = process.argv.slice(2);
91
+ const { flags } = parseArgs(argv);
92
+ const json = wantsJson(argv, { flags });
93
+ const verbosity = getVerbosityLevel(process.env);
94
+ const steps = createStepPrinter({ enabled: Boolean(process.stdout.isTTY && !json && verbosity === 0) });
95
+
96
+ if (wantsHelp(argv, { flags })) {
97
+ printResult({ json, data: { usage: usage() }, text: usage() });
98
+ return;
99
+ }
100
+
101
+ await assertCliPrereqs({ git: true, pnpm: true });
102
+
103
+ // Determine a stable base stack name from PR inputs (used for sandbox discovery),
104
+ // and a per-run unique stack name by default (prevents browser storage collisions across deleted sandboxes).
105
+ const prHappy = (kvValue(argv, ['--happy']) ?? '').trim();
106
+ const prCli = (kvValue(argv, ['--happy-cli']) ?? '').trim();
107
+ const prServer = (kvValue(argv, ['--happy-server']) ?? '').trim();
108
+ const prServerLight = (kvValue(argv, ['--happy-server-light']) ?? '').trim();
109
+ const explicitName = (kvValue(argv, ['--name']) ?? '').trim();
110
+
111
+ const baseStackName = explicitName
112
+ ? sanitizeStackName(explicitName, { fallback: 'pr', maxLen: 64 })
113
+ : inferPrStackBaseName({ happy: prHappy, happyCli: prCli, server: prServer, serverLight: prServerLight, fallback: 'pr' });
114
+
115
+ const shouldAutoSuffix = !explicitName;
116
+ const uniqueSuffix = randomToken(4); // short, URL-safe-ish
117
+ const newStackName = shouldAutoSuffix
118
+ ? sanitizeStackName(`${baseStackName}-${uniqueSuffix}`, { fallback: baseStackName, maxLen: 64 })
119
+ : baseStackName;
120
+
121
+ // Look for leftover sandboxes for the same PR base name (typically due to --keep-sandbox / failures).
122
+ const canPrompt = Boolean(process.stdout.isTTY && process.stdin.isTTY && !json);
123
+ const existingSandboxes = canPrompt ? await listReviewPrSandboxes({ baseStackName }) : [];
124
+
125
+ if (process.stdout.isTTY && !json) {
126
+ const intro = [
127
+ '',
128
+ '',
129
+ bold(`✨ ${cyan('Happy Stacks')} review-pr ✨`),
130
+ '',
131
+ 'It will help you review a PR for Happy in a completely isolated environment.',
132
+ dim('Uses `happy-server-light` (no Redis, no Postgres, no Docker).'),
133
+ dim('Desktop browser + optional mobile review (Expo dev-client).'),
134
+ '',
135
+ bold('What will happen:'),
136
+ `- ${cyan('sandbox')}: temporary isolated Happy install`,
137
+ `- ${cyan('components')}: clone/install (inside the sandbox only)`,
138
+ `- ${cyan('start')}: start the Happy stack in sandbox (server, daemon, web, mobile)`,
139
+ `- ${cyan('login')}: guide you through Happy login for this sandbox`,
140
+ `- ${cyan('browser')}: open the Happy web app`,
141
+ `- ${cyan('mobile')}: start Expo dev-client (optional)`,
142
+ `- ${cyan('cleanup')}: stop processes + delete sandbox on exit`,
143
+ '',
144
+ dim('Everything is deleted automatically when you exit.'),
145
+ dim('Your main Happy installation remains untouched.'),
146
+ '',
147
+ dim('Tips:'),
148
+ dim('- Add `-v` / `-vv` / `-vvv` to show the full logs'),
149
+ dim('- Add `--keep-sandbox` to keep the sandbox directory between runs'),
150
+ '',
151
+ existingSandboxes.length
152
+ ? bold('Choose how to proceed') + dim(' (or Ctrl+C to cancel).')
153
+ : bold('Press Enter to proceed') + dim(' (or Ctrl+C to cancel).'),
154
+ ].join('\n');
155
+ // eslint-disable-next-line no-console
156
+ console.log(intro);
157
+ if (!existingSandboxes.length) {
158
+ await withRl(async (rl) => {
159
+ await prompt(rl, '', { defaultValue: '' });
160
+ });
161
+ }
162
+ }
163
+
164
+ let sandboxDir = '';
165
+ let createdNewSandbox = false;
166
+ let reusedSandboxMeta = null;
167
+
168
+ if (existingSandboxes.length) {
169
+ const picked = await withRl(async (rl) => {
170
+ const options = [
171
+ { label: 'Create a new sandbox (recommended)', value: 'new' },
172
+ ...existingSandboxes.map((s) => {
173
+ const stackLabel = s.stackName ? `stack=${s.stackName}` : 'stack=?';
174
+ return { label: `Reuse existing sandbox (${stackLabel}) — ${s.dir}`, value: s.dir };
175
+ }),
176
+ ];
177
+ return await promptSelect(rl, {
178
+ title: 'Review-pr sandbox:',
179
+ options,
180
+ defaultIndex: 0,
181
+ });
182
+ });
183
+ if (picked === 'new') {
184
+ steps.start('create temporary sandbox');
185
+ const prefix = reviewPrSandboxPrefixPath(baseStackName);
186
+ sandboxDir = resolve(await mkdtemp(prefix));
187
+ createdNewSandbox = true;
188
+ steps.stop('✓', 'create temporary sandbox');
189
+ } else {
190
+ sandboxDir = resolve(String(picked));
191
+ reusedSandboxMeta = existingSandboxes.find((s) => resolve(s.dir) === sandboxDir) ?? null;
192
+ }
193
+ } else {
194
+ steps.start('create temporary sandbox');
195
+ const prefix = reviewPrSandboxPrefixPath(baseStackName);
196
+ sandboxDir = resolve(await mkdtemp(prefix));
197
+ createdNewSandbox = true;
198
+ steps.stop('✓', 'create temporary sandbox');
199
+ }
200
+
201
+ // If we're reusing a sandbox, prefer the stack name recorded in its meta file (keeps hostname stable),
202
+ // but only when the user did not explicitly pass --name.
203
+ const effectiveStackName =
204
+ !explicitName && reusedSandboxMeta?.stackName
205
+ ? sanitizeStackName(reusedSandboxMeta.stackName, { fallback: baseStackName, maxLen: 64 })
206
+ : newStackName;
207
+
208
+ // Safety marker to ensure we only delete what we created.
209
+ const markerPath = join(sandboxDir, '.happy-stacks-sandbox-marker');
210
+ // Always ensure the marker exists for safety; write meta only for new sandboxes.
211
+ try {
212
+ if (!existsSync(markerPath)) {
213
+ await writeFile(markerPath, 'review-pr\n', 'utf-8');
214
+ }
215
+ } catch {
216
+ // ignore; deletion guard will fail closed later if marker is missing
217
+ }
218
+ if (createdNewSandbox && existsSync(markerPath)) {
219
+ try {
220
+ await writeReviewPrSandboxMeta({ sandboxDir, baseStackName, stackName: effectiveStackName, argv });
221
+ } catch {
222
+ // ignore
223
+ }
224
+ }
225
+
226
+ const bin = join(rootDir, 'bin', 'happys.mjs');
227
+
228
+ let child = null;
229
+ let gotSignal = null;
230
+ let childExitCode = null;
231
+
232
+ const forwardSignal = (sig) => {
233
+ const first = gotSignal == null;
234
+ gotSignal = gotSignal ?? sig;
235
+ if (first && process.stdout.isTTY && !json) {
236
+ // eslint-disable-next-line no-console
237
+ console.log('\n[review-pr] received Ctrl+C — cleaning up sandbox, please wait...');
238
+ }
239
+ try {
240
+ child?.kill(sig);
241
+ } catch {
242
+ // ignore
243
+ }
244
+ };
245
+
246
+ const onSigInt = () => forwardSignal('SIGINT');
247
+ const onSigTerm = () => forwardSignal('SIGTERM');
248
+ process.on('SIGINT', onSigInt);
249
+ process.on('SIGTERM', onSigTerm);
250
+
251
+ try {
252
+ const wantsStart = flags.has('--start') || flags.has('--prod');
253
+ const hasMobileFlag = argv.includes('--mobile') || argv.includes('--with-mobile') || argv.includes('--no-mobile');
254
+ const argvWithDefaults =
255
+ process.stdout.isTTY && !json && !wantsStart && !hasMobileFlag ? [...argv, '--mobile'] : argv;
256
+
257
+ // If the caller did not explicitly name the stack, make it unique per run.
258
+ // This prevents browser storage collisions when sandboxes are deleted between runs.
259
+ const hasNameFlag = argvWithDefaults.some((a) => a === '--name' || a.startsWith('--name='));
260
+ const argvFinal = hasNameFlag ? argvWithDefaults : [...argvWithDefaults, `--name=${effectiveStackName}`];
261
+
262
+ child = spawn(process.execPath, [bin, '--sandbox-dir', sandboxDir, 'setup-pr', ...argvFinal], {
263
+ cwd: rootDir,
264
+ env: process.env,
265
+ stdio: 'inherit',
266
+ });
267
+
268
+ const { code } = await waitForExit(child);
269
+ childExitCode = code;
270
+ process.exitCode = code;
271
+ } finally {
272
+ process.off('SIGINT', onSigInt);
273
+ process.off('SIGTERM', onSigTerm);
274
+
275
+ steps.start('stop sandbox processes (best-effort)');
276
+ try {
277
+ // Best-effort stop before deleting the sandbox.
278
+ await tryStopSandbox({ rootDir, sandboxDir });
279
+ steps.stop('✓', 'stop sandbox processes (best-effort)');
280
+ } catch {
281
+ steps.stop('x', 'stop sandbox processes (best-effort)');
282
+ // eslint-disable-next-line no-console
283
+ console.warn(`[review-pr] warning: failed to stop all sandbox processes. Attempting cleanup anyway.`);
284
+ }
285
+
286
+ // On failure, offer to keep the sandbox for inspection (TTY only).
287
+ // - `--keep-sandbox` always wins (no prompt)
288
+ // - on signals, don't prompt (just follow the normal cleanup rules)
289
+ const keepSandbox = flags.has('--keep-sandbox');
290
+ const failed = !json && (childExitCode ?? 0) !== 0;
291
+ const canPromptKeep =
292
+ failed &&
293
+ !keepSandbox &&
294
+ !gotSignal &&
295
+ Boolean(process.stdout.isTTY && process.stdin.isTTY) &&
296
+ !json;
297
+
298
+ let keepOnFail = false;
299
+ if (failed && !keepSandbox && !gotSignal) {
300
+ if (canPromptKeep) {
301
+ // Default: keep in verbose mode, delete otherwise.
302
+ const defaultKeep = getVerbosityLevel(process.env) > 0;
303
+ keepOnFail = await withRl(async (rl) => {
304
+ return await promptSelect(rl, {
305
+ title: 'Review-pr failed. Keep the sandbox for inspection?',
306
+ options: [
307
+ { label: 'yes (keep sandbox directory)', value: true },
308
+ { label: 'no (delete sandbox directory)', value: false },
309
+ ],
310
+ defaultIndex: defaultKeep ? 0 : 1,
311
+ });
312
+ });
313
+ } else {
314
+ // Non-interactive: keep old behavior (verbose keeps, otherwise delete).
315
+ keepOnFail = getVerbosityLevel(process.env) > 0;
316
+ }
317
+ }
318
+
319
+ const shouldDeleteSandbox = !keepSandbox && !(failed && keepOnFail);
320
+
321
+ steps.start('delete sandbox directory');
322
+ // Only delete if marker exists (paranoia guard).
323
+ // Note: if marker is missing, we intentionally leave the sandbox dir on disk.
324
+ try {
325
+ if (!existsSync(markerPath)) {
326
+ throw new Error('missing marker');
327
+ }
328
+ if (!shouldDeleteSandbox) {
329
+ steps.stop('!', 'delete sandbox directory');
330
+ // eslint-disable-next-line no-console
331
+ console.warn(`[review-pr] sandbox preserved at: ${sandboxDir}`);
332
+ if (!json && (childExitCode ?? 0) !== 0) {
333
+ // eslint-disable-next-line no-console
334
+ console.warn(`[review-pr] tip: inspect stack wiring with:`);
335
+ // eslint-disable-next-line no-console
336
+ console.warn(` npx happy-stacks --sandbox-dir "${sandboxDir}" stack info ${effectiveStackName}`);
337
+ }
338
+ } else {
339
+ await rm(markerPath, { force: false });
340
+ await rm(sandboxDir, { recursive: true, force: true });
341
+ steps.stop('✓', 'delete sandbox directory');
342
+ }
343
+ } catch {
344
+ steps.stop('x', 'delete sandbox directory');
345
+ // eslint-disable-next-line no-console
346
+ console.warn(`[review-pr] warning: failed to delete sandbox directory: ${sandboxDir}`);
347
+ // eslint-disable-next-line no-console
348
+ console.warn(`[review-pr] you can remove it manually after stopping any remaining processes.`);
349
+ // Preserve conventional exit codes on signals.
350
+ if (gotSignal) {
351
+ const code = gotSignal === 'SIGINT' ? 130 : gotSignal === 'SIGTERM' ? 143 : 1;
352
+ process.exitCode = process.exitCode ?? code;
353
+ }
354
+ return;
355
+ }
356
+ // Preserve conventional exit codes on signals.
357
+ if (gotSignal) {
358
+ const code = gotSignal === 'SIGINT' ? 130 : gotSignal === 'SIGTERM' ? 143 : 1;
359
+ process.exitCode = process.exitCode ?? code;
360
+ }
361
+ }
362
+ }
363
+
364
+ main().catch((err) => {
365
+ console.error('[review-pr] failed:', err);
366
+ process.exit(1);
367
+ });
368
+