happy-stacks 0.2.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 (149) hide show
  1. package/README.md +84 -25
  2. package/bin/happys.mjs +116 -17
  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 +59 -208
  8. package/scripts/build.mjs +58 -12
  9. package/scripts/cli-link.mjs +3 -3
  10. package/scripts/completion.mjs +5 -5
  11. package/scripts/daemon.mjs +168 -20
  12. package/scripts/dev.mjs +196 -70
  13. package/scripts/doctor.mjs +20 -36
  14. package/scripts/edison.mjs +105 -78
  15. package/scripts/happy.mjs +8 -19
  16. package/scripts/init.mjs +8 -14
  17. package/scripts/install.mjs +119 -23
  18. package/scripts/lint.mjs +31 -32
  19. package/scripts/menubar.mjs +6 -13
  20. package/scripts/migrate.mjs +11 -21
  21. package/scripts/mobile.mjs +93 -108
  22. package/scripts/mobile_dev_client.mjs +83 -0
  23. package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
  24. package/scripts/review.mjs +217 -0
  25. package/scripts/review_pr.mjs +368 -0
  26. package/scripts/run.mjs +95 -21
  27. package/scripts/self.mjs +11 -29
  28. package/scripts/server_flavor.mjs +4 -4
  29. package/scripts/service.mjs +19 -29
  30. package/scripts/setup.mjs +63 -160
  31. package/scripts/setup_pr.mjs +592 -52
  32. package/scripts/stack.mjs +608 -200
  33. package/scripts/stop.mjs +3 -3
  34. package/scripts/tailscale.mjs +44 -11
  35. package/scripts/test.mjs +52 -36
  36. package/scripts/tui.mjs +314 -74
  37. package/scripts/typecheck.mjs +31 -32
  38. package/scripts/ui_gateway.mjs +1 -1
  39. package/scripts/uninstall.mjs +6 -6
  40. package/scripts/utils/auth/daemon_gate.mjs +55 -0
  41. package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
  42. package/scripts/utils/auth/dev_key.mjs +163 -0
  43. package/scripts/utils/{auth_files.mjs → auth/files.mjs} +2 -4
  44. package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
  45. package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
  46. package/scripts/utils/auth/handy_master_secret.mjs +68 -0
  47. package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
  48. package/scripts/utils/{auth_login_ux.mjs → auth/login_ux.mjs} +32 -13
  49. package/scripts/utils/auth/sources.mjs +38 -0
  50. package/scripts/utils/auth/stack_guided_login.mjs +353 -0
  51. package/scripts/utils/cli/cli_registry.mjs +24 -0
  52. package/scripts/utils/cli/cwd_scope.mjs +82 -0
  53. package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
  54. package/scripts/utils/cli/flags.mjs +17 -0
  55. package/scripts/utils/cli/log_forwarder.mjs +157 -0
  56. package/scripts/utils/cli/normalize.mjs +16 -0
  57. package/scripts/utils/cli/prereqs.mjs +72 -0
  58. package/scripts/utils/cli/progress.mjs +126 -0
  59. package/scripts/utils/cli/smoke_help.mjs +2 -2
  60. package/scripts/utils/cli/verbosity.mjs +12 -0
  61. package/scripts/utils/cli/wizard.mjs +1 -1
  62. package/scripts/utils/crypto/tokens.mjs +14 -0
  63. package/scripts/utils/{dev_daemon.mjs → dev/daemon.mjs} +51 -7
  64. package/scripts/utils/dev/expo_dev.mjs +246 -0
  65. package/scripts/utils/dev/expo_dev.test.mjs +76 -0
  66. package/scripts/utils/{dev_server.mjs → dev/server.mjs} +22 -32
  67. package/scripts/utils/dev_auth_key.mjs +1 -1
  68. package/scripts/utils/{config.mjs → env/config.mjs} +3 -2
  69. package/scripts/utils/{dotenv.mjs → env/dotenv.mjs} +3 -0
  70. package/scripts/utils/{env.mjs → env/env.mjs} +5 -3
  71. package/scripts/utils/{env_file.mjs → env/env_file.mjs} +2 -1
  72. package/scripts/utils/{env_local.mjs → env/env_local.mjs} +1 -0
  73. package/scripts/utils/env/read.mjs +30 -0
  74. package/scripts/utils/env/values.mjs +13 -0
  75. package/scripts/utils/expo/command.mjs +52 -0
  76. package/scripts/utils/{expo.mjs → expo/expo.mjs} +23 -10
  77. package/scripts/utils/expo/metro_ports.mjs +114 -0
  78. package/scripts/utils/fs/json.mjs +25 -0
  79. package/scripts/utils/fs/ops.mjs +29 -0
  80. package/scripts/utils/fs/package_json.mjs +8 -0
  81. package/scripts/utils/fs/tail.mjs +12 -0
  82. package/scripts/utils/git/git.mjs +67 -0
  83. package/scripts/utils/git/refs.mjs +26 -0
  84. package/scripts/utils/{worktrees.mjs → git/worktrees.mjs} +27 -23
  85. package/scripts/utils/handy_master_secret.mjs +2 -2
  86. package/scripts/utils/mobile/config.mjs +31 -0
  87. package/scripts/utils/mobile/dev_client_links.mjs +60 -0
  88. package/scripts/utils/mobile/identifiers.mjs +47 -0
  89. package/scripts/utils/mobile/identifiers.test.mjs +42 -0
  90. package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
  91. package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
  92. package/scripts/utils/net/dns.mjs +10 -0
  93. package/scripts/utils/net/lan_ip.mjs +24 -0
  94. package/scripts/utils/{ports.mjs → net/ports.mjs} +12 -6
  95. package/scripts/utils/net/url.mjs +30 -0
  96. package/scripts/utils/net/url.test.mjs +20 -0
  97. package/scripts/utils/paths/localhost_host.mjs +56 -0
  98. package/scripts/utils/{paths.mjs → paths/paths.mjs} +52 -45
  99. package/scripts/utils/{runtime.mjs → paths/runtime.mjs} +3 -1
  100. package/scripts/utils/proc/commands.mjs +34 -0
  101. package/scripts/utils/{ownership.mjs → proc/ownership.mjs} +1 -1
  102. package/scripts/utils/proc/package_scripts.mjs +31 -0
  103. package/scripts/utils/proc/parallel.mjs +25 -0
  104. package/scripts/utils/proc/pids.mjs +11 -0
  105. package/scripts/utils/{pm.mjs → proc/pm.mjs} +128 -158
  106. package/scripts/utils/{proc.mjs → proc/proc.mjs} +77 -2
  107. package/scripts/utils/review/base_ref.mjs +74 -0
  108. package/scripts/utils/review/base_ref.test.mjs +54 -0
  109. package/scripts/utils/review/runners/coderabbit.mjs +19 -0
  110. package/scripts/utils/review/runners/codex.mjs +51 -0
  111. package/scripts/utils/review/targets.mjs +24 -0
  112. package/scripts/utils/review/targets.test.mjs +36 -0
  113. package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
  114. package/scripts/utils/{happy_server_infra.mjs → server/infra/happy_server_infra.mjs} +10 -49
  115. package/scripts/utils/server/mobile_api_url.mjs +61 -0
  116. package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
  117. package/scripts/utils/server/port.mjs +68 -0
  118. package/scripts/utils/{server.mjs → server/server.mjs} +12 -0
  119. package/scripts/utils/server/urls.mjs +101 -0
  120. package/scripts/utils/server/validate.mjs +88 -0
  121. package/scripts/utils/service/autostart_darwin.mjs +182 -0
  122. package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
  123. package/scripts/utils/stack/context.mjs +23 -0
  124. package/scripts/utils/stack/dirs.mjs +27 -0
  125. package/scripts/utils/stack/editor_workspace.mjs +152 -0
  126. package/scripts/utils/stack/names.mjs +12 -0
  127. package/scripts/utils/stack/pr_stack_name.mjs +16 -0
  128. package/scripts/utils/stack/runtime_state.mjs +88 -0
  129. package/scripts/utils/stack/stacks.mjs +45 -0
  130. package/scripts/utils/{stack_startup.mjs → stack/startup.mjs} +9 -2
  131. package/scripts/utils/{stack_stop.mjs → stack/stop.mjs} +24 -19
  132. package/scripts/utils/stack_context.mjs +3 -3
  133. package/scripts/utils/stack_runtime_state.mjs +1 -1
  134. package/scripts/utils/stacks.mjs +2 -2
  135. package/scripts/utils/{browser.mjs → ui/browser.mjs} +1 -1
  136. package/scripts/utils/ui/qr.mjs +17 -0
  137. package/scripts/utils/ui/text.mjs +16 -0
  138. package/scripts/utils/validate.mjs +1 -1
  139. package/scripts/where.mjs +6 -6
  140. package/scripts/worktrees.mjs +171 -113
  141. package/scripts/utils/auth_sources.mjs +0 -12
  142. package/scripts/utils/dev_expo_web.mjs +0 -112
  143. package/scripts/utils/localhost_host.mjs +0 -17
  144. package/scripts/utils/server_port.mjs +0 -9
  145. package/scripts/utils/server_urls.mjs +0 -54
  146. /package/scripts/utils/{sandbox.mjs → env/sandbox.mjs} +0 -0
  147. /package/scripts/utils/{fs.mjs → fs/fs.mjs} +0 -0
  148. /package/scripts/utils/{canonical_home.mjs → paths/canonical_home.mjs} +0 -0
  149. /package/scripts/utils/{watch.mjs → proc/watch.mjs} +0 -0
