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.
- package/README.md +93 -40
- package/bin/happys.mjs +158 -16
- package/docs/codex-mcp-resume.md +130 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
- package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
- package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
- package/docs/happy-development.md +3 -4
- package/docs/isolated-linux-vm.md +82 -0
- package/docs/mobile-ios.md +112 -54
- package/docs/monorepo-migration.md +286 -0
- package/docs/server-flavors.md +19 -3
- package/docs/stacks.md +35 -0
- package/package.json +5 -1
- package/scripts/auth.mjs +32 -10
- package/scripts/build.mjs +55 -8
- package/scripts/daemon.mjs +166 -10
- package/scripts/dev.mjs +198 -50
- package/scripts/doctor.mjs +0 -4
- package/scripts/edison.mjs +6 -4
- package/scripts/env.mjs +150 -0
- package/scripts/env_cmd.test.mjs +128 -0
- package/scripts/init.mjs +8 -3
- package/scripts/install.mjs +207 -69
- package/scripts/lint.mjs +24 -4
- package/scripts/migrate.mjs +3 -12
- package/scripts/mobile.mjs +88 -104
- package/scripts/mobile_dev_client.mjs +83 -0
- package/scripts/monorepo.mjs +1096 -0
- package/scripts/monorepo_port.test.mjs +1470 -0
- package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
- package/scripts/review.mjs +908 -0
- package/scripts/review_pr.mjs +353 -0
- package/scripts/run.mjs +101 -21
- package/scripts/service.mjs +2 -2
- package/scripts/setup.mjs +189 -68
- package/scripts/setup_pr.mjs +586 -38
- package/scripts/stack.mjs +990 -196
- package/scripts/stack_archive_cmd.test.mjs +91 -0
- package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
- package/scripts/stack_env_cmd.test.mjs +87 -0
- package/scripts/stack_happy_cmd.test.mjs +126 -0
- package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
- package/scripts/stack_monorepo_defaults.test.mjs +62 -0
- package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
- package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
- package/scripts/stack_shorthand_cmd.test.mjs +55 -0
- package/scripts/stack_wt_list.test.mjs +128 -0
- package/scripts/tailscale.mjs +37 -1
- package/scripts/test.mjs +45 -8
- package/scripts/tui.mjs +395 -39
- package/scripts/typecheck.mjs +24 -4
- package/scripts/utils/auth/daemon_gate.mjs +55 -0
- package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
- package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
- package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
- package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
- package/scripts/utils/auth/login_ux.mjs +32 -13
- package/scripts/utils/auth/sources.mjs +26 -0
- package/scripts/utils/auth/stack_guided_login.mjs +353 -0
- package/scripts/utils/cli/cli_registry.mjs +43 -4
- package/scripts/utils/cli/cwd_scope.mjs +136 -0
- package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/prereqs.mjs +75 -0
- package/scripts/utils/cli/prereqs.test.mjs +34 -0
- package/scripts/utils/cli/progress.mjs +126 -0
- package/scripts/utils/cli/verbosity.mjs +12 -0
- package/scripts/utils/cli/wizard.mjs +17 -9
- package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
- package/scripts/utils/dev/daemon.mjs +61 -4
- package/scripts/utils/dev/expo_dev.mjs +430 -0
- package/scripts/utils/dev/expo_dev.test.mjs +76 -0
- package/scripts/utils/dev/server.mjs +36 -42
- package/scripts/utils/dev_auth_key.mjs +169 -0
- package/scripts/utils/edison/git_roots.mjs +29 -0
- package/scripts/utils/edison/git_roots.test.mjs +36 -0
- package/scripts/utils/env/env.mjs +7 -3
- package/scripts/utils/env/env_file.mjs +4 -2
- package/scripts/utils/env/env_file.test.mjs +44 -0
- package/scripts/utils/expo/command.mjs +52 -0
- package/scripts/utils/expo/expo.mjs +20 -1
- package/scripts/utils/expo/metro_ports.mjs +114 -0
- package/scripts/utils/git/git.mjs +67 -0
- package/scripts/utils/git/worktrees.mjs +80 -25
- package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/mobile/config.mjs +31 -0
- package/scripts/utils/mobile/dev_client_links.mjs +60 -0
- package/scripts/utils/mobile/identifiers.mjs +47 -0
- package/scripts/utils/mobile/identifiers.test.mjs +42 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
- package/scripts/utils/net/lan_ip.mjs +24 -0
- package/scripts/utils/net/ports.mjs +9 -1
- package/scripts/utils/net/tcp_forward.mjs +162 -0
- package/scripts/utils/net/url.mjs +30 -0
- package/scripts/utils/net/url.test.mjs +20 -0
- package/scripts/utils/paths/localhost_host.mjs +50 -3
- package/scripts/utils/paths/paths.mjs +159 -40
- package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
- package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
- package/scripts/utils/proc/commands.mjs +2 -3
- package/scripts/utils/proc/parallel.mjs +25 -0
- package/scripts/utils/proc/pm.mjs +176 -22
- package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
- package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
- package/scripts/utils/proc/proc.mjs +136 -4
- package/scripts/utils/proc/proc.test.mjs +77 -0
- package/scripts/utils/review/base_ref.mjs +74 -0
- package/scripts/utils/review/base_ref.test.mjs +54 -0
- package/scripts/utils/review/chunks.mjs +55 -0
- package/scripts/utils/review/chunks.test.mjs +51 -0
- package/scripts/utils/review/findings.mjs +165 -0
- package/scripts/utils/review/findings.test.mjs +85 -0
- package/scripts/utils/review/head_slice.mjs +153 -0
- package/scripts/utils/review/head_slice.test.mjs +91 -0
- package/scripts/utils/review/instructions/deep.md +20 -0
- package/scripts/utils/review/runners/coderabbit.mjs +61 -0
- package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
- package/scripts/utils/review/runners/codex.mjs +61 -0
- package/scripts/utils/review/runners/codex.test.mjs +35 -0
- package/scripts/utils/review/slices.mjs +140 -0
- package/scripts/utils/review/slices.test.mjs +32 -0
- package/scripts/utils/review/targets.mjs +24 -0
- package/scripts/utils/review/targets.test.mjs +36 -0
- package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
- package/scripts/utils/server/flavor_scripts.mjs +98 -0
- package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
- package/scripts/utils/server/mobile_api_url.mjs +61 -0
- package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
- package/scripts/utils/server/prisma_import.mjs +37 -0
- package/scripts/utils/server/prisma_import.test.mjs +70 -0
- package/scripts/utils/server/ui_env.mjs +14 -0
- package/scripts/utils/server/ui_env.test.mjs +46 -0
- package/scripts/utils/server/urls.mjs +14 -4
- package/scripts/utils/server/validate.mjs +53 -16
- package/scripts/utils/server/validate.test.mjs +89 -0
- package/scripts/utils/service/autostart_darwin.mjs +42 -2
- package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
- package/scripts/utils/stack/context.mjs +2 -2
- package/scripts/utils/stack/editor_workspace.mjs +6 -6
- package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
- package/scripts/utils/stack/pr_stack_name.mjs +16 -0
- package/scripts/utils/stack/runtime_state.mjs +2 -1
- package/scripts/utils/stack/startup.mjs +120 -13
- package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
- package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
- package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
- package/scripts/utils/stack/stop.mjs +15 -4
- package/scripts/utils/stack_context.mjs +23 -0
- package/scripts/utils/stack_runtime_state.mjs +104 -0
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/tailscale/ip.mjs +116 -0
- package/scripts/utils/ui/ansi.mjs +39 -0
- package/scripts/utils/ui/qr.mjs +17 -0
- package/scripts/utils/validate.mjs +88 -0
- package/scripts/where.mjs +2 -2
- package/scripts/worktrees.mjs +755 -179
- package/scripts/worktrees_archive_cmd.test.mjs +245 -0
- package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
- package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
- package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
- package/scripts/utils/dev/expo_web.mjs +0 -112
|
@@ -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
|
-
|
|
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(
|
|
118
|
+
paths: (watchPaths.length ? watchPaths : [cliDir]).map((p) => resolve(p)),
|
|
82
119
|
debounceMs: 500,
|
|
83
120
|
onChange: async () => {
|
|
84
121
|
if (isShuttingDown?.()) return;
|
|
@@ -87,7 +124,27 @@ export function watchHappyCliAndRestartDaemon({
|
|
|
87
124
|
try {
|
|
88
125
|
// eslint-disable-next-line no-console
|
|
89
126
|
console.log('[local] watch: happy-cli changed → rebuilding + restarting daemon...');
|
|
90
|
-
|
|
127
|
+
try {
|
|
128
|
+
await ensureCliBuilt(cliDir, { buildCli });
|
|
129
|
+
} catch (e) {
|
|
130
|
+
// IMPORTANT:
|
|
131
|
+
// - A rebuild can legitimately fail while an agent is mid-edit (e.g. TS errors).
|
|
132
|
+
// - In that case we must NOT restart the daemon (we'd just restart into a broken build),
|
|
133
|
+
// and we must NOT crash the parent dev process. Keep watching for the next change.
|
|
134
|
+
const msg = e instanceof Error ? e.stack || e.message : String(e);
|
|
135
|
+
// eslint-disable-next-line no-console
|
|
136
|
+
console.error('[local] watch: happy-cli rebuild failed; keeping daemon running (will retry on next change).');
|
|
137
|
+
// eslint-disable-next-line no-console
|
|
138
|
+
console.error(msg);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const distEntrypoint = join(cliDir, 'dist', 'index.mjs');
|
|
142
|
+
if (!existsSync(distEntrypoint)) {
|
|
143
|
+
console.warn(
|
|
144
|
+
`[local] watch: happy-cli build did not produce ${distEntrypoint}; refusing to restart daemon to avoid downtime.`
|
|
145
|
+
);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
91
148
|
await startLocalDaemonWithAuth({
|
|
92
149
|
cliBin,
|
|
93
150
|
cliHomeDir,
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { fork } from 'node:child_process';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import {
|
|
6
|
+
ensureExpoIsolationEnv,
|
|
7
|
+
getExpoStatePaths,
|
|
8
|
+
isStateProcessRunning,
|
|
9
|
+
wantsExpoClearCache,
|
|
10
|
+
writePidState,
|
|
11
|
+
} from '../expo/expo.mjs';
|
|
12
|
+
import { pickExpoDevMetroPort } from '../expo/metro_ports.mjs';
|
|
13
|
+
import { recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
|
|
14
|
+
import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
|
|
15
|
+
import { expoSpawn } from '../expo/command.mjs';
|
|
16
|
+
import { resolveMobileExpoConfig } from '../mobile/config.mjs';
|
|
17
|
+
import { resolveMobileReachableServerUrl } from '../server/mobile_api_url.mjs';
|
|
18
|
+
import { getTailscaleStatus } from '../tailscale/ip.mjs';
|
|
19
|
+
import { pickLanIpv4 } from '../net/lan_ip.mjs';
|
|
20
|
+
|
|
21
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
22
|
+
const __dirname = dirname(__filename);
|
|
23
|
+
|
|
24
|
+
function normalizeExpoHost(raw) {
|
|
25
|
+
const v = String(raw ?? '').trim().toLowerCase();
|
|
26
|
+
if (v === 'localhost' || v === 'lan' || v === 'tunnel') return v;
|
|
27
|
+
return 'lan';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve whether Tailscale forwarding for Expo is enabled.
|
|
32
|
+
*
|
|
33
|
+
* Can be enabled via:
|
|
34
|
+
* - --expo-tailscale flag (passed as expoTailscale option)
|
|
35
|
+
* - HAPPY_STACKS_EXPO_TAILSCALE=1 env var
|
|
36
|
+
* - HAPPY_LOCAL_EXPO_TAILSCALE=1 env var (legacy)
|
|
37
|
+
*/
|
|
38
|
+
export function resolveExpoTailscaleEnabled({ env = process.env, expoTailscale = false } = {}) {
|
|
39
|
+
if (expoTailscale) return true;
|
|
40
|
+
const envVal = (env.HAPPY_STACKS_EXPO_TAILSCALE ?? env.HAPPY_LOCAL_EXPO_TAILSCALE ?? '').toString().trim();
|
|
41
|
+
return envVal === '1' || envVal.toLowerCase() === 'true';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Start a TCP forwarder process for Expo Tailscale access.
|
|
46
|
+
*
|
|
47
|
+
* Forwards from Tailscale IP:port to the LAN IP:port where Expo actually binds.
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} options
|
|
50
|
+
* @param {number} options.metroPort - The Metro bundler port
|
|
51
|
+
* @param {Object} options.baseEnv - Base environment variables
|
|
52
|
+
* @param {string} options.stackName - Stack name for logging
|
|
53
|
+
* @param {Array} options.children - Array to track child processes
|
|
54
|
+
* @returns {Promise<{ ok: boolean, pid?: number, tailscaleIp?: string, lanIp?: string, error?: string }>}
|
|
55
|
+
*/
|
|
56
|
+
export async function startExpoTailscaleForwarder({ metroPort, baseEnv, stackName, children }) {
|
|
57
|
+
const ts = await getTailscaleStatus();
|
|
58
|
+
if (!ts.available || !ts.ip) {
|
|
59
|
+
// Common case: Tailscale app installed but toggle is off / not connected.
|
|
60
|
+
// This must never fail stack startup; just skip with a clear message.
|
|
61
|
+
return { ok: false, error: ts.error || 'Tailscale is not connected' };
|
|
62
|
+
}
|
|
63
|
+
const tailscaleIp = ts.ip;
|
|
64
|
+
|
|
65
|
+
// Some platforms / Tailscale variants report an IP but do not allow binding to it (EADDRNOTAVAIL).
|
|
66
|
+
// If we can't bind *at all*, don't spawn the forwarder process (it will just error noisily).
|
|
67
|
+
const canBind = await new Promise((resolve) => {
|
|
68
|
+
const srv = net.createServer();
|
|
69
|
+
const done = (ok, err) => {
|
|
70
|
+
try {
|
|
71
|
+
srv.close(() => resolve({ ok, err }));
|
|
72
|
+
} catch {
|
|
73
|
+
resolve({ ok, err });
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
srv.once('error', (err) => done(false, err));
|
|
77
|
+
srv.listen(0, tailscaleIp, () => done(true, null));
|
|
78
|
+
});
|
|
79
|
+
if (!canBind.ok) {
|
|
80
|
+
const code = canBind.err && typeof canBind.err === 'object' ? canBind.err.code : '';
|
|
81
|
+
const msg = canBind.err instanceof Error ? canBind.err.message : String(canBind.err ?? '');
|
|
82
|
+
const hint =
|
|
83
|
+
code === 'EADDRNOTAVAIL'
|
|
84
|
+
? `Tailscale IP ${tailscaleIp} is not bindable on this machine (EADDRNOTAVAIL).`
|
|
85
|
+
: `Tailscale IP ${tailscaleIp} is not bindable (${code || 'error'}).`;
|
|
86
|
+
return { ok: false, error: `${hint}${msg ? ` ${msg}` : ''}`.trim() };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Determine where Expo binds (LAN IP when host=lan, localhost otherwise)
|
|
90
|
+
const host = resolveExpoDevHost({ env: baseEnv });
|
|
91
|
+
let targetHost = '127.0.0.1';
|
|
92
|
+
if (host === 'lan') {
|
|
93
|
+
const lanIp = pickLanIpv4();
|
|
94
|
+
if (lanIp) targetHost = lanIp;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const label = `expo-ts-fwd${stackName ? `-${stackName}` : ''}`;
|
|
98
|
+
const forwarderScript = join(__dirname, '..', 'net', 'tcp_forward.mjs');
|
|
99
|
+
|
|
100
|
+
// Fork the forwarder as a child process
|
|
101
|
+
// Note: fork() requires 'ipc' in stdio array
|
|
102
|
+
const forwarderProc = fork(forwarderScript, [
|
|
103
|
+
`--listen-host=${tailscaleIp}`,
|
|
104
|
+
`--listen-port=${metroPort}`,
|
|
105
|
+
`--target-host=${targetHost}`,
|
|
106
|
+
`--target-port=${metroPort}`,
|
|
107
|
+
`--label=${label}`,
|
|
108
|
+
], {
|
|
109
|
+
env: { ...baseEnv },
|
|
110
|
+
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
111
|
+
detached: process.platform !== 'win32',
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Prefix forwarder output
|
|
115
|
+
const outPrefix = `[${label}] `;
|
|
116
|
+
forwarderProc.stdout?.on('data', (d) => process.stdout.write(outPrefix + d.toString()));
|
|
117
|
+
forwarderProc.stderr?.on('data', (d) => process.stderr.write(outPrefix + d.toString()));
|
|
118
|
+
|
|
119
|
+
// Wait until the forwarder actually starts listening (or fails) before declaring success.
|
|
120
|
+
const ready = await new Promise((resolve) => {
|
|
121
|
+
const t = setTimeout(() => resolve({ ok: false, error: 'forwarder startup timed out' }), 2000);
|
|
122
|
+
const done = (res) => {
|
|
123
|
+
clearTimeout(t);
|
|
124
|
+
resolve(res);
|
|
125
|
+
};
|
|
126
|
+
forwarderProc.once('message', (m) => {
|
|
127
|
+
if (m && typeof m === 'object' && m.type === 'ready') {
|
|
128
|
+
done({ ok: true });
|
|
129
|
+
} else if (m && typeof m === 'object' && m.type === 'error') {
|
|
130
|
+
done({ ok: false, error: m.message ? String(m.message) : 'failed to start' });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
forwarderProc.once('exit', (code, sig) => {
|
|
134
|
+
done({ ok: false, error: `exited (code=${code}, sig=${sig})` });
|
|
135
|
+
});
|
|
136
|
+
forwarderProc.once('error', (e) => {
|
|
137
|
+
done({ ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (!ready.ok) {
|
|
142
|
+
try {
|
|
143
|
+
forwarderProc.kill('SIGKILL');
|
|
144
|
+
} catch {
|
|
145
|
+
// ignore
|
|
146
|
+
}
|
|
147
|
+
return { ok: false, error: ready.error || 'failed to start forwarder' };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
children.push(forwarderProc);
|
|
151
|
+
|
|
152
|
+
// eslint-disable-next-line no-console
|
|
153
|
+
console.log(`[local] expo: Tailscale forwarder started (${tailscaleIp}:${metroPort} -> ${targetHost}:${metroPort})`);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
pid: forwarderProc.pid,
|
|
158
|
+
tailscaleIp,
|
|
159
|
+
lanIp: targetHost,
|
|
160
|
+
proc: forwarderProc,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function resolveExpoDevHost({ env = process.env } = {}) {
|
|
165
|
+
// Always prefer LAN by default so phones can reach Metro.
|
|
166
|
+
const raw = (env.HAPPY_STACKS_EXPO_HOST ?? env.HAPPY_LOCAL_EXPO_HOST ?? '').toString();
|
|
167
|
+
return normalizeExpoHost(raw || 'lan');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function buildExpoStartArgs({ port, host, wantWeb, wantDevClient, scheme, clearCache }) {
|
|
171
|
+
const metroPort = Number(port);
|
|
172
|
+
if (!Number.isFinite(metroPort) || metroPort <= 0) {
|
|
173
|
+
throw new Error(`[expo] invalid Metro port: ${String(port)}`);
|
|
174
|
+
}
|
|
175
|
+
if (!wantWeb && !wantDevClient) {
|
|
176
|
+
throw new Error('[expo] cannot build Expo args: neither web nor dev-client requested');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// IMPORTANT:
|
|
180
|
+
// - We must only run one Expo per stack.
|
|
181
|
+
// - Expo dev-client mode is known to still serve web when accessed locally, so when mobile is
|
|
182
|
+
// requested we prefer `--dev-client` as the single shared process (no second `--web` process).
|
|
183
|
+
const args = wantDevClient
|
|
184
|
+
? ['start', '--dev-client', '--host', host, '--port', String(metroPort)]
|
|
185
|
+
: ['start', '--web', '--host', host, '--port', String(metroPort)];
|
|
186
|
+
|
|
187
|
+
if (wantDevClient) {
|
|
188
|
+
const s = String(scheme ?? '').trim();
|
|
189
|
+
if (s) {
|
|
190
|
+
args.push('--scheme', s);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (clearCache && !args.includes('--clear')) {
|
|
195
|
+
args.push('--clear');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return args;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function expoModeLabel({ wantWeb, wantDevClient }) {
|
|
202
|
+
if (wantWeb && wantDevClient) return 'dev-client+web';
|
|
203
|
+
if (wantDevClient) return 'dev-client';
|
|
204
|
+
if (wantWeb) return 'web';
|
|
205
|
+
return 'disabled';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function ensureDevExpoServer({
|
|
209
|
+
startUi,
|
|
210
|
+
startMobile,
|
|
211
|
+
uiDir,
|
|
212
|
+
autostart,
|
|
213
|
+
baseEnv,
|
|
214
|
+
apiServerUrl,
|
|
215
|
+
restart,
|
|
216
|
+
stackMode,
|
|
217
|
+
runtimeStatePath,
|
|
218
|
+
stackName,
|
|
219
|
+
envPath,
|
|
220
|
+
children,
|
|
221
|
+
spawnOptions = {},
|
|
222
|
+
expoTailscale = false,
|
|
223
|
+
} = {}) {
|
|
224
|
+
const wantWeb = Boolean(startUi);
|
|
225
|
+
const wantDevClient = Boolean(startMobile);
|
|
226
|
+
if (!wantWeb && !wantDevClient) {
|
|
227
|
+
return { ok: true, skipped: true, reason: 'disabled' };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const env = { ...(baseEnv || process.env) };
|
|
231
|
+
delete env.CI;
|
|
232
|
+
// Expo app config: this is what both web + native app use to reach the Happy server.
|
|
233
|
+
// When dev-client is enabled, `localhost` / `*.localhost` are not reachable from the phone,
|
|
234
|
+
// so rewrite to LAN IP here (centralized) to avoid relying on call sites.
|
|
235
|
+
const serverPortFromEnvRaw = (env.HAPPY_STACKS_SERVER_PORT ?? env.HAPPY_LOCAL_SERVER_PORT ?? '').toString().trim();
|
|
236
|
+
const serverPortFromEnv = serverPortFromEnvRaw ? Number(serverPortFromEnvRaw) : null;
|
|
237
|
+
const effectiveApiServerUrl = wantDevClient
|
|
238
|
+
? resolveMobileReachableServerUrl({
|
|
239
|
+
env,
|
|
240
|
+
serverUrl: apiServerUrl,
|
|
241
|
+
serverPort: Number.isFinite(serverPortFromEnv) ? serverPortFromEnv : null,
|
|
242
|
+
})
|
|
243
|
+
: apiServerUrl;
|
|
244
|
+
env.EXPO_PUBLIC_HAPPY_SERVER_URL = effectiveApiServerUrl;
|
|
245
|
+
env.EXPO_PUBLIC_DEBUG = env.EXPO_PUBLIC_DEBUG ?? '1';
|
|
246
|
+
|
|
247
|
+
// Optional: allow per-stack storage isolation inside a single dev-client build by
|
|
248
|
+
// scoping app persistence (MMKV / SecureStore) to a stack-specific namespace.
|
|
249
|
+
//
|
|
250
|
+
// This stays upstream-safe because the app behavior is unchanged unless the Expo public
|
|
251
|
+
// env var is explicitly set. Happy Stacks sets it automatically for stack-mode dev-client.
|
|
252
|
+
if (wantDevClient) {
|
|
253
|
+
const explicitScope = (
|
|
254
|
+
env.HAPPY_STACKS_STORAGE_SCOPE ??
|
|
255
|
+
env.HAPPY_LOCAL_STORAGE_SCOPE ??
|
|
256
|
+
env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE ??
|
|
257
|
+
''
|
|
258
|
+
)
|
|
259
|
+
.toString()
|
|
260
|
+
.trim();
|
|
261
|
+
const defaultScope = stackMode && stackName ? String(stackName).trim() : '';
|
|
262
|
+
const scope = explicitScope || defaultScope;
|
|
263
|
+
if (scope && !env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE) {
|
|
264
|
+
env.EXPO_PUBLIC_HAPPY_STORAGE_SCOPE = scope;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// We own the browser opening behavior in Happy Stacks so we can reliably open the correct origin.
|
|
269
|
+
env.EXPO_NO_BROWSER = '1';
|
|
270
|
+
env.BROWSER = 'none';
|
|
271
|
+
|
|
272
|
+
// Mobile config is needed for `--scheme` and for the app's environment.
|
|
273
|
+
let scheme = '';
|
|
274
|
+
if (wantDevClient) {
|
|
275
|
+
const cfg = resolveMobileExpoConfig({ env });
|
|
276
|
+
env.APP_ENV = cfg.appEnv;
|
|
277
|
+
scheme = cfg.scheme;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const paths = getExpoStatePaths({
|
|
281
|
+
baseDir: autostart.baseDir,
|
|
282
|
+
kind: 'expo-dev',
|
|
283
|
+
projectDir: uiDir,
|
|
284
|
+
stateFileName: 'expo.state.json',
|
|
285
|
+
});
|
|
286
|
+
await ensureExpoIsolationEnv({ env, stateDir: paths.stateDir, expoHomeDir: paths.expoHomeDir, tmpDir: paths.tmpDir });
|
|
287
|
+
|
|
288
|
+
const running = await isStateProcessRunning(paths.statePath);
|
|
289
|
+
const alreadyRunning = Boolean(running.running);
|
|
290
|
+
|
|
291
|
+
// Resolve Tailscale forwarding preference
|
|
292
|
+
const wantTailscale = resolveExpoTailscaleEnabled({ env: baseEnv, expoTailscale });
|
|
293
|
+
|
|
294
|
+
// Always publish runtime metadata when we can.
|
|
295
|
+
const publishRuntime = async ({ pid, port, tailscaleForwarderPid = null, tailscaleIp = null }) => {
|
|
296
|
+
if (!stackMode || !runtimeStatePath) return;
|
|
297
|
+
const nPid = Number(pid);
|
|
298
|
+
const nPort = Number(port);
|
|
299
|
+
const nTsPid = Number(tailscaleForwarderPid);
|
|
300
|
+
await recordStackRuntimeUpdate(runtimeStatePath, {
|
|
301
|
+
processes: {
|
|
302
|
+
expoPid: Number.isFinite(nPid) && nPid > 1 ? nPid : null,
|
|
303
|
+
expoTailscaleForwarderPid: Number.isFinite(nTsPid) && nTsPid > 1 ? nTsPid : null,
|
|
304
|
+
},
|
|
305
|
+
expo: {
|
|
306
|
+
port: Number.isFinite(nPort) && nPort > 0 ? nPort : null,
|
|
307
|
+
// For now keep these populated for callers that still expect webPort/mobilePort.
|
|
308
|
+
webPort: wantWeb && Number.isFinite(nPort) && nPort > 0 ? nPort : null,
|
|
309
|
+
mobilePort: wantDevClient && Number.isFinite(nPort) && nPort > 0 ? nPort : null,
|
|
310
|
+
webEnabled: wantWeb,
|
|
311
|
+
devClientEnabled: wantDevClient,
|
|
312
|
+
host: resolveExpoDevHost({ env }),
|
|
313
|
+
scheme: wantDevClient ? scheme : null,
|
|
314
|
+
tailscaleEnabled: wantTailscale,
|
|
315
|
+
tailscaleIp: tailscaleIp ?? null,
|
|
316
|
+
},
|
|
317
|
+
}).catch(() => {});
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
if (alreadyRunning && !restart) {
|
|
321
|
+
const pid = Number(running.state?.pid);
|
|
322
|
+
const port = Number(running.state?.port);
|
|
323
|
+
|
|
324
|
+
// Capability check: refuse to spawn a second Expo, so if the existing process doesn't match the
|
|
325
|
+
// requested capabilities we fail closed and instruct a restart with the superset.
|
|
326
|
+
const stateWeb = Boolean(running.state?.webEnabled);
|
|
327
|
+
const stateDevClient = Boolean(running.state?.devClientEnabled);
|
|
328
|
+
const stateHasCaps = 'webEnabled' in (running.state ?? {}) || 'devClientEnabled' in (running.state ?? {});
|
|
329
|
+
const missingWeb = wantWeb && stateHasCaps && !stateWeb;
|
|
330
|
+
const missingDevClient = wantDevClient && stateHasCaps && !stateDevClient;
|
|
331
|
+
if (missingWeb || missingDevClient) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`[expo] Expo already running for stack=${stackName}, but it does not match the requested mode.\n` +
|
|
334
|
+
`- running: ${expoModeLabel({ wantWeb: stateWeb, wantDevClient: stateDevClient })}\n` +
|
|
335
|
+
`- wanted: ${expoModeLabel({ wantWeb, wantDevClient })}\n` +
|
|
336
|
+
`Fix: re-run with --restart (and include --mobile if you need dev-client).`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
await publishRuntime({ pid, port });
|
|
341
|
+
return {
|
|
342
|
+
ok: true,
|
|
343
|
+
skipped: true,
|
|
344
|
+
reason: 'already_running',
|
|
345
|
+
pid: Number.isFinite(pid) ? pid : null,
|
|
346
|
+
port: Number.isFinite(port) ? port : null,
|
|
347
|
+
mode: expoModeLabel({ wantWeb, wantDevClient }),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const metroPort = await pickExpoDevMetroPort({ env: baseEnv, stackMode, stackName });
|
|
352
|
+
env.RCT_METRO_PORT = String(metroPort);
|
|
353
|
+
const host = resolveExpoDevHost({ env });
|
|
354
|
+
const args = buildExpoStartArgs({
|
|
355
|
+
port: metroPort,
|
|
356
|
+
host,
|
|
357
|
+
wantWeb,
|
|
358
|
+
wantDevClient,
|
|
359
|
+
scheme,
|
|
360
|
+
clearCache: wantsExpoClearCache({ env: baseEnv || process.env }),
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
if (restart && running.state?.pid) {
|
|
364
|
+
const prevPid = Number(running.state.pid);
|
|
365
|
+
const res = await killProcessGroupOwnedByStack(prevPid, { stackName, envPath, label: 'expo', json: true });
|
|
366
|
+
if (!res.killed) {
|
|
367
|
+
// eslint-disable-next-line no-console
|
|
368
|
+
console.warn(
|
|
369
|
+
`[local] expo: not stopping existing Expo pid=${prevPid} because it does not look stack-owned.\n` +
|
|
370
|
+
`[local] expo: continuing by starting a new Expo process on a free port.`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// eslint-disable-next-line no-console
|
|
376
|
+
console.log(`[local] expo: starting Expo (${expoModeLabel({ wantWeb, wantDevClient })}, metro port=${metroPort}, host=${host})`);
|
|
377
|
+
const proc = await expoSpawn({ label: 'expo', dir: uiDir, args, env, options: spawnOptions });
|
|
378
|
+
children.push(proc);
|
|
379
|
+
|
|
380
|
+
// Start Tailscale forwarder if enabled
|
|
381
|
+
let tailscaleResult = null;
|
|
382
|
+
if (wantTailscale) {
|
|
383
|
+
tailscaleResult = await startExpoTailscaleForwarder({
|
|
384
|
+
metroPort,
|
|
385
|
+
baseEnv,
|
|
386
|
+
stackName,
|
|
387
|
+
children,
|
|
388
|
+
});
|
|
389
|
+
if (!tailscaleResult.ok) {
|
|
390
|
+
// eslint-disable-next-line no-console
|
|
391
|
+
console.warn(`[local] expo: Tailscale forwarder not started: ${tailscaleResult.error}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
await publishRuntime({
|
|
396
|
+
pid: proc.pid,
|
|
397
|
+
port: metroPort,
|
|
398
|
+
tailscaleForwarderPid: tailscaleResult?.pid ?? null,
|
|
399
|
+
tailscaleIp: tailscaleResult?.tailscaleIp ?? null,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
await writePidState(paths.statePath, {
|
|
404
|
+
pid: proc.pid,
|
|
405
|
+
port: metroPort,
|
|
406
|
+
uiDir,
|
|
407
|
+
startedAt: new Date().toISOString(),
|
|
408
|
+
webEnabled: wantWeb,
|
|
409
|
+
devClientEnabled: wantDevClient,
|
|
410
|
+
host,
|
|
411
|
+
scheme: wantDevClient ? scheme : null,
|
|
412
|
+
tailscaleEnabled: wantTailscale,
|
|
413
|
+
tailscaleForwarderPid: tailscaleResult?.pid ?? null,
|
|
414
|
+
tailscaleIp: tailscaleResult?.tailscaleIp ?? null,
|
|
415
|
+
});
|
|
416
|
+
} catch {
|
|
417
|
+
// ignore
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
ok: true,
|
|
422
|
+
skipped: false,
|
|
423
|
+
pid: proc.pid,
|
|
424
|
+
port: metroPort,
|
|
425
|
+
proc,
|
|
426
|
+
mode: expoModeLabel({ wantWeb, wantDevClient }),
|
|
427
|
+
tailscale: tailscaleResult ?? null,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
|
|
4
|
+
import { buildExpoStartArgs, resolveExpoDevHost } from './expo_dev.mjs';
|
|
5
|
+
|
|
6
|
+
test('resolveExpoDevHost defaults to lan and normalizes values', () => {
|
|
7
|
+
assert.equal(resolveExpoDevHost({ env: {} }), 'lan');
|
|
8
|
+
assert.equal(resolveExpoDevHost({ env: { HAPPY_STACKS_EXPO_HOST: 'LAN' } }), 'lan');
|
|
9
|
+
assert.equal(resolveExpoDevHost({ env: { HAPPY_STACKS_EXPO_HOST: 'localhost' } }), 'localhost');
|
|
10
|
+
assert.equal(resolveExpoDevHost({ env: { HAPPY_STACKS_EXPO_HOST: 'tunnel' } }), 'tunnel');
|
|
11
|
+
assert.equal(resolveExpoDevHost({ env: { HAPPY_STACKS_EXPO_HOST: 'nope' } }), 'lan');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('buildExpoStartArgs builds dev-client args (preferred when mobile enabled)', () => {
|
|
15
|
+
const args = buildExpoStartArgs({
|
|
16
|
+
port: 8081,
|
|
17
|
+
host: 'lan',
|
|
18
|
+
wantWeb: true,
|
|
19
|
+
wantDevClient: true,
|
|
20
|
+
scheme: 'happy',
|
|
21
|
+
clearCache: true,
|
|
22
|
+
});
|
|
23
|
+
assert.deepEqual(args, ['start', '--dev-client', '--host', 'lan', '--port', '8081', '--scheme', 'happy', '--clear']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('buildExpoStartArgs builds web args when dev-client is not requested', () => {
|
|
27
|
+
const args = buildExpoStartArgs({
|
|
28
|
+
port: 8081,
|
|
29
|
+
host: 'lan',
|
|
30
|
+
wantWeb: true,
|
|
31
|
+
wantDevClient: false,
|
|
32
|
+
scheme: '',
|
|
33
|
+
clearCache: false,
|
|
34
|
+
});
|
|
35
|
+
assert.deepEqual(args, ['start', '--web', '--host', 'lan', '--port', '8081']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('buildExpoStartArgs omits --scheme when empty', () => {
|
|
39
|
+
const args = buildExpoStartArgs({
|
|
40
|
+
port: 8081,
|
|
41
|
+
host: 'lan',
|
|
42
|
+
wantWeb: false,
|
|
43
|
+
wantDevClient: true,
|
|
44
|
+
scheme: '',
|
|
45
|
+
clearCache: false,
|
|
46
|
+
});
|
|
47
|
+
assert.deepEqual(args, ['start', '--dev-client', '--host', 'lan', '--port', '8081']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('buildExpoStartArgs throws on invalid requests', () => {
|
|
51
|
+
assert.throws(
|
|
52
|
+
() =>
|
|
53
|
+
buildExpoStartArgs({
|
|
54
|
+
port: 0,
|
|
55
|
+
host: 'lan',
|
|
56
|
+
wantWeb: true,
|
|
57
|
+
wantDevClient: false,
|
|
58
|
+
scheme: '',
|
|
59
|
+
clearCache: false,
|
|
60
|
+
}),
|
|
61
|
+
/invalid Metro port/i
|
|
62
|
+
);
|
|
63
|
+
assert.throws(
|
|
64
|
+
() =>
|
|
65
|
+
buildExpoStartArgs({
|
|
66
|
+
port: 8081,
|
|
67
|
+
host: 'lan',
|
|
68
|
+
wantWeb: false,
|
|
69
|
+
wantDevClient: false,
|
|
70
|
+
scheme: '',
|
|
71
|
+
clearCache: false,
|
|
72
|
+
}),
|
|
73
|
+
/neither web nor dev-client requested/i
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|