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,157 @@
1
+ import { createReadStream, existsSync } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+
5
+ function splitLines(s) {
6
+ return String(s ?? '').split(/\r?\n/);
7
+ }
8
+
9
+ function supportsAnsi() {
10
+ if (!process.stdout.isTTY) return false;
11
+ if (process.env.NO_COLOR) return false;
12
+ if ((process.env.TERM ?? '').toLowerCase() === 'dumb') return false;
13
+ return true;
14
+ }
15
+
16
+ function dim(s) {
17
+ return supportsAnsi() ? `\x1b[2m${s}\x1b[0m` : String(s);
18
+ }
19
+
20
+ /**
21
+ * Lightweight file log forwarder (tail-like) with pause/resume.
22
+ *
23
+ * - Always advances the file offset (prevents backpressure issues).
24
+ * - While paused, it buffers the last N lines and prints them once resumed.
25
+ */
26
+ export function createFileLogForwarder({
27
+ path,
28
+ enabled = true,
29
+ pollMs = 200,
30
+ maxBytesPerTick = 256 * 1024,
31
+ bufferedLinesWhilePaused = 120,
32
+ startFromEnd = true,
33
+ label = 'logs',
34
+ } = {}) {
35
+ const p = String(path ?? '').trim();
36
+ if (!enabled || !p) {
37
+ return {
38
+ ok: false,
39
+ start: async () => {},
40
+ stop: async () => {},
41
+ pause: () => {},
42
+ resume: () => {},
43
+ isPaused: () => false,
44
+ path: p,
45
+ };
46
+ }
47
+
48
+ let running = false;
49
+ let paused = false;
50
+ let offset = 0;
51
+ let partial = '';
52
+ let buffered = [];
53
+
54
+ const pushBufferedLine = (line) => {
55
+ if (!line) return;
56
+ buffered.push(line);
57
+ if (buffered.length > bufferedLinesWhilePaused) {
58
+ buffered = buffered.slice(buffered.length - bufferedLinesWhilePaused);
59
+ }
60
+ };
61
+
62
+ const flushBuffered = () => {
63
+ if (!buffered.length) return;
64
+ // eslint-disable-next-line no-console
65
+ console.log(dim(`[${label}] (showing last ${buffered.length} lines while paused)`));
66
+ for (const l of buffered) {
67
+ // eslint-disable-next-line no-console
68
+ console.log(l);
69
+ }
70
+ buffered = [];
71
+ };
72
+
73
+ const readNewBytes = async () => {
74
+ if (!existsSync(p)) return;
75
+ let st = null;
76
+ try {
77
+ st = await stat(p);
78
+ } catch {
79
+ return;
80
+ }
81
+ const size = Number(st?.size ?? 0);
82
+ if (!Number.isFinite(size) || size <= 0) return;
83
+ if (size < offset) {
84
+ // truncated/rotated
85
+ offset = 0;
86
+ }
87
+ if (size === offset) return;
88
+
89
+ const end = Math.min(size, offset + maxBytesPerTick);
90
+ const start = offset;
91
+ offset = end;
92
+
93
+ await new Promise((resolvePromise) => {
94
+ const chunks = [];
95
+ const stream = createReadStream(p, { start, end: end - 1 });
96
+ stream.on('data', (d) => chunks.push(Buffer.from(d)));
97
+ stream.on('error', () => resolvePromise());
98
+ stream.on('close', () => {
99
+ const text = partial + Buffer.concat(chunks).toString('utf-8');
100
+ const lines = splitLines(text);
101
+ partial = lines.pop() ?? '';
102
+ for (const line of lines) {
103
+ if (paused) {
104
+ pushBufferedLine(line);
105
+ } else {
106
+ // eslint-disable-next-line no-console
107
+ console.log(line);
108
+ }
109
+ }
110
+ resolvePromise();
111
+ });
112
+ });
113
+ };
114
+
115
+ const loop = async () => {
116
+ while (running) {
117
+ // eslint-disable-next-line no-await-in-loop
118
+ await readNewBytes();
119
+ // eslint-disable-next-line no-await-in-loop
120
+ await delay(pollMs);
121
+ }
122
+ };
123
+
124
+ return {
125
+ ok: true,
126
+ path: p,
127
+ start: async () => {
128
+ if (running) return;
129
+ running = true;
130
+ // By default, start at end (don't replay historical logs).
131
+ if (startFromEnd) {
132
+ try {
133
+ const st = await stat(p);
134
+ offset = Number(st?.size ?? 0) || 0;
135
+ } catch {
136
+ offset = 0;
137
+ }
138
+ } else {
139
+ offset = 0;
140
+ }
141
+ void loop();
142
+ },
143
+ stop: async () => {
144
+ running = false;
145
+ },
146
+ pause: () => {
147
+ paused = true;
148
+ buffered = [];
149
+ },
150
+ resume: () => {
151
+ paused = false;
152
+ flushBuffered();
153
+ },
154
+ isPaused: () => paused,
155
+ };
156
+ }
157
+
@@ -0,0 +1,72 @@
1
+ import { commandExists } from '../proc/commands.mjs';
2
+
3
+ function formatMissingTool({ name, why, install }) {
4
+ return [`- ${name}: ${why}`, ...(install?.length ? install.map((l) => ` ${l}`) : [])].join('\n');
5
+ }
6
+
7
+ export async function assertCliPrereqs({ git = false, pnpm = false, codex = false, coderabbit = false } = {}) {
8
+ const missing = [];
9
+
10
+ if (git) {
11
+ const hasGit = await commandExists('git');
12
+ if (!hasGit) {
13
+ const install =
14
+ process.platform === 'darwin'
15
+ ? ['Install Xcode Command Line Tools: `xcode-select --install`', 'Or install Git via Homebrew: `brew install git`']
16
+ : ['Install Git using your package manager (e.g. `apt install git`, `dnf install git`)'];
17
+ missing.push({
18
+ name: 'git',
19
+ why: 'required for cloning + updating PR worktrees',
20
+ install,
21
+ });
22
+ }
23
+ }
24
+
25
+ if (pnpm) {
26
+ const hasPnpm = await commandExists('pnpm');
27
+ if (!hasPnpm) {
28
+ missing.push({
29
+ name: 'pnpm',
30
+ why: 'required to install dependencies for Happy Stacks components',
31
+ install: ['Install it via Corepack: `corepack enable && corepack prepare pnpm@latest --activate`'],
32
+ });
33
+ }
34
+ }
35
+
36
+ if (codex) {
37
+ const hasCodex = await commandExists('codex');
38
+ if (!hasCodex) {
39
+ missing.push({
40
+ name: 'codex',
41
+ why: 'required to run Codex review',
42
+ install: [
43
+ 'Install Codex CLI and ensure `codex` is on PATH',
44
+ 'If using a managed install, ensure your PATH includes the Codex binary',
45
+ ],
46
+ });
47
+ }
48
+ }
49
+
50
+ if (coderabbit) {
51
+ const hasCodeRabbit = await commandExists('coderabbit');
52
+ if (!hasCodeRabbit) {
53
+ missing.push({
54
+ name: 'coderabbit',
55
+ why: 'required to run CodeRabbit CLI review',
56
+ install: [
57
+ 'Install CodeRabbit CLI: `curl -fsSL https://cli.coderabbit.ai/install.sh | sh`',
58
+ 'Then authenticate: `coderabbit auth login`',
59
+ ],
60
+ });
61
+ }
62
+ }
63
+
64
+ if (!missing.length) return;
65
+
66
+ throw new Error(
67
+ `[prereqs] missing required tools:\n` +
68
+ `${missing.map(formatMissingTool).join('\n')}\n\n` +
69
+ `[prereqs] After installing, re-run the command.`
70
+ );
71
+ }
72
+
@@ -0,0 +1,126 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { mkdir } from 'node:fs/promises';
4
+ import { dirname } from 'node:path';
5
+
6
+ function isTty() {
7
+ return Boolean(process.stdout.isTTY && process.stderr.isTTY);
8
+ }
9
+
10
+ function spinnerFrames() {
11
+ return ['|', '/', '-', '\\'];
12
+ }
13
+
14
+ export function createStepPrinter({ enabled = true } = {}) {
15
+ const tty = enabled && isTty();
16
+ const frames = spinnerFrames();
17
+ let timer = null;
18
+ let idx = 0;
19
+ let currentLine = '';
20
+
21
+ const write = (s) => process.stdout.write(s);
22
+
23
+ const start = (label) => {
24
+ if (!tty) {
25
+ write(`- [..] ${label}\n`);
26
+ return;
27
+ }
28
+ currentLine = `- [${frames[idx % frames.length]}] ${label}`;
29
+ write(currentLine);
30
+ timer = setInterval(() => {
31
+ idx++;
32
+ const next = `- [${frames[idx % frames.length]}] ${label}`;
33
+ const pad = currentLine.length > next.length ? ' '.repeat(currentLine.length - next.length) : '';
34
+ currentLine = next;
35
+ write(`\r${next}${pad}`);
36
+ }, 120);
37
+ };
38
+
39
+ const stop = (result, label) => {
40
+ if (timer) clearInterval(timer);
41
+ timer = null;
42
+ if (!tty) {
43
+ write(`- [${result}] ${label}\n`);
44
+ return;
45
+ }
46
+ const out = `- [${result}] ${label}`;
47
+ const pad = currentLine.length > out.length ? ' '.repeat(currentLine.length - out.length) : '';
48
+ currentLine = '';
49
+ write(`\r${out}${pad}\n`);
50
+ };
51
+
52
+ const info = (line) => {
53
+ write(`${line}\n`);
54
+ };
55
+
56
+ return { start, stop, info };
57
+ }
58
+
59
+ export async function runCommandLogged({
60
+ label,
61
+ cmd,
62
+ args,
63
+ cwd,
64
+ env,
65
+ logPath,
66
+ showSteps = true,
67
+ quiet = true,
68
+ }) {
69
+ const steps = createStepPrinter({ enabled: showSteps });
70
+ if (quiet) {
71
+ await mkdir(dirname(logPath), { recursive: true }).catch(() => {});
72
+ }
73
+
74
+ steps.start(label);
75
+
76
+ const child = spawn(cmd, args, {
77
+ cwd,
78
+ env,
79
+ stdio: quiet ? ['ignore', 'pipe', 'pipe'] : 'inherit',
80
+ shell: false,
81
+ });
82
+
83
+ let stdout = '';
84
+ let stderr = '';
85
+ let logStream = null;
86
+ if (quiet) {
87
+ logStream = createWriteStream(logPath, { flags: 'a' });
88
+ child.stdout?.on('data', (d) => {
89
+ const s = d.toString();
90
+ stdout += s;
91
+ logStream?.write(s);
92
+ });
93
+ child.stderr?.on('data', (d) => {
94
+ const s = d.toString();
95
+ stderr += s;
96
+ logStream?.write(s);
97
+ });
98
+ }
99
+
100
+ const res = await new Promise((resolvePromise, rejectPromise) => {
101
+ child.on('error', rejectPromise);
102
+ child.on('close', (code, signal) => resolvePromise({ code: code ?? 1, signal: signal ?? null }));
103
+ });
104
+
105
+ try {
106
+ logStream?.end();
107
+ } catch {
108
+ // ignore
109
+ }
110
+
111
+ if (res.code === 0) {
112
+ steps.stop('✓', label);
113
+ return { ok: true, code: 0, stdout, stderr, logPath };
114
+ }
115
+
116
+ steps.stop('x', label);
117
+ const err = new Error(`${cmd} failed (code=${res.code}${res.signal ? `, sig=${res.signal}` : ''})`);
118
+ err.code = 'EEXIT';
119
+ err.exitCode = res.code;
120
+ err.signal = res.signal;
121
+ err.stdout = stdout;
122
+ err.stderr = stderr;
123
+ err.logPath = logPath;
124
+ throw err;
125
+ }
126
+
@@ -0,0 +1,12 @@
1
+ export function getVerbosityLevel(env = process.env) {
2
+ const raw = (env.HAPPY_STACKS_VERBOSE ?? '').toString().trim();
3
+ if (!raw) return 0;
4
+ const n = Number(raw);
5
+ if (!Number.isFinite(n)) return 1;
6
+ return Math.max(0, Math.min(3, Math.floor(n)));
7
+ }
8
+
9
+ export function isVerbose(env = process.env) {
10
+ return getVerbosityLevel(env) > 0;
11
+ }
12
+
@@ -1,4 +1,5 @@
1
- import { resolve } from 'node:path';
1
+ import { join, resolve } from 'node:path';
2
+ import { existsSync } from 'node:fs';
2
3
 
