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
package/scripts/tui.mjs CHANGED
@@ -1,19 +1,34 @@
1
1
  import './utils/env/env.mjs';
2
2
  import { spawn } from 'node:child_process';
3
+ import { mkdir } from 'node:fs/promises';
3
4
  import { join, resolve, sep } from 'node:path';
4
5
 
5
6
  import { printResult } from './utils/cli/cli.mjs';
6
7
  import { readEnvObjectFromFile } from './utils/env/read.mjs';
7
- import { getRootDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
8
+ import { getComponentsDir, getRootDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
8
9
  import { getStackRuntimeStatePath, readStackRuntimeStateFile } from './utils/stack/runtime_state.mjs';
9
10
  import { getEnvValueAny } from './utils/env/values.mjs';
10
11
  import { padRight, parsePrefixedLabel, stripAnsi } from './utils/ui/text.mjs';
12
+ import { commandExists } from './utils/proc/commands.mjs';
13
+ import { renderQrAscii } from './utils/ui/qr.mjs';
14
+ import { resolveMobileQrPayload } from './utils/mobile/dev_client_links.mjs';
11
15
 
12
16
  function nowTs() {
13
17
  const d = new Date();
14
18
  return d.toISOString().slice(11, 19);
15
19
  }
16
20
 
21
+ function supportsAnsi() {
22
+ if (!process.stdout.isTTY) return false;
23
+ if (process.env.NO_COLOR) return false;
24
+ if ((process.env.TERM ?? '').toLowerCase() === 'dumb') return false;
25
+ return true;
26
+ }
27
+
28
+ function cyan(s) {
29
+ return supportsAnsi() ? `\x1b[36m${s}\x1b[0m` : String(s);
30
+ }
31
+
17
32
  function clamp(n, lo, hi) {
18
33
  return Math.max(lo, Math.min(hi, n));
19
34
  }
@@ -29,7 +44,13 @@ function pushLine(pane, line, { maxLines = 4000 } = {}) {
29
44
  }
30
45
  }
31
46
 
32
- function drawBox({ x, y, w, h, title, lines, scroll }) {
47
+ function getPaneHeightForLines(lines, { min = 3, max = 16 } = {}) {
48
+ const n = Array.isArray(lines) ? lines.length : 0;
49
+ // +2 for box borders
50
+ return clamp(n + 2, min, max);
51
+ }
52
+
53
+ function drawBox({ x, y, w, h, title, lines, scroll, active = false }) {
33
54
  const top = y;
34
55
  const bottom = y + h - 1;
35
56
  const left = x;
@@ -53,12 +74,14 @@ function drawBox({ x, y, w, h, title, lines, scroll }) {
53
74
  const midLine = '│' + ' '.repeat(Math.max(0, w - 2)) + '│';
54
75
  const botLine = '└' + horiz + '┘';
55
76
 
77
+ const style = (s) => (active ? cyan(s) : s);
78
+
56
79
  const out = [];
57
- out.push({ row: top, col: left, text: topLine });
80
+ out.push({ row: top, col: left, text: style(topLine) });
58
81
  for (let r = top + 1; r < bottom; r++) {
59
- out.push({ row: r, col: left, text: midLine });
82
+ out.push({ row: r, col: left, text: style(midLine) });
60
83
  }
61
- out.push({ row: bottom, col: left, text: botLine });
84
+ out.push({ row: bottom, col: left, text: style(botLine) });
62
85
 
63
86
  const innerW = Math.max(0, w - 2);
64
87
  const innerH = Math.max(0, h - 2);
@@ -93,13 +116,104 @@ function inferStackNameFromForwardedArgs(args) {
93
116
 
94
117
  const readEnvObject = readEnvObjectFromFile;
95
118
 
119
+ async function preflightCorepackYarnForStack({ envPath }) {
120
+ // Corepack caches (and therefore "download yarn?" prompts) are tied to XDG/HOME.
121
+ // In stack mode we isolate HOME/XDG caches per stack, which can cause Corepack to prompt
122
+ // the first time a stack runs Yarn.
123
+ //
124
+ // In `happys tui`, the child runs under a pseudo-TTY (via `script`) and the TUI consumes
125
+ // all keyboard input, so Corepack's interactive prompt deadlocks.
126
+ //
127
+ // Fix: pre-download Yarn in a *non-tty* subprocess using the stack's isolated HOME/XDG,
128
+ // so later pty runs don't prompt.
129
+ if (!envPath) return;
130
+ const baseDir = resolve(join(envPath, '..'));
131
+ const stackHome = join(baseDir, 'home');
132
+ const cacheBase = join(baseDir, 'cache');
133
+ const env = {
134
+ ...process.env,
135
+ HOME: stackHome,
136
+ USERPROFILE: stackHome,
137
+ XDG_CACHE_HOME: join(cacheBase, 'xdg'),
138
+ YARN_CACHE_FOLDER: join(cacheBase, 'yarn'),
139
+ npm_config_cache: join(cacheBase, 'npm'),
140
+ // Avoid Corepack mutating package.json automatically.
141
+ COREPACK_ENABLE_AUTO_PIN: '0',
142
+ // Best-effort: disable download prompts (may not be honored by all Corepack versions).
143
+ COREPACK_ENABLE_DOWNLOAD_PROMPT: '0',
144
+ // Treat this as non-interactive (helps some tooling).
145
+ CI: process.env.CI ?? '1',
146
+ };
147
+
148
+ await mkdir(stackHome, { recursive: true }).catch(() => {});
149
+ await mkdir(env.XDG_CACHE_HOME, { recursive: true }).catch(() => {});
150
+ await mkdir(env.YARN_CACHE_FOLDER, { recursive: true }).catch(() => {});
151
+ await mkdir(env.npm_config_cache, { recursive: true }).catch(() => {});
152
+ await mkdir(env.COREPACK_HOME, { recursive: true }).catch(() => {});
153
+
154
+ await new Promise((resolvePromise) => {
155
+ const proc = spawn('yarn', ['--version'], {
156
+ env,
157
+ cwd: baseDir,
158
+ // Non-tty stdio: Corepack typically won't prompt; if it does, we still provide "y\n".
159
+ stdio: ['pipe', 'ignore', 'ignore'],
160
+ shell: false,
161
+ });
162
+ try {
163
+ proc.stdin?.write('y\n');
164
+ proc.stdin?.end();
165
+ } catch {
166
+ // ignore
167
+ }
168
+
169
+ const t = setTimeout(() => {
170
+ try {
171
+ proc.kill('SIGKILL');
172
+ } catch {
173
+ // ignore
174
+ }
175
+ resolvePromise();
176
+ }, 60_000);
177
+
178
+ proc.on('exit', () => {
179
+ clearTimeout(t);
180
+ resolvePromise();
181
+ });
182
+ proc.on('error', () => {
183
+ clearTimeout(t);
184
+ resolvePromise();
185
+ });
186
+ });
187
+ }
188
+
189
+ function getEnvVal(env, key, legacyKey) {
190
+ return getEnvValueAny(env, [key, legacyKey]) || '';
191
+ }
192
+
193
+ function nextLineBreakIndex(s) {
194
+ const n = s.indexOf('\n');
195
+ const r = s.indexOf('\r');
196
+ if (n < 0) return r;
197
+ if (r < 0) return n;
198
+ return Math.min(n, r);
199
+ }
200
+
201
+ function consumeLineBreak(buf) {
202
+ if (buf.startsWith('\r\n')) return buf.slice(2);
203
+ if (buf.startsWith('\n') || buf.startsWith('\r')) return buf.slice(1);
204
+ return buf;
205
+ }
206
+
96
207
  function formatComponentRef({ rootDir, component, dir }) {
97
208
  const raw = String(dir ?? '').trim();
98
209
  if (!raw) return '(unset)';
99
210
 
100
211
  const abs = resolve(raw);
101
- const defaultDir = resolve(join(rootDir, 'components', component));
102
- const worktreesPrefix = resolve(join(rootDir, 'components', '.worktrees', component)) + sep;
212
+ // Respect sandbox workspace layout:
213
+ // - default: <workspace>/components/<component>
214
+ // - worktrees: <workspace>/components/.worktrees/<component>/<owner>/<branch...>
215
+ const defaultDir = resolve(join(getComponentsDir(rootDir), component));
216
+ const worktreesPrefix = resolve(join(getComponentsDir(rootDir), '.worktrees', component)) + sep;
103
217
 
104
218
  if (abs === defaultDir) return 'default';
105
219
  if (abs.startsWith(worktreesPrefix)) {
@@ -118,7 +232,9 @@ async function buildStackSummaryLines({ rootDir, stackName }) {
118
232
  getEnvValueAny(env, ['HAPPY_STACKS_SERVER_COMPONENT', 'HAPPY_LOCAL_SERVER_COMPONENT']) || 'happy-server-light';
119
233
 
120
234
  const ports = runtime?.ports && typeof runtime.ports === 'object' ? runtime.ports : {};
121
- const expoWebPort = runtime?.expo && typeof runtime.expo === 'object' ? runtime.expo.webPort : null;
235
+ const expo = runtime?.expo && typeof runtime.expo === 'object' ? runtime.expo : {};
236
+ const expoPort = expo?.port ?? expo?.webPort ?? expo?.mobilePort ?? null;
237
+ const expoDevClientEnabled = Boolean(expo?.devClientEnabled);
122
238
  const processes = runtime?.processes && typeof runtime.processes === 'object' ? runtime.processes : {};
123
239
 
124
240
  const components = [
@@ -150,13 +266,21 @@ async function buildStackSummaryLines({ rootDir, stackName }) {
150
266
  lines.push('');
151
267
  lines.push('ports:');
152
268
  lines.push(` server: ${ports?.server ?? '(unknown)'}`);
153
- if (expoWebPort) lines.push(` ui: ${expoWebPort}`);
269
+ if (expoPort) lines.push(` expo: ${expoPort}`);
154
270
  if (ports?.backend) lines.push(` backend: ${ports.backend}`);
155
271
 
272
+ if (expoPort && expoDevClientEnabled) {
273
+ const payload = resolveMobileQrPayload({ env: process.env, port: Number(expoPort) });
274
+ lines.push('');
275
+ lines.push('expo dev-client links:');
276
+ if (payload.metroUrl) lines.push(` metro: ${payload.metroUrl}`);
277
+ if (payload.scheme && payload.deepLink) lines.push(` link: ${payload.deepLink}`);
278
+ }
279
+
156
280
  lines.push('');
157
281
  lines.push('pids:');
158
282
  if (processes?.serverPid) lines.push(` serverPid: ${processes.serverPid}`);
159
- if (processes?.expoWebPid) lines.push(` expoWebPid: ${processes.expoWebPid}`);
283
+ if (processes?.expoPid) lines.push(` expoPid: ${processes.expoPid}`);
160
284
  if (processes?.daemonPid) lines.push(` daemonPid: ${processes.daemonPid}`);
161
285
  if (processes?.uiGatewayPid) lines.push(` uiGatewayPid: ${processes.uiGatewayPid}`);
162
286
 
@@ -170,6 +294,29 @@ async function buildStackSummaryLines({ rootDir, stackName }) {
170
294
  return lines;
171
295
  }
172
296
 
297
+ async function buildExpoQrPaneLines({ stackName }) {
298
+ const runtimePath = getStackRuntimeStatePath(stackName);
299
+ const runtime = await readStackRuntimeStateFile(runtimePath);
300
+ const expo = runtime?.expo && typeof runtime.expo === 'object' ? runtime.expo : {};
301
+ const port = Number(expo?.port ?? expo?.mobilePort ?? expo?.webPort);
302
+ const enabled = Boolean(expo?.devClientEnabled);
303
+ if (!enabled || !Number.isFinite(port) || port <= 0) {
304
+ return { visible: false, lines: [] };
305
+ }
306
+
307
+ const payload = resolveMobileQrPayload({ env: process.env, port });
308
+ // Try to keep the QR compact:
309
+ // - qrcode-terminal uses a terminal-friendly pattern with adequate quiet-zone.
310
+ const qr = await renderQrAscii(payload.payload, { small: true });
311
+ const lines = [];
312
+ if (qr.ok) {
313
+ lines.push(...qr.lines);
314
+ } else {
315
+ lines.push(`(QR unavailable) ${qr.error || ''}`.trim());
316
+ }
317
+ return { visible: true, lines };
318
+ }
319
+
173
320
  async function main() {
174
321
  const argv = process.argv.slice(2);
175
322
 
@@ -203,7 +350,7 @@ async function main() {
203
350
  ' q / Ctrl+C : quit (sends SIGINT to child)',
204
351
  '',
205
352
  'panes (default):',
206
- ' orchestration | summary | local | server | ui | daemon | stack logs',
353
+ ' orchestration | summary | local | server | expo | daemon | stack logs',
207
354
  ].join('\n'),
208
355
  });
209
356
  return;
@@ -218,15 +365,18 @@ async function main() {
218
365
  const forwarded = argv;
219
366
 
220
367
  const stackName = inferStackNameFromForwardedArgs(forwarded);
368
+ const { envPath: stackEnvPath } = resolveStackEnvPath(stackName);
221
369
 
222
370
  const panes = [
223
371
  mkPane('orch', 'orchestration', { visible: true, kind: 'log' }),
224
372
  mkPane('summary', `stack summary (${stackName})`, { visible: true, kind: 'summary' }),
373
+ // Data-only pane: we render QR inside the Expo pane (no separate box).
374
+ mkPane('qr', 'expo QR', { visible: false, kind: 'qr' }),
225
375
  mkPane('local', 'local', { visible: true, kind: 'log' }),
226
- mkPane('server', 'server', { visible: true, kind: 'log' }),
227
- mkPane('ui', 'ui', { visible: true, kind: 'log' }),
228
- mkPane('daemon', 'daemon', { visible: true, kind: 'log' }),
229
- mkPane('stacklog', 'stack logs', { visible: true, kind: 'log' }),
376
+ mkPane('server', 'server', { visible: false, kind: 'log' }),
377
+ mkPane('expo', 'expo', { visible: false, kind: 'log' }),
378
+ mkPane('daemon', 'daemon', { visible: false, kind: 'log' }),
379
+ mkPane('stacklog', 'stack logs', { visible: false, kind: 'log' }),
230
380
  ];
231
381
 
232
382
  const paneIndexById = new Map(panes.map((p, i) => [p.id, i]));
@@ -237,12 +387,18 @@ async function main() {
237
387
 
238
388
  let paneId = 'local';
239
389
  if (normalized.includes('server')) paneId = 'server';
240
- else if (normalized === 'ui') paneId = 'ui';
390
+ else if (normalized === 'ui') paneId = 'expo';
391
+ else if (normalized === 'mobile') paneId = 'expo';
392
+ else if (normalized === 'expo') paneId = 'expo';
241
393
  else if (normalized.includes('daemon')) paneId = 'daemon';
242
394
  else if (normalized === 'stack') paneId = 'stacklog';
243
395
  else if (normalized === 'local') paneId = 'local';
244
396
 
245
397
  const idx = paneIndexById.get(paneId) ?? paneIndexById.get('local');
398
+ if (panes[idx] && !panes[idx].visible && panes[idx].kind === 'log') {
399
+ panes[idx].visible = true;
400
+ // If the focused pane was hidden before, keep focus stable but ensure render updates layout.
401
+ }
246
402
  pushLine(panes[idx], line);
247
403
  };
248
404
 
@@ -250,29 +406,55 @@ async function main() {
250
406
  pushLine(panes[paneIndexById.get('orch')], `[${nowTs()}] ${msg}`);
251
407
  };
252
408
 
409
+ // Preflight Yarn/Corepack for this stack before spawning the pty child.
410
+ // This prevents Corepack "download yarn? [Y/n]" prompts from deadlocking the TUI.
411
+ await preflightCorepackYarnForStack({ envPath: stackEnvPath });
412
+
253
413
  let layout = 'columns'; // single | split | columns
254
- let focused = 2; // local
414
+ let focused = paneIndexById.get('local'); // default focus
255
415
  let paused = false;
256
416
  let renderScheduled = false;
257
417
 
258
- const child = spawn(process.execPath, [happysBin, ...forwarded], {
259
- cwd: rootDir,
260
- env: { ...process.env },
261
- stdio: ['ignore', 'pipe', 'pipe'],
262
- detached: process.platform !== 'win32',
263
- });
418
+ const wantsPty = process.platform !== 'win32' && (await commandExists('script', { cwd: rootDir }));
419
+ // In TUI mode, we intentionally do not forward keyboard input to the child process (stdin is ignored),
420
+ // so any interactive prompts inside the child would deadlock.
421
+ // Mark the child env so dependency installers can auto-approve safe prompts (Corepack yarn downloads).
422
+ const childEnv = {
423
+ ...process.env,
424
+ HAPPY_STACKS_TUI: '1',
425
+ HAPPY_LOCAL_TUI: '1',
426
+ // Avoid Corepack mutating package.json automatically.
427
+ COREPACK_ENABLE_AUTO_PIN: '0',
428
+ };
429
+ const child = wantsPty
430
+ ? // Use a pseudo-terminal so tools like Expo print QR/status output that they hide in non-TTY mode.
431
+ // `script` is available by default on macOS (and common on Linux).
432
+ spawn('script', ['-q', '/dev/null', process.execPath, happysBin, ...forwarded], {
433
+ cwd: rootDir,
434
+ env: childEnv,
435
+ stdio: ['ignore', 'pipe', 'pipe'],
436
+ detached: process.platform !== 'win32',
437
+ })
438
+ : spawn(process.execPath, [happysBin, ...forwarded], {
439
+ cwd: rootDir,
440
+ env: childEnv,
441
+ stdio: ['ignore', 'pipe', 'pipe'],
442
+ detached: process.platform !== 'win32',
443
+ });
264
444
 
265
- logOrch(`spawned: node ${happysBin} ${forwarded.join(' ')} (pid=${child.pid})`);
445
+ logOrch(
446
+ `spawned: ${wantsPty ? 'script -q /dev/null ' : ''}node ${happysBin} ${forwarded.join(' ')} (pid=${child.pid})`
447
+ );
266
448
 
267
449
  const buf = { out: '', err: '' };
268
450
  const flush = (kind) => {
269
451
  const key = kind === 'stderr' ? 'err' : 'out';
270
452
  let b = buf[key];
271
453
  while (true) {
272
- const idx = b.indexOf('\n');
454
+ const idx = nextLineBreakIndex(b);
273
455
  if (idx < 0) break;
274
456
  const line = b.slice(0, idx);
275
- b = b.slice(idx + 1);
457
+ b = consumeLineBreak(b.slice(idx));
276
458
  routeLine(line);
277
459
  }
278
460
  buf[key] = b;
@@ -301,6 +483,19 @@ async function main() {
301
483
  } catch (e) {
302
484
  panes[idx].lines = [`summary error: ${e instanceof Error ? e.message : String(e)}`];
303
485
  }
486
+
487
+ // QR pane: driven by runtime state (expo port) and rendered independently of logs.
488
+ try {
489
+ const qrIdx = paneIndexById.get('qr');
490
+ const qr = await buildExpoQrPaneLines({ stackName });
491
+ // Data-only pane (kept hidden): rendered inside the expo pane.
492
+ panes[qrIdx].visible = false;
493
+ panes[qrIdx].lines = qr.lines;
494
+ } catch {
495
+ const qrIdx = paneIndexById.get('qr');
496
+ panes[qrIdx].visible = false;
497
+ panes[qrIdx].lines = [];
498
+ }
304
499
  scheduleRender();
305
500
  }
306
501
 
@@ -374,7 +569,9 @@ async function main() {
374
569
  process.stdout.write('\x1b[?25l');
375
570
  process.stdout.write('\x1b[2J\x1b[H');
376
571
 
377
- const header = `happys tui | ${forwarded.join(' ')} | layout=${layout} | focus=${panes[focused]?.title ?? focused}`;
572
+ const focusPane = panes[focused];
573
+ const focusLabel = focusPane ? `${focusPane.id} (${focusPane.title})` : String(focused);
574
+ const header = `happys tui | ${forwarded.join(' ')} | layout=${layout} | focus=${focusLabel}`;
378
575
  process.stdout.write(padRight(header, cols) + '\n');
379
576
 
380
577
  const bodyY = 1;
@@ -382,9 +579,22 @@ async function main() {
382
579
  const footerY = rows - 1;
383
580
 
384
581
  const drawWrites = [];
582
+
583
+ const contentY = bodyY;
584
+ let contentH = bodyH;
585
+
385
586
  if (layout === 'single') {
386
587
  const pane = panes[focused];
387
- const box = drawBox({ x: 0, y: bodyY, w: cols, h: bodyH, title: pane.title, lines: pane.lines, scroll: pane.scroll });
588
+ const box = drawBox({
589
+ x: 0,
590
+ y: contentY,
591
+ w: cols,
592
+ h: contentH,
593
+ title: pane.title,
594
+ lines: pane.lines,
595
+ scroll: pane.scroll,
596
+ active: true,
597
+ });
388
598
  pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
389
599
  drawWrites.push(...box.out);
390
600
  } else if (layout === 'split') {
@@ -394,38 +604,184 @@ async function main() {
394
604
  const leftPane = panes[paneIndexById.get('orch')];
395
605
  const rightPane = panes[focused === paneIndexById.get('orch') ? paneIndexById.get('local') : focused];
396
606
 
397
- const leftBox = drawBox({ x: 0, y: bodyY, w: leftW, h: bodyH, title: leftPane.title, lines: leftPane.lines, scroll: leftPane.scroll });
607
+ const leftBox = drawBox({
608
+ x: 0,
609
+ y: contentY,
610
+ w: leftW,
611
+ h: contentH,
612
+ title: leftPane.title,
613
+ lines: leftPane.lines,
614
+ scroll: leftPane.scroll,
615
+ active: focused === paneIndexById.get('orch'),
616
+ });
398
617
  leftPane.scroll = clamp(leftPane.scroll, 0, leftBox.maxScroll);
399
618
  drawWrites.push(...leftBox.out);
400
619
 
401
- const rightBox = drawBox({ x: leftW, y: bodyY, w: rightW, h: bodyH, title: rightPane.title, lines: rightPane.lines, scroll: rightPane.scroll });
620
+ const rightBox = drawBox({
621
+ x: leftW,
622
+ y: contentY,
623
+ w: rightW,
624
+ h: contentH,
625
+ title: rightPane.title,
626
+ lines: rightPane.lines,
627
+ scroll: rightPane.scroll,
628
+ active: focused === (paneIndexById.get(rightPane.id) ?? focused),
629
+ });
402
630
  rightPane.scroll = clamp(rightPane.scroll, 0, rightBox.maxScroll);
403
631
  drawWrites.push(...rightBox.out);
404
632
  } else {
405
- // columns: render all visible panes in two columns, stacked.
406
- const visible = visiblePaneIndexes().map((idx) => panes[idx]);
633
+ // columns: render a compact top row (orch + summary), then render QR alongside Expo logs.
634
+ const orchIdx = paneIndexById.get('orch');
635
+ const summaryIdx = paneIndexById.get('summary');
636
+ const qrIdx = paneIndexById.get('qr');
637
+ const qrPane = panes[qrIdx];
638
+ const qrVisible = Boolean(qrPane?.visible && qrPane.lines?.length);
639
+
640
+ const topPanes = [panes[orchIdx], panes[summaryIdx]];
641
+ const topCount = topPanes.length;
642
+ const topH = getPaneHeightForLines(panes[summaryIdx].lines, { min: 6, max: 14 });
643
+
644
+ const topY = contentY;
645
+ const belowY = contentY + topH;
646
+ const belowH = Math.max(0, contentH - topH);
647
+
648
+ const colW = Math.floor(cols / topCount);
649
+ for (let i = 0; i < topCount; i++) {
650
+ const pane = topPanes[i];
651
+ const x = i === topCount - 1 ? colW * i : colW * i;
652
+ const w = i === topCount - 1 ? cols - colW * i : colW;
653
+ const box = drawBox({
654
+ x,
655
+ y: topY,
656
+ w,
657
+ h: topH,
658
+ title: pane.title,
659
+ lines: pane.lines,
660
+ scroll: pane.scroll,
661
+ active: paneIndexById.get(pane.id) === focused,
662
+ });
663
+ pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
664
+ drawWrites.push(...box.out);
665
+ }
666
+
667
+ // Remaining panes: exclude the top-row panes. QR is rendered inside the expo pane.
668
+ const visibleAll = visiblePaneIndexes()
669
+ .filter((idx) => idx !== orchIdx && idx !== summaryIdx && idx !== qrIdx)
670
+ .map((idx) => panes[idx]);
407
671
  const leftW = Math.floor(cols / 2);
408
672
  const rightW = cols - leftW;
409
673
 
410
674
  const leftPanes = [];
411
675
  const rightPanes = [];
676
+ const expoPane = panes[paneIndexById.get('expo')];
677
+ const visible = visibleAll.filter((p) => p !== expoPane);
412
678
  for (let i = 0; i < visible.length; i++) {
413
679
  (i % 2 === 0 ? leftPanes : rightPanes).push(visible[i]);
414
680
  }
681
+ if (expoPane?.visible) {
682
+ rightPanes.unshift(expoPane);
683
+ }
415
684
 
416
685
  const layoutColumn = (colX, colW, colPanes) => {
417
686
  if (!colPanes.length) return;
418
687
  const n = colPanes.length;
419
- const base = Math.max(3, Math.floor(bodyH / n));
420
- let y = bodyY;
688
+ const base = Math.max(3, Math.floor(belowH / n));
689
+ let y = belowY;
421
690
  for (let i = 0; i < n; i++) {
422
691
  const pane = colPanes[i];
423
- const remaining = bodyY + bodyH - y;
424
- const h = i === n - 1 ? remaining : Math.min(base, remaining);
692
+ const remaining = belowY + belowH - y;
693
+ let h = i === n - 1 ? remaining : Math.min(base, remaining);
425
694
  if (h < 3) break;
426
- const box = drawBox({ x: colX, y, w: colW, h, title: pane.title, lines: pane.lines, scroll: pane.scroll });
427
- pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
428
- drawWrites.push(...box.out);
695
+ if (pane.id === 'expo') {
696
+ const qrLines = Array.isArray(qrPane?.lines) ? qrPane.lines : [];
697
+ const qrHas = Boolean(qrLines.length);
698
+ const qrMinH = qrHas ? Math.max(6, qrLines.length + 2) : 0; // +2 borders
699
+ if (qrMinH && h < qrMinH) {
700
+ h = Math.min(remaining, qrMinH);
701
+ if (h < 3) break;
702
+ }
703
+
704
+ if (qrHas) {
705
+ // Split the expo pane horizontally:
706
+ // left = expo logs, right = QR. This uses width instead of extra height.
707
+ const maxLineLen = qrLines.reduce((m, l) => Math.max(m, stripAnsi(l).length), 0);
708
+ const minLogW = 24;
709
+ const minQrW = 22;
710
+ const maxQrW = Math.max(0, Math.min(80, colW - minLogW));
711
+ const fixedQrWRaw = (process.env.HAPPY_STACKS_TUI_QR_WIDTH ?? process.env.HAPPY_LOCAL_TUI_QR_WIDTH ?? '').toString().trim();
712
+ const fixedQrW = fixedQrWRaw ? Number(fixedQrWRaw) : 44;
713
+ const qrW = clamp(Number.isFinite(fixedQrW) && fixedQrW > 0 ? fixedQrW : maxLineLen + 2, minQrW, maxQrW);
714
+ const canSplit = qrW >= minQrW && colW - qrW >= minLogW;
715
+
716
+ if (canSplit) {
717
+ const logW = colW - qrW;
718
+ const logBox = drawBox({
719
+ x: colX,
720
+ y,
721
+ w: logW,
722
+ h,
723
+ title: pane.title,
724
+ lines: pane.lines,
725
+ scroll: pane.scroll,
726
+ active: paneIndexById.get(pane.id) === focused,
727
+ });
728
+ pane.scroll = clamp(pane.scroll, 0, logBox.maxScroll);
729
+ drawWrites.push(...logBox.out);
730
+
731
+ const qrBox = drawBox({
732
+ x: colX + logW,
733
+ y,
734
+ w: qrW,
735
+ h,
736
+ title: qrPane.title,
737
+ lines: qrLines,
738
+ scroll: 0,
739
+ active: paneIndexById.get(pane.id) === focused,
740
+ });
741
+ drawWrites.push(...qrBox.out);
742
+ } else {
743
+ // Too narrow to split cleanly: fallback to single expo log box.
744
+ const box = drawBox({
745
+ x: colX,
746
+ y,
747
+ w: colW,
748
+ h,
749
+ title: pane.title,
750
+ lines: pane.lines,
751
+ scroll: pane.scroll,
752
+ active: paneIndexById.get(pane.id) === focused,
753
+ });
754
+ pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
755
+ drawWrites.push(...box.out);
756
+ }
757
+ } else {
758
+ const box = drawBox({
759
+ x: colX,
760
+ y,
761
+ w: colW,
762
+ h,
763
+ title: pane.title,
764
+ lines: pane.lines,
765
+ scroll: pane.scroll,
766
+ active: paneIndexById.get(pane.id) === focused,
767
+ });
768
+ pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
769
+ drawWrites.push(...box.out);
770
+ }
771
+ } else {
772
+ const box = drawBox({
773
+ x: colX,
774
+ y,
775
+ w: colW,
776
+ h,
777
+ title: pane.title,
778
+ lines: pane.lines,
779
+ scroll: pane.scroll,
780
+ active: paneIndexById.get(pane.id) === focused,
781
+ });
782
+ pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
783
+ drawWrites.push(...box.out);
784
+ }
429
785
  y += h;
430
786
  }
431
787
  };
@@ -1,11 +1,12 @@
1
1
  import './utils/env/env.mjs';
2
2
  import { parseArgs } from './utils/cli/args.mjs';
3
3
  import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
4
- import { getComponentDir, getRootDir } from './utils/paths/paths.mjs';
4
+ import { componentDirEnvKey, getComponentDir, getRootDir } from './utils/paths/paths.mjs';
5
5
  import { ensureDepsInstalled } from './utils/proc/pm.mjs';
6
6
  import { pathExists } from './utils/fs/fs.mjs';
7
7
  import { run } from './utils/proc/proc.mjs';
8
8
  import { detectPackageManagerCmd, pickFirstScript, readPackageJsonScripts } from './utils/proc/package_scripts.mjs';
9
+ import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
9
10
 
10
11
  const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
11
12
 
@@ -40,18 +41,37 @@ async function main() {
40
41
  'examples:',
41
42
  ' happys typecheck',
42
43
  ' happys typecheck happy happy-cli',
44
+ '',
45
+ 'note:',
46
+ ' If run from inside a component checkout/worktree and no components are provided, defaults to that component.',
43
47
  ].join('\n'),
44
48
  });
45
49
  return;
46
50
  }
47
51
 
52
+ const rootDir = getRootDir(import.meta.url);
53
+
48
54
  const positionals = argv.filter((a) => !a.startsWith('--'));
49
- const requested = positionals.length ? positionals : ['all'];
55
+ const inferred =
56
+ positionals.length === 0
57
+ ? inferComponentFromCwd({
58
+ rootDir,
59
+ invokedCwd: getInvokedCwd(process.env),
60
+ components: DEFAULT_COMPONENTS,
61
+ })
62
+ : null;
63
+ if (inferred) {
64
+ const stacksKey = componentDirEnvKey(inferred.component);
65
+ const legacyKey = stacksKey.replace(/^HAPPY_STACKS_/, 'HAPPY_LOCAL_');
66
+ if (!(process.env[stacksKey] ?? '').toString().trim() && !(process.env[legacyKey] ?? '').toString().trim()) {
67
+ process.env[stacksKey] = inferred.repoDir;
68
+ }
69
+ }
70
+
71
+ const requested = positionals.length ? positionals : inferred ? [inferred.component] : ['all'];
50
72
  const wantAll = requested.includes('all');
51
73
  const components = wantAll ? DEFAULT_COMPONENTS : requested;
52
74
 
53
- const rootDir = getRootDir(import.meta.url);
54
-
55
75
  const results = [];
56
76
  for (const component of components) {
57
77
  if (!DEFAULT_COMPONENTS.includes(component)) {