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.
Files changed (165) hide show
  1. package/README.md +93 -40
  2. package/bin/happys.mjs +158 -16
  3. package/docs/codex-mcp-resume.md +130 -0
  4. package/docs/commit-audits/happy/leeroy-wip.commit-analysis.md +17640 -0
  5. package/docs/commit-audits/happy/leeroy-wip.commit-export.fuller-stat.md +3845 -0
  6. package/docs/commit-audits/happy/leeroy-wip.commit-inventory.md +102 -0
  7. package/docs/commit-audits/happy/leeroy-wip.commit-manual-review.md +1452 -0
  8. package/docs/commit-audits/happy/leeroy-wip.manual-review-queue.md +116 -0
  9. package/docs/happy-development.md +3 -4
  10. package/docs/isolated-linux-vm.md +82 -0
  11. package/docs/mobile-ios.md +112 -54
  12. package/docs/monorepo-migration.md +286 -0
  13. package/docs/server-flavors.md +19 -3
  14. package/docs/stacks.md +35 -0
  15. package/package.json +5 -1
  16. package/scripts/auth.mjs +32 -10
  17. package/scripts/build.mjs +55 -8
  18. package/scripts/daemon.mjs +166 -10
  19. package/scripts/dev.mjs +198 -50
  20. package/scripts/doctor.mjs +0 -4
  21. package/scripts/edison.mjs +6 -4
  22. package/scripts/env.mjs +150 -0
  23. package/scripts/env_cmd.test.mjs +128 -0
  24. package/scripts/init.mjs +8 -3
  25. package/scripts/install.mjs +207 -69
  26. package/scripts/lint.mjs +24 -4
  27. package/scripts/migrate.mjs +3 -12
  28. package/scripts/mobile.mjs +88 -104
  29. package/scripts/mobile_dev_client.mjs +83 -0
  30. package/scripts/monorepo.mjs +1096 -0
  31. package/scripts/monorepo_port.test.mjs +1470 -0
  32. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  33. package/scripts/review.mjs +908 -0
  34. package/scripts/review_pr.mjs +353 -0
  35. package/scripts/run.mjs +101 -21
  36. package/scripts/service.mjs +2 -2
  37. package/scripts/setup.mjs +189 -68
  38. package/scripts/setup_pr.mjs +586 -38
  39. package/scripts/stack.mjs +990 -196
  40. package/scripts/stack_archive_cmd.test.mjs +91 -0
  41. package/scripts/stack_editor_workspace_monorepo_root.test.mjs +65 -0
  42. package/scripts/stack_env_cmd.test.mjs +87 -0
  43. package/scripts/stack_happy_cmd.test.mjs +126 -0
  44. package/scripts/stack_interactive_monorepo_group.test.mjs +71 -0
  45. package/scripts/stack_monorepo_defaults.test.mjs +62 -0
  46. package/scripts/stack_monorepo_server_light_from_happy_spec.test.mjs +66 -0
  47. package/scripts/stack_server_flavors_defaults.test.mjs +55 -0
  48. package/scripts/stack_shorthand_cmd.test.mjs +55 -0
  49. package/scripts/stack_wt_list.test.mjs +128 -0
  50. package/scripts/tailscale.mjs +37 -1
  51. package/scripts/test.mjs +45 -8
  52. package/scripts/tui.mjs +395 -39
  53. package/scripts/typecheck.mjs +24 -4
  54. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  55. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  56. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  57. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  58. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  59. package/scripts/utils/auth/login_ux.mjs +32 -13
  60. package/scripts/utils/auth/sources.mjs +26 -0
  61. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  62. package/scripts/utils/cli/cli_registry.mjs +43 -4
  63. package/scripts/utils/cli/cwd_scope.mjs +136 -0
  64. package/scripts/utils/cli/cwd_scope.test.mjs +110 -0
  65. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  66. package/scripts/utils/cli/prereqs.mjs +75 -0
  67. package/scripts/utils/cli/prereqs.test.mjs +34 -0
  68. package/scripts/utils/cli/progress.mjs +126 -0
  69. package/scripts/utils/cli/verbosity.mjs +12 -0
  70. package/scripts/utils/cli/wizard.mjs +17 -9
  71. package/scripts/utils/cli/wizard_prompt_worktree_source_lazy.test.mjs +60 -0
  72. package/scripts/utils/dev/daemon.mjs +61 -4
  73. package/scripts/utils/dev/expo_dev.mjs +430 -0
  74. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  75. package/scripts/utils/dev/server.mjs +36 -42
  76. package/scripts/utils/dev_auth_key.mjs +169 -0
  77. package/scripts/utils/edison/git_roots.mjs +29 -0
  78. package/scripts/utils/edison/git_roots.test.mjs +36 -0
  79. package/scripts/utils/env/env.mjs +7 -3
  80. package/scripts/utils/env/env_file.mjs +4 -2
  81. package/scripts/utils/env/env_file.test.mjs +44 -0
  82. package/scripts/utils/expo/command.mjs +52 -0
  83. package/scripts/utils/expo/expo.mjs +20 -1
  84. package/scripts/utils/expo/metro_ports.mjs +114 -0
  85. package/scripts/utils/git/git.mjs +67 -0
  86. package/scripts/utils/git/worktrees.mjs +80 -25
  87. package/scripts/utils/git/worktrees_monorepo.test.mjs +54 -0
  88. package/scripts/utils/handy_master_secret.mjs +94 -0
  89. package/scripts/utils/mobile/config.mjs +31 -0
  90. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  91. package/scripts/utils/mobile/identifiers.mjs +47 -0
  92. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  93. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  94. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  95. package/scripts/utils/net/lan_ip.mjs +24 -0
  96. package/scripts/utils/net/ports.mjs +9 -1
  97. package/scripts/utils/net/tcp_forward.mjs +162 -0
  98. package/scripts/utils/net/url.mjs +30 -0
  99. package/scripts/utils/net/url.test.mjs +20 -0
  100. package/scripts/utils/paths/localhost_host.mjs +50 -3
  101. package/scripts/utils/paths/paths.mjs +159 -40
  102. package/scripts/utils/paths/paths_monorepo.test.mjs +58 -0
  103. package/scripts/utils/paths/paths_server_flavors.test.mjs +45 -0
  104. package/scripts/utils/proc/commands.mjs +2 -3
  105. package/scripts/utils/proc/parallel.mjs +25 -0
  106. package/scripts/utils/proc/pm.mjs +176 -22
  107. package/scripts/utils/proc/pm_spawn.test.mjs +76 -0
  108. package/scripts/utils/proc/pm_stack_cache_env.test.mjs +142 -0
  109. package/scripts/utils/proc/proc.mjs +136 -4
  110. package/scripts/utils/proc/proc.test.mjs +77 -0
  111. package/scripts/utils/review/base_ref.mjs +74 -0
  112. package/scripts/utils/review/base_ref.test.mjs +54 -0
  113. package/scripts/utils/review/chunks.mjs +55 -0
  114. package/scripts/utils/review/chunks.test.mjs +51 -0
  115. package/scripts/utils/review/findings.mjs +165 -0
  116. package/scripts/utils/review/findings.test.mjs +85 -0
  117. package/scripts/utils/review/head_slice.mjs +153 -0
  118. package/scripts/utils/review/head_slice.test.mjs +91 -0
  119. package/scripts/utils/review/instructions/deep.md +20 -0
  120. package/scripts/utils/review/runners/coderabbit.mjs +61 -0
  121. package/scripts/utils/review/runners/coderabbit.test.mjs +59 -0
  122. package/scripts/utils/review/runners/codex.mjs +61 -0
  123. package/scripts/utils/review/runners/codex.test.mjs +35 -0
  124. package/scripts/utils/review/slices.mjs +140 -0
  125. package/scripts/utils/review/slices.test.mjs +32 -0
  126. package/scripts/utils/review/targets.mjs +24 -0
  127. package/scripts/utils/review/targets.test.mjs +36 -0
  128. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  129. package/scripts/utils/server/flavor_scripts.mjs +98 -0
  130. package/scripts/utils/server/flavor_scripts.test.mjs +146 -0
  131. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  132. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  133. package/scripts/utils/server/prisma_import.mjs +37 -0
  134. package/scripts/utils/server/prisma_import.test.mjs +70 -0
  135. package/scripts/utils/server/ui_env.mjs +14 -0
  136. package/scripts/utils/server/ui_env.test.mjs +46 -0
  137. package/scripts/utils/server/urls.mjs +14 -4
  138. package/scripts/utils/server/validate.mjs +53 -16
  139. package/scripts/utils/server/validate.test.mjs +89 -0
  140. package/scripts/utils/service/autostart_darwin.mjs +42 -2
  141. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  142. package/scripts/utils/stack/context.mjs +2 -2
  143. package/scripts/utils/stack/editor_workspace.mjs +6 -6
  144. package/scripts/utils/stack/interactive_stack_config.mjs +185 -0
  145. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  146. package/scripts/utils/stack/runtime_state.mjs +2 -1
  147. package/scripts/utils/stack/startup.mjs +120 -13
  148. package/scripts/utils/stack/startup_server_light_dirs.test.mjs +64 -0
  149. package/scripts/utils/stack/startup_server_light_generate.test.mjs +70 -0
  150. package/scripts/utils/stack/startup_server_light_legacy.test.mjs +88 -0
  151. package/scripts/utils/stack/stop.mjs +15 -4
  152. package/scripts/utils/stack_context.mjs +23 -0
  153. package/scripts/utils/stack_runtime_state.mjs +104 -0
  154. package/scripts/utils/stacks.mjs +38 -0
  155. package/scripts/utils/tailscale/ip.mjs +116 -0
  156. package/scripts/utils/ui/ansi.mjs +39 -0
  157. package/scripts/utils/ui/qr.mjs +17 -0
  158. package/scripts/utils/validate.mjs +88 -0
  159. package/scripts/where.mjs +2 -2
  160. package/scripts/worktrees.mjs +755 -179
  161. package/scripts/worktrees_archive_cmd.test.mjs +245 -0
  162. package/scripts/worktrees_cursor_monorepo_root.test.mjs +63 -0
  163. package/scripts/worktrees_list_specs_no_recurse.test.mjs +33 -0
  164. package/scripts/worktrees_monorepo_use_group.test.mjs +67 -0
  165. 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-cli=<pr-url|number>] [--dev]',
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] (alias: env)',
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
+ }