3
4
  import { ensureCliBuilt, ensureDepsInstalled } from '../proc/pm.mjs';
4
5
  import { watchDebounced } from '../proc/watch.mjs';
@@ -7,7 +8,26 @@ import { startLocalDaemonWithAuth } from '../../daemon.mjs';
7
8
 
8
9
  export async function ensureDevCliReady({ cliDir, buildCli }) {
9
10
  await ensureDepsInstalled(cliDir, 'happy-cli');
10
- return await ensureCliBuilt(cliDir, { buildCli });
11
+ const res = await ensureCliBuilt(cliDir, { buildCli });
12
+
13
+ // Fail closed: dev mode must never start the daemon without a usable happy-cli build output.
14
+ // Even if the user disabled CLI builds globally (or build mode is "never"), missing dist will
15
+ // cause an immediate MODULE_NOT_FOUND crash when spawning the daemon.
16
+ const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
17
+ if (!existsSync(distEntrypoint)) {
18
+ // Last-chance recovery: force a build once.
19
+ await ensureCliBuilt(cliDir, { buildCli: true });
20
+ if (!existsSync(distEntrypoint)) {
21
+ throw new Error(
22
+ `[local] happy-cli build output is missing.\n` +
23
+ `Expected: ${distEntrypoint}\n` +
24
+ `Fix: run the component build directly and inspect its output:\n` +
25
+ ` cd "${cliDir}" && yarn build`
26
+ );
27
+ }
28
+ }
29
+
30
+ return res;
11
31
  }
