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
|
@@ -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
|
+
|
|
@@ -34,9 +34,17 @@ export function getHappysRegistry() {
|
|
|
34
34
|
aliases: ['setupPR', 'setuppr'],
|
|
35
35
|
kind: 'node',
|
|
36
36
|
scriptRelPath: 'scripts/setup_pr.mjs',
|
|
37
|
-
rootUsage: 'happys setup-pr --happy=<pr-url|number> [--happy-
|
|
37
|
+
rootUsage: 'happys setup-pr --happy=<pr-url|number> [--happy-server-light=<pr-url|number>] [--dev|--start] [--json] [-- ...]',
|
|
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-server-light=<pr-url|number>] [--dev|--start] [--json] [-- ...]',
|
|
46
|
+
description: 'Run setup-pr in a temporary sandbox (auto-cleaned)',
|
|
47
|
+
},
|
|
40
48
|
{
|
|
41
49
|
name: 'uninstall',
|
|
42
50
|
kind: 'node',
|
|
@@ -46,12 +54,18 @@ export function getHappysRegistry() {
|
|
|
46
54
|
},
|
|
47
55
|
{
|
|
48
56
|
name: 'where',
|
|
49
|
-
aliases: ['env'],
|
|
50
57
|
kind: 'node',
|
|
51
58
|
scriptRelPath: 'scripts/where.mjs',
|
|
52
|
-
rootUsage: 'happys where [--json]
|
|
59
|
+
rootUsage: 'happys where [--json]',
|
|
53
60
|
description: 'Show resolved paths and env sources',
|
|
54
61
|
},
|
|
62
|
+
{
|
|
63
|
+
name: 'env',
|
|
64
|
+
kind: 'node',
|
|
65
|
+
scriptRelPath: 'scripts/env.mjs',
|
|
66
|
+
rootUsage: 'happys env set KEY=VALUE [KEY2=VALUE2...] (defaults to main stack)',
|
|
67
|
+
description: 'Set per-stack env vars (defaults to main)',
|
|
68
|
+
},
|
|
55
69
|
{
|
|
56
70
|
name: 'bootstrap',
|
|
57
71
|
kind: 'node',
|
|
@@ -88,6 +102,14 @@ export function getHappysRegistry() {
|
|
|
88
102
|
rootUsage: 'happys build [-- ...]',
|
|
89
103
|
description: 'Build UI bundle',
|
|
90
104
|
},
|
|
105
|
+
{
|
|
106
|
+
name: 'review',
|
|
107
|
+
kind: 'node',
|
|
108
|
+
scriptRelPath: 'scripts/review.mjs',
|
|
109
|
+
rootUsage:
|
|
110
|
+
'happys review [component...] [--reviewers=coderabbit,codex] [--base-remote=<remote>] [--base-branch=<branch>] [--base-ref=<ref>] [--json]',
|
|
111
|
+
description: 'Run CodeRabbit/Codex reviews for component worktrees',
|
|
112
|
+
},
|
|
91
113
|
{
|
|
92
114
|
name: 'lint',
|
|
93
115
|
kind: 'node',
|
|
@@ -124,6 +146,13 @@ export function getHappysRegistry() {
|
|
|
124
146
|
rootUsage: 'happys migrate light-to-server --from-stack=<name> --to-stack=<name> [--include-files] [--force] [--json]',
|
|
125
147
|
description: 'Migrate data between server flavors (experimental)',
|
|
126
148
|
},
|
|
149
|
+
{
|
|
150
|
+
name: 'monorepo',
|
|
151
|
+
kind: 'node',
|
|
152
|
+
scriptRelPath: 'scripts/monorepo.mjs',
|
|
153
|
+
rootUsage: 'happys monorepo port --target=/abs/path/to/monorepo [--branch=port/<name>] [--dry-run] [--3way] [--json]',
|
|
154
|
+
description: 'Port split-repo commits into monorepo (experimental)',
|
|
155
|
+
},
|
|
127
156
|
{
|
|
128
157
|
name: 'mobile',
|
|
129
158
|
kind: 'node',
|
|
@@ -131,6 +160,14 @@ export function getHappysRegistry() {
|
|
|
131
160
|
rootUsage: 'happys mobile [-- ...]',
|
|
132
161
|
description: 'Mobile helper (iOS)',
|
|
133
162
|
},
|
|
163
|
+
{
|
|
164
|
+
name: 'mobile-dev-client',
|
|
165
|
+
aliases: ['dev-client', 'devclient'],
|
|
166
|
+
kind: 'node',
|
|
167
|
+
scriptRelPath: 'scripts/mobile_dev_client.mjs',
|
|
168
|
+
rootUsage: 'happys mobile-dev-client --install [--device=...] [--clean] [--configuration=Debug|Release] [--json]',
|
|
169
|
+
description: 'Install the shared Happy Stacks dev-client app (iOS)',
|
|
170
|
+
},
|
|
134
171
|
{
|
|
135
172
|
name: 'doctor',
|
|
136
173
|
kind: 'node',
|
|
@@ -323,6 +360,9 @@ export function renderHappysRootHelp() {
|
|
|
323
360
|
'usage:',
|
|
324
361
|
...usageLines.map((l) => ` ${l}`),
|
|
325
362
|
'',
|
|
363
|
+
'stack shorthand:',
|
|
364
|
+
' happys <stack> <command> ... (equivalent to: happys stack <command> <stack> ...)',
|
|
365
|
+
'',
|
|
326
366
|
'commands:',
|
|
327
367
|
...commandsLines,
|
|
328
368
|
'',
|
|
@@ -330,4 +370,3 @@ export function renderHappysRootHelp() {
|
|
|
330
370
|
' happys help [command]',
|
|
331
371
|
].join('\n');
|
|
332
372
|
}
|
|
333
|
-
|
|
@@ -0,0 +1,136 @@
|
|
|
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, isHappyMonorepoRoot } 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
|
+
function resolveHappyMonorepoComponentFromPath({ monorepoRoot, absPath }) {
|
|
49
|
+
const root = resolve(monorepoRoot);
|
|
50
|
+
const abs = resolve(absPath);
|
|
51
|
+
const map = [
|
|
52
|
+
{ component: 'happy', dir: join(root, 'expo-app') },
|
|
53
|
+
{ component: 'happy-cli', dir: join(root, 'cli') },
|
|
54
|
+
{ component: 'happy-server', dir: join(root, 'server') },
|
|
55
|
+
];
|
|
56
|
+
for (const m of map) {
|
|
57
|
+
if (isPathInside(abs, m.dir)) {
|
|
58
|
+
// We return the shared git root so callers can safely use it as an env override
|
|
59
|
+
// for any of the monorepo components.
|
|
60
|
+
return { component: m.component, repoDir: root };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function inferComponentFromCwd({ rootDir, invokedCwd, components }) {
|
|
67
|
+
const cwd = String(invokedCwd ?? '').trim();
|
|
68
|
+
const list = Array.isArray(components) ? components : [];
|
|
69
|
+
if (!rootDir || !cwd || !list.length) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const abs = resolve(cwd);
|
|
74
|
+
const componentsDir = getComponentsDir(rootDir);
|
|
75
|
+
const worktreesRoot = getWorktreesRoot(rootDir);
|
|
76
|
+
|
|
77
|
+
// Monorepo-aware inference:
|
|
78
|
+
// If we're inside a happy monorepo checkout/worktree, infer which "logical component"
|
|
79
|
+
// (expo-app/cli/server) the user is working in and return that package dir.
|
|
80
|
+
//
|
|
81
|
+
// This enables workflows like:
|
|
82
|
+
// - running `happys dev` from inside components/happy/cli (should infer happy-cli)
|
|
83
|
+
// - running from inside components/.worktrees/happy/<owner>/<branch>/server (should infer happy-server)
|
|
84
|
+
{
|
|
85
|
+
const monorepoScopes = [
|
|
86
|
+
resolve(join(componentsDir, 'happy')),
|
|
87
|
+
resolve(join(worktreesRoot, 'happy')),
|
|
88
|
+
];
|
|
89
|
+
for (const scope of monorepoScopes) {
|
|
90
|
+
if (!isPathInside(abs, scope)) continue;
|
|
91
|
+
const repoRoot = findGitRoot(abs, scope);
|
|
92
|
+
if (!repoRoot) continue;
|
|
93
|
+
if (!isHappyMonorepoRoot(repoRoot)) continue;
|
|
94
|
+
|
|
95
|
+
const inferred = resolveHappyMonorepoComponentFromPath({ monorepoRoot: repoRoot, absPath: abs });
|
|
96
|
+
if (inferred) {
|
|
97
|
+
// Only return components the caller asked us to consider.
|
|
98
|
+
if (list.includes(inferred.component)) {
|
|
99
|
+
return inferred;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// If we are inside the monorepo root but not inside a known package dir, default to `happy`
|
|
105
|
+
// (the UI) when the caller allows it. This keeps legacy behavior where running from the
|
|
106
|
+
// repo root still "belongs" to the UI component.
|
|
107
|
+
if (list.includes('happy')) {
|
|
108
|
+
return { component: 'happy', repoDir: repoRoot };
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const component of list) {
|
|
115
|
+
const c = String(component ?? '').trim();
|
|
116
|
+
if (!c) continue;
|
|
117
|
+
|
|
118
|
+
const wtBase = resolve(join(worktreesRoot, c));
|
|
119
|
+
if (isPathInside(abs, wtBase)) {
|
|
120
|
+
const repoDir = findGitRoot(abs, wtBase);
|
|
121
|
+
if (repoDir) {
|
|
122
|
+
return { component: c, repoDir };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const primaryBase = resolve(join(componentsDir, c));
|
|
127
|
+
if (isPathInside(abs, primaryBase)) {
|
|
128
|
+
const repoDir = findGitRoot(abs, primaryBase);
|
|
129
|
+
if (repoDir) {
|
|
130
|
+
return { component: c, repoDir };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return null;
|
|
136
|
+
}
|