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
package/scripts/tui.mjs CHANGED
@@ -4,16 +4,30 @@ import { join, resolve, sep } from 'node:path';
4
4
 
5
5
  import { printResult } from './utils/cli/cli.mjs';
6
6
  import { readEnvObjectFromFile } from './utils/env/read.mjs';
7
- import { getRootDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
7
+ import { getComponentsDir, getRootDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
8
8
  import { getStackRuntimeStatePath, readStackRuntimeStateFile } from './utils/stack/runtime_state.mjs';
9
9
  import { getEnvValueAny } from './utils/env/values.mjs';
10
10
  import { padRight, parsePrefixedLabel, stripAnsi } from './utils/ui/text.mjs';
11
+ import { commandExists } from './utils/proc/commands.mjs';
12
+ import { renderQrAscii } from './utils/ui/qr.mjs';
13
+ import { resolveMobileQrPayload } from './utils/mobile/dev_client_links.mjs';
11
14
 
12
15
  function nowTs() {
13
16
  const d = new Date();
14
17
  return d.toISOString().slice(11, 19);
15
18
  }
16
19
 
20
+ function supportsAnsi() {
21
+ if (!process.stdout.isTTY) return false;
22
+ if (process.env.NO_COLOR) return false;
23
+ if ((process.env.TERM ?? '').toLowerCase() === 'dumb') return false;
24
+ return true;
25
+ }
26
+
27
+ function cyan(s) {
28
+ return supportsAnsi() ? `\x1b[36m${s}\x1b[0m` : String(s);
29
+ }
30
+
17
31
  function clamp(n, lo, hi) {
18
32
  return Math.max(lo, Math.min(hi, n));
19
33
  }
@@ -29,7 +43,13 @@ function pushLine(pane, line, { maxLines = 4000 } = {}) {
29
43
  }
30
44
  }
31
45
 
32
- function drawBox({ x, y, w, h, title, lines, scroll }) {
46
+ function getPaneHeightForLines(lines, { min = 3, max = 16 } = {}) {
47
+ const n = Array.isArray(lines) ? lines.length : 0;
48
+ // +2 for box borders
49
+ return clamp(n + 2, min, max);
50
+ }
51
+
52
+ function drawBox({ x, y, w, h, title, lines, scroll, active = false }) {
33
53
  const top = y;
34
54
  const bottom = y + h - 1;
35
55
  const left = x;
@@ -53,12 +73,14 @@ function drawBox({ x, y, w, h, title, lines, scroll }) {
53
73
  const midLine = '│' + ' '.repeat(Math.max(0, w - 2)) + '│';
54
74
  const botLine = '└' + horiz + '┘';
55
75
 
76
+ const style = (s) => (active ? cyan(s) : s);
77
+
56
78
  const out = [];
57
- out.push({ row: top, col: left, text: topLine });
79
+ out.push({ row: top, col: left, text: style(topLine) });
58
80
  for (let r = top + 1; r < bottom; r++) {
59
- out.push({ row: r, col: left, text: midLine });
81
+ out.push({ row: r, col: left, text: style(midLine) });
60
82
  }
61
- out.push({ row: bottom, col: left, text: botLine });
83
+ out.push({ row: bottom, col: left, text: style(botLine) });
62
84
 
63
85
  const innerW = Math.max(0, w - 2);
64
86
  const innerH = Math.max(0, h - 2);
@@ -93,13 +115,34 @@ function inferStackNameFromForwardedArgs(args) {
93
115
 
94
116
  const readEnvObject = readEnvObjectFromFile;
95
117
 
118
+ function getEnvVal(env, key, legacyKey) {
119
+ return getEnvValueAny(env, [key, legacyKey]) || '';
120
+ }
121
+
122
+ function nextLineBreakIndex(s) {
123
+ const n = s.indexOf('\n');
124
+ const r = s.indexOf('\r');
125
+ if (n < 0) return r;
126
+ if (r < 0) return n;
127
+ return Math.min(n, r);
128
+ }
129
+
130
+ function consumeLineBreak(buf) {
131
+ if (buf.startsWith('\r\n')) return buf.slice(2);
132
+ if (buf.startsWith('\n') || buf.startsWith('\r')) return buf.slice(1);
133
+ return buf;
134
+ }
135
+
96
136
  function formatComponentRef({ rootDir, component, dir }) {
97
137
  const raw = String(dir ?? '').trim();
98
138
  if (!raw) return '(unset)';
99
139
 
100
140
  const abs = resolve(raw);
101
- const defaultDir = resolve(join(rootDir, 'components', component));
102
- const worktreesPrefix = resolve(join(rootDir, 'components', '.worktrees', component)) + sep;
141
+ // Respect sandbox workspace layout:
142
+ // - default: <workspace>/components/<component>
143
+ // - worktrees: <workspace>/components/.worktrees/<component>/<owner>/<branch...>
144
+ const defaultDir = resolve(join(getComponentsDir(rootDir), component));
145
+ const worktreesPrefix = resolve(join(getComponentsDir(rootDir), '.worktrees', component)) + sep;
103
146
 
104
147
  if (abs === defaultDir) return 'default';
105
148
  if (abs.startsWith(worktreesPrefix)) {
@@ -118,7 +161,9 @@ async function buildStackSummaryLines({ rootDir, stackName }) {
118
161
  getEnvValueAny(env, ['HAPPY_STACKS_SERVER_COMPONENT', 'HAPPY_LOCAL_SERVER_COMPONENT']) || 'happy-server-light';
119
162
 
120
163
  const ports = runtime?.ports && typeof runtime.ports === 'object' ? runtime.ports : {};
121
- const expoWebPort = runtime?.expo && typeof runtime.expo === 'object' ? runtime.expo.webPort : null;
164
+ const expo = runtime?.expo && typeof runtime.expo === 'object' ? runtime.expo : {};
165
+ const expoPort = expo?.port ?? expo?.webPort ?? expo?.mobilePort ?? null;
166
+ const expoDevClientEnabled = Boolean(expo?.devClientEnabled);
122
167
  const processes = runtime?.processes && typeof runtime.processes === 'object' ? runtime.processes : {};
123
168
 
124
169
  const components = [
@@ -150,13 +195,21 @@ async function buildStackSummaryLines({ rootDir, stackName }) {
150
195
  lines.push('');
151
196
  lines.push('ports:');
152
197
  lines.push(` server: ${ports?.server ?? '(unknown)'}`);
153
- if (expoWebPort) lines.push(` ui: ${expoWebPort}`);
198
+ if (expoPort) lines.push(` expo: ${expoPort}`);
154
199
  if (ports?.backend) lines.push(` backend: ${ports.backend}`);
155
200
 
201
+ if (expoPort && expoDevClientEnabled) {
202
+ const payload = resolveMobileQrPayload({ env: process.env, port: Number(expoPort) });
203
+ lines.push('');
204
+ lines.push('expo dev-client links:');
205
+ if (payload.metroUrl) lines.push(` metro: ${payload.metroUrl}`);
206
+ if (payload.scheme && payload.deepLink) lines.push(` link: ${payload.deepLink}`);
207
+ }
208
+
156
209
  lines.push('');
157
210
  lines.push('pids:');
158
211
  if (processes?.serverPid) lines.push(` serverPid: ${processes.serverPid}`);
159
- if (processes?.expoWebPid) lines.push(` expoWebPid: ${processes.expoWebPid}`);
212
+ if (processes?.expoPid) lines.push(` expoPid: ${processes.expoPid}`);
160
213
  if (processes?.daemonPid) lines.push(` daemonPid: ${processes.daemonPid}`);
161
214
  if (processes?.uiGatewayPid) lines.push(` uiGatewayPid: ${processes.uiGatewayPid}`);
162
215
 
@@ -170,6 +223,29 @@ async function buildStackSummaryLines({ rootDir, stackName }) {
170
223
  return lines;
171
224
  }
172
225
 
226
+ async function buildExpoQrPaneLines({ stackName }) {
227
+ const runtimePath = getStackRuntimeStatePath(stackName);
228
+ const runtime = await readStackRuntimeStateFile(runtimePath);
229
+ const expo = runtime?.expo && typeof runtime.expo === 'object' ? runtime.expo : {};
230
+ const port = Number(expo?.port ?? expo?.mobilePort ?? expo?.webPort);
231
+ const enabled = Boolean(expo?.devClientEnabled);
232
+ if (!enabled || !Number.isFinite(port) || port <= 0) {
233
+ return { visible: false, lines: [] };
234
+ }
235
+
236
+ const payload = resolveMobileQrPayload({ env: process.env, port });
237
+ // Try to keep the QR compact:
238
+ // - qrcode-terminal uses a terminal-friendly pattern with adequate quiet-zone.
239
+ const qr = await renderQrAscii(payload.payload, { small: true });
240
+ const lines = [];
241
+ if (qr.ok) {
242
+ lines.push(...qr.lines);
243
+ } else {
244
+ lines.push(`(QR unavailable) ${qr.error || ''}`.trim());
245
+ }
246
+ return { visible: true, lines };
247
+ }
248
+
173
249
  async function main() {
174
250
  const argv = process.argv.slice(2);
175
251
 
@@ -203,7 +279,7 @@ async function main() {
203
279
  ' q / Ctrl+C : quit (sends SIGINT to child)',
204
280
  '',
205
281
  'panes (default):',
206
- ' orchestration | summary | local | server | ui | daemon | stack logs',
282
+ ' orchestration | summary | local | server | expo | daemon | stack logs',
207
283
  ].join('\n'),
208
284
  });
209
285
  return;
@@ -222,11 +298,13 @@ async function main() {
222
298
  const panes = [
223
299
  mkPane('orch', 'orchestration', { visible: true, kind: 'log' }),
224
300
  mkPane('summary', `stack summary (${stackName})`, { visible: true, kind: 'summary' }),
301
+ // Data-only pane: we render QR inside the Expo pane (no separate box).
302
+ mkPane('qr', 'expo QR', { visible: false, kind: 'qr' }),
225
303
  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' }),
304
+ mkPane('server', 'server', { visible: false, kind: 'log' }),
305
+ mkPane('expo', 'expo', { visible: false, kind: 'log' }),
306
+ mkPane('daemon', 'daemon', { visible: false, kind: 'log' }),
307
+ mkPane('stacklog', 'stack logs', { visible: false, kind: 'log' }),
230
308
  ];
231
309
 
232
310
  const paneIndexById = new Map(panes.map((p, i) => [p.id, i]));
@@ -237,12 +315,18 @@ async function main() {
237
315
 
238
316
  let paneId = 'local';
239
317
  if (normalized.includes('server')) paneId = 'server';
240
- else if (normalized === 'ui') paneId = 'ui';
318
+ else if (normalized === 'ui') paneId = 'expo';
319
+ else if (normalized === 'mobile') paneId = 'expo';
320
+ else if (normalized === 'expo') paneId = 'expo';
241
321
  else if (normalized.includes('daemon')) paneId = 'daemon';
242
322
  else if (normalized === 'stack') paneId = 'stacklog';
243
323
  else if (normalized === 'local') paneId = 'local';
244
324
 
245
325
  const idx = paneIndexById.get(paneId) ?? paneIndexById.get('local');
326
+ if (panes[idx] && !panes[idx].visible && panes[idx].kind === 'log') {
327
+ panes[idx].visible = true;
328
+ // If the focused pane was hidden before, keep focus stable but ensure render updates layout.
329
+ }
246
330
  pushLine(panes[idx], line);
247
331
  };
248
332
 
@@ -251,28 +335,40 @@ async function main() {
251
335
  };
252
336
 
253
337
  let layout = 'columns'; // single | split | columns
254
- let focused = 2; // local
338
+ let focused = paneIndexById.get('local'); // default focus
255
339
  let paused = false;
256
340
  let renderScheduled = false;
257
341
 
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
- });
342
+ const wantsPty = process.platform !== 'win32' && (await commandExists('script', { cwd: rootDir }));
343
+ const child = wantsPty
344
+ ? // Use a pseudo-terminal so tools like Expo print QR/status output that they hide in non-TTY mode.
345
+ // `script` is available by default on macOS (and common on Linux).
346
+ spawn('script', ['-q', '/dev/null', process.execPath, happysBin, ...forwarded], {
347
+ cwd: rootDir,
348
+ env: { ...process.env },
349
+ stdio: ['ignore', 'pipe', 'pipe'],
350
+ detached: process.platform !== 'win32',
351
+ })
352
+ : spawn(process.execPath, [happysBin, ...forwarded], {
353
+ cwd: rootDir,
354
+ env: { ...process.env },
355
+ stdio: ['ignore', 'pipe', 'pipe'],
356
+ detached: process.platform !== 'win32',
357
+ });
264
358
 
265
- logOrch(`spawned: node ${happysBin} ${forwarded.join(' ')} (pid=${child.pid})`);
359
+ logOrch(
360
+ `spawned: ${wantsPty ? 'script -q /dev/null ' : ''}node ${happysBin} ${forwarded.join(' ')} (pid=${child.pid})`
361
+ );
266
362
 
267
363
  const buf = { out: '', err: '' };
268
364
  const flush = (kind) => {
269
365
  const key = kind === 'stderr' ? 'err' : 'out';
270
366
  let b = buf[key];
271
367
  while (true) {
272
- const idx = b.indexOf('\n');
368
+ const idx = nextLineBreakIndex(b);
273
369
  if (idx < 0) break;
274
370
  const line = b.slice(0, idx);
275
- b = b.slice(idx + 1);
371
+ b = consumeLineBreak(b.slice(idx));
276
372
  routeLine(line);
277
373
  }
278
374
  buf[key] = b;
@@ -301,6 +397,19 @@ async function main() {
301
397
  } catch (e) {
302
398
  panes[idx].lines = [`summary error: ${e instanceof Error ? e.message : String(e)}`];
303
399
  }
400
+
401
+ // QR pane: driven by runtime state (expo port) and rendered independently of logs.
402
+ try {
403
+ const qrIdx = paneIndexById.get('qr');
404
+ const qr = await buildExpoQrPaneLines({ stackName });
405
+ // Data-only pane (kept hidden): rendered inside the expo pane.
406
+ panes[qrIdx].visible = false;
407
+ panes[qrIdx].lines = qr.lines;
408
+ } catch {
409
+ const qrIdx = paneIndexById.get('qr');
410
+ panes[qrIdx].visible = false;
411
+ panes[qrIdx].lines = [];
412
+ }
304
413
  scheduleRender();
305
414
  }
306
415
 
@@ -374,7 +483,9 @@ async function main() {
374
483
  process.stdout.write('\x1b[?25l');
375
484
  process.stdout.write('\x1b[2J\x1b[H');
376
485
 
377
- const header = `happys tui | ${forwarded.join(' ')} | layout=${layout} | focus=${panes[focused]?.title ?? focused}`;
486
+ const focusPane = panes[focused];
487
+ const focusLabel = focusPane ? `${focusPane.id} (${focusPane.title})` : String(focused);
488
+ const header = `happys tui | ${forwarded.join(' ')} | layout=${layout} | focus=${focusLabel}`;
378
489
  process.stdout.write(padRight(header, cols) + '\n');
379
490
 
380
491
  const bodyY = 1;
@@ -382,9 +493,22 @@ async function main() {
382
493
  const footerY = rows - 1;
383
494
 
384
495
  const drawWrites = [];
496
+
497
+ const contentY = bodyY;
498
+ let contentH = bodyH;
499
+
385
500
  if (layout === 'single') {
386
501
  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 });
502
+ const box = drawBox({
503
+ x: 0,
504
+ y: contentY,
505
+ w: cols,
506
+ h: contentH,
507
+ title: pane.title,
508
+ lines: pane.lines,
509
+ scroll: pane.scroll,
510
+ active: true,
511
+ });
388
512
  pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
389
513
  drawWrites.push(...box.out);
390
514
  } else if (layout === 'split') {
@@ -394,38 +518,184 @@ async function main() {
394
518
  const leftPane = panes[paneIndexById.get('orch')];
395
519
  const rightPane = panes[focused === paneIndexById.get('orch') ? paneIndexById.get('local') : focused];
396
520
 
397
- const leftBox = drawBox({ x: 0, y: bodyY, w: leftW, h: bodyH, title: leftPane.title, lines: leftPane.lines, scroll: leftPane.scroll });
521
+ const leftBox = drawBox({
522
+ x: 0,
523
+ y: contentY,
524
+ w: leftW,
525
+ h: contentH,
526
+ title: leftPane.title,
527
+ lines: leftPane.lines,
528
+ scroll: leftPane.scroll,
529
+ active: focused === paneIndexById.get('orch'),
530
+ });
398
531
  leftPane.scroll = clamp(leftPane.scroll, 0, leftBox.maxScroll);
399
532
  drawWrites.push(...leftBox.out);
400
533
 
401
- const rightBox = drawBox({ x: leftW, y: bodyY, w: rightW, h: bodyH, title: rightPane.title, lines: rightPane.lines, scroll: rightPane.scroll });
534
+ const rightBox = drawBox({
535
+ x: leftW,
536
+ y: contentY,
537
+ w: rightW,
538
+ h: contentH,
539
+ title: rightPane.title,
540
+ lines: rightPane.lines,
541
+ scroll: rightPane.scroll,
542
+ active: focused === (paneIndexById.get(rightPane.id) ?? focused),
543
+ });
402
544
  rightPane.scroll = clamp(rightPane.scroll, 0, rightBox.maxScroll);
403
545
  drawWrites.push(...rightBox.out);
404
546
  } else {
405
- // columns: render all visible panes in two columns, stacked.
406
- const visible = visiblePaneIndexes().map((idx) => panes[idx]);
547
+ // columns: render a compact top row (orch + summary), then render QR alongside Expo logs.
548
+ const orchIdx = paneIndexById.get('orch');
549
+ const summaryIdx = paneIndexById.get('summary');
550
+ const qrIdx = paneIndexById.get('qr');
551
+ const qrPane = panes[qrIdx];
552
+ const qrVisible = Boolean(qrPane?.visible && qrPane.lines?.length);
553
+
554
+ const topPanes = [panes[orchIdx], panes[summaryIdx]];
555
+ const topCount = topPanes.length;
556
+ const topH = getPaneHeightForLines(panes[summaryIdx].lines, { min: 6, max: 14 });
557
+
558
+ const topY = contentY;
559
+ const belowY = contentY + topH;
560
+ const belowH = Math.max(0, contentH - topH);
561
+
562
+ const colW = Math.floor(cols / topCount);
563
+ for (let i = 0; i < topCount; i++) {
564
+ const pane = topPanes[i];
565
+ const x = i === topCount - 1 ? colW * i : colW * i;
566
+ const w = i === topCount - 1 ? cols - colW * i : colW;
567
+ const box = drawBox({
568
+ x,
569
+ y: topY,
570
+ w,
571
+ h: topH,
572
+ title: pane.title,
573
+ lines: pane.lines,
574
+ scroll: pane.scroll,
575
+ active: paneIndexById.get(pane.id) === focused,
576
+ });
577
+ pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
578
+ drawWrites.push(...box.out);
579
+ }
580
+
581
+ // Remaining panes: exclude the top-row panes. QR is rendered inside the expo pane.
582
+ const visibleAll = visiblePaneIndexes()
583
+ .filter((idx) => idx !== orchIdx && idx !== summaryIdx && idx !== qrIdx)
584
+ .map((idx) => panes[idx]);
407
585
  const leftW = Math.floor(cols / 2);
408
586
  const rightW = cols - leftW;
409
587
 
410
588
  const leftPanes = [];
411
589
  const rightPanes = [];
590
+ const expoPane = panes[paneIndexById.get('expo')];
591
+ const visible = visibleAll.filter((p) => p !== expoPane);
412
592
  for (let i = 0; i < visible.length; i++) {
413
593
  (i % 2 === 0 ? leftPanes : rightPanes).push(visible[i]);
414
594
  }
595
+ if (expoPane?.visible) {
596
+ rightPanes.unshift(expoPane);
597
+ }
415
598
 
416
599
  const layoutColumn = (colX, colW, colPanes) => {
417
600
  if (!colPanes.length) return;
418
601
  const n = colPanes.length;
419
- const base = Math.max(3, Math.floor(bodyH / n));
420
- let y = bodyY;
602
+ const base = Math.max(3, Math.floor(belowH / n));
603
+ let y = belowY;
421
604
  for (let i = 0; i < n; i++) {
422
605
  const pane = colPanes[i];
423
- const remaining = bodyY + bodyH - y;
424
- const h = i === n - 1 ? remaining : Math.min(base, remaining);
606
+ const remaining = belowY + belowH - y;
607
+ let h = i === n - 1 ? remaining : Math.min(base, remaining);
425
608
  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);
609
+ if (pane.id === 'expo') {
610
+ const qrLines = Array.isArray(qrPane?.lines) ? qrPane.lines : [];
611
+ const qrHas = Boolean(qrLines.length);
612
+ const qrMinH = qrHas ? Math.max(6, qrLines.length + 2) : 0; // +2 borders
613
+ if (qrMinH && h < qrMinH) {
614
+ h = Math.min(remaining, qrMinH);
615
+ if (h < 3) break;
616
+ }
617
+
618
+ if (qrHas) {
619
+ // Split the expo pane horizontally:
620
+ // left = expo logs, right = QR. This uses width instead of extra height.
621
+ const maxLineLen = qrLines.reduce((m, l) => Math.max(m, stripAnsi(l).length), 0);
622
+ const minLogW = 24;
623
+ const minQrW = 22;
624
+ const maxQrW = Math.max(0, Math.min(80, colW - minLogW));
625
+ const fixedQrWRaw = (process.env.HAPPY_STACKS_TUI_QR_WIDTH ?? process.env.HAPPY_LOCAL_TUI_QR_WIDTH ?? '').toString().trim();
626
+ const fixedQrW = fixedQrWRaw ? Number(fixedQrWRaw) : 44;
627
+ const qrW = clamp(Number.isFinite(fixedQrW) && fixedQrW > 0 ? fixedQrW : maxLineLen + 2, minQrW, maxQrW);
628
+ const canSplit = qrW >= minQrW && colW - qrW >= minLogW;
629
+
630
+ if (canSplit) {
631
+ const logW = colW - qrW;
632
+ const logBox = drawBox({
633
+ x: colX,
634
+ y,
635
+ w: logW,
636
+ h,
637
+ title: pane.title,
638
+ lines: pane.lines,
639
+ scroll: pane.scroll,
640
+ active: paneIndexById.get(pane.id) === focused,
641
+ });
642
+ pane.scroll = clamp(pane.scroll, 0, logBox.maxScroll);
643
+ drawWrites.push(...logBox.out);
644
+
645
+ const qrBox = drawBox({
646
+ x: colX + logW,
647
+ y,
648
+ w: qrW,
649
+ h,
650
+ title: qrPane.title,
651
+ lines: qrLines,
652
+ scroll: 0,
653
+ active: paneIndexById.get(pane.id) === focused,
654
+ });
655
+ drawWrites.push(...qrBox.out);
656
+ } else {
657
+ // Too narrow to split cleanly: fallback to single expo log box.
658
+ const box = drawBox({
659
+ x: colX,
660
+ y,
661
+ w: colW,
662
+ h,
663
+ title: pane.title,
664
+ lines: pane.lines,
665
+ scroll: pane.scroll,
666
+ active: paneIndexById.get(pane.id) === focused,
667
+ });
668
+ pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
669
+ drawWrites.push(...box.out);
670
+ }
671
+ } else {
672
+ const box = drawBox({
673
+ x: colX,
674
+ y,
675
+ w: colW,
676
+ h,
677
+ title: pane.title,
678
+ lines: pane.lines,
679
+ scroll: pane.scroll,
680
+ active: paneIndexById.get(pane.id) === focused,
681
+ });
682
+ pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
683
+ drawWrites.push(...box.out);
684
+ }
685
+ } else {
686
+ const box = drawBox({
687
+ x: colX,
688
+ y,
689
+ w: colW,
690
+ h,
691
+ title: pane.title,
692
+ lines: pane.lines,
693
+ scroll: pane.scroll,
694
+ active: paneIndexById.get(pane.id) === focused,
695
+ });
696
+ pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
697
+ drawWrites.push(...box.out);
698
+ }
429
699
  y += h;
430
700
  }
431
701
  };
@@ -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)) {
@@ -0,0 +1,55 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Shared policy for when the stack runner should start the Happy daemon.
6
+ *
7
+ * In `setup-pr` / `review-pr` guided login flows we intentionally start server+UI first,
8
+ * then guide authentication, then start daemon post-auth. Starting the daemon before
9
+ * credentials exist can strand it in its own auth flow (lock held, no machine registration),
10
+ * which leads to "no machines" in the UI.
11
+ */
12
+
13
+ export function credentialsPathForCliHomeDir(cliHomeDir) {
14
+ return join(String(cliHomeDir ?? ''), 'access.key');
15
+ }
16
+
17
+ export function hasStackCredentials({ cliHomeDir }) {
18
+ if (!cliHomeDir) return false;
19
+ return existsSync(credentialsPathForCliHomeDir(cliHomeDir));
20
+ }
21
+
22
+ export function isAuthFlowEnabled(env) {
23
+ const v = (env?.HAPPY_STACKS_AUTH_FLOW ?? env?.HAPPY_LOCAL_AUTH_FLOW ?? '').toString().trim();
24
+ return v === '1' || v.toLowerCase() === 'true';
25
+ }
26
+
27
+ /**
28
+ * Returns { ok: boolean, reason: string } where ok=true means it's safe to start the daemon now.
29
+ * When ok=false, callers should either:
30
+ * - run interactive auth first (TTY), or
31
+ * - skip daemon start without error in orchestrated auth flows, or
32
+ * - fail closed in non-interactive contexts.
33
+ */
34
+ export function daemonStartGate({ env, cliHomeDir }) {
35
+ if (hasStackCredentials({ cliHomeDir })) {
36
+ return { ok: true, reason: 'credentials_present' };
37
+ }
38
+ if (isAuthFlowEnabled(env)) {
39
+ // Orchestrated auth flow (setup-pr/review-pr): keep server/UI up and let the orchestrator
40
+ // run guided login; starting the daemon now is counterproductive.
41
+ return { ok: false, reason: 'auth_flow_missing_credentials' };
42
+ }
43
+ return { ok: false, reason: 'missing_credentials' };
44
+ }
45
+
46
+ export function formatDaemonAuthRequiredError({ stackName, cliHomeDir }) {
47
+ const name = (stackName ?? '').toString().trim() || 'main';
48
+ const path = credentialsPathForCliHomeDir(cliHomeDir);
49
+ return (
50
+ `[local] daemon auth required: credentials not found for stack "${name}".\n` +
51
+ `[local] expected: ${path}\n` +
52
+ `[local] fix: run \`happy auth login\` (stack-scoped), or re-run with UI enabled to complete guided login.`
53
+ );
54
+ }
55
+
@@ -0,0 +1,37 @@
1
+ import { mkdtemp, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { test } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+
7
+ import { daemonStartGate, hasStackCredentials } from './daemon_gate.mjs';
8
+
9
+ test('hasStackCredentials detects access.key', async () => {
10
+ const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-daemon-gate-'));
11
+ assert.equal(hasStackCredentials({ cliHomeDir: dir }), false);
12
+ await writeFile(join(dir, 'access.key'), 'dummy', 'utf-8');
13
+ assert.equal(hasStackCredentials({ cliHomeDir: dir }), true);
14
+ });
15
+
16
+ test('daemonStartGate blocks daemon start in auth flow when missing credentials', async () => {
17
+ const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-daemon-gate-'));
18
+ const gate = daemonStartGate({ env: { HAPPY_STACKS_AUTH_FLOW: '1' }, cliHomeDir: dir });
19
+ assert.equal(gate.ok, false);
20
+ assert.equal(gate.reason, 'auth_flow_missing_credentials');
21
+ });
22
+
23
+ test('daemonStartGate blocks daemon start when missing credentials (non-auth flow)', async () => {
24
+ const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-daemon-gate-'));
25
+ const gate = daemonStartGate({ env: {}, cliHomeDir: dir });
26
+ assert.equal(gate.ok, false);
27
+ assert.equal(gate.reason, 'missing_credentials');
28
+ });
29
+
30
+ test('daemonStartGate allows daemon start when credentials exist', async () => {
31
+ const dir = await mkdtemp(join(tmpdir(), 'happy-stacks-daemon-gate-'));
32
+ await writeFile(join(dir, 'access.key'), 'dummy', 'utf-8');
33
+ const gate = daemonStartGate({ env: {}, cliHomeDir: dir });
34
+ assert.equal(gate.ok, true);
35
+ assert.equal(gate.reason, 'credentials_present');
36
+ });
37
+