happy-stacks 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -25
- package/bin/happys.mjs +116 -17
- package/docs/happy-development.md +2 -2
- package/docs/isolated-linux-vm.md +82 -0
- package/docs/mobile-ios.md +112 -54
- package/package.json +5 -1
- package/scripts/auth.mjs +59 -208
- package/scripts/build.mjs +58 -12
- package/scripts/cli-link.mjs +3 -3
- package/scripts/completion.mjs +5 -5
- package/scripts/daemon.mjs +168 -20
- package/scripts/dev.mjs +196 -70
- package/scripts/doctor.mjs +20 -36
- package/scripts/edison.mjs +105 -78
- package/scripts/happy.mjs +8 -19
- package/scripts/init.mjs +8 -14
- package/scripts/install.mjs +119 -23
- package/scripts/lint.mjs +31 -32
- package/scripts/menubar.mjs +6 -13
- package/scripts/migrate.mjs +11 -21
- package/scripts/mobile.mjs +93 -108
- package/scripts/mobile_dev_client.mjs +83 -0
- package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
- package/scripts/review.mjs +217 -0
- package/scripts/review_pr.mjs +368 -0
- package/scripts/run.mjs +95 -21
- package/scripts/self.mjs +11 -29
- package/scripts/server_flavor.mjs +4 -4
- package/scripts/service.mjs +19 -29
- package/scripts/setup.mjs +63 -160
- package/scripts/setup_pr.mjs +592 -52
- package/scripts/stack.mjs +608 -200
- package/scripts/stop.mjs +3 -3
- package/scripts/tailscale.mjs +44 -11
- package/scripts/test.mjs +52 -36
- package/scripts/tui.mjs +314 -74
- package/scripts/typecheck.mjs +31 -32
- package/scripts/ui_gateway.mjs +1 -1
- package/scripts/uninstall.mjs +6 -6
- package/scripts/utils/auth/daemon_gate.mjs +55 -0
- package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
- package/scripts/utils/auth/dev_key.mjs +163 -0
- package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
- 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/handy_master_secret.mjs +68 -0
- package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
- package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +32 -13
- package/scripts/utils/auth/sources.mjs +38 -0
- package/scripts/utils/auth/stack_guided_login.mjs +353 -0
- package/scripts/utils/cli/cli_registry.mjs +24 -0
- package/scripts/utils/cli/cwd_scope.mjs +82 -0
- package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
- package/scripts/utils/cli/flags.mjs +17 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/normalize.mjs +16 -0
- package/scripts/utils/cli/prereqs.mjs +72 -0
- package/scripts/utils/cli/progress.mjs +126 -0
- package/scripts/utils/cli/smoke_help.mjs +2 -2
- package/scripts/utils/cli/verbosity.mjs +12 -0
- package/scripts/utils/cli/wizard.mjs +1 -1
- package/scripts/utils/crypto/tokens.mjs +14 -0
- package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +51 -7
- package/scripts/utils/dev/expo_dev.mjs +246 -0
- package/scripts/utils/dev/expo_dev.test.mjs +76 -0
- package/scripts/utils/{dev_server.mjs → dev/server.mjs} +22 -32
- package/scripts/utils/dev_auth_key.mjs +1 -1
- package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
- package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
- package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
- package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
- package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
- package/scripts/utils/env/read.mjs +30 -0
- package/scripts/utils/env/values.mjs +13 -0
- package/scripts/utils/expo/command.mjs +52 -0
- package/scripts/utils/{expo.mjs → expo/expo.mjs} +23 -10
- package/scripts/utils/expo/metro_ports.mjs +114 -0
- package/scripts/utils/fs/json.mjs +25 -0
- package/scripts/utils/fs/ops.mjs +29 -0
- package/scripts/utils/fs/package_json.mjs +8 -0
- package/scripts/utils/fs/tail.mjs +12 -0
- package/scripts/utils/git/git.mjs +67 -0
- package/scripts/utils/git/refs.mjs +26 -0
- package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +27 -23
- package/scripts/utils/handy_master_secret.mjs +2 -2
- 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/dns.mjs +10 -0
- package/scripts/utils/net/lan_ip.mjs +24 -0
- package/scripts/utils/{ports.mjs → net/ports.mjs} +12 -6
- package/scripts/utils/net/url.mjs +30 -0
- package/scripts/utils/net/url.test.mjs +20 -0
- package/scripts/utils/paths/localhost_host.mjs +56 -0
- package/scripts/utils/{paths.mjs → paths/paths.mjs} +52 -45
- package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
- package/scripts/utils/proc/commands.mjs +34 -0
- package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
- package/scripts/utils/proc/package_scripts.mjs +31 -0
- package/scripts/utils/proc/parallel.mjs +25 -0
- package/scripts/utils/proc/pids.mjs +11 -0
- package/scripts/utils/{pm.mjs → proc/pm.mjs} +128 -158
- package/scripts/utils/{proc.mjs → proc/proc.mjs} +77 -2
- package/scripts/utils/review/base_ref.mjs +74 -0
- package/scripts/utils/review/base_ref.test.mjs +54 -0
- package/scripts/utils/review/runners/coderabbit.mjs +19 -0
- package/scripts/utils/review/runners/codex.mjs +51 -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/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
- 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/port.mjs +68 -0
- package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
- package/scripts/utils/server/urls.mjs +101 -0
- package/scripts/utils/server/validate.mjs +88 -0
- package/scripts/utils/service/autostart_darwin.mjs +182 -0
- package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
- package/scripts/utils/stack/context.mjs +23 -0
- package/scripts/utils/stack/dirs.mjs +27 -0
- package/scripts/utils/stack/editor_workspace.mjs +152 -0
- package/scripts/utils/stack/names.mjs +12 -0
- package/scripts/utils/stack/pr_stack_name.mjs +16 -0
- package/scripts/utils/stack/runtime_state.mjs +88 -0
- package/scripts/utils/stack/stacks.mjs +45 -0
- package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +9 -2
- package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +24 -19
- package/scripts/utils/stack_context.mjs +3 -3
- package/scripts/utils/stack_runtime_state.mjs +1 -1
- package/scripts/utils/stacks.mjs +2 -2
- package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
- package/scripts/utils/ui/qr.mjs +17 -0
- package/scripts/utils/ui/text.mjs +16 -0
- package/scripts/utils/validate.mjs +1 -1
- package/scripts/where.mjs +6 -6
- package/scripts/worktrees.mjs +171 -113
- package/scripts/utils/auth_sources.mjs +0 -12
- package/scripts/utils/dev_expo_web.mjs +0 -112
- package/scripts/utils/localhost_host.mjs +0 -17
- package/scripts/utils/server_port.mjs +0 -9
- package/scripts/utils/server_urls.mjs +0 -54
- /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
- /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
- /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
- /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
|
@@ -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
|
+
|
|
@@ -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
|
+
|
|
@@ -1,42 +1,32 @@
|
|
|
1
1
|
import { join, resolve } from 'node:path';
|
|
2
2
|
|
|
3
|
-
import { ensureDepsInstalled, pmSpawnScript } from '
|
|
4
|
-
import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from '
|
|
5
|
-
import { waitForServerReady } from '
|
|
6
|
-
import { isTcpPortFree, pickNextFreeTcpPort } from '
|
|
7
|
-
import { readStackRuntimeStateFile, recordStackRuntimeUpdate } from '
|
|
8
|
-
import { killProcessGroupOwnedByStack } from '
|
|
9
|
-
import { watchDebounced } from '
|
|
10
|
-
|
|
11
|
-
function hashStringToInt(s) {
|
|
12
|
-
let h = 0;
|
|
13
|
-
const str = String(s ?? '');
|
|
14
|
-
for (let i = 0; i < str.length; i++) {
|
|
15
|
-
h = (h * 31 + str.charCodeAt(i)) >>> 0;
|
|
16
|
-
}
|
|
17
|
-
return h >>> 0;
|
|
18
|
-
}
|
|
3
|
+
import { ensureDepsInstalled, pmSpawnScript } from '../proc/pm.mjs';
|
|
4
|
+
import { applyHappyServerMigrations, ensureHappyServerManagedInfra } from '../server/infra/happy_server_infra.mjs';
|
|
5
|
+
import { waitForServerReady } from '../server/server.mjs';
|
|
6
|
+
import { isTcpPortFree, pickNextFreeTcpPort } from '../net/ports.mjs';
|
|
7
|
+
import { readStackRuntimeStateFile, recordStackRuntimeUpdate } from '../stack/runtime_state.mjs';
|
|
8
|
+
import { killProcessGroupOwnedByStack } from '../proc/ownership.mjs';
|
|
9
|
+
import { watchDebounced } from '../proc/watch.mjs';
|
|
10
|
+
import { pickMetroPort, resolveStablePortStart } from '../expo/metro_ports.mjs';
|
|
19
11
|
|
|
20
12
|
export function resolveStackUiDevPortStart({ env = process.env, stackName }) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
13
|
+
return resolveStablePortStart({
|
|
14
|
+
env: {
|
|
15
|
+
...env,
|
|
16
|
+
HAPPY_STACKS_UI_DEV_PORT_BASE: (env.HAPPY_STACKS_UI_DEV_PORT_BASE ?? env.HAPPY_LOCAL_UI_DEV_PORT_BASE ?? '8081').toString(),
|
|
17
|
+
HAPPY_STACKS_UI_DEV_PORT_RANGE: (env.HAPPY_STACKS_UI_DEV_PORT_RANGE ?? env.HAPPY_LOCAL_UI_DEV_PORT_RANGE ?? '1000').toString(),
|
|
18
|
+
},
|
|
19
|
+
stackName,
|
|
20
|
+
baseKey: 'HAPPY_STACKS_UI_DEV_PORT_BASE',
|
|
21
|
+
rangeKey: 'HAPPY_STACKS_UI_DEV_PORT_RANGE',
|
|
22
|
+
defaultBase: 8081,
|
|
23
|
+
defaultRange: 1000,
|
|
24
|
+
});
|
|
28
25
|
}
|
|
29
26
|
|
|
30
27
|
export async function pickDevMetroPort({ startPort, reservedPorts = new Set(), host = '127.0.0.1' } = {}) {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
const forced = Number(forcedRaw);
|
|
34
|
-
if (Number.isFinite(forced) && forced > 0) {
|
|
35
|
-
const ok = await isTcpPortFree(forced, { host });
|
|
36
|
-
if (ok) return forced;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return await pickNextFreeTcpPort(startPort, { reservedPorts, host });
|
|
28
|
+
const forcedPort = (process.env.HAPPY_STACKS_UI_DEV_PORT ?? process.env.HAPPY_LOCAL_UI_DEV_PORT ?? '').toString().trim();
|
|
29
|
+
return await pickMetroPort({ startPort, forcedPort, reservedPorts, host });
|
|
40
30
|
}
|
|
41
31
|
|
|
42
32
|
export async function startDevServer({
|
|
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
|
|
6
|
-
import { expandHome } from './canonical_home.mjs';
|
|
6
|
+
import { expandHome } from './paths/canonical_home.mjs';
|
|
7
7
|
|
|
8
8
|
export function resolveHappyStacksHomeDir(env = process.env) {
|
|
9
9
|
const fromEnv = (env.HAPPY_STACKS_HOME_DIR ?? env.HAPPY_LOCAL_HOME_DIR ?? '').toString().trim();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { ensureEnvFileUpdated } from './env_file.mjs';
|
|
3
|
-
import { getHappyStacksHomeDir, resolveStackEnvPath } from '
|
|
4
|
-
import { getCanonicalHomeDirFromEnv } from '
|
|
3
|
+
import { getHappyStacksHomeDir, resolveStackEnvPath } from '../paths/paths.mjs';
|
|
4
|
+
import { getCanonicalHomeDirFromEnv } from '../paths/canonical_home.mjs';
|
|
5
5
|
|
|
6
6
|
export function getHomeEnvPath() {
|
|
7
7
|
return join(getHappyStacksHomeDir(), '.env');
|
|
@@ -50,3 +50,4 @@ export async function ensureUserConfigEnvUpdated({ cliRootDir, updates }) {
|
|
|
50
50
|
await ensureEnvFileUpdated({ envPath, updates });
|
|
51
51
|
return envPath;
|
|
52
52
|
}
|
|
53
|
+
|
|
@@ -4,7 +4,7 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { parseDotenv } from './dotenv.mjs';
|
|
7
|
-
import { expandHome, getCanonicalHomeEnvPathFromEnv } from '
|
|
7
|
+
import { expandHome, getCanonicalHomeEnvPathFromEnv } from '../paths/canonical_home.mjs';
|
|
8
8
|
import { isSandboxed, sandboxAllowsGlobalSideEffects } from './sandbox.mjs';
|
|
9
9
|
|
|
10
10
|
async function loadEnvFile(path, { override = false, overridePrefix = null } = {}) {
|
|
@@ -57,8 +57,9 @@ async function loadEnvFileIgnoringPrefixes(path, { ignorePrefixes = [] } = {}) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// Load happy-stacks env (optional). This is intentionally lightweight and does not require extra deps.
|
|
60
|
-
// This file lives under scripts/utils
|
|
61
|
-
const
|
|
60
|
+
// This file lives under scripts/utils/env, so repo root is three directories up.
|
|
61
|
+
const __envDir = dirname(fileURLToPath(import.meta.url));
|
|
62
|
+
const __utilsDir = dirname(__envDir);
|
|
62
63
|
const __scriptsDir = dirname(__utilsDir);
|
|
63
64
|
const __cliRootDir = dirname(__scriptsDir);
|
|
64
65
|
|
|
@@ -207,3 +208,4 @@ process.env.NPM_CONFIG_PACKAGE_MANAGER_STRICT = process.env.NPM_CONFIG_PACKAGE_M
|
|
|
207
208
|
const next = [...want.filter((p) => p && !current.includes(p)), ...current];
|
|
208
209
|
process.env.PATH = next.join(delimiter);
|
|
209
210
|
})();
|
|
211
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
|
-
import { pathExists } from '
|
|
3
|
+
import { pathExists } from '../fs/fs.mjs';
|
|
4
4
|
|
|
5
5
|
export async function ensureEnvFileUpdated({ envPath, updates }) {
|
|
6
6
|
if (!updates.length) {
|
|
@@ -93,3 +93,4 @@ async function writeFileIfChanged(existingContent, nextContent, path) {
|
|
|
93
93
|
}
|
|
94
94
|
await writeFile(path, normalizedNext, 'utf-8');
|
|
95
95
|
}
|
|
96
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
import { parseDotenv } from './dotenv.mjs';
|
|
5
|
+
|
|
6
|
+
export async function readEnvValueFromFile(envPath, key, { defaultValue = '' } = {}) {
|
|
7
|
+
try {
|
|
8
|
+
const p = String(envPath ?? '').trim();
|
|
9
|
+
const k = String(key ?? '').trim();
|
|
10
|
+
if (!p || !k) return defaultValue;
|
|
11
|
+
if (!existsSync(p)) return defaultValue;
|
|
12
|
+
const raw = await readFile(p, 'utf-8');
|
|
13
|
+
const parsed = parseDotenv(raw ?? '');
|
|
14
|
+
return String(parsed.get(k) ?? '').trim();
|
|
15
|
+
} catch {
|
|
16
|
+
return defaultValue;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readEnvObjectFromFile(envPath) {
|
|
21
|
+
try {
|
|
22
|
+
const p = String(envPath ?? '').trim();
|
|
23
|
+
if (!p || !existsSync(p)) return {};
|
|
24
|
+
const raw = await readFile(p, 'utf-8');
|
|
25
|
+
return Object.fromEntries(parseDotenv(raw ?? '').entries());
|
|
26
|
+
} catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function getEnvValue(obj, key) {
|
|
2
|
+
const v = (obj?.[key] ?? '').toString().trim();
|
|
3
|
+
return v || '';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function getEnvValueAny(obj, keys) {
|
|
7
|
+
for (const k of keys) {
|
|
8
|
+
const v = getEnvValue(obj, k);
|
|
9
|
+
if (v) return v;
|
|
10
|
+
}
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ensureDepsInstalled, pmExecBin, pmSpawnBin } from '../proc/pm.mjs';
|
|
2
|
+
import { ensureExpoIsolationEnv, getExpoStatePaths, wantsExpoClearCache } from './expo.mjs';
|
|
3
|
+
|
|
4
|
+
export async function prepareExpoCommandEnv({
|
|
5
|
+
baseDir,
|
|
6
|
+
kind,
|
|
7
|
+
projectDir,
|
|
8
|
+
baseEnv,
|
|
9
|
+
stateFileName,
|
|
10
|
+
}) {
|
|
11
|
+
const env = { ...(baseEnv ?? process.env) };
|
|
12
|
+
const paths = getExpoStatePaths({ baseDir, kind, projectDir, stateFileName });
|
|
13
|
+
await ensureExpoIsolationEnv({ env, stateDir: paths.stateDir, expoHomeDir: paths.expoHomeDir, tmpDir: paths.tmpDir });
|
|
14
|
+
return { env, paths };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function maybeAddExpoClear({ args, env }) {
|
|
18
|
+
const next = [...(args ?? [])];
|
|
19
|
+
if (wantsExpoClearCache({ env: env ?? process.env })) {
|
|
20
|
+
// Expo supports `--clear` for start, and `-c` for export.
|
|
21
|
+
// Callers should pass the right flag for their subcommand; we only add when missing.
|
|
22
|
+
if (!next.includes('--clear') && !next.includes('-c')) {
|
|
23
|
+
// Prefer `--clear` as a safe default; callers can override per-command.
|
|
24
|
+
next.push('--clear');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return next;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function expoExec({
|
|
31
|
+
dir,
|
|
32
|
+
args,
|
|
33
|
+
env,
|
|
34
|
+
ensureDepsLabel = 'happy',
|
|
35
|
+
quiet = false,
|
|
36
|
+
}) {
|
|
37
|
+
await ensureDepsInstalled(dir, ensureDepsLabel, { quiet });
|
|
38
|
+
await pmExecBin({ dir, bin: 'expo', args, env, quiet });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function expoSpawn({
|
|
42
|
+
label,
|
|
43
|
+
dir,
|
|
44
|
+
args,
|
|
45
|
+
env,
|
|
46
|
+
ensureDepsLabel = 'happy',
|
|
47
|
+
options,
|
|
48
|
+
}) {
|
|
49
|
+
await ensureDepsInstalled(dir, ensureDepsLabel);
|
|
50
|
+
return await pmSpawnBin({ label, dir, bin: 'expo', args, env, options });
|
|
51
|
+
}
|
|
52
|
+
|
|
@@ -3,6 +3,10 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { isPidAlive } from '../proc/pids.mjs';
|
|
7
|
+
import { isTcpPortFree } from '../net/ports.mjs';
|
|
8
|
+
|
|
9
|
+
export { isPidAlive };
|
|
6
10
|
|
|
7
11
|
function hashDir(dir) {
|
|
8
12
|
return createHash('sha1').update(String(dir ?? '')).digest('hex').slice(0, 12);
|
|
@@ -56,20 +60,29 @@ export async function readPidState(statePath) {
|
|
|
56
60
|
}
|
|
57
61
|
}
|
|
58
62
|
|
|
59
|
-
export function isPidAlive(pid) {
|
|
60
|
-
try {
|
|
61
|
-
process.kill(pid, 0);
|
|
62
|
-
return true;
|
|
63
|
-
} catch {
|
|
64
|
-
return false;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
63
|
export async function isStateProcessRunning(statePath) {
|
|
69
64
|
const state = await readPidState(statePath);
|
|
70
65
|
if (!state) return { running: false, state: null };
|
|
71
66
|
const pid = Number(state.pid);
|
|
72
|
-
|
|
67
|
+
if (isPidAlive(pid)) {
|
|
68
|
+
return { running: true, state, reason: 'pid' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Expo/Metro can sometimes be “up” even if the original wrapper pid exited (pm/yarn layers).
|
|
72
|
+
// If we have a port and something is listening on it, treat it as running.
|
|
73
|
+
const port = Number(state?.port);
|
|
74
|
+
if (Number.isFinite(port) && port > 0) {
|
|
75
|
+
try {
|
|
76
|
+
const free = await isTcpPortFree(port, { host: '127.0.0.1' });
|
|
77
|
+
if (!free) {
|
|
78
|
+
return { running: true, state, reason: 'port' };
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// ignore
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { running: false, state };
|
|
73
86
|
}
|
|
74
87
|
|
|
75
88
|
export async function writePidState(statePath, state) {
|