package/scripts/tui.mjs CHANGED
@@ -1,33 +1,31 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import { spawn } from 'node:child_process';
3
- import { existsSync } from 'node:fs';
4
- import { readFile } from 'node:fs/promises';
5
3
  import { join, resolve, sep } from 'node:path';
6
4
 
7
- import { parseDotenv } from './utils/dotenv.mjs';
8
5
  import { printResult } from './utils/cli/cli.mjs';
9
- import { getRootDir, resolveStackEnvPath } from './utils/paths.mjs';
10
- import { getStackRuntimeStatePath, readStackRuntimeStateFile } from './utils/stack_runtime_state.mjs';
6
+ import { readEnvObjectFromFile } from './utils/env/read.mjs';
7
+ import { getComponentsDir, getRootDir, resolveStackEnvPath } from './utils/paths/paths.mjs';
8
+ import { getStackRuntimeStatePath, readStackRuntimeStateFile } from './utils/stack/runtime_state.mjs';
9
+ import { getEnvValueAny } from './utils/env/values.mjs';
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
- function stripAnsi(s) {
13
- // eslint-disable-next-line no-control-regex
14
- return String(s ?? '').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
15
+ function nowTs() {
16
+ const d = new Date();
17
+ return d.toISOString().slice(11, 19);
15
18
  }
16
19
 
17
- function padRight(s, n) {
18
- const str = String(s ?? '');
19
- if (str.length >= n) return str.slice(0, n);
20
- return str + ' '.repeat(n - str.length);
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;
21
25
  }
22
26
 
23
- function parsePrefixedLabel(line) {
24
- const m = String(line ?? '').match(/^\[([^\]]+)\]\s*/);
25
- return m ? m[1] : null;
26
- }
27
-
28
- function nowTs() {
29
- const d = new Date();
30
- return d.toISOString().slice(11, 19);
27
+ function cyan(s) {
28
+ return supportsAnsi() ? `\x1b[36m${s}\x1b[0m` : String(s);
31
29
  }
32
30
 
33
31
  function clamp(n, lo, hi) {
@@ -45,7 +43,13 @@ function pushLine(pane, line, { maxLines = 4000 } = {}) {
45
43
  }
46
44
  }
47
45
 
48
- 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 }) {
49
53
  const top = y;
50
54
  const bottom = y + h - 1;
51
55
  const left = x;
@@ -69,12 +73,14 @@ function drawBox({ x, y, w, h, title, lines, scroll }) {
69
73
  const midLine = '│' + ' '.repeat(Math.max(0, w - 2)) + '│';
70
74
  const botLine = '└' + horiz + '┘';
71
75
 
76
+ const style = (s) => (active ? cyan(s) : s);
77
+
72
78
  const out = [];
73
- out.push({ row: top, col: left, text: topLine });
79
+ out.push({ row: top, col: left, text: style(topLine) });
74
80
  for (let r = top + 1; r < bottom; r++) {
75
- out.push({ row: r, col: left, text: midLine });
81
+ out.push({ row: r, col: left, text: style(midLine) });
76
82
  }
77
- out.push({ row: bottom, col: left, text: botLine });
83
+ out.push({ row: bottom, col: left, text: style(botLine) });
78
84
 
79
85
  const innerW = Math.max(0, w - 2);
80
86
  const innerH = Math.max(0, h - 2);
@@ -107,14 +113,24 @@ function inferStackNameFromForwardedArgs(args) {
107
113
  return (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
108
114
  }
109
115
 
110
- async function readEnvObject(path) {
111
- try {
112
- if (!path || !existsSync(path)) return {};
113
- const raw = await readFile(path, 'utf-8');
114
- return Object.fromEntries(parseDotenv(raw).entries());
115
- } catch {
116
- return {};
117
- }
116
+ const readEnvObject = readEnvObjectFromFile;
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;
118
134
  }
119
135
 
120
136
  function formatComponentRef({ rootDir, component, dir }) {
@@ -122,8 +138,11 @@ function formatComponentRef({ rootDir, component, dir }) {
122
138
  if (!raw) return '(unset)';
123
139
 
124
140
  const abs = resolve(raw);
125
- const defaultDir = resolve(join(rootDir, 'components', component));
126
- 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;
127
146
 
128
147
  if (abs === defaultDir) return 'default';
129
148
  if (abs.startsWith(worktreesPrefix)) {
@@ -132,12 +151,6 @@ function formatComponentRef({ rootDir, component, dir }) {
132
151
  return abs;
133
152
  }
134
153
 
135
- function getEnvVal(env, k1, k2) {
136
- const a = String(env?.[k1] ?? '').trim();
137
- if (a) return a;
138
- return String(env?.[k2] ?? '').trim();
139
- }
140
-
141
154
  async function buildStackSummaryLines({ rootDir, stackName }) {
142
155
  const { envPath, baseDir } = resolveStackEnvPath(stackName);
143
156
  const env = await readEnvObject(envPath);
@@ -145,10 +158,12 @@ async function buildStackSummaryLines({ rootDir, stackName }) {
145
158
  const runtime = await readStackRuntimeStateFile(runtimePath);
146
159
 
147
160
  const serverComponent =
148
- getEnvVal(env, 'HAPPY_STACKS_SERVER_COMPONENT', 'HAPPY_LOCAL_SERVER_COMPONENT') || 'happy-server-light';
161
+ getEnvValueAny(env, ['HAPPY_STACKS_SERVER_COMPONENT', 'HAPPY_LOCAL_SERVER_COMPONENT']) || 'happy-server-light';
149
162
 
150
163
  const ports = runtime?.ports && typeof runtime.ports === 'object' ? runtime.ports : {};
151
- 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);
152
167
  const processes = runtime?.processes && typeof runtime.processes === 'object' ? runtime.processes : {};
153
168
 
154
169
  const components = [
@@ -180,13 +195,21 @@ async function buildStackSummaryLines({ rootDir, stackName }) {
180
195
  lines.push('');
181
196
  lines.push('ports:');
182
197
  lines.push(` server: ${ports?.server ?? '(unknown)'}`);
183
- if (expoWebPort) lines.push(` ui: ${expoWebPort}`);
198
+ if (expoPort) lines.push(` expo: ${expoPort}`);
184
199
  if (ports?.backend) lines.push(` backend: ${ports.backend}`);
185
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
+
186
209
  lines.push('');
187
210
  lines.push('pids:');
188
211
  if (processes?.serverPid) lines.push(` serverPid: ${processes.serverPid}`);
189
- if (processes?.expoWebPid) lines.push(` expoWebPid: ${processes.expoWebPid}`);
212
+ if (processes?.expoPid) lines.push(` expoPid: ${processes.expoPid}`);
190
213
  if (processes?.daemonPid) lines.push(` daemonPid: ${processes.daemonPid}`);
191
214
  if (processes?.uiGatewayPid) lines.push(` uiGatewayPid: ${processes.uiGatewayPid}`);
192
215
 
@@ -200,6 +223,29 @@ async function buildStackSummaryLines({ rootDir, stackName }) {
200
223
  return lines;
201
224
  }
202
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
+
203
249
  async function main() {
204
250
  const argv = process.argv.slice(2);
205
251
 
@@ -233,7 +279,7 @@ async function main() {
233
279
  ' q / Ctrl+C : quit (sends SIGINT to child)',
234
280
  '',
235
281
  'panes (default):',
236
- ' orchestration | summary | local | server | ui | daemon | stack logs',
282
+ ' orchestration | summary | local | server | expo | daemon | stack logs',
237
283
  ].join('\n'),
238
284
  });
239
285
  return;
@@ -252,11 +298,13 @@ async function main() {
252
298
  const panes = [
253
299
  mkPane('orch', 'orchestration', { visible: true, kind: 'log' }),
254
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' }),
255
303
  mkPane('local', 'local', { visible: true, kind: 'log' }),
256
- mkPane('server', 'server', { visible: true, kind: 'log' }),
257
- mkPane('ui', 'ui', { visible: true, kind: 'log' }),
258
- mkPane('daemon', 'daemon', { visible: true, kind: 'log' }),
259
- 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' }),
260
308
  ];
261
309
 
262
310
  const paneIndexById = new Map(panes.map((p, i) => [p.id, i]));
@@ -267,12 +315,18 @@ async function main() {
267
315
 
268
316
  let paneId = 'local';
269
317
  if (normalized.includes('server')) paneId = 'server';
270
- 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';
271
321
  else if (normalized.includes('daemon')) paneId = 'daemon';
272
322
  else if (normalized === 'stack') paneId = 'stacklog';
273
323
  else if (normalized === 'local') paneId = 'local';
274
324
 
275
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
+ }
276
330
  pushLine(panes[idx], line);
277
331
  };
278
332
 
@@ -281,28 +335,40 @@ async function main() {
281
335
  };
282
336
 
283
337
  let layout = 'columns'; // single | split | columns
284
- let focused = 2; // local
338
+ let focused = paneIndexById.get('local'); // default focus
285
339
  let paused = false;
286
340
  let renderScheduled = false;
287
341
 
288
- const child = spawn(process.execPath, [happysBin, ...forwarded], {
289
- cwd: rootDir,
290
- env: { ...process.env },
291
- stdio: ['ignore', 'pipe', 'pipe'],
292
- detached: process.platform !== 'win32',
293
- });
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
+ });
294
358
 
295
- 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
+ );
296
362
 
297
363
  const buf = { out: '', err: '' };
298
364
  const flush = (kind) => {
299
365
  const key = kind === 'stderr' ? 'err' : 'out';
300
366
  let b = buf[key];
301
367
  while (true) {
302
- const idx = b.indexOf('\n');
368
+ const idx = nextLineBreakIndex(b);
303
369
  if (idx < 0) break;
304
370
  const line = b.slice(0, idx);
305
- b = b.slice(idx + 1);
371
+ b = consumeLineBreak(b.slice(idx));
306
372
  routeLine(line);
307
373
  }
308
374
  buf[key] = b;
@@ -331,6 +397,19 @@ async function main() {
331
397
  } catch (e) {
332
398
  panes[idx].lines = [`summary error: ${e instanceof Error ? e.message : String(e)}`];
333
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
+ }
334
413
  scheduleRender();
335
414
  }
336
415
 
@@ -404,7 +483,9 @@ async function main() {
404
483
  process.stdout.write('\x1b[?25l');
405
484
  process.stdout.write('\x1b[2J\x1b[H');
406
485
 
407
- 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}`;
408
489
  process.stdout.write(padRight(header, cols) + '\n');
409
490
 
410
491
  const bodyY = 1;
@@ -412,9 +493,22 @@ async function main() {
412
493
  const footerY = rows - 1;
413
494
 
414
495
  const drawWrites = [];
496
+
497
+ const contentY = bodyY;
498
+ let contentH = bodyH;
499
+
415
500
  if (layout === 'single') {
416
501
  const pane = panes[focused];
417
- 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
+ });
418
512
  pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
419
513
  drawWrites.push(...box.out);
420
514
  } else if (layout === 'split') {
@@ -424,38 +518,184 @@ async function main() {
424
518
  const leftPane = panes[paneIndexById.get('orch')];
425
519
  const rightPane = panes[focused === paneIndexById.get('orch') ? paneIndexById.get('local') : focused];
426
520
 
427
- 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
+ });
428
531
  leftPane.scroll = clamp(leftPane.scroll, 0, leftBox.maxScroll);
429
532
  drawWrites.push(...leftBox.out);
430
533
 
431
- 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
+ });
432
544
  rightPane.scroll = clamp(rightPane.scroll, 0, rightBox.maxScroll);
433
545
  drawWrites.push(...rightBox.out);
434
546
  } else {
435
- // columns: render all visible panes in two columns, stacked.
436
- 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]);
437
585
  const leftW = Math.floor(cols / 2);
438
586
  const rightW = cols - leftW;
439
587
 
440
588
  const leftPanes = [];
441
589
  const rightPanes = [];
590
+ const expoPane = panes[paneIndexById.get('expo')];
591
+ const visible = visibleAll.filter((p) => p !== expoPane);
442
592
  for (let i = 0; i < visible.length; i++) {
443
593
  (i % 2 === 0 ? leftPanes : rightPanes).push(visible[i]);
444
594
  }
595
+ if (expoPane?.visible) {
596
+ rightPanes.unshift(expoPane);
597
+ }
445
598
 
446
599
  const layoutColumn = (colX, colW, colPanes) => {
447
600
  if (!colPanes.length) return;
448
601
  const n = colPanes.length;
449
- const base = Math.max(3, Math.floor(bodyH / n));
450
- let y = bodyY;
602
+ const base = Math.max(3, Math.floor(belowH / n));
603
+ let y = belowY;
451
604
  for (let i = 0; i < n; i++) {
452
605
  const pane = colPanes[i];
453
- const remaining = bodyY + bodyH - y;
454
- 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);
455
608
  if (h < 3) break;
456
- const box = drawBox({ x: colX, y, w: colW, h, title: pane.title, lines: pane.lines, scroll: pane.scroll });
457
- pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
458
- 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
+ }
459
699
  y += h;
460
700
  }
461
701
  };
@@ -1,36 +1,16 @@
1
- import './utils/env.mjs';
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.mjs';
5
- import { ensureDepsInstalled, requirePnpm } from './utils/pm.mjs';
6
- import { pathExists } from './utils/fs.mjs';
7
- import { run } from './utils/proc.mjs';
8
- import { join } from 'node:path';
9
- import { readFile } from 'node:fs/promises';
4
+ import { componentDirEnvKey, getComponentDir, getRootDir } from './utils/paths/paths.mjs';
5
+ import { ensureDepsInstalled } from './utils/proc/pm.mjs';
6
+ import { pathExists } from './utils/fs/fs.mjs';
7
+ import { run } from './utils/proc/proc.mjs';
8
+ import { detectPackageManagerCmd, pickFirstScript, readPackageJsonScripts } from './utils/proc/package_scripts.mjs';
9
+ import { getInvokedCwd, inferComponentFromCwd } from './utils/cli/cwd_scope.mjs';
10
10
 
11
11
  const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
12
12
 
13
- async function detectPackageManagerCmd(dir) {
14
- if (await pathExists(join(dir, 'yarn.lock'))) {
15
- return { name: 'yarn', cmd: 'yarn', argsForScript: (script) => ['-s', script] };
16
- }
17
- await requirePnpm();
18
- return { name: 'pnpm', cmd: 'pnpm', argsForScript: (script) => ['--silent', script] };
19
- }
20
-
21
- async function readScripts(dir) {
22
- try {
23
- const raw = await readFile(join(dir, 'package.json'), 'utf-8');
24
- const pkg = JSON.parse(raw);
25
- const scripts = pkg?.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {};
26
- return scripts;
27
- } catch {
28
- return null;
29
- }
30
- }
31
-
32
13
  function pickTypecheckScript(scripts) {
33
- if (!scripts) return null;
34
14
  const candidates = [
35
15
  'typecheck',
36
16
  'type-check',
@@ -39,7 +19,7 @@ function pickTypecheckScript(scripts) {
39
19
  'tsc',
40
20
  'typescript',
41
21
  ];
42
- return candidates.find((k) => typeof scripts[k] === 'string' && scripts[k].trim()) ?? null;
22
+ return pickFirstScript(scripts, candidates);
43
23
  }
44
24
 
45
25
  async function main() {
@@ -61,18 +41,37 @@ async function main() {
61
41
  'examples:',
62
42
  ' happys typecheck',
63
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.',
64
47
  ].join('\n'),
65
48
  });
66
49
  return;
67
50
  }
68
51
 
52
+ const rootDir = getRootDir(import.meta.url);
53
+
69
54
  const positionals = argv.filter((a) => !a.startsWith('--'));
70
- 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'];
71
72
  const wantAll = requested.includes('all');
72
73
  const components = wantAll ? DEFAULT_COMPONENTS : requested;
73
74
 
74
- const rootDir = getRootDir(import.meta.url);
75
-
76
75
  const results = [];
77
76
  for (const component of components) {
78
77
  if (!DEFAULT_COMPONENTS.includes(component)) {
@@ -86,7 +85,7 @@ async function main() {
86
85
  continue;
87
86
  }
88
87
 
89
- const scripts = await readScripts(dir);
88
+ const scripts = await readPackageJsonScripts(dir);
90
89
  if (!scripts) {
91
90
  results.push({ component, ok: true, skipped: true, dir, reason: 'no package.json' });
92
91
  continue;
@@ -1,4 +1,4 @@
1
- import './utils/env.mjs';
1
+ import './utils/env/env.mjs';
2
2
  import http from 'node:http';
3
3
  import net from 'node:net';
4
4
  import { extname, resolve, sep } from 'node:path';