happy-stacks 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +29 -7
  2. package/bin/happys.mjs +114 -15
  3. package/docs/happy-development.md +2 -2
  4. package/docs/isolated-linux-vm.md +82 -0
  5. package/docs/mobile-ios.md +112 -54
  6. package/package.json +5 -1
  7. package/scripts/auth.mjs +11 -7
  8. package/scripts/build.mjs +54 -7
  9. package/scripts/daemon.mjs +166 -10
  10. package/scripts/dev.mjs +181 -46
  11. package/scripts/edison.mjs +4 -2
  12. package/scripts/init.mjs +3 -1
  13. package/scripts/install.mjs +112 -16
  14. package/scripts/lint.mjs +24 -4
  15. package/scripts/mobile.mjs +88 -104
  16. package/scripts/mobile_dev_client.mjs +83 -0
  17. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  18. package/scripts/review.mjs +217 -0
  19. package/scripts/review_pr.mjs +368 -0
  20. package/scripts/run.mjs +83 -9
  21. package/scripts/service.mjs +2 -2
  22. package/scripts/setup.mjs +42 -43
  23. package/scripts/setup_pr.mjs +591 -34
  24. package/scripts/stack.mjs +503 -45
  25. package/scripts/tailscale.mjs +37 -1
  26. package/scripts/test.mjs +45 -8
  27. package/scripts/tui.mjs +309 -39
  28. package/scripts/typecheck.mjs +24 -4
  29. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  30. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  31. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  32. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  33. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  34. package/scripts/utils/auth/login_ux.mjs +32 -13
  35. package/scripts/utils/auth/sources.mjs +26 -0
  36. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  37. package/scripts/utils/cli/cli_registry.mjs +24 -0
  38. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  39. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  40. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  41. package/scripts/utils/cli/prereqs.mjs +72 -0
  42. package/scripts/utils/cli/progress.mjs +126 -0
  43. package/scripts/utils/cli/verbosity.mjs +12 -0
  44. package/scripts/utils/dev/daemon.mjs +47 -3
  45. package/scripts/utils/dev/expo_dev.mjs +246 -0
  46. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  47. package/scripts/utils/dev/server.mjs +15 -25
  48. package/scripts/utils/dev_auth_key.mjs +169 -0
  49. package/scripts/utils/expo/command.mjs +52 -0
  50. package/scripts/utils/expo/expo.mjs +20 -1
  51. package/scripts/utils/expo/metro_ports.mjs +114 -0
  52. package/scripts/utils/git/git.mjs +67 -0
  53. package/scripts/utils/git/worktrees.mjs +24 -20
  54. package/scripts/utils/handy_master_secret.mjs +94 -0
  55. package/scripts/utils/mobile/config.mjs +31 -0
  56. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  57. package/scripts/utils/mobile/identifiers.mjs +47 -0
  58. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  59. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  60. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  61. package/scripts/utils/net/lan_ip.mjs +24 -0
  62. package/scripts/utils/net/ports.mjs +9 -1
  63. package/scripts/utils/net/url.mjs +30 -0
  64. package/scripts/utils/net/url.test.mjs +20 -0
  65. package/scripts/utils/paths/localhost_host.mjs +50 -3
  66. package/scripts/utils/paths/paths.mjs +42 -38
  67. package/scripts/utils/proc/parallel.mjs +25 -0
  68. package/scripts/utils/proc/pm.mjs +69 -12
  69. package/scripts/utils/proc/proc.mjs +76 -2
  70. package/scripts/utils/review/base_ref.mjs +74 -0
  71. package/scripts/utils/review/base_ref.test.mjs +54 -0
  72. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  73. package/scripts/utils/review/runners/codex.mjs +51 -0
  74. package/scripts/utils/review/targets.mjs +24 -0
  75. package/scripts/utils/review/targets.test.mjs +36 -0
  76. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  77. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  78. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  79. package/scripts/utils/server/urls.mjs +14 -4
  80. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  81. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  82. package/scripts/utils/stack/context.mjs +2 -2
  83. package/scripts/utils/stack/editor_workspace.mjs +2 -2
  84. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  85. package/scripts/utils/stack/runtime_state.mjs +2 -1
  86. package/scripts/utils/stack/startup.mjs +7 -0
  87. package/scripts/utils/stack/stop.mjs +15 -4
  88. package/scripts/utils/stack_context.mjs +23 -0
  89. package/scripts/utils/stack_runtime_state.mjs +104 -0
  90. package/scripts/utils/stacks.mjs +38 -0
  91. package/scripts/utils/ui/qr.mjs +17 -0
  92. package/scripts/utils/validate.mjs +88 -0
  93. package/scripts/worktrees.mjs +141 -55
  94. package/scripts/utils/dev/expo_web.mjs +0 -112
@@ -0,0 +1,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
+