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.
- package/README.md +29 -7
- package/bin/happys.mjs +114 -15
- package/docs/happy-development.md +2 -2
- package/docs/isolated-linux-vm.md +82 -0
- package/docs/mobile-ios.md +112 -54
- package/package.json +5 -1
- package/scripts/auth.mjs +11 -7
- package/scripts/build.mjs +54 -7
- package/scripts/daemon.mjs +166 -10
- package/scripts/dev.mjs +181 -46
- package/scripts/edison.mjs +4 -2
- package/scripts/init.mjs +3 -1
- package/scripts/install.mjs +112 -16
- package/scripts/lint.mjs +24 -4
- package/scripts/mobile.mjs +88 -104
- package/scripts/mobile_dev_client.mjs +83 -0
- package/scripts/provision/linux-ubuntu-review-pr.sh +51 -0
- package/scripts/review.mjs +217 -0
- package/scripts/review_pr.mjs +368 -0
- package/scripts/run.mjs +83 -9
- package/scripts/service.mjs +2 -2
- package/scripts/setup.mjs +42 -43
- package/scripts/setup_pr.mjs +591 -34
- package/scripts/stack.mjs +503 -45
- package/scripts/tailscale.mjs +37 -1
- package/scripts/test.mjs +45 -8
- package/scripts/tui.mjs +309 -39
- package/scripts/typecheck.mjs +24 -4
- package/scripts/utils/auth/daemon_gate.mjs +55 -0
- package/scripts/utils/auth/daemon_gate.test.mjs +37 -0
- package/scripts/utils/auth/guided_pr_auth.mjs +79 -0
- package/scripts/utils/auth/guided_stack_web_login.mjs +75 -0
- package/scripts/utils/auth/interactive_stack_auth.mjs +72 -0
- package/scripts/utils/auth/login_ux.mjs +32 -13
- package/scripts/utils/auth/sources.mjs +26 -0
- package/scripts/utils/auth/stack_guided_login.mjs +353 -0
- package/scripts/utils/cli/cli_registry.mjs +24 -0
- package/scripts/utils/cli/cwd_scope.mjs +82 -0
- package/scripts/utils/cli/cwd_scope.test.mjs +77 -0
- package/scripts/utils/cli/log_forwarder.mjs +157 -0
- package/scripts/utils/cli/prereqs.mjs +72 -0
- package/scripts/utils/cli/progress.mjs +126 -0
- package/scripts/utils/cli/verbosity.mjs +12 -0
- package/scripts/utils/dev/daemon.mjs +47 -3
- package/scripts/utils/dev/expo_dev.mjs +246 -0
- package/scripts/utils/dev/expo_dev.test.mjs +76 -0
- package/scripts/utils/dev/server.mjs +15 -25
- package/scripts/utils/dev_auth_key.mjs +169 -0
- package/scripts/utils/expo/command.mjs +52 -0
- package/scripts/utils/expo/expo.mjs +20 -1
- package/scripts/utils/expo/metro_ports.mjs +114 -0
- package/scripts/utils/git/git.mjs +67 -0
- package/scripts/utils/git/worktrees.mjs +24 -20
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/mobile/config.mjs +31 -0
- package/scripts/utils/mobile/dev_client_links.mjs +60 -0
- package/scripts/utils/mobile/identifiers.mjs +47 -0
- package/scripts/utils/mobile/identifiers.test.mjs +42 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.mjs +128 -0
- package/scripts/utils/mobile/ios_xcodeproj_patch.test.mjs +98 -0
- package/scripts/utils/net/lan_ip.mjs +24 -0
- package/scripts/utils/net/ports.mjs +9 -1
- package/scripts/utils/net/url.mjs +30 -0
- package/scripts/utils/net/url.test.mjs +20 -0
- package/scripts/utils/paths/localhost_host.mjs +50 -3
- package/scripts/utils/paths/paths.mjs +42 -38
- package/scripts/utils/proc/parallel.mjs +25 -0
- package/scripts/utils/proc/pm.mjs +69 -12
- package/scripts/utils/proc/proc.mjs +76 -2
- package/scripts/utils/review/base_ref.mjs +74 -0
- package/scripts/utils/review/base_ref.test.mjs +54 -0
- package/scripts/utils/review/runners/coderabbit.mjs +19 -0
- package/scripts/utils/review/runners/codex.mjs +51 -0
- package/scripts/utils/review/targets.mjs +24 -0
- package/scripts/utils/review/targets.test.mjs +36 -0
- package/scripts/utils/sandbox/review_pr_sandbox.mjs +106 -0
- package/scripts/utils/server/mobile_api_url.mjs +61 -0
- package/scripts/utils/server/mobile_api_url.test.mjs +41 -0
- package/scripts/utils/server/urls.mjs +14 -4
- package/scripts/utils/service/autostart_darwin.mjs +42 -2
- package/scripts/utils/service/autostart_darwin.test.mjs +50 -0
- package/scripts/utils/stack/context.mjs +2 -2
- package/scripts/utils/stack/editor_workspace.mjs +2 -2
- package/scripts/utils/stack/pr_stack_name.mjs +16 -0
- package/scripts/utils/stack/runtime_state.mjs +2 -1
- package/scripts/utils/stack/startup.mjs +7 -0
- package/scripts/utils/stack/stop.mjs +15 -4
- package/scripts/utils/stack_context.mjs +23 -0
- package/scripts/utils/stack_runtime_state.mjs +104 -0
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/ui/qr.mjs +17 -0
- package/scripts/utils/validate.mjs +88 -0
- package/scripts/worktrees.mjs +141 -55
- 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
|
|
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
|
-
|
|
102
|
-
|
|
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
|
|
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 (
|
|
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?.
|
|
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 |
|
|
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:
|
|
227
|
-
mkPane('
|
|
228
|
-
mkPane('daemon', 'daemon', { visible:
|
|
229
|
-
mkPane('stacklog', 'stack logs', { visible:
|
|
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 = '
|
|
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 =
|
|
338
|
+
let focused = paneIndexById.get('local'); // default focus
|
|
255
339
|
let paused = false;
|
|
256
340
|
let renderScheduled = false;
|
|
257
341
|
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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(
|
|
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
|
|
368
|
+
const idx = nextLineBreakIndex(b);
|
|
273
369
|
if (idx < 0) break;
|
|
274
370
|
const line = b.slice(0, idx);
|
|
275
|
-
b = b.slice(idx
|
|
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
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
|
406
|
-
const
|
|
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(
|
|
420
|
-
let y =
|
|
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 =
|
|
424
|
-
|
|
606
|
+
const remaining = belowY + belowH - y;
|
|
607
|
+
let h = i === n - 1 ? remaining : Math.min(base, remaining);
|
|
425
608
|
if (h < 3) break;
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
};
|
package/scripts/typecheck.mjs
CHANGED
|
@@ -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
|
|
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
|
+
|