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
@@ -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
+
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';
@@ -14,12 +14,16 @@ import { isDaemonRunning, startLocalDaemonWithAuth, stopLocalDaemon } from './da
14
14
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
15
15
  import { assertServerComponentDirMatches, assertServerPrismaProviderMatches } from './utils/server/validate.mjs';
16
16
  import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from './utils/server/infra/happy_server_infra.mjs';
17
- import { getAccountCountForServerComponent, prepareDaemonAuthSeedIfNeeded } from './utils/stack/startup.mjs';
17
+ import { getAccountCountForServerComponent, prepareDaemonAuthSeedIfNeeded, resolveAutoCopyFromMainEnabled } from './utils/stack/startup.mjs';
18
18
  import { recordStackRuntimeStart, recordStackRuntimeUpdate } from './utils/stack/runtime_state.mjs';
19
19
  import { resolveStackContext } from './utils/stack/context.mjs';
20
20
  import { getPublicServerUrlEnvOverride, resolveServerPortFromEnv, resolveServerUrls } from './utils/server/urls.mjs';
21
- import { resolveLocalhostHost } from './utils/paths/localhost_host.mjs';
21
+ import { preferStackLocalhostUrl } from './utils/paths/localhost_host.mjs';
22
22
  import { openUrlInBrowser } from './utils/ui/browser.mjs';
23
+ import { ensureDevExpoServer } from './utils/dev/expo_dev.mjs';
24
+ import { maybeRunInteractiveStackAuthSetup } from './utils/auth/interactive_stack_auth.mjs';
25
+ import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
26
+ import { daemonStartGate, formatDaemonAuthRequiredError } from './utils/auth/daemon_gate.mjs';
23
27
 
24
28
  /**
25
29
  * Run the local stack in "production-like" mode:
@@ -27,7 +31,7 @@ import { openUrlInBrowser } from './utils/ui/browser.mjs';
27
31
  * - happy-cli daemon
28
32
  * - serve prebuilt UI via happy-server-light (/)
29
33
  *
30
- * No Expo dev server.
34
+ * Optional: Expo dev-client Metro for mobile reviewers (`--mobile`).
31
35
  */
32
36
 
33
37
  async function main() {
@@ -37,12 +41,15 @@ async function main() {
37
41
  if (wantsHelp(argv, { flags })) {
38
42
  printResult({
39
43
  json,
40
- data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--no-browser'], json: true },
44
+ data: { flags: ['--server=happy-server|happy-server-light', '--no-ui', '--no-daemon', '--restart', '--no-browser', '--mobile'], json: true },
41
45
  text: [
42
46
  '[start] usage:',
43
47
  ' happys start [--server=happy-server|happy-server-light] [--restart] [--json]',
44
48
  ' (legacy in a cloned repo): pnpm start [-- --server=happy-server|happy-server-light] [--json]',
45
49
  ' note: --json prints the resolved config (dry-run) and exits.',
50
+ '',
51
+ 'note:',
52
+ ' If run from inside a component checkout/worktree, that checkout is used for this run (without requiring `happys wt use`).',
46
53
  ].join('\n'),
47
54
  });
48
55
  return;
@@ -50,6 +57,20 @@ async function main() {
50
57
 
51
58
  const rootDir = getRootDir(import.meta.url);
52
59
 
60
+ const inferred = inferComponentFromCwd({
61
+ rootDir,
62
+ invokedCwd: getInvokedCwd(process.env),
63
+ components: ['happy', 'happy-cli', 'happy-server-light', 'happy-server'],
64
+ });
65
+ if (inferred) {
66
+ const stacksKey = componentDirEnvKey(inferred.component);
67
+ const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
68
+ // Stack env should win. Only infer from CWD when the component dir isn't already configured.
69
+ if (!(process.env[stacksKey] ?? '').toString().trim() && !(process.env[legacyKey] ?? '').toString().trim()) {
70
+ process.env[stacksKey] = inferred.repoDir;
71
+ }
72
+ }
73
+
53
74
  const serverPort = resolveServerPortFromEnv({ defaultPort: 3005 });
54
75
 
55
76
  // Internal URL used by local processes on this machine.
@@ -67,6 +88,7 @@ async function main() {
67
88
  const startDaemon = !flags.has('--no-daemon') && (process.env.HAPPY_LOCAL_DAEMON ?? '1') !== '0';
68
89
  const serveUiWanted = !flags.has('--no-ui') && (process.env.HAPPY_LOCAL_SERVE_UI ?? '1') !== '0';
69
90
  const serveUi = serveUiWanted;
91
+ const startMobile = flags.has('--mobile') || flags.has('--with-mobile');
70
92
  const noBrowser = flags.has('--no-browser') || (process.env.HAPPY_STACKS_NO_BROWSER ?? process.env.HAPPY_LOCAL_NO_BROWSER ?? '').toString().trim() === '1';
71
93
  const uiPrefix = process.env.HAPPY_LOCAL_UI_PREFIX?.trim() ? process.env.HAPPY_LOCAL_UI_PREFIX.trim() : '/';
72
94
  const autostart = getDefaultAutostartPaths();
@@ -78,12 +100,16 @@ async function main() {
78
100
 
79
101
  const serverDir = getComponentDir(rootDir, serverComponentName);
80
102
  const cliDir = getComponentDir(rootDir, 'happy-cli');
103
+ const uiDir = getComponentDir(rootDir, 'happy');
81
104
 
82
105
  assertServerComponentDirMatches({ rootDir, serverComponentName, serverDir });
83
106
  assertServerPrismaProviderMatches({ serverComponentName, serverDir });
84
107
 
85
108
  await requireDir(serverComponentName, serverDir);
86
109
  await requireDir('happy-cli', cliDir);
110
+ if (startMobile) {
111
+ await requireDir('happy', uiDir);
112
+ }
87
113
 
88
114
  const cliBin = join(cliDir, 'bin', 'happy.mjs');
89
115
 
@@ -99,12 +125,14 @@ async function main() {
99
125
  mode: 'start',
100
126
  serverComponentName,
101
127
  serverDir,
128
+ uiDir,
102
129
  cliDir,
103
130
  serverPort,
104
131
  internalServerUrl,
105
132
  publicServerUrl,
106
133
  startDaemon,
107
134
  serveUi,
135
+ startMobile,
108
136
  uiPrefix,
109
137
  uiBuildDir,
110
138
  cliHomeDir,
@@ -125,7 +153,7 @@ async function main() {
125
153
  let shuttingDown = false;
126
154
  const baseEnv = { ...process.env };
127
155
  const stackCtx = resolveStackContext({ env: baseEnv, autostart });
128
- const { stackMode, runtimeStatePath, stackName, ephemeral } = stackCtx;
156
+ const { stackMode, runtimeStatePath, stackName, envPath, ephemeral } = stackCtx;
129
157
 
130
158
  // Ensure happy-cli is install+build ready before starting the daemon.
131
159
  const buildCli = (baseEnv.HAPPY_STACKS_CLI_BUILD ?? baseEnv.HAPPY_LOCAL_CLI_BUILD ?? '1').toString().trim() !== '0';
@@ -133,12 +161,18 @@ async function main() {
133
161
 
134
162
  // Ensure server deps exist before any Prisma/docker work.
135
163
  await ensureDepsInstalled(serverDir, serverComponentName);
164
+ if (startMobile) {
165
+ await ensureDepsInstalled(uiDir, 'happy');
166
+ }
136
167
 
137
168
  // Public URL automation:
138
169
  // - Only the main stack should ever auto-enable Tailscale Serve by default.
139
170
  // - Non-main stacks default to localhost unless the user explicitly configured a public URL
140
171
  // OR Tailscale Serve is already configured for this stack's internal URL (status matches).
141
- const allowEnableTailscale = !stackMode || stackName === 'main';
172
+ const allowEnableTailscale =
173
+ !stackMode ||
174
+ stackName === 'main' ||
175
+ (baseEnv.HAPPY_STACKS_TAILSCALE_SERVE ?? baseEnv.HAPPY_LOCAL_TAILSCALE_SERVE ?? '0').toString().trim() === '1';
142
176
  const resolvedUrls = await resolveServerUrls({ env: baseEnv, serverPort, allowEnable: allowEnableTailscale });
143
177
  if (stackMode && stackName !== 'main' && !resolvedUrls.envPublicUrl) {
144
178
  const src = String(resolvedUrls.publicServerUrlSource ?? '');
@@ -331,9 +365,8 @@ async function main() {
331
365
  // Auto-open UI (interactive only) using the stack-scoped hostname when applicable.
332
366
  const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
333
367
  if (isInteractive && !noBrowser) {
334
- const host = resolveLocalhostHost({ stackMode, stackName: autostart.stackName });
335
368
  const prefix = uiPrefix.startsWith('/') ? uiPrefix : `/${uiPrefix}`;
336
- const openUrl = `http://${host}:${serverPort}${prefix}`;
369
+ const openUrl = await preferStackLocalhostUrl(`http://localhost:${serverPort}${prefix}`, { stackName: autostart.stackName });
337
370
  const res = await openUrlInBrowser(openUrl);
338
371
  if (!res.ok) {
339
372
  console.warn(`[local] ui: failed to open browser automatically (${res.error}).`);
@@ -343,6 +376,16 @@ async function main() {
343
376
 
344
377
  // Daemon
345
378
  if (startDaemon) {
379
+ const gate = daemonStartGate({ env: baseEnv, cliHomeDir });
380
+ if (!gate.ok) {
381
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
382
+ // In orchestrated auth flows, keep server/UI up and let the orchestrator start daemon post-auth.
383
+ if (gate.reason === 'auth_flow_missing_credentials') {
384
+ console.log('[local] auth flow: skipping daemon start until credentials exist');
385
+ } else if (!isInteractive) {
386
+ throw new Error(formatDaemonAuthRequiredError({ stackName: autostart.stackName, cliHomeDir }));
387
+ }
388
+ } else {
346
389
  const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
347
390
  if (serverComponentName === 'happy-server' && happyServerAccountCount == null) {
348
391
  const acct = await getAccountCountForServerComponent({
@@ -355,6 +398,16 @@ async function main() {
355
398
  }
356
399
  const accountCount =
357
400
  serverComponentName === 'happy-server-light' ? serverLightAccountCount : happyServerAccountCount;
401
+ const autoSeedEnabled = resolveAutoCopyFromMainEnabled({ env: baseEnv, stackName: autostart.stackName, isInteractive });
402
+ await maybeRunInteractiveStackAuthSetup({
403
+ rootDir,
404
+ env: baseEnv,
405
+ stackName: autostart.stackName,
406
+ cliHomeDir,
407
+ accountCount,
408
+ isInteractive,
409
+ autoSeedEnabled,
410
+ });
358
411
  await prepareDaemonAuthSeedIfNeeded({
359
412
  rootDir,
360
413
  env: baseEnv,
@@ -372,6 +425,27 @@ async function main() {
372
425
  publicServerUrl,
373
426
  isShuttingDown: () => shuttingDown,
374
427
  forceRestart: restart,
428
+ env: baseEnv,
429
+ stackName: autostart.stackName,
430
+ });
431
+ }
432
+ }
433
+
434
+ // Optional: start Expo dev-client Metro for mobile reviewers.
435
+ if (startMobile) {
436
+ await ensureDevExpoServer({
437
+ startUi: false,
438
+ startMobile: true,
439
+ uiDir,
440
+ autostart,
441
+ baseEnv,
442
+ apiServerUrl: publicServerUrl,
443
+ restart,
444
+ stackMode,
445
+ runtimeStatePath,
446
+ stackName,
447
+ envPath,
448
+ children,
375
449
  });
376
450
  }
377
451
 
@@ -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');