12
32
 
13
33
  export async function prepareDaemonAuthSeed({
@@ -77,8 +97,25 @@ export function watchHappyCliAndRestartDaemon({
77
97
  if (!enabled || !startDaemon) return null;
78
98
 
79
99
  let inFlight = false;
100
+
101
+ // IMPORTANT:
102
+ // Watch only source/config paths, not build outputs. Watching the whole repo can
103
+ // trigger rebuild loops because `yarn build` writes to `dist/` (and may touch other
104
+ // generated files), which then retriggers the watcher.
105
+ const watchPaths = [
106
+ join(cliDir, 'src'),
107
+ join(cliDir, 'bin'),
108
+ join(cliDir, 'codex'),
109
+ join(cliDir, 'package.json'),
110
+ join(cliDir, 'tsconfig.json'),
111
+ join(cliDir, 'tsconfig.build.json'),
112
+ join(cliDir, 'pkgroll.config.mjs'),
113
+ join(cliDir, 'yarn.lock'),
114
+ join(cliDir, 'pnpm-lock.yaml'),
115
+ ].filter((p) => existsSync(p));
116
+
80
117
  return watchDebounced({
81
- paths: [resolve(cliDir)],
118
+ paths: (watchPaths.length ? watchPaths : [cliDir]).map((p) => resolve(p)),
82
119
  debounceMs: 500,
83
120
  onChange: async () => {
84
121
  if (isShuttingDown?.()) return;
@@ -88,6 +125,13 @@ export function watchHappyCliAndRestartDaemon({
88
125
  // eslint-disable-next-line no-console
89
126
  console.log('[local] watch: happy-cli changed → rebuilding + restarting daemon...');
90
127
  await ensureCliBuilt(cliDir, { buildCli });
128
+ const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
129
+ if (!existsSync(distEntrypoint)) {
130
+ console.warn(
131
+ `[local] watch: happy-cli build did not produce ${distEntrypoint}; refusing to restart daemon to avoid downtime.`
132
+ );
133
+ return;
134
+ }
91
135
  await startLocalDaemonWithAuth({
92
136
  cliBin,
93
137
  cliHomeDir,
@@ -0,0 +1,246 @@
1
+ import {
2
+ ensureExpoIsolationEnv,
3
+ getExpoStatePaths,
4
+ isStateProcessRunning,
5
+ wantsExpoClearCache,
6
+ writePidState,
7
+ } from '../expo/expo.mjs';
8
+ import { pickExpoDevMetroPort } from '../expo/metro_ports.mjs';
9
+ import { recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
10
+ import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
11
+ import { expoSpawn } from '../expo/command.mjs';
12
+ import { resolveMobileExpoConfig } from '../mobile/config.mjs';
13
+ import { resolveMobileReachableServerUrl } from '../server/mobile_api_url.mjs';
14
+
15
+ function normalizeExpoHost(raw) {
16
+ const v = String(raw ?? '').trim().toLowerCase();
17
+ if (v === 'localhost' || v === 'lan' || v === 'tunnel') return v;
18
+ return 'lan';
19
+ }
20
+
21
+ export function resolveExpoDevHost({ env = process.env } = {}) {
22
+ // Always prefer LAN by default so phones can reach Metro.
23
+ const raw = (env.HAPPY_STACKS_EXPO_HOST ?? env.HAPPY_LOCAL_EXPO_HOST ?? '').toString();
24
+ return normalizeExpoHost(raw || 'lan');
25
+ }
26
+
27
+ export function buildExpoStartArgs({ port, host, wantWeb, wantDevClient, scheme, clearCache }) {
28
+ const metroPort = Number(port);
29
+ if (!Number.isFinite(metroPort) || metroPort <= 0) {
30
+ throw new Error(`[expo] invalid Metro port: ${String(port)}`);
31
+ }
32
+ if (!wantWeb && !wantDevClient) {
33
+ throw new Error('[expo] cannot build Expo args: neither web nor dev-client requested');
34
+ }
35
+
36
+ // IMPORTANT:
37
+ // - We must only run one Expo per stack.
38
+ // - Expo dev-client mode is known to still serve web when accessed locally, so when mobile is
39
+ // requested we prefer `--dev-client` as the single shared process (no second `--web` process).
40
+ const args = wantDevClient
41
+ ? ['start', '--dev-client', '--host', host, '--port', String(metroPort)]
42
+ : ['start', '--web', '--host', host, '--port', String(metroPort)];
43
+
44
+ if (wantDevClient) {
45
+ const s = String(scheme ?? '').trim();
46
+ if (s) {
47
+ args.push('--scheme', s);
48
+ }
49
+ }
50
+
51
+ if (clearCache && !args.includes('--clear')) {
52
+ args.push('--clear');
53
+ }
54
+
55
+ return args;
56
+ }
57
+
58
+ function expoModeLabel({ wantWeb, wantDevClient }) {
59
+ if (wantWeb && wantDevClient) return 'dev-client+web';
60
+ if (wantDevClient) return 'dev-client';
61
+ if (wantWeb) return 'web';
62
+ return 'disabled';
63
+ }
64
+
65
+ export async function ensureDevExpoServer({
66
+ startUi,
67
+ startMobile,
68
+ uiDir,
69
+ autostart,
70
+ baseEnv,
71
+ apiServerUrl,
72
+ restart,
73
+ stackMode,
74
+ runtimeStatePath,
75
+ stackName,
76
+ envPath,
77
+ children,
78
+ spawnOptions = {},
79
+ } = {}) {
80
+ const wantWeb = Boolean(startUi);
81
+ const wantDevClient = Boolean(startMobile);
82
+ if (!wantWeb && !wantDevClient) {
83
+ return { ok: true, skipped: true, reason: 'disabled' };
84
+ }
85
+
86
+ const env = { ...(baseEnv || process.env) };
87
+ delete env.CI;
88
+ // Expo app config: this is what both web + native app use to reach the Happy server.
89
+ // When dev-client is enabled, `localhost` / `*.localhost` are not reachable from the phone,
90
+ // so rewrite to LAN IP here (centralized) to avoid relying on call sites.
91
+ const serverPortFromEnvRaw = (env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim();
92
+ const serverPortFromEnv = serverPortFromEnvRaw ? Number(serverPortFromEnvRaw) : null;
93
+ const effectiveApiServerUrl = wantDevClient
94
+ ? resolveMobileReachableServerUrl({
95
+ env,
96
+ serverUrl: apiServerUrl,
97
+ serverPort: Number.isFinite(serverPortFromEnv) ? serverPortFromEnv : null,
98
+ })
99
+ : apiServerUrl;
100
+ env.EXPO_PUBLIC_HAPPY_SERVER_URL = effectiveApiServerUrl;
101
+ env.EXPO_PUBLIC_DEBUG = env.EXPO_PUBLIC_DEBUG ?? '1';
102
+
103
+ // Optional: allow per-stack storage isolation inside a single dev-client build by
104
+ // scoping app persistence (MMKV / SecureStore) to a stack-specific namespace.
105
+ //
106
+ // This stays upstream-safe because the app behavior is unchanged unless the Expo public
107
+ // env var is explicitly set. Happy Stacks sets it automatically for stack-mode dev-client.
108
+ if (wantDevClient) {
109
+ const explicitScope = (
110
+ env.HAPPY_STACKS_STORAGE_SCOPE ??
111
+ env.HAPPY_LOCAL_STORAGE_SCOPE ??
112
+ env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE ??
113
+ ''
114
+ )
115
+ .toString()
116
+ .trim();
117
+ const defaultScope = stackMode && stackName ? String(stackName).trim() : '';
118
+ const scope = explicitScope || defaultScope;
119
+ if (scope && !env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE) {
120
+ env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE = scope;
121
+ }
122
+ }
123
+
124
+ // We own the browser opening behavior in Happy Stacks so we can reliably open the correct origin.
125
+ env.EXPO_NO_BROWSER = '1';
126
+ env.BROWSER = 'none';
127
+
128
+ // Mobile config is needed for `--scheme` and for the app's environment.
129
+ let scheme = '';
130
+ if (wantDevClient) {
131
+ const cfg = resolveMobileExpoConfig({ env });
132
+ env.APP_ENV = cfg.appEnv;
133
+ scheme = cfg.scheme;
134
+ }
135
+
136
+ const paths = getExpoStatePaths({
137
+ baseDir: autostart.baseDir,
138
+ kind: 'expo-dev',
139
+ projectDir: uiDir,
140
+ stateFileName: 'expo.state.json',
141
+ });
142
+ await ensureExpoIsolationEnv({ env, stateDir: paths.stateDir, expoHomeDir: paths.expoHomeDir, tmpDir: paths.tmpDir });
143
+
144
+ const running = await isStateProcessRunning(paths.statePath);
145
+ const alreadyRunning = Boolean(running.running);
146
+
147
+ // Always publish runtime metadata when we can.
148
+ const publishRuntime = async ({ pid, port }) => {
149
+ if (!stackMode || !runtimeStatePath) return;
150
+ const nPid = Number(pid);
151
+ const nPort = Number(port);
152
+ await recordStackRuntimeUpdate(runtimeStatePath, {
153
+ processes: { expoPid: Number.isFinite(nPid) && nPid > 1 ? nPid : null },
154
+ expo: {
155
+ port: Number.isFinite(nPort) && nPort > 0 ? nPort : null,
156
+ // For now keep these populated for callers that still expect webPort/mobilePort.
157
+ webPort: wantWeb && Number.isFinite(nPort) && nPort > 0 ? nPort : null,
158
+ mobilePort: wantDevClient && Number.isFinite(nPort) && nPort > 0 ? nPort : null,
159
+ webEnabled: wantWeb,
160
+ devClientEnabled: wantDevClient,
161
+ host: resolveExpoDevHost({ env }),
162
+ scheme: wantDevClient ? scheme : null,
163
+ },
164
+ }).catch(() => {});
165
+ };
166
+
167
+ if (alreadyRunning && !restart) {
168
+ const pid = Number(running.state?.pid);
169
+ const port = Number(running.state?.port);
170
+
171
+ // Capability check: refuse to spawn a second Expo, so if the existing process doesn't match the
172
+ // requested capabilities we fail closed and instruct a restart with the superset.
173
+ const stateWeb = Boolean(running.state?.webEnabled);
174
+ const stateDevClient = Boolean(running.state?.devClientEnabled);
175
+ const stateHasCaps = 'webEnabled' in (running.state ?? {}) || 'devClientEnabled' in (running.state ?? {});
176
+ const missingWeb = wantWeb && stateHasCaps && !stateWeb;
177
+ const missingDevClient = wantDevClient && stateHasCaps && !stateDevClient;
178
+ if (missingWeb || missingDevClient) {
179
+ throw new Error(
180
+ `[expo] Expo already running for stack=${stackName}, but it does not match the requested mode.\n` +
181
+ `- running: ${expoModeLabel({ wantWeb: stateWeb, wantDevClient: stateDevClient })}\n` +
182
+ `- wanted: ${expoModeLabel({ wantWeb, wantDevClient })}\n` +
183
+ `Fix: re-run with --restart (and include --mobile if you need dev-client).`
184
+ );
185
+ }
186
+
187
+ await publishRuntime({ pid, port });
188
+ return {
189
+ ok: true,
190
+ skipped: true,
191
+ reason: 'already_running',
192
+ pid: Number.isFinite(pid) ? pid : null,
193
+ port: Number.isFinite(port) ? port : null,
194
+ mode: expoModeLabel({ wantWeb, wantDevClient }),
195
+ };
196
+ }
197
+
198
+ const metroPort = await pickExpoDevMetroPort({ env: baseEnv, stackMode, stackName });
199
+ env.RCT_METRO_PORT = String(metroPort);
200
+ const host = resolveExpoDevHost({ env });
201
+ const args = buildExpoStartArgs({
202
+ port: metroPort,
203
+ host,
204
+ wantWeb,
205
+ wantDevClient,
206
+ scheme,
207
+ clearCache: wantsExpoClearCache({ env: baseEnv || process.env }),
208
+ });
209
+
210
+ if (restart && running.state?.pid) {
211
+ const prevPid = Number(running.state.pid);
212
+ const res = await killProcessGroupOwnedByStack(prevPid, { stackName, envPath, label: 'expo', json: true });
213
+ if (!res.killed) {
214
+ // eslint-disable-next-line no-console
215
+ console.warn(
216
+ `[local] expo: not stopping existing Expo pid=${prevPid} because it does not look stack-owned.\n` +
217
+ `[local] expo: continuing by starting a new Expo process on a free port.`
218
+ );
219
+ }
220
+ }
221
+
222
+ // eslint-disable-next-line no-console
223
+ console.log(`[local] expo: starting Expo (${expoModeLabel({ wantWeb, wantDevClient })}, metro port=${metroPort}, host=${host})`);
224
+ const proc = await expoSpawn({ label: 'expo', dir: uiDir, args, env, options: spawnOptions });
225
+ children.push(proc);
226
+
227
+ await publishRuntime({ pid: proc.pid, port: metroPort });
228
+
229
+ try {
230
+ await writePidState(paths.statePath, {
231
+ pid: proc.pid,
232
+ port: metroPort,
233
+ uiDir,
234
+ startedAt: new Date().toISOString(),
235
+ webEnabled: wantWeb,
236
+ devClientEnabled: wantDevClient,
237
+ host,
238
+ scheme: wantDevClient ? scheme : null,
239
+ });
240
+ } catch {
241
+ // ignore
242
+ }
243
+
244
+ return { ok: true, skipped: false, pid: proc.pid, port: metroPort, proc, mode: expoModeLabel({ wantWeb, wantDevClient }) };
245
+ }
246
+