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
@@ -0,0 +1,353 @@
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
+ import { bold, cyan, dim } from './utils/ui/ansi.mjs';
19
+
20
+ function usage() {
21
+ return [
22
+ '[review-pr] usage:',
23
+ ' happys review-pr --happy=<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...>]',
24
+ '',
25
+ 'What it does:',
26
+ '- creates a temporary sandbox dir',
27
+ '- runs `happys setup-pr ...` inside that sandbox (fully isolated state)',
28
+ '- on exit (including Ctrl+C): stops sandbox processes and deletes the sandbox dir',
29
+ '',
30
+ 'legacy note:',
31
+ '- `--happy-cli` / `--happy-server` are legacy split-repo flags; in monorepo mode, use `--happy` only.',
32
+ ].join('\n');
33
+ }
34
+
35
+ function waitForExit(child) {
36
+ return new Promise((resolvePromise, rejectPromise) => {
37
+ child.on('error', rejectPromise);
38
+ child.on('close', (code, signal) => resolvePromise({ code: code ?? 1, signal: signal ?? null }));
39
+ });
40
+ }
41
+
42
+ async function tryStopSandbox({ rootDir, sandboxDir }) {
43
+ const bin = join(rootDir, 'bin', 'happys.mjs');
44
+ const child = spawn(process.execPath, [bin, '--sandbox-dir', sandboxDir, 'stop', '--yes', '--aggressive', '--sweep-owned', '--no-service'], {
45
+ cwd: rootDir,
46
+ env: process.env,
47
+ stdio: 'ignore',
48
+ });
49
+ await waitForExit(child);
50
+ }
51
+
52
+ function argvHasFlag(argv, names) {
53
+ for (const n of names) {
54
+ if (argv.includes(n)) return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ function kvValue(argv, names) {
60
+ for (const a of argv) {
61
+ for (const n of names) {
62
+ if (a === n) {
63
+ return '';
64
+ }
65
+ if (a.startsWith(`${n}=`)) {
66
+ return a.slice(`${n}=`.length);
67
+ }
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ async function main() {
74
+ const rootDir = getRootDir(import.meta.url);
75
+ const argv = process.argv.slice(2);
76
+ const { flags } = parseArgs(argv);
77
+ const json = wantsJson(argv, { flags });
78
+ const verbosity = getVerbosityLevel(process.env);
79
+ const steps = createStepPrinter({ enabled: Boolean(process.stdout.isTTY && !json && verbosity === 0) });
80
+
81
+ if (wantsHelp(argv, { flags })) {
82
+ printResult({ json, data: { usage: usage() }, text: usage() });
83
+ return;
84
+ }
85
+
86
+ await assertCliPrereqs({ git: true, pnpm: true });
87
+
88
+ // Determine a stable base stack name from PR inputs (used for sandbox discovery),
89
+ // and a per-run unique stack name by default (prevents browser storage collisions across deleted sandboxes).
90
+ const prHappy = (kvValue(argv, ['--happy']) ?? '').trim();
91
+ const prCli = (kvValue(argv, ['--happy-cli']) ?? '').trim();
92
+ const prServer = (kvValue(argv, ['--happy-server']) ?? '').trim();
93
+ const prServerLight = (kvValue(argv, ['--happy-server-light']) ?? '').trim();
94
+ const explicitName = (kvValue(argv, ['--name']) ?? '').trim();
95
+
96
+ const baseStackName = explicitName
97
+ ? sanitizeStackName(explicitName, { fallback: 'pr', maxLen: 64 })
98
+ : inferPrStackBaseName({ happy: prHappy, happyCli: prCli, server: prServer, serverLight: prServerLight, fallback: 'pr' });
99
+
100
+ const shouldAutoSuffix = !explicitName;
101
+ const uniqueSuffix = randomToken(4); // short, URL-safe-ish
102
+ const newStackName = shouldAutoSuffix
103
+ ? sanitizeStackName(`${baseStackName}-${uniqueSuffix}`, { fallback: baseStackName, maxLen: 64 })
104
+ : baseStackName;
105
+
106
+ // Look for leftover sandboxes for the same PR base name (typically due to --keep-sandbox / failures).
107
+ const canPrompt = Boolean(process.stdout.isTTY && process.stdin.isTTY && !json);
108
+ const existingSandboxes = canPrompt ? await listReviewPrSandboxes({ baseStackName }) : [];
109
+
110
+ if (process.stdout.isTTY && !json) {
111
+ const intro = [
112
+ '',
113
+ '',
114
+ bold(`✨ ${cyan('Happy Stacks')} review-pr ✨`),
115
+ '',
116
+ 'It will help you review a PR for Happy in a completely isolated environment.',
117
+ dim('Uses `happy-server-light` (no Redis, no Postgres, no Docker).'),
118
+ dim('Desktop browser + optional mobile review (Expo dev-client).'),
119
+ '',
120
+ bold('What will happen:'),
121
+ `- ${cyan('sandbox')}: temporary isolated Happy install`,
122
+ `- ${cyan('components')}: clone/install (inside the sandbox only)`,
123
+ `- ${cyan('start')}: start the Happy stack in sandbox (server, daemon, web, mobile)`,
124
+ `- ${cyan('login')}: guide you through Happy login for this sandbox`,
125
+ `- ${cyan('browser')}: open the Happy web app`,
126
+ `- ${cyan('mobile')}: start Expo dev-client (optional)`,
127
+ `- ${cyan('cleanup')}: stop processes + delete sandbox on exit`,
128
+ '',
129
+ dim('Everything is deleted automatically when you exit.'),
130
+ dim('Your main Happy installation remains untouched.'),
131
+ '',
132
+ dim('Tips:'),
133
+ dim('- Add `-v` / `-vv` / `-vvv` to show the full logs'),
134
+ dim('- Add `--keep-sandbox` to keep the sandbox directory between runs'),
135
+ '',
136
+ existingSandboxes.length
137
+ ? bold('Choose how to proceed') + dim(' (or Ctrl+C to cancel).')
138
+ : bold('Press Enter to proceed') + dim(' (or Ctrl+C to cancel).'),
139
+ ].join('\n');
140
+ // eslint-disable-next-line no-console
141
+ console.log(intro);
142
+ if (!existingSandboxes.length) {
143
+ await withRl(async (rl) => {
144
+ await prompt(rl, '', { defaultValue: '' });
145
+ });
146
+ }
147
+ }
148
+
149
+ let sandboxDir = '';
150
+ let createdNewSandbox = false;
151
+ let reusedSandboxMeta = null;
152
+
153
+ if (existingSandboxes.length) {
154
+ const picked = await withRl(async (rl) => {
155
+ const options = [
156
+ { label: 'Create a new sandbox (recommended)', value: 'new' },
157
+ ...existingSandboxes.map((s) => {
158
+ const stackLabel = s.stackName ? `stack=${s.stackName}` : 'stack=?';
159
+ return { label: `Reuse existing sandbox (${stackLabel}) — ${s.dir}`, value: s.dir };
160
+ }),
161
+ ];
162
+ return await promptSelect(rl, {
163
+ title: 'Review-pr sandbox:',
164
+ options,
165
+ defaultIndex: 0,
166
+ });
167
+ });
168
+ if (picked === 'new') {
169
+ steps.start('create temporary sandbox');
170
+ const prefix = reviewPrSandboxPrefixPath(baseStackName);
171
+ sandboxDir = resolve(await mkdtemp(prefix));
172
+ createdNewSandbox = true;
173
+ steps.stop('✓', 'create temporary sandbox');
174
+ } else {
175
+ sandboxDir = resolve(String(picked));
176
+ reusedSandboxMeta = existingSandboxes.find((s) => resolve(s.dir) === sandboxDir) ?? null;
177
+ }
178
+ } else {
179
+ steps.start('create temporary sandbox');
180
+ const prefix = reviewPrSandboxPrefixPath(baseStackName);
181
+ sandboxDir = resolve(await mkdtemp(prefix));
182
+ createdNewSandbox = true;
183
+ steps.stop('✓', 'create temporary sandbox');
184
+ }
185
+
186
+ // If we're reusing a sandbox, prefer the stack name recorded in its meta file (keeps hostname stable),
187
+ // but only when the user did not explicitly pass --name.
188
+ const effectiveStackName =
189
+ !explicitName && reusedSandboxMeta?.stackName
190
+ ? sanitizeStackName(reusedSandboxMeta.stackName, { fallback: baseStackName, maxLen: 64 })
191
+ : newStackName;
192
+
193
+ // Safety marker to ensure we only delete what we created.
194
+ const markerPath = join(sandboxDir, '.happy-stacks-sandbox-marker');
195
+ // Always ensure the marker exists for safety; write meta only for new sandboxes.
196
+ try {
197
+ if (!existsSync(markerPath)) {
198
+ await writeFile(markerPath, 'review-pr\n', 'utf-8');
199
+ }
200
+ } catch {
201
+ // ignore; deletion guard will fail closed later if marker is missing
202
+ }
203
+ if (createdNewSandbox && existsSync(markerPath)) {
204
+ try {
205
+ await writeReviewPrSandboxMeta({ sandboxDir, baseStackName, stackName: effectiveStackName, argv });
206
+ } catch {
207
+ // ignore
208
+ }
209
+ }
210
+
211
+ const bin = join(rootDir, 'bin', 'happys.mjs');
212
+
213
+ let child = null;
214
+ let gotSignal = null;
215
+ let childExitCode = null;
216
+
217
+ const forwardSignal = (sig) => {
218
+ const first = gotSignal == null;
219
+ gotSignal = gotSignal ?? sig;
220
+ if (first && process.stdout.isTTY && !json) {
221
+ // eslint-disable-next-line no-console
222
+ console.log('\n[review-pr] received Ctrl+C — cleaning up sandbox, please wait...');
223
+ }
224
+ try {
225
+ child?.kill(sig);
226
+ } catch {
227
+ // ignore
228
+ }
229
+ };
230
+
231
+ const onSigInt = () => forwardSignal('SIGINT');
232
+ const onSigTerm = () => forwardSignal('SIGTERM');
233
+ process.on('SIGINT', onSigInt);
234
+ process.on('SIGTERM', onSigTerm);
235
+
236
+ try {
237
+ const wantsStart = flags.has('--start') || flags.has('--prod');
238
+ const hasMobileFlag = argv.includes('--mobile') || argv.includes('--with-mobile') || argv.includes('--no-mobile');
239
+ const argvWithDefaults =
240
+ process.stdout.isTTY && !json && !wantsStart && !hasMobileFlag ? [...argv, '--mobile'] : argv;
241
+
242
+ // If the caller did not explicitly name the stack, make it unique per run.
243
+ // This prevents browser storage collisions when sandboxes are deleted between runs.
244
+ const hasNameFlag = argvWithDefaults.some((a) => a === '--name' || a.startsWith('--name='));
245
+ const argvFinal = hasNameFlag ? argvWithDefaults : [...argvWithDefaults, `--name=${effectiveStackName}`];
246
+
247
+ child = spawn(process.execPath, [bin, '--sandbox-dir', sandboxDir, 'setup-pr', ...argvFinal], {
248
+ cwd: rootDir,
249
+ env: process.env,
250
+ stdio: 'inherit',
251
+ });
252
+
253
+ const { code } = await waitForExit(child);
254
+ childExitCode = code;
255
+ process.exitCode = code;
256
+ } finally {
257
+ process.off('SIGINT', onSigInt);
258
+ process.off('SIGTERM', onSigTerm);
259
+
260
+ steps.start('stop sandbox processes (best-effort)');
261
+ try {
262
+ // Best-effort stop before deleting the sandbox.
263
+ await tryStopSandbox({ rootDir, sandboxDir });
264
+ steps.stop('✓', 'stop sandbox processes (best-effort)');
265
+ } catch {
266
+ steps.stop('x', 'stop sandbox processes (best-effort)');
267
+ // eslint-disable-next-line no-console
268
+ console.warn(`[review-pr] warning: failed to stop all sandbox processes. Attempting cleanup anyway.`);
269
+ }
270
+
271
+ // On failure, offer to keep the sandbox for inspection (TTY only).
272
+ // - `--keep-sandbox` always wins (no prompt)
273
+ // - on signals, don't prompt (just follow the normal cleanup rules)
274
+ const keepSandbox = flags.has('--keep-sandbox');
275
+ const failed = !json && (childExitCode ?? 0) !== 0;
276
+ const canPromptKeep =
277
+ failed &&
278
+ !keepSandbox &&
279
+ !gotSignal &&
280
+ Boolean(process.stdout.isTTY && process.stdin.isTTY) &&
281
+ !json;
282
+
283
+ let keepOnFail = false;
284
+ if (failed && !keepSandbox && !gotSignal) {
285
+ if (canPromptKeep) {
286
+ // Default: keep in verbose mode, delete otherwise.
287
+ const defaultKeep = getVerbosityLevel(process.env) > 0;
288
+ keepOnFail = await withRl(async (rl) => {
289
+ return await promptSelect(rl, {
290
+ title: 'Review-pr failed. Keep the sandbox for inspection?',
291
+ options: [
292
+ { label: 'yes (keep sandbox directory)', value: true },
293
+ { label: 'no (delete sandbox directory)', value: false },
294
+ ],
295
+ defaultIndex: defaultKeep ? 0 : 1,
296
+ });
297
+ });
298
+ } else {
299
+ // Non-interactive: keep old behavior (verbose keeps, otherwise delete).
300
+ keepOnFail = getVerbosityLevel(process.env) > 0;
301
+ }
302
+ }
303
+
304
+ const shouldDeleteSandbox = !keepSandbox && !(failed && keepOnFail);
305
+
306
+ steps.start('delete sandbox directory');
307
+ // Only delete if marker exists (paranoia guard).
308
+ // Note: if marker is missing, we intentionally leave the sandbox dir on disk.
309
+ try {
310
+ if (!existsSync(markerPath)) {
311
+ throw new Error('missing marker');
312
+ }
313
+ if (!shouldDeleteSandbox) {
314
+ steps.stop('!', 'delete sandbox directory');
315
+ // eslint-disable-next-line no-console
316
+ console.warn(`[review-pr] sandbox preserved at: ${sandboxDir}`);
317
+ if (!json && (childExitCode ?? 0) !== 0) {
318
+ // eslint-disable-next-line no-console
319
+ console.warn(`[review-pr] tip: inspect stack wiring with:`);
320
+ // eslint-disable-next-line no-console
321
+ console.warn(` npx happy-stacks --sandbox-dir "${sandboxDir}" stack info ${effectiveStackName}`);
322
+ }
323
+ } else {
324
+ await rm(markerPath, { force: false });
325
+ await rm(sandboxDir, { recursive: true, force: true });
326
+ steps.stop('✓', 'delete sandbox directory');
327
+ }
328
+ } catch {
329
+ steps.stop('x', 'delete sandbox directory');
330
+ // eslint-disable-next-line no-console
331
+ console.warn(`[review-pr] warning: failed to delete sandbox directory: ${sandboxDir}`);
332
+ // eslint-disable-next-line no-console
333
+ console.warn(`[review-pr] you can remove it manually after stopping any remaining processes.`);
334
+ // Preserve conventional exit codes on signals.
335
+ if (gotSignal) {
336
+ const code = gotSignal === 'SIGINT' ? 130 : gotSignal === 'SIGTERM' ? 143 : 1;
337
+ process.exitCode = process.exitCode ?? code;
338
+ }
339
+ return;
340
+ }
341
+ // Preserve conventional exit codes on signals.
342
+ if (gotSignal) {
343
+ const code = gotSignal === 'SIGINT' ? 130 : gotSignal === 'SIGTERM' ? 143 : 1;
344
+ process.exitCode = process.exitCode ?? code;
345
+ }
346
+ }
347
+ }
348
+
349
+ main().catch((err) => {
350
+ console.error('[review-pr] failed:', err);
351
+ process.exit(1);
352
+ });
353
+
package/scripts/run.mjs CHANGED
@@ -2,7 +2,7 @@ import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { pathExists } from './utils/fs/fs.mjs';
4
4
  import { killProcessTree, runCapture, spawnProc } from './utils/proc/proc.mjs';
5
- import { getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
5
+ import { componentDirEnvKey, getComponentDir, getDefaultAutostartPaths, getRootDir } from './utils/paths/paths.mjs';
6
6
  import { killPortListeners } from './utils/net/ports.mjs';
7
7
  import { getServerComponentName, isHappyServerRunning, waitForServerReady } from './utils/server/server.mjs';
8
8
  import { ensureCliBuilt, ensureDepsInstalled, pmExecBin, pmSpawnScript, requireDir } from './utils/proc/pm.mjs';
@@ -13,21 +13,27 @@ import { maybeResetTailscaleServe } from './tailscale.mjs';
13
13
  import { isDaemonRunning, startLocalDaemonWithAuth, stopLocalDaemon } from './daemon.mjs';
14
14
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
15
15
  import { assertServerComponentDirMatches, assertServerPrismaProviderMatches } from './utils/server/validate.mjs';
16
+ import { resolveServerStartScript } from './utils/server/flavor_scripts.mjs';
16
17
  import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from './utils/server/infra/happy_server_infra.mjs';
17
- import { getAccountCountForServerComponent, prepareDaemonAuthSeedIfNeeded } from './utils/stack/startup.mjs';
18
+ import { getAccountCountForServerComponent, prepareDaemonAuthSeedIfNeeded, resolveAutoCopyFromMainEnabled } from './utils/stack/startup.mjs';
18
19
  import { recordStackRuntimeStart, recordStackRuntimeUpdate } from './utils/stack/runtime_state.mjs';
19
20
  import { resolveStackContext } from './utils/stack/context.mjs';
20
21
  import { getPublicServerUrlEnvOverride, resolveServerPortFromEnv, resolveServerUrls } from './utils/server/urls.mjs';
21
- import { resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
22
+ import { preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
22
23
  import { openUrlInBrowser } from './utils/ui/browser.mjs';
24
+ import { ensureDevExpoServer, resolveExpoTailscaleEnabled } from './utils/dev/expo_dev.mjs';
25
+ import { maybeRunInteractiveStackAuthSetup } from './utils/auth/interactive_stack_auth.mjs';
26
+ import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
27
+ import { daemonStartGate, formatDaemonAuthRequiredError } from './utils/auth/daemon_gate.mjs';
28
+ import { resolveServerUiEnv } from './utils/server/ui_env.mjs';
23
29
 
24
30
  /**
25
31
  * Run the local stack in "production-like" mode:
26
- * - happy-server-light
32
+ * - server (happy-server-light by default)
27
33
  * - happy-cli daemon
28
- * - serve prebuilt UI via happy-server-light (/)
34
+ * - optionally serve prebuilt UI (via server or gateway)
29
35
  *
30
- * No Expo dev server.
36
+ * Optional: Expo dev-client Metro for mobile reviewers (`--mobile`).
31
37
  */
32
38
 
33
39
  async function main() {
@@ -37,12 +43,17 @@ async function main() {
37
43
  if (wantsHelp(argv, { flags })) {
38
44
  printResult({
39
45
  json,
40
- data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--no-browser'], json: true },
46
+ data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--no-browser', '--mobile', '--expo-tailscale'], json: true },
41
47
  text: [
42
48
  '[start] usage:',
43
49
  ' happys start [--server=happy-server|happy-server-light] [--restart] [--json]',
50
+ ' happys start --mobile # also start Expo dev-client Metro for mobile',
51
+ ' happys start --expo-tailscale # forward Expo to Tailscale interface for remote access',
44
52
  ' (legacy in a cloned repo): pnpm start [-- --server=happy-server|happy-server-light] [--json]',
45
53
  ' note: --json prints the resolved config (dry-run) and exits.',
54
+ '',
55
+ 'note:',
56
+ ' If run from inside a component checkout/worktree, that checkout is used for this run (without requiring `happys wt use`).',
46
57
  ].join('\n'),
47
58
  });
48
59
  return;
@@ -50,6 +61,20 @@ async function main() {
50
61
 
51
62
  const rootDir = getRootDir(import.meta.url);
52
63
 
64
+ const inferred = inferComponentFromCwd({
65
+ rootDir,
66
+ invokedCwd: getInvokedCwd(process.env),
67
+ components: ['happy', 'happy-cli', 'happy-server-light', 'happy-server'],
68
+ });
69
+ if (inferred) {
70
+ const stacksKey = componentDirEnvKey(inferred.component);
71
+ const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
72
+ // Stack env should win. Only infer from CWD when the component dir isn't already configured.
73
+ if (!(process.env[stacksKey] ?? '').toString().trim() && !(process.env[legacyKey] ?? '').toString().trim()) {
74
+ process.env[stacksKey] = inferred.repoDir;
75
+ }
76
+ }
77
+
53
78
  const serverPort = resolveServerPortFromEnv({ defaultPort: 3005 });
54
79
 
55
80
  // Internal URL used by local processes on this machine.
@@ -67,6 +92,8 @@ async function main() {
67
92
  const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
68
93
  const serveUiWanted = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_SERVE_UI ?? '1') !== '0';
69
94
  const serveUi = serveUiWanted;
95
+ const startMobile = flags.has('--mobile') || flags.has('--with-mobile');
96
+ const expoTailscale = flags.has('--expo-tailscale') || resolveExpoTailscaleEnabled({ env: process.env });
70
97
  const noBrowser = flags.has('--no-browser') || (process.env.HAPPY_STACKS_NO_BROWSER ?? process.env.HAPPY_LOCAL_NO_BROWSER ?? '').toString().trim() === '1';
71
98
  const uiPrefix = process.env.HAPPY_LOCAL_UI_PREFIX?.trim() ? process.env.HAPPY_LOCAL_UI_PREFIX.trim() : '/';
72
99
  const autostart = getDefaultAutostartPaths();
@@ -78,12 +105,17 @@ async function main() {
78
105
 
79
106
  const serverDir = getComponentDir(rootDir, serverComponentName);
80
107
  const cliDir = getComponentDir(rootDir, 'happy-cli');
108
+ const uiDir = getComponentDir(rootDir, 'happy');
109
+ const serverStartScript = resolveServerStartScript({ serverComponentName, serverDir });
81
110
 
82
111
  assertServerComponentDirMatches({ rootDir, serverComponentName, serverDir });
83
112
  assertServerPrismaProviderMatches({ serverComponentName, serverDir });
84
113
 
85
114
  await requireDir(serverComponentName, serverDir);
86
115
  await requireDir('happy-cli', cliDir);
116
+ if (startMobile) {
117
+ await requireDir('happy', uiDir);
118
+ }
87
119
 
88
120
  const cliBin = join(cliDir, 'bin', 'happy.mjs');
89
121
 
@@ -99,12 +131,14 @@ async function main() {
99
131
  mode: 'start',
100
132
  serverComponentName,
101
133
  serverDir,
134
+ uiDir,
102
135
  cliDir,
103
136
  serverPort,
104
137
  internalServerUrl,
105
138
  publicServerUrl,
106
139
  startDaemon,
107
140
  serveUi,
141
+ startMobile,
108
142
  uiPrefix,
109
143
  uiBuildDir,
110
144
  cliHomeDir,
@@ -113,19 +147,20 @@ async function main() {
113
147
  return;
114
148
  }
115
149
 
116
- if (serveUi && !(await pathExists(uiBuildDir))) {
150
+ const uiBuildDirExists = await pathExists(uiBuildDir);
151
+ if (serveUi && !uiBuildDirExists) {
117
152
  if (serverComponentName === 'happy-server-light') {
118
153
  throw new Error(`[local] UI build directory not found at ${uiBuildDir}. Run: happys build (legacy in a cloned repo: pnpm build)`);
119
154
  }
120
- // For happy-server, UI serving is optional via the UI gateway.
121
- console.log(`[local] UI build directory not found at ${uiBuildDir}; UI gateway will be disabled`);
155
+ // For happy-server, UI serving is optional.
156
+ console.log(`[local] UI build directory not found at ${uiBuildDir}; UI serving will be disabled`);
122
157
  }
123
158
 
124
159
  const children = [];
125
160
  let shuttingDown = false;
126
161
  const baseEnv = { ...process.env };
127
162
  const stackCtx = resolveStackContext({ env: baseEnv, autostart });
128
- const { stackMode, runtimeStatePath, stackName, ephemeral } = stackCtx;
163
+ const { stackMode, runtimeStatePath, stackName, envPath, ephemeral } = stackCtx;
129
164
 
130
165
  // Ensure happy-cli is install+build ready before starting the daemon.
131
166
  const buildCli = (baseEnv.HAPPY_STACKS_CLI_BUILD ?? baseEnv.HAPPY_LOCAL_CLI_BUILD ?? '1').toString().trim() !== '0';
@@ -133,12 +168,18 @@ async function main() {
133
168
 
134
169
  // Ensure server deps exist before any Prisma/docker work.
135
170
  await ensureDepsInstalled(serverDir, serverComponentName);
171
+ if (startMobile) {
172
+ await ensureDepsInstalled(uiDir, 'happy');
173
+ }
136
174
 
137
175
  // Public URL automation:
138
176
  // - Only the main stack should ever auto-enable Tailscale Serve by default.
139
177
  // - Non-main stacks default to localhost unless the user explicitly configured a public URL
140
178
  // OR Tailscale Serve is already configured for this stack's internal URL (status matches).
141
- const allowEnableTailscale = !stackMode || stackName === 'main';
179
+ const allowEnableTailscale =
180
+ !stackMode ||
181
+ stackName === 'main' ||
182
+ (baseEnv.HAPPY_STACKS_TAILSCALE_SERVE ?? baseEnv.HAPPY_LOCAL_TAILSCALE_SERVE ?? '0').toString().trim() === '1';
142
183
  const resolvedUrls = await resolveServerUrls({ env: baseEnv, serverPort, allowEnable: allowEnableTailscale });
143
184
  if (stackMode && stackName !== 'main' && !resolvedUrls.envPublicUrl) {
144
185
  const src = String(resolvedUrls.publicServerUrlSource ?? '');
@@ -181,12 +222,7 @@ async function main() {
181
222
  // Avoid noisy failures if a previous run left the metrics port busy.
182
223
  // You can override with METRICS_ENABLED=true if you want it.
183
224
  METRICS_ENABLED: baseEnv.METRICS_ENABLED ?? 'false',
184
- ...(serveUi && serverComponentName === 'happy-server-light'
185
- ? {
186
- HAPPY_SERVER_LIGHT_UI_DIR: uiBuildDir,
187
- HAPPY_SERVER_LIGHT_UI_PREFIX: uiPrefix,
188
- }
189
- : {}),
225
+ ...resolveServerUiEnv({ serveUi, uiBuildDir, uiPrefix, uiBuildDirExists }),
190
226
  };
191
227
  let serverLightAccountCount = null;
192
228
  let happyServerAccountCount = null;
@@ -285,7 +321,7 @@ async function main() {
285
321
  // Default server start (happy-server-light, or happy-server without managed infra).
286
322
  if (!(serverComponentName === 'happy-server' && (baseEnv.HAPPY_STACKS_MANAGED_INFRA ?? baseEnv.HAPPY_LOCAL_MANAGED_INFRA ?? '1') !== '0')) {
287
323
  if (!serverAlreadyRunning || restart) {
288
- const server = await pmSpawnScript({ label: 'server', dir: serverDir, script: 'start', env: serverEnv });
324
+ const server = await pmSpawnScript({ label: 'server', dir: serverDir, script: serverStartScript, env: serverEnv });
289
325
  children.push(server);
290
326
  if (stackMode && runtimeStatePath) {
291
327
  await recordStackRuntimeUpdate(runtimeStatePath, { processes: { serverPid: server.pid } }).catch(() => {});
@@ -331,9 +367,8 @@ async function main() {
331
367
  // Auto-open UI (interactive only) using the stack-scoped hostname when applicable.
332
368
  const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
333
369
  if (isInteractive && !noBrowser) {
334
- const host = resolveLocalhostHost({ stackMode, stackName: autostart.stackName });
335
370
  const prefix = uiPrefix.startsWith('/') ? uiPrefix : `/${uiPrefix}`;
336
- const openUrl = `http://${host}:${serverPort}${prefix}`;
371
+ const openUrl = await preferStackLocalhostUrl(`http://localhost:${serverPort}${prefix}`, { stackName: autostart.stackName });
337
372
  const res = await openUrlInBrowser(openUrl);
338
373
  if (!res.ok) {
339
374
  console.warn(`[local] ui: failed to open browser automatically (${res.error}).`);
@@ -343,6 +378,16 @@ async function main() {
343
378
 
344
379
  // Daemon
345
380
  if (startDaemon) {
381
+ const gate = daemonStartGate({ env: baseEnv, cliHomeDir });
382
+ if (!gate.ok) {
383
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
384
+ // In orchestrated auth flows, keep server/UI up and let the orchestrator start daemon post-auth.
385
+ if (gate.reason === 'auth_flow_missing_credentials') {
386
+ console.log('[local] auth flow: skipping daemon start until credentials exist');
387
+ } else if (!isInteractive) {
388
+ throw new Error(formatDaemonAuthRequiredError({ stackName: autostart.stackName, cliHomeDir }));
389
+ }
390
+ } else {
346
391
  const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
347
392
  if (serverComponentName === 'happy-server' && happyServerAccountCount == null) {
348
393
  const acct = await getAccountCountForServerComponent({
@@ -355,6 +400,16 @@ async function main() {
355
400
  }
356
401
  const accountCount =
357
402
  serverComponentName === 'happy-server-light' ? serverLightAccountCount : happyServerAccountCount;
403
+ const autoSeedEnabled = resolveAutoCopyFromMainEnabled({ env: baseEnv, stackName: autostart.stackName, isInteractive });
404
+ await maybeRunInteractiveStackAuthSetup({
405
+ rootDir,
406
+ env: baseEnv,
407
+ stackName: autostart.stackName,
408
+ cliHomeDir,
409
+ accountCount,
410
+ isInteractive,
411
+ autoSeedEnabled,
412
+ });
358
413
  await prepareDaemonAuthSeedIfNeeded({
359
414
  rootDir,
360
415
  env: baseEnv,
@@ -372,7 +427,32 @@ async function main() {
372
427
  publicServerUrl,
373
428
  isShuttingDown: () => shuttingDown,
374
429
  forceRestart: restart,
430
+ env: baseEnv,
431
+ stackName: autostart.stackName,
375
432
  });
433
+ }
434
+ }
435
+
436
+ // Optional: start Expo dev-client Metro for mobile reviewers.
437
+ if (startMobile) {
438
+ const expoRes = await ensureDevExpoServer({
439
+ startUi: false,
440
+ startMobile: true,
441
+ uiDir,
442
+ autostart,
443
+ baseEnv,
444
+ apiServerUrl: publicServerUrl,
445
+ restart,
446
+ stackMode,
447
+ runtimeStatePath,
448
+ stackName,
449
+ envPath,
450
+ children,
451
+ expoTailscale,
452
+ });
453
+ if (expoRes?.tailscale?.ok && expoRes.tailscale.tailscaleIp && expoRes.port) {
454
+ console.log(`[local] expo tailscale: http://${expoRes.tailscale.tailscaleIp}:${expoRes.port}`);
455
+ }
376
456
  }
377
457
 
378
458
  const shutdown = async () => {
@@ -1,6 +1,6 @@
1
1
  import './utils/env/env.mjs';
2
2
  import { run, runCapture } from './utils/proc/proc.mjs';
3
- import { getDefaultAutostartPaths, getRootDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
3
+ import { getComponentDir, getDefaultAutostartPaths, getRootDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
4
4
  import { getInternalServerUrl, getPublicServerUrlEnvOverride } from './utils/server/urls.mjs';
5
5
  import { ensureMacAutostartDisabled, ensureMacAutostartEnabled } from './utils/service/autostart_darwin.mjs';
6
6
  import { getCanonicalHomeDir } from './utils/env/config.mjs';
@@ -278,7 +278,7 @@ async function postStartDiagnostics() {
278
278
  }
279
279
  const { publicServerUrl: publicUrl } = getPublicServerUrlEnvOverride({ env: process.env, serverPort: port });
280
280
 
281
- const cliDir = join(rootDir, 'components', 'happy-cli');
281
+ const cliDir = getComponentDir(rootDir, 'happy-cli');
282
282
  const cliBin = join(cliDir, 'bin', 'happy.mjs');
283
283
 
284
284
  const accessKey = join(cliHomeDir, 'access.key');