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,38 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
import { resolveStackEnvPath } from '../paths/paths.mjs';
|
|
6
|
+
|
|
7
|
+
export function isLegacyAuthSourceName(name) {
|
|
8
|
+
const s = String(name ?? '').trim().toLowerCase();
|
|
9
|
+
return s === 'legacy' || s === 'system' || s === 'local-install';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getLegacyHappyBaseDir() {
|
|
13
|
+
return join(homedir(), '.happy');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function stackHasAccessKey(stackName) {
|
|
17
|
+
try {
|
|
18
|
+
const { baseDir, envPath } = resolveStackEnvPath(stackName);
|
|
19
|
+
if (!existsSync(envPath)) return false;
|
|
20
|
+
return existsSync(join(baseDir, 'cli', 'access.key'));
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Seed sources that are safe to reuse locally.
|
|
28
|
+
*
|
|
29
|
+
* Note: deliberately does NOT include legacy ~/.happy sources; in many contexts we cannot reliably
|
|
30
|
+
* seed DB Account rows, which leads to broken stacks.
|
|
31
|
+
*/
|
|
32
|
+
export function detectSeedableAuthSources() {
|
|
33
|
+
const out = [];
|
|
34
|
+
if (stackHasAccessKey('dev-auth')) out.push('dev-auth');
|
|
35
|
+
if (stackHasAccessKey('main')) out.push('main');
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
|
|
6
|
+
import { run, runCapture } from '../proc/proc.mjs';
|
|
7
|
+
import { preferStackLocalhostUrl } from '../paths/localhost_host.mjs';
|
|
8
|
+
import { guidedStackWebSignupThenLogin } from './guided_stack_web_login.mjs';
|
|
9
|
+
import { resolveStackEnvPath, getWorkspaceDir } from '../paths/paths.mjs';
|
|
10
|
+
import { getExpoStatePaths, isStateProcessRunning } from '../expo/expo.mjs';
|
|
11
|
+
import { resolveLocalhostHost } from '../paths/localhost_host.mjs';
|
|
12
|
+
import { getStackRuntimeStatePath, isPidAlive, readStackRuntimeStateFile } from '../stack/runtime_state.mjs';
|
|
13
|
+
import { readEnvObjectFromFile } from '../env/read.mjs';
|
|
14
|
+
import { expandHome } from '../paths/canonical_home.mjs';
|
|
15
|
+
|
|
16
|
+
function extractEnvVar(cmd, key) {
|
|
17
|
+
const re = new RegExp(`${key}="([^"]+)"`);
|
|
18
|
+
const m = String(cmd ?? '').match(re);
|
|
19
|
+
return m?.[1] ? String(m[1]) : '';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function resolveRuntimeExpoWebappUrlForAuth({ stackName }) {
|
|
23
|
+
try {
|
|
24
|
+
const runtimeStatePath = getStackRuntimeStatePath(stackName);
|
|
25
|
+
const st = await readStackRuntimeStateFile(runtimeStatePath);
|
|
26
|
+
const ownerPid = Number(st?.ownerPid);
|
|
27
|
+
if (!isPidAlive(ownerPid)) return '';
|
|
28
|
+
const port = Number(st?.expo?.port ?? st?.expo?.webPort ?? st?.expo?.mobilePort);
|
|
29
|
+
if (!Number.isFinite(port) || port <= 0) return '';
|
|
30
|
+
const host = resolveLocalhostHost({ stackMode: true, stackName });
|
|
31
|
+
return `http://${host}:${port}`;
|
|
32
|
+
} catch {
|
|
33
|
+
return '';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function resolveExpoWebappUrlForAuth({ rootDir, stackName, timeoutMs }) {
|
|
38
|
+
const baseDir = resolveStackEnvPath(stackName).baseDir;
|
|
39
|
+
void rootDir; // kept for API stability; url resolution is stack-dir based
|
|
40
|
+
|
|
41
|
+
// IMPORTANT:
|
|
42
|
+
// In PR stacks (and especially in sandbox), the UI directory is typically a worktree path.
|
|
43
|
+
// Expo state paths include a hash derived from projectDir, so we cannot assume a stable uiDir
|
|
44
|
+
// here (e.g. `components/happy`). Instead, scan the stack's expo-dev state directory and pick
|
|
45
|
+
// the running Expo instance.
|
|
46
|
+
const expoDevRoot = join(baseDir, 'expo-dev');
|
|
47
|
+
|
|
48
|
+
async function resolveExpectedUiDir() {
|
|
49
|
+
try {
|
|
50
|
+
const { envPath } = resolveStackEnvPath(stackName);
|
|
51
|
+
const stackEnv = await readEnvObjectFromFile(envPath);
|
|
52
|
+
const raw = (stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY ?? stackEnv.HAPPY_LOCAL_COMPONENT_DIR_HAPPY ?? '').trim();
|
|
53
|
+
if (!raw) return '';
|
|
54
|
+
|
|
55
|
+
const expanded = expandHome(raw);
|
|
56
|
+
if (expanded.startsWith('/')) return resolve(expanded);
|
|
57
|
+
|
|
58
|
+
const wsRaw = (stackEnv.HAPPY_STACKS_WORKSPACE_DIR ?? stackEnv.HAPPY_LOCAL_WORKSPACE_DIR ?? '').trim();
|
|
59
|
+
const wsExpanded = wsRaw ? expandHome(wsRaw) : '';
|
|
60
|
+
const workspaceDir = wsExpanded ? (wsExpanded.startsWith('/') ? wsExpanded : resolve(getWorkspaceDir(rootDir), wsExpanded)) : getWorkspaceDir(rootDir);
|
|
61
|
+
return resolve(workspaceDir, expanded);
|
|
62
|
+
} catch {
|
|
63
|
+
return '';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function looksLikeExpoMetro({ port }) {
|
|
68
|
+
const p = Number(port);
|
|
69
|
+
if (!Number.isFinite(p) || p <= 0) return false;
|
|
70
|
+
|
|
71
|
+
// Metro exposes `/status` which returns "packager-status:running".
|
|
72
|
+
const url = `http://127.0.0.1:${p}/status`;
|
|
73
|
+
try {
|
|
74
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
75
|
+
const timeout = setTimeout(() => controller?.abort(), 800);
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch(url, { signal: controller?.signal });
|
|
78
|
+
const txt = await res.text().catch(() => '');
|
|
79
|
+
return res.ok && String(txt).toLowerCase().includes('packager-status:running');
|
|
80
|
+
} finally {
|
|
81
|
+
clearTimeout(timeout);
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function findRunningExpoStateUrl() {
|
|
89
|
+
if (!existsSync(expoDevRoot)) return '';
|
|
90
|
+
let entries = [];
|
|
91
|
+
try {
|
|
92
|
+
entries = await readdir(expoDevRoot, { withFileTypes: true });
|
|
93
|
+
} catch {
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const expectedUiDir = await resolveExpectedUiDir();
|
|
98
|
+
const expectedUiDirResolved = expectedUiDir ? resolve(expectedUiDir) : '';
|
|
99
|
+
|
|
100
|
+
let best = null;
|
|
101
|
+
for (const ent of entries) {
|
|
102
|
+
if (!ent.isDirectory()) continue;
|
|
103
|
+
const statePath = join(expoDevRoot, ent.name, 'expo.state.json');
|
|
104
|
+
if (!existsSync(statePath)) continue;
|
|
105
|
+
// eslint-disable-next-line no-await-in-loop
|
|
106
|
+
const running = await isStateProcessRunning(statePath);
|
|
107
|
+
if (!running.running) continue;
|
|
108
|
+
|
|
109
|
+
// If the state includes capabilities, require web for auth (dev-client-only isn't enough).
|
|
110
|
+
const hasCaps = running.state && typeof running.state === 'object' && 'webEnabled' in running.state;
|
|
111
|
+
const webEnabled = hasCaps ? Boolean(running.state?.webEnabled) : true;
|
|
112
|
+
if (!webEnabled) continue;
|
|
113
|
+
|
|
114
|
+
// Tighten: if the stack env specifies an explicit UI directory, only accept Expo state that
|
|
115
|
+
// matches it. This avoids accidentally selecting stale Expo state left under this stack dir.
|
|
116
|
+
if (expectedUiDirResolved) {
|
|
117
|
+
const uiDirRaw = String(running.state?.uiDir ?? '').trim();
|
|
118
|
+
if (!uiDirRaw) continue;
|
|
119
|
+
if (resolve(uiDirRaw) !== expectedUiDirResolved) continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const port = Number(running.state?.port);
|
|
123
|
+
if (!Number.isFinite(port) || port <= 0) continue;
|
|
124
|
+
|
|
125
|
+
// If we're only considering this "running" because the port is occupied (pid not alive),
|
|
126
|
+
// do a quick Metro probe so we don't accept an unrelated process reusing the port.
|
|
127
|
+
if (running.reason === 'port') {
|
|
128
|
+
// eslint-disable-next-line no-await-in-loop
|
|
129
|
+
const ok = await looksLikeExpoMetro({ port });
|
|
130
|
+
if (!ok) continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Prefer newest (startedAt) and prefer real pid-verified instances.
|
|
134
|
+
const startedAtMs = Date.parse(String(running.state?.startedAt ?? '')) || 0;
|
|
135
|
+
const score = (running.reason === 'pid' ? 1_000_000_000 : 0) + startedAtMs;
|
|
136
|
+
if (!best || score > best.score) {
|
|
137
|
+
best = { port, score };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!best) return '';
|
|
142
|
+
const host = resolveLocalhostHost({ stackMode: stackName !== 'main', stackName });
|
|
143
|
+
return `http://${host}:${best.port}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const deadline = Date.now() + timeoutMs;
|
|
147
|
+
while (Date.now() < deadline) {
|
|
148
|
+
// eslint-disable-next-line no-await-in-loop
|
|
149
|
+
const url = await findRunningExpoStateUrl();
|
|
150
|
+
if (url) return url;
|
|
151
|
+
// eslint-disable-next-line no-await-in-loop
|
|
152
|
+
await delay(200);
|
|
153
|
+
}
|
|
154
|
+
return '';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function fetchText(url, { timeoutMs = 2000 } = {}) {
|
|
158
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
159
|
+
const timeout = setTimeout(() => controller?.abort(), timeoutMs);
|
|
160
|
+
try {
|
|
161
|
+
const res = await fetch(url, { signal: controller?.signal });
|
|
162
|
+
const text = await res.text().catch(() => '');
|
|
163
|
+
return { ok: res.ok, status: res.status, text, headers: res.headers };
|
|
164
|
+
} catch (e) {
|
|
165
|
+
return { ok: false, status: 0, text: String(e?.message ?? e), headers: null };
|
|
166
|
+
} finally {
|
|
167
|
+
clearTimeout(timeout);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function pickHtmlBundlePath(html) {
|
|
172
|
+
const m = String(html ?? '').match(/<script[^>]+src="([^"]+)"[^>]*><\/script>/i);
|
|
173
|
+
return m?.[1] ? String(m[1]) : '';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function detectSymlinkedNodeModules({ worktreeDir }) {
|
|
177
|
+
try {
|
|
178
|
+
const p = join(worktreeDir, 'node_modules');
|
|
179
|
+
const st = await stat(p);
|
|
180
|
+
return Boolean(st.isSymbolicLink && st.isSymbolicLink());
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function assertExpoWebappBundlesOrThrow({ rootDir, stackName, webappUrl }) {
|
|
187
|
+
const u = new URL(webappUrl);
|
|
188
|
+
const port = u.port ? Number(u.port) : null;
|
|
189
|
+
const probeHost = Number.isFinite(port) ? '127.0.0.1' : u.hostname;
|
|
190
|
+
const base = `${u.protocol}//${probeHost}${u.port ? `:${u.port}` : ''}`;
|
|
191
|
+
|
|
192
|
+
// Retry briefly: Metro can be up while the first bundle compile is still warming.
|
|
193
|
+
const deadline = Date.now() + 60_000;
|
|
194
|
+
let lastError = '';
|
|
195
|
+
while (Date.now() < deadline) {
|
|
196
|
+
// eslint-disable-next-line no-await-in-loop
|
|
197
|
+
const htmlRes = await fetchText(`${base}/`, { timeoutMs: 2500 });
|
|
198
|
+
if (!htmlRes.ok) {
|
|
199
|
+
lastError = `HTTP ${htmlRes.status} loading ${base}/`;
|
|
200
|
+
// eslint-disable-next-line no-await-in-loop
|
|
201
|
+
await delay(500);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const bundlePath = pickHtmlBundlePath(htmlRes.text);
|
|
206
|
+
if (!bundlePath) {
|
|
207
|
+
lastError = `could not find bundle <script src> in ${base}/`;
|
|
208
|
+
// eslint-disable-next-line no-await-in-loop
|
|
209
|
+
await delay(500);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// eslint-disable-next-line no-await-in-loop
|
|
214
|
+
const bundleRes = await fetchText(`${base}${bundlePath.startsWith('/') ? '' : '/'}${bundlePath}`, { timeoutMs: 8000 });
|
|
215
|
+
if (bundleRes.ok) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Metro resolver errors are deterministic: surface immediately with actionable hints.
|
|
220
|
+
try {
|
|
221
|
+
const parsed = JSON.parse(String(bundleRes.text ?? ''));
|
|
222
|
+
const type = String(parsed?.type ?? '').trim();
|
|
223
|
+
const msg = String(parsed?.message ?? '').trim();
|
|
224
|
+
if (type === 'UnableToResolveError' || msg.includes('Unable to resolve module')) {
|
|
225
|
+
let hint = '';
|
|
226
|
+
try {
|
|
227
|
+
const { envPath } = resolveStackEnvPath(stackName);
|
|
228
|
+
const stackEnv = await readEnvObjectFromFile(envPath);
|
|
229
|
+
const uiDir = (stackEnv.HAPPY_STACKS_COMPONENT_DIR_HAPPY ?? stackEnv.HAPPY_LOCAL_COMPONENT_DIR_HAPPY ?? '').trim();
|
|
230
|
+
const symlinked = uiDir ? await detectSymlinkedNodeModules({ worktreeDir: uiDir }) : false;
|
|
231
|
+
if (symlinked) {
|
|
232
|
+
hint =
|
|
233
|
+
'\n' +
|
|
234
|
+
'[auth] Hint: this looks like an Expo/Metro resolution failure with symlinked node_modules.\n' +
|
|
235
|
+
'[auth] Fix: re-run review-pr/setup-pr with `--deps=install` (avoid linking node_modules for happy).\n';
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// ignore
|
|
239
|
+
}
|
|
240
|
+
throw new Error(
|
|
241
|
+
'[auth] Expo web UI is running, but the web bundle failed to build.\n' +
|
|
242
|
+
`[auth] URL: ${webappUrl}\n` +
|
|
243
|
+
`[auth] Error: ${msg || type || `HTTP ${bundleRes.status}`}\n` +
|
|
244
|
+
hint
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
// not JSON / not a known error
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
lastError = `HTTP ${bundleRes.status} loading bundle ${bundlePath}`;
|
|
252
|
+
// eslint-disable-next-line no-await-in-loop
|
|
253
|
+
await delay(500);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (lastError) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
'[auth] Expo web UI did not become ready for guided login (bundle not loadable).\n' +
|
|
259
|
+
`[auth] URL: ${webappUrl}\n` +
|
|
260
|
+
`[auth] Last error: ${lastError}\n` +
|
|
261
|
+
'[auth] Tip: re-run with --verbose to see Expo logs (or open the stack runner log file).'
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function resolveStackWebappUrlForAuth({ rootDir, stackName, env = process.env }) {
|
|
267
|
+
// Fast path: if the stack runner already recorded Expo webPort in stack.runtime.json,
|
|
268
|
+
// use it immediately (runtime state is authoritative).
|
|
269
|
+
const runtimeExpoUrl = await resolveRuntimeExpoWebappUrlForAuth({ stackName });
|
|
270
|
+
if (runtimeExpoUrl) {
|
|
271
|
+
return await preferStackLocalhostUrl(runtimeExpoUrl, { stackName });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const authFlow =
|
|
275
|
+
(env.HAPPY_STACKS_AUTH_FLOW ?? env.HAPPY_LOCAL_AUTH_FLOW ?? '').toString().trim() === '1' ||
|
|
276
|
+
(env.HAPPY_STACKS_DAEMON_WAIT_FOR_AUTH ?? env.HAPPY_LOCAL_DAEMON_WAIT_FOR_AUTH ?? '').toString().trim() === '1';
|
|
277
|
+
|
|
278
|
+
// Prefer the Expo web UI URL when running in dev mode.
|
|
279
|
+
// This is crucial for guided login: the browser needs the UI origin, not the server port.
|
|
280
|
+
const timeoutMsRaw =
|
|
281
|
+
(env.HAPPY_STACKS_AUTH_UI_READY_TIMEOUT_MS ?? env.HAPPY_LOCAL_AUTH_UI_READY_TIMEOUT_MS ?? '180000').toString().trim();
|
|
282
|
+
const timeoutMs = timeoutMsRaw ? Number(timeoutMsRaw) : 180_000;
|
|
283
|
+
const expoUrl = await resolveExpoWebappUrlForAuth({
|
|
284
|
+
rootDir,
|
|
285
|
+
stackName,
|
|
286
|
+
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 180_000,
|
|
287
|
+
});
|
|
288
|
+
if (expoUrl) {
|
|
289
|
+
return await preferStackLocalhostUrl(expoUrl, { stackName });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Fail closed for guided auth flows: falling back to server URLs opens the wrong origin.
|
|
293
|
+
if (authFlow) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`[auth] failed to resolve Expo web UI URL for guided login.\n` +
|
|
296
|
+
`[auth] Reason: Expo web UI did not become ready within ${Number.isFinite(timeoutMs) ? timeoutMs : 180_000}ms.\n` +
|
|
297
|
+
`[auth] Fix: re-run and wait for Expo to start, or run in prod mode (--start) if you want server-served UI.`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const raw = await runCapture(
|
|
303
|
+
process.execPath,
|
|
304
|
+
[join(rootDir, 'scripts', 'stack.mjs'), 'auth', stackName, '--', 'login', '--print', '--json'],
|
|
305
|
+
{
|
|
306
|
+
cwd: rootDir,
|
|
307
|
+
env,
|
|
308
|
+
}
|
|
309
|
+
);
|
|
310
|
+
const parsed = JSON.parse(String(raw ?? '').trim());
|
|
311
|
+
const cmd = typeof parsed?.cmd === 'string' ? parsed.cmd : '';
|
|
312
|
+
const url = extractEnvVar(cmd, 'HAPPY_WEBAPP_URL');
|
|
313
|
+
return url ? await preferStackLocalhostUrl(url, { stackName }) : '';
|
|
314
|
+
} catch {
|
|
315
|
+
return '';
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export async function guidedStackAuthLoginNow({ rootDir, stackName, env = process.env, webappUrl = null }) {
|
|
320
|
+
const resolved = (webappUrl ?? '').toString().trim() || (await resolveStackWebappUrlForAuth({ rootDir, stackName, env }));
|
|
321
|
+
if (!resolved) {
|
|
322
|
+
throw new Error('[auth] cannot start guided login: web UI URL is empty');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const skipBundleCheck = (env.HAPPY_STACKS_AUTH_SKIP_BUNDLE_CHECK ?? env.HAPPY_LOCAL_AUTH_SKIP_BUNDLE_CHECK ?? '').toString().trim() === '1';
|
|
326
|
+
// Surface common "blank page" issues (Metro resolver errors) even in quiet mode.
|
|
327
|
+
if (!skipBundleCheck) {
|
|
328
|
+
await assertExpoWebappBundlesOrThrow({ rootDir, stackName, webappUrl: resolved });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
await guidedStackWebSignupThenLogin({ webappUrl: resolved, stackName });
|
|
332
|
+
await run(process.execPath, [join(rootDir, 'scripts', 'stack.mjs'), 'auth', stackName, '--', 'login'], {
|
|
333
|
+
cwd: rootDir,
|
|
334
|
+
env,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function stackAuthCopyFrom({ rootDir, stackName, fromStackName, env = process.env, link = true }) {
|
|
339
|
+
await run(
|
|
340
|
+
process.execPath,
|
|
341
|
+
[
|
|
342
|
+
join(rootDir, 'scripts', 'stack.mjs'),
|
|
343
|
+
'auth',
|
|
344
|
+
stackName,
|
|
345
|
+
'--',
|
|
346
|
+
'copy-from',
|
|
347
|
+
fromStackName,
|
|
348
|
+
...(link ? ['--link'] : []),
|
|
349
|
+
],
|
|
350
|
+
{ cwd: rootDir, env }
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
@@ -37,6 +37,14 @@ export function getHappysRegistry() {
|
|
|
37
37
|
rootUsage: 'happys setup-pr --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--dev]',
|
|
38
38
|
description: 'One-shot: set up + run a PR stack (maintainer-friendly)',
|
|
39
39
|
},
|
|
40
|
+
{
|
|
41
|
+
name: 'review-pr',
|
|
42
|
+
aliases: ['reviewPR', 'reviewpr'],
|
|
43
|
+
kind: 'node',
|
|
44
|
+
scriptRelPath: 'scripts/review_pr.mjs',
|
|
45
|
+
rootUsage: 'happys review-pr --happy=<pr-url|number> [--happy-cli=<pr-url|number>] [--dev]',
|
|
46
|
+
description: 'Run setup-pr in a temporary sandbox (auto-cleaned)',
|
|
47
|
+
},
|
|
40
48
|
{
|
|
41
49
|
name: 'uninstall',
|
|
42
50
|
kind: 'node',
|
|
@@ -88,6 +96,14 @@ export function getHappysRegistry() {
|
|
|
88
96
|
rootUsage: 'happys build [-- ...]',
|
|
89
97
|
description: 'Build UI bundle',
|
|
90
98
|
},
|
|
99
|
+
{
|
|
100
|
+
name: 'review',
|
|
101
|
+
kind: 'node',
|
|
102
|
+
scriptRelPath: 'scripts/review.mjs',
|
|
103
|
+
rootUsage:
|
|
104
|
+
'happys review [component...] [--reviewers=coderabbit,codex] [--base-remote=<remote>] [--base-branch=<branch>] [--base-ref=<ref>] [--json]',
|
|
105
|
+
description: 'Run CodeRabbit/Codex reviews for component worktrees',
|
|
106
|
+
},
|
|
91
107
|
{
|
|
92
108
|
name: 'lint',
|
|
93
109
|
kind: 'node',
|
|
@@ -131,6 +147,14 @@ export function getHappysRegistry() {
|
|
|
131
147
|
rootUsage: 'happys mobile [-- ...]',
|
|
132
148
|
description: 'Mobile helper (iOS)',
|
|
133
149
|
},
|
|
150
|
+
{
|
|
151
|
+
name: 'mobile-dev-client',
|
|
152
|
+
aliases: ['dev-client', 'devclient'],
|
|
153
|
+
kind: 'node',
|
|
154
|
+
scriptRelPath: 'scripts/mobile_dev_client.mjs',
|
|
155
|
+
rootUsage: 'happys mobile-dev-client --install [--device=...] [--clean] [--configuration=Debug|Release] [--json]',
|
|
156
|
+
description: 'Install the shared Happy Stacks dev-client app (iOS)',
|
|
157
|
+
},
|
|
134
158
|
{
|
|
135
159
|
name: 'doctor',
|
|
136
160
|
kind: 'node',
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { getWorktreesRoot } from '../git/worktrees.mjs';
|
|
5
|
+
import { getComponentsDir } from '../paths/paths.mjs';
|
|
6
|
+
|
|
7
|
+
export function getInvokedCwd(env = process.env) {
|
|
8
|
+
return String(env.HAPPY_STACKS_INVOKED_CWD ?? env.HAPPY_LOCAL_INVOKED_CWD ?? env.PWD ?? '').trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function hasGitMarker(dir) {
|
|
12
|
+
try {
|
|
13
|
+
// In a worktree, `.git` is typically a file; in the primary checkout it may be a directory.
|
|
14
|
+
return existsSync(join(dir, '.git'));
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isPathInside(path, parentDir) {
|
|
21
|
+
const p = resolve(path);
|
|
22
|
+
const d = resolve(parentDir);
|
|
23
|
+
return p === d || p.startsWith(d.endsWith(sep) ? d : d + sep);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function findGitRoot(startDir, stopAtDir) {
|
|
27
|
+
let cur = resolve(startDir);
|
|
28
|
+
const stop = stopAtDir ? resolve(stopAtDir) : '';
|
|
29
|
+
|
|
30
|
+
while (true) {
|
|
31
|
+
if (hasGitMarker(cur)) {
|
|
32
|
+
return cur;
|
|
33
|
+
}
|
|
34
|
+
if (stop && cur === stop) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const parent = dirname(cur);
|
|
38
|
+
if (parent === cur) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (stop && !isPathInside(parent, stop)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
cur = parent;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function inferComponentFromCwd({ rootDir, invokedCwd, components }) {
|
|
49
|
+
const cwd = String(invokedCwd ?? '').trim();
|
|
50
|
+
const list = Array.isArray(components) ? components : [];
|
|
51
|
+
if (!rootDir || !cwd || !list.length) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const abs = resolve(cwd);
|
|
56
|
+
const componentsDir = getComponentsDir(rootDir);
|
|
57
|
+
const worktreesRoot = getWorktreesRoot(rootDir);
|
|
58
|
+
|
|
59
|
+
for (const component of list) {
|
|
60
|
+
const c = String(component ?? '').trim();
|
|
61
|
+
if (!c) continue;
|
|
62
|
+
|
|
63
|
+
const wtBase = resolve(join(worktreesRoot, c));
|
|
64
|
+
if (isPathInside(abs, wtBase)) {
|
|
65
|
+
const repoDir = findGitRoot(abs, wtBase);
|
|
66
|
+
if (repoDir) {
|
|
67
|
+
return { component: c, repoDir };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const primaryBase = resolve(join(componentsDir, c));
|
|
72
|
+
if (isPathInside(abs, primaryBase)) {
|
|
73
|
+
const repoDir = findGitRoot(abs, primaryBase);
|
|
74
|
+
if (repoDir) {
|
|
75
|
+
return { component: c, repoDir };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { inferComponentFromCwd } from './cwd_scope.mjs';
|
|
8
|
+
|
|
9
|
+
async function withTempRoot(t) {
|
|
10
|
+
const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-cwd-scope-'));
|
|
11
|
+
t.after(async () => {
|
|
12
|
+
await rm(dir, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('inferComponentFromCwd resolves components/<component> repo root', async (t) => {
|
|
18
|
+
const rootDir = await withTempRoot(t);
|
|
19
|
+
const prevWorkspace = process.env.HAPPY_STACKS_WORKSPACE_DIR;
|
|
20
|
+
process.env.HAPPY_STACKS_WORKSPACE_DIR = rootDir;
|
|
21
|
+
t.after(() => {
|
|
22
|
+
if (prevWorkspace == null) {
|
|
23
|
+
delete process.env.HAPPY_STACKS_WORKSPACE_DIR;
|
|
24
|
+
} else {
|
|
25
|
+
process.env.HAPPY_STACKS_WORKSPACE_DIR = prevWorkspace;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const repoRoot = join(rootDir, 'components', 'happy');
|
|
30
|
+
await mkdir(join(repoRoot, 'src'), { recursive: true });
|
|
31
|
+
await writeFile(join(repoRoot, '.git'), 'gitdir: /tmp/fake\n', 'utf-8');
|
|
32
|
+
|
|
33
|
+
const invokedCwd = join(repoRoot, 'src');
|
|
34
|
+
const inferred = inferComponentFromCwd({ rootDir, invokedCwd, components: ['happy', 'happy-cli'] });
|
|
35
|
+
assert.deepEqual(inferred, { component: 'happy', repoDir: repoRoot });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('inferComponentFromCwd resolves components/.worktrees/<component>/<owner>/<branch> repo root', async (t) => {
|
|
39
|
+
const rootDir = await withTempRoot(t);
|
|
40
|
+
const prevWorkspace = process.env.HAPPY_STACKS_WORKSPACE_DIR;
|
|
41
|
+
process.env.HAPPY_STACKS_WORKSPACE_DIR = rootDir;
|
|
42
|
+
t.after(() => {
|
|
43
|
+
if (prevWorkspace == null) {
|
|
44
|
+
delete process.env.HAPPY_STACKS_WORKSPACE_DIR;
|
|
45
|
+
} else {
|
|
46
|
+
process.env.HAPPY_STACKS_WORKSPACE_DIR = prevWorkspace;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const wtRoot = join(rootDir, 'components', '.worktrees', 'happy', 'slopus', 'pr', '123-fix', 'nested');
|
|
51
|
+
await mkdir(wtRoot, { recursive: true });
|
|
52
|
+
const repoRoot = join(rootDir, 'components', '.worktrees', 'happy', 'slopus', 'pr', '123-fix');
|
|
53
|
+
await writeFile(join(repoRoot, '.git'), 'gitdir: /tmp/fake\n', 'utf-8');
|
|
54
|
+
|
|
55
|
+
const invokedCwd = join(repoRoot, 'nested');
|
|
56
|
+
const inferred = inferComponentFromCwd({ rootDir, invokedCwd, components: ['happy'] });
|
|
57
|
+
assert.deepEqual(inferred, { component: 'happy', repoDir: repoRoot });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('inferComponentFromCwd returns null outside known component roots', async (t) => {
|
|
61
|
+
const rootDir = await withTempRoot(t);
|
|
62
|
+
const prevWorkspace = process.env.HAPPY_STACKS_WORKSPACE_DIR;
|
|
63
|
+
process.env.HAPPY_STACKS_WORKSPACE_DIR = rootDir;
|
|
64
|
+
t.after(() => {
|
|
65
|
+
if (prevWorkspace == null) {
|
|
66
|
+
delete process.env.HAPPY_STACKS_WORKSPACE_DIR;
|
|
67
|
+
} else {
|
|
68
|
+
process.env.HAPPY_STACKS_WORKSPACE_DIR = prevWorkspace;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const invokedCwd = join(rootDir, 'somewhere', 'else');
|
|
73
|
+
await mkdir(invokedCwd, { recursive: true });
|
|
74
|
+
const inferred = inferComponentFromCwd({ rootDir, invokedCwd, components: ['happy'] });
|
|
75
|
+
assert.equal(inferred, null);
|
|
76
|
+
});
|
|
77
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function boolFromFlags({ flags, onFlag, offFlag, defaultValue }) {
|
|
2
|
+
if (flags.has(offFlag)) return false;
|
|
3
|
+
if (flags.has(onFlag)) return true;
|
|
4
|
+
return defaultValue;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function boolFromFlagsOrKv({ flags, kv, onFlag, offFlag, key, defaultValue }) {
|
|
8
|
+
if (flags.has(offFlag)) return false;
|
|
9
|
+
if (flags.has(onFlag)) return true;
|
|
10
|
+
if (key && kv.has(key)) {
|
|
11
|
+
const raw = String(kv.get(key) ?? '').trim().toLowerCase();
|
|
12
|
+
if (raw === '1' || raw === 'true' || raw === 'yes' || raw === 'y') return true;
|
|
13
|
+
if (raw === '0' || raw === 'false' || raw === 'no' || raw === 'n') return false;
|
|
14
|
+
}
|
|
15
|
+
return defaultValue;
|
|
16
|
+
}
|
|
17
|
+
|