happy-stacks 0.1.0 → 0.2.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 +130 -74
- package/bin/happys.mjs +140 -9
- package/docs/edison.md +381 -0
- package/docs/happy-development.md +733 -0
- package/docs/menubar.md +54 -0
- package/docs/paths-and-env.md +141 -0
- package/docs/server-flavors.md +61 -2
- package/docs/stacks.md +55 -4
- package/extras/swiftbar/auth-login.sh +10 -7
- package/extras/swiftbar/git-cache-refresh.sh +130 -0
- package/extras/swiftbar/happy-stacks.5s.sh +175 -83
- package/extras/swiftbar/happys-term.sh +128 -0
- package/extras/swiftbar/happys.sh +35 -0
- package/extras/swiftbar/install.sh +99 -13
- package/extras/swiftbar/lib/git.sh +309 -1
- package/extras/swiftbar/lib/icons.sh +2 -2
- package/extras/swiftbar/lib/render.sh +279 -132
- package/extras/swiftbar/lib/system.sh +64 -10
- package/extras/swiftbar/lib/utils.sh +469 -10
- package/extras/swiftbar/pnpm-term.sh +2 -122
- package/extras/swiftbar/pnpm.sh +4 -14
- package/extras/swiftbar/set-interval.sh +10 -5
- package/extras/swiftbar/set-server-flavor.sh +19 -10
- package/extras/swiftbar/wt-pr.sh +10 -3
- package/package.json +2 -1
- package/scripts/auth.mjs +833 -14
- package/scripts/build.mjs +24 -4
- package/scripts/cli-link.mjs +3 -3
- package/scripts/completion.mjs +15 -8
- package/scripts/daemon.mjs +200 -23
- package/scripts/dev.mjs +230 -57
- package/scripts/doctor.mjs +26 -21
- package/scripts/edison.mjs +1828 -0
- package/scripts/happy.mjs +3 -7
- package/scripts/init.mjs +275 -46
- package/scripts/install.mjs +14 -8
- package/scripts/lint.mjs +145 -0
- package/scripts/menubar.mjs +81 -8
- package/scripts/migrate.mjs +302 -0
- package/scripts/mobile.mjs +59 -21
- package/scripts/run.mjs +222 -43
- package/scripts/self.mjs +3 -7
- package/scripts/server_flavor.mjs +3 -3
- package/scripts/service.mjs +190 -38
- package/scripts/setup.mjs +790 -0
- package/scripts/setup_pr.mjs +182 -0
- package/scripts/stack.mjs +2273 -92
- package/scripts/stop.mjs +160 -0
- package/scripts/tailscale.mjs +164 -23
- package/scripts/test.mjs +144 -0
- package/scripts/tui.mjs +556 -0
- package/scripts/typecheck.mjs +145 -0
- package/scripts/ui_gateway.mjs +248 -0
- package/scripts/uninstall.mjs +21 -13
- package/scripts/utils/auth_files.mjs +58 -0
- package/scripts/utils/auth_login_ux.mjs +76 -0
- package/scripts/utils/auth_sources.mjs +12 -0
- package/scripts/utils/browser.mjs +22 -0
- package/scripts/utils/canonical_home.mjs +20 -0
- package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +71 -0
- package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
- package/scripts/utils/config.mjs +13 -1
- package/scripts/utils/dev_auth_key.mjs +169 -0
- package/scripts/utils/dev_daemon.mjs +104 -0
- package/scripts/utils/dev_expo_web.mjs +112 -0
- package/scripts/utils/dev_server.mjs +183 -0
- package/scripts/utils/env.mjs +94 -23
- package/scripts/utils/env_file.mjs +36 -0
- package/scripts/utils/expo.mjs +96 -0
- package/scripts/utils/handy_master_secret.mjs +94 -0
- package/scripts/utils/happy_server_infra.mjs +484 -0
- package/scripts/utils/localhost_host.mjs +17 -0
- package/scripts/utils/ownership.mjs +135 -0
- package/scripts/utils/paths.mjs +5 -2
- package/scripts/utils/pm.mjs +132 -22
- package/scripts/utils/ports.mjs +51 -13
- package/scripts/utils/proc.mjs +75 -7
- package/scripts/utils/runtime.mjs +1 -3
- package/scripts/utils/sandbox.mjs +14 -0
- package/scripts/utils/server.mjs +61 -0
- package/scripts/utils/server_port.mjs +9 -0
- package/scripts/utils/server_urls.mjs +54 -0
- package/scripts/utils/stack_context.mjs +23 -0
- package/scripts/utils/stack_runtime_state.mjs +104 -0
- package/scripts/utils/stack_startup.mjs +208 -0
- package/scripts/utils/stack_stop.mjs +255 -0
- package/scripts/utils/stacks.mjs +38 -0
- package/scripts/utils/validate.mjs +42 -1
- package/scripts/utils/watch.mjs +63 -0
- package/scripts/utils/worktrees.mjs +57 -1
- package/scripts/where.mjs +14 -7
- package/scripts/worktrees.mjs +135 -15
- /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
- /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
- /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
package/scripts/tui.mjs
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import './utils/env.mjs';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { join, resolve, sep } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { parseDotenv } from './utils/dotenv.mjs';
|
|
8
|
+
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';
|
|
11
|
+
|
|
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
|
+
}
|
|
16
|
+
|
|
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);
|
|
21
|
+
}
|
|
22
|
+
|
|
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);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function clamp(n, lo, hi) {
|
|
34
|
+
return Math.max(lo, Math.min(hi, n));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mkPane(id, title, { visible = true, kind = 'log' } = {}) {
|
|
38
|
+
return { id, title, kind, visible, lines: [], scroll: 0 };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function pushLine(pane, line, { maxLines = 4000 } = {}) {
|
|
42
|
+
pane.lines.push(line);
|
|
43
|
+
if (pane.lines.length > maxLines) {
|
|
44
|
+
pane.lines.splice(0, pane.lines.length - maxLines);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function drawBox({ x, y, w, h, title, lines, scroll }) {
|
|
49
|
+
const top = y;
|
|
50
|
+
const bottom = y + h - 1;
|
|
51
|
+
const left = x;
|
|
52
|
+
const horiz = '─'.repeat(Math.max(0, w - 2));
|
|
53
|
+
const t = title ? ` ${title} ` : '';
|
|
54
|
+
const titleStart = Math.max(1, Math.min(w - 2 - t.length, 2));
|
|
55
|
+
const topLine =
|
|
56
|
+
'┌' +
|
|
57
|
+
horiz
|
|
58
|
+
.split('')
|
|
59
|
+
.map((ch, i) => {
|
|
60
|
+
const pos = i + 1;
|
|
61
|
+
if (t && pos >= titleStart && pos < titleStart + t.length) {
|
|
62
|
+
return t[pos - titleStart];
|
|
63
|
+
}
|
|
64
|
+
return ch;
|
|
65
|
+
})
|
|
66
|
+
.join('') +
|
|
67
|
+
'┐';
|
|
68
|
+
|
|
69
|
+
const midLine = '│' + ' '.repeat(Math.max(0, w - 2)) + '│';
|
|
70
|
+
const botLine = '└' + horiz + '┘';
|
|
71
|
+
|
|
72
|
+
const out = [];
|
|
73
|
+
out.push({ row: top, col: left, text: topLine });
|
|
74
|
+
for (let r = top + 1; r < bottom; r++) {
|
|
75
|
+
out.push({ row: r, col: left, text: midLine });
|
|
76
|
+
}
|
|
77
|
+
out.push({ row: bottom, col: left, text: botLine });
|
|
78
|
+
|
|
79
|
+
const innerW = Math.max(0, w - 2);
|
|
80
|
+
const innerH = Math.max(0, h - 2);
|
|
81
|
+
const maxScroll = Math.max(0, lines.length - innerH);
|
|
82
|
+
const s = clamp(scroll, 0, maxScroll);
|
|
83
|
+
const start = Math.max(0, lines.length - innerH - s);
|
|
84
|
+
const slice = lines.slice(start, start + innerH);
|
|
85
|
+
for (let i = 0; i < innerH; i++) {
|
|
86
|
+
const line = stripAnsi(slice[i] ?? '');
|
|
87
|
+
out.push({ row: top + 1 + i, col: left + 1, text: padRight(line, innerW) });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { out, maxScroll };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isTuiHelp(argv) {
|
|
94
|
+
if (!argv.length) return true;
|
|
95
|
+
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === 'help')) return true;
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function inferStackNameFromForwardedArgs(args) {
|
|
100
|
+
// Primary: stack-scoped usage: `happys tui stack <subcmd> <name> ...`
|
|
101
|
+
const i = args.indexOf('stack');
|
|
102
|
+
if (i >= 0) {
|
|
103
|
+
const name = args[i + 2];
|
|
104
|
+
if (name && !name.startsWith('-')) return name;
|
|
105
|
+
}
|
|
106
|
+
// Fallback: use current environment stack (or main).
|
|
107
|
+
return (process.env.HAPPY_STACKS_STACK ?? process.env.HAPPY_LOCAL_STACK ?? '').trim() || 'main';
|
|
108
|
+
}
|
|
109
|
+
|
|
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
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatComponentRef({ rootDir, component, dir }) {
|
|
121
|
+
const raw = String(dir ?? '').trim();
|
|
122
|
+
if (!raw) return '(unset)';
|
|
123
|
+
|
|
124
|
+
const abs = resolve(raw);
|
|
125
|
+
const defaultDir = resolve(join(rootDir, 'components', component));
|
|
126
|
+
const worktreesPrefix = resolve(join(rootDir, 'components', '.worktrees', component)) + sep;
|
|
127
|
+
|
|
128
|
+
if (abs === defaultDir) return 'default';
|
|
129
|
+
if (abs.startsWith(worktreesPrefix)) {
|
|
130
|
+
return abs.slice(worktreesPrefix.length);
|
|
131
|
+
}
|
|
132
|
+
return abs;
|
|
133
|
+
}
|
|
134
|
+
|
|
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
|
+
async function buildStackSummaryLines({ rootDir, stackName }) {
|
|
142
|
+
const { envPath, baseDir } = resolveStackEnvPath(stackName);
|
|
143
|
+
const env = await readEnvObject(envPath);
|
|
144
|
+
const runtimePath = getStackRuntimeStatePath(stackName);
|
|
145
|
+
const runtime = await readStackRuntimeStateFile(runtimePath);
|
|
146
|
+
|
|
147
|
+
const serverComponent =
|
|
148
|
+
getEnvVal(env, 'HAPPY_STACKS_SERVER_COMPONENT', 'HAPPY_LOCAL_SERVER_COMPONENT') || 'happy-server-light';
|
|
149
|
+
|
|
150
|
+
const ports = runtime?.ports && typeof runtime.ports === 'object' ? runtime.ports : {};
|
|
151
|
+
const expoWebPort = runtime?.expo && typeof runtime.expo === 'object' ? runtime.expo.webPort : null;
|
|
152
|
+
const processes = runtime?.processes && typeof runtime.processes === 'object' ? runtime.processes : {};
|
|
153
|
+
|
|
154
|
+
const components = [
|
|
155
|
+
{ key: 'happy', envKey: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY', legacyKey: 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY' },
|
|
156
|
+
{ key: 'happy-cli', envKey: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_CLI', legacyKey: 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_CLI' },
|
|
157
|
+
{
|
|
158
|
+
key: serverComponent === 'happy-server' ? 'happy-server' : 'happy-server-light',
|
|
159
|
+
envKey:
|
|
160
|
+
serverComponent === 'happy-server'
|
|
161
|
+
? 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER'
|
|
162
|
+
: 'HAPPY_STACKS_COMPONENT_DIR_HAPPY_SERVER_LIGHT',
|
|
163
|
+
legacyKey:
|
|
164
|
+
serverComponent === 'happy-server'
|
|
165
|
+
? 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER'
|
|
166
|
+
: 'HAPPY_LOCAL_COMPONENT_DIR_HAPPY_SERVER_LIGHT',
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
const lines = [];
|
|
171
|
+
lines.push(`stack: ${stackName}`);
|
|
172
|
+
lines.push(`server: ${serverComponent}`);
|
|
173
|
+
lines.push(`baseDir: ${baseDir}`);
|
|
174
|
+
lines.push(`env: ${envPath}`);
|
|
175
|
+
lines.push(`runtime: ${runtimePath}${runtime ? '' : ' (missing)'}`);
|
|
176
|
+
if (runtime?.startedAt) lines.push(`startedAt: ${runtime.startedAt}`);
|
|
177
|
+
if (runtime?.updatedAt) lines.push(`updatedAt: ${runtime.updatedAt}`);
|
|
178
|
+
if (runtime?.ownerPid) lines.push(`ownerPid: ${runtime.ownerPid}`);
|
|
179
|
+
|
|
180
|
+
lines.push('');
|
|
181
|
+
lines.push('ports:');
|
|
182
|
+
lines.push(` server: ${ports?.server ?? '(unknown)'}`);
|
|
183
|
+
if (expoWebPort) lines.push(` ui: ${expoWebPort}`);
|
|
184
|
+
if (ports?.backend) lines.push(` backend: ${ports.backend}`);
|
|
185
|
+
|
|
186
|
+
lines.push('');
|
|
187
|
+
lines.push('pids:');
|
|
188
|
+
if (processes?.serverPid) lines.push(` serverPid: ${processes.serverPid}`);
|
|
189
|
+
if (processes?.expoWebPid) lines.push(` expoWebPid: ${processes.expoWebPid}`);
|
|
190
|
+
if (processes?.daemonPid) lines.push(` daemonPid: ${processes.daemonPid}`);
|
|
191
|
+
if (processes?.uiGatewayPid) lines.push(` uiGatewayPid: ${processes.uiGatewayPid}`);
|
|
192
|
+
|
|
193
|
+
lines.push('');
|
|
194
|
+
lines.push('components:');
|
|
195
|
+
for (const c of components) {
|
|
196
|
+
const dir = getEnvVal(env, c.envKey, c.legacyKey);
|
|
197
|
+
lines.push(` ${padRight(c.key, 16)} ${formatComponentRef({ rootDir, component: c.key, dir })}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return lines;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function main() {
|
|
204
|
+
const argv = process.argv.slice(2);
|
|
205
|
+
|
|
206
|
+
if (isTuiHelp(argv)) {
|
|
207
|
+
printResult({
|
|
208
|
+
json: false,
|
|
209
|
+
data: { usage: 'happys tui <happys args...>', json: false },
|
|
210
|
+
text: [
|
|
211
|
+
'[tui] usage:',
|
|
212
|
+
' happys tui <happys args...>',
|
|
213
|
+
'',
|
|
214
|
+
'examples:',
|
|
215
|
+
' happys tui stack dev resume-upstream',
|
|
216
|
+
' happys tui stack start resume-upstream',
|
|
217
|
+
' happys tui stack auth dev-auth login',
|
|
218
|
+
'',
|
|
219
|
+
'layouts:',
|
|
220
|
+
' single : one pane (focused)',
|
|
221
|
+
' split : two panes (left=orchestration, right=focused)',
|
|
222
|
+
' columns : multiple panes stacked in two columns (toggle visibility per pane)',
|
|
223
|
+
'',
|
|
224
|
+
'keys:',
|
|
225
|
+
' tab / shift+tab : focus next/prev (visible panes only)',
|
|
226
|
+
' 1..9 : jump to pane index',
|
|
227
|
+
' v : cycle layout (single → split → columns)',
|
|
228
|
+
' m : toggle focused pane visibility (columns layout)',
|
|
229
|
+
' c : clear focused pane',
|
|
230
|
+
' p : pause/resume rendering',
|
|
231
|
+
' ↑/↓, PgUp/PgDn : scroll focused pane',
|
|
232
|
+
' Home/End : jump bottom/top (focused pane)',
|
|
233
|
+
' q / Ctrl+C : quit (sends SIGINT to child)',
|
|
234
|
+
'',
|
|
235
|
+
'panes (default):',
|
|
236
|
+
' orchestration | summary | local | server | ui | daemon | stack logs',
|
|
237
|
+
].join('\n'),
|
|
238
|
+
});
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
243
|
+
throw new Error('[tui] requires a TTY (interactive terminal)');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const rootDir = getRootDir(import.meta.url);
|
|
247
|
+
const happysBin = join(rootDir, 'bin', 'happys.mjs');
|
|
248
|
+
const forwarded = argv;
|
|
249
|
+
|
|
250
|
+
const stackName = inferStackNameFromForwardedArgs(forwarded);
|
|
251
|
+
|
|
252
|
+
const panes = [
|
|
253
|
+
mkPane('orch', 'orchestration', { visible: true, kind: 'log' }),
|
|
254
|
+
mkPane('summary', `stack summary (${stackName})`, { visible: true, kind: 'summary' }),
|
|
255
|
+
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' }),
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
const paneIndexById = new Map(panes.map((p, i) => [p.id, i]));
|
|
263
|
+
|
|
264
|
+
const routeLine = (line) => {
|
|
265
|
+
const label = parsePrefixedLabel(line);
|
|
266
|
+
const normalized = label ? label.toLowerCase() : '';
|
|
267
|
+
|
|
268
|
+
let paneId = 'local';
|
|
269
|
+
if (normalized.includes('server')) paneId = 'server';
|
|
270
|
+
else if (normalized === 'ui') paneId = 'ui';
|
|
271
|
+
else if (normalized.includes('daemon')) paneId = 'daemon';
|
|
272
|
+
else if (normalized === 'stack') paneId = 'stacklog';
|
|
273
|
+
else if (normalized === 'local') paneId = 'local';
|
|
274
|
+
|
|
275
|
+
const idx = paneIndexById.get(paneId) ?? paneIndexById.get('local');
|
|
276
|
+
pushLine(panes[idx], line);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const logOrch = (msg) => {
|
|
280
|
+
pushLine(panes[paneIndexById.get('orch')], `[${nowTs()}] ${msg}`);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
let layout = 'columns'; // single | split | columns
|
|
284
|
+
let focused = 2; // local
|
|
285
|
+
let paused = false;
|
|
286
|
+
let renderScheduled = false;
|
|
287
|
+
|
|
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
|
+
});
|
|
294
|
+
|
|
295
|
+
logOrch(`spawned: node ${happysBin} ${forwarded.join(' ')} (pid=${child.pid})`);
|
|
296
|
+
|
|
297
|
+
const buf = { out: '', err: '' };
|
|
298
|
+
const flush = (kind) => {
|
|
299
|
+
const key = kind === 'stderr' ? 'err' : 'out';
|
|
300
|
+
let b = buf[key];
|
|
301
|
+
while (true) {
|
|
302
|
+
const idx = b.indexOf('\n');
|
|
303
|
+
if (idx < 0) break;
|
|
304
|
+
const line = b.slice(0, idx);
|
|
305
|
+
b = b.slice(idx + 1);
|
|
306
|
+
routeLine(line);
|
|
307
|
+
}
|
|
308
|
+
buf[key] = b;
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
child.stdout?.on('data', (d) => {
|
|
312
|
+
buf.out += d.toString();
|
|
313
|
+
flush('stdout');
|
|
314
|
+
scheduleRender();
|
|
315
|
+
});
|
|
316
|
+
child.stderr?.on('data', (d) => {
|
|
317
|
+
buf.err += d.toString();
|
|
318
|
+
flush('stderr');
|
|
319
|
+
scheduleRender();
|
|
320
|
+
});
|
|
321
|
+
child.on('exit', (code, sig) => {
|
|
322
|
+
logOrch(`child exited (code=${code}, sig=${sig ?? 'null'})`);
|
|
323
|
+
scheduleRender();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
async function refreshSummary() {
|
|
327
|
+
const idx = paneIndexById.get('summary');
|
|
328
|
+
try {
|
|
329
|
+
const lines = await buildStackSummaryLines({ rootDir, stackName });
|
|
330
|
+
panes[idx].lines = lines;
|
|
331
|
+
} catch (e) {
|
|
332
|
+
panes[idx].lines = [`summary error: ${e instanceof Error ? e.message : String(e)}`];
|
|
333
|
+
}
|
|
334
|
+
scheduleRender();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const summaryTimer = setInterval(() => {
|
|
338
|
+
if (!paused) {
|
|
339
|
+
void refreshSummary();
|
|
340
|
+
}
|
|
341
|
+
}, 1000);
|
|
342
|
+
|
|
343
|
+
function scheduleRender() {
|
|
344
|
+
if (paused) return;
|
|
345
|
+
if (renderScheduled) return;
|
|
346
|
+
renderScheduled = true;
|
|
347
|
+
setTimeout(() => {
|
|
348
|
+
renderScheduled = false;
|
|
349
|
+
render();
|
|
350
|
+
}, 16);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function visiblePaneIndexes() {
|
|
354
|
+
return panes
|
|
355
|
+
.map((p, idx) => ({ p, idx }))
|
|
356
|
+
.filter(({ p }) => p.visible)
|
|
357
|
+
.map(({ idx }) => idx);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function focusNext(delta) {
|
|
361
|
+
const visible = visiblePaneIndexes();
|
|
362
|
+
if (!visible.length) return;
|
|
363
|
+
const pos = Math.max(0, visible.indexOf(focused));
|
|
364
|
+
const next = (pos + delta + visible.length) % visible.length;
|
|
365
|
+
focused = visible[next];
|
|
366
|
+
scheduleRender();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function scrollFocused(delta) {
|
|
370
|
+
const pane = panes[focused];
|
|
371
|
+
pane.scroll = Math.max(0, pane.scroll + delta);
|
|
372
|
+
scheduleRender();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function clearFocused() {
|
|
376
|
+
const pane = panes[focused];
|
|
377
|
+
if (pane.kind === 'summary') return;
|
|
378
|
+
pane.lines = [];
|
|
379
|
+
pane.scroll = 0;
|
|
380
|
+
scheduleRender();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function cycleLayout() {
|
|
384
|
+
layout = layout === 'single' ? 'split' : layout === 'split' ? 'columns' : 'single';
|
|
385
|
+
scheduleRender();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function toggleFocusedVisibility() {
|
|
389
|
+
if (layout !== 'columns') return;
|
|
390
|
+
const pane = panes[focused];
|
|
391
|
+
if (pane.id === 'orch') return; // always visible
|
|
392
|
+
pane.visible = !pane.visible;
|
|
393
|
+
if (!pane.visible) {
|
|
394
|
+
// Move focus to next visible pane.
|
|
395
|
+
focusNext(+1);
|
|
396
|
+
}
|
|
397
|
+
scheduleRender();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function render() {
|
|
401
|
+
if (paused) return;
|
|
402
|
+
const cols = process.stdout.columns ?? 120;
|
|
403
|
+
const rows = process.stdout.rows ?? 40;
|
|
404
|
+
process.stdout.write('\x1b[?25l');
|
|
405
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
406
|
+
|
|
407
|
+
const header = `happys tui | ${forwarded.join(' ')} | layout=${layout} | focus=${panes[focused]?.title ?? focused}`;
|
|
408
|
+
process.stdout.write(padRight(header, cols) + '\n');
|
|
409
|
+
|
|
410
|
+
const bodyY = 1;
|
|
411
|
+
const bodyH = rows - 2;
|
|
412
|
+
const footerY = rows - 1;
|
|
413
|
+
|
|
414
|
+
const drawWrites = [];
|
|
415
|
+
if (layout === 'single') {
|
|
416
|
+
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 });
|
|
418
|
+
pane.scroll = clamp(pane.scroll, 0, box.maxScroll);
|
|
419
|
+
drawWrites.push(...box.out);
|
|
420
|
+
} else if (layout === 'split') {
|
|
421
|
+
const leftW = Math.floor(cols / 2);
|
|
422
|
+
const rightW = cols - leftW;
|
|
423
|
+
|
|
424
|
+
const leftPane = panes[paneIndexById.get('orch')];
|
|
425
|
+
const rightPane = panes[focused === paneIndexById.get('orch') ? paneIndexById.get('local') : focused];
|
|
426
|
+
|
|
427
|
+
const leftBox = drawBox({ x: 0, y: bodyY, w: leftW, h: bodyH, title: leftPane.title, lines: leftPane.lines, scroll: leftPane.scroll });
|
|
428
|
+
leftPane.scroll = clamp(leftPane.scroll, 0, leftBox.maxScroll);
|
|
429
|
+
drawWrites.push(...leftBox.out);
|
|
430
|
+
|
|
431
|
+
const rightBox = drawBox({ x: leftW, y: bodyY, w: rightW, h: bodyH, title: rightPane.title, lines: rightPane.lines, scroll: rightPane.scroll });
|
|
432
|
+
rightPane.scroll = clamp(rightPane.scroll, 0, rightBox.maxScroll);
|
|
433
|
+
drawWrites.push(...rightBox.out);
|
|
434
|
+
} else {
|
|
435
|
+
// columns: render all visible panes in two columns, stacked.
|
|
436
|
+
const visible = visiblePaneIndexes().map((idx) => panes[idx]);
|
|
437
|
+
const leftW = Math.floor(cols / 2);
|
|
438
|
+
const rightW = cols - leftW;
|
|
439
|
+
|
|
440
|
+
const leftPanes = [];
|
|
441
|
+
const rightPanes = [];
|
|
442
|
+
for (let i = 0; i < visible.length; i++) {
|
|
443
|
+
(i % 2 === 0 ? leftPanes : rightPanes).push(visible[i]);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const layoutColumn = (colX, colW, colPanes) => {
|
|
447
|
+
if (!colPanes.length) return;
|
|
448
|
+
const n = colPanes.length;
|
|
449
|
+
const base = Math.max(3, Math.floor(bodyH / n));
|
|
450
|
+
let y = bodyY;
|
|
451
|
+
for (let i = 0; i < n; i++) {
|
|
452
|
+
const pane = colPanes[i];
|
|
453
|
+
const remaining = bodyY + bodyH - y;
|
|
454
|
+
const h = i === n - 1 ? remaining : Math.min(base, remaining);
|
|
455
|
+
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);
|
|
459
|
+
y += h;
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
layoutColumn(0, leftW, leftPanes);
|
|
464
|
+
layoutColumn(leftW, rightW, rightPanes);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
for (const w of drawWrites) {
|
|
468
|
+
process.stdout.write(`\x1b[${w.row + 1};${w.col + 1}H${w.text}`);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const footer =
|
|
472
|
+
'tab:next shift+tab:prev 1..9:jump v:layout m:toggle-pane c:clear p:pause arrows:scroll q/Ctrl+C:quit';
|
|
473
|
+
process.stdout.write(`\x1b[${footerY + 1};1H` + padRight(footer, cols));
|
|
474
|
+
process.stdout.write('\x1b[?25h');
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function shutdown() {
|
|
478
|
+
clearInterval(summaryTimer);
|
|
479
|
+
try {
|
|
480
|
+
process.stdin.setRawMode(false);
|
|
481
|
+
} catch {
|
|
482
|
+
// ignore
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
process.stdin.pause();
|
|
486
|
+
} catch {
|
|
487
|
+
// ignore
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
if (child.exitCode == null && child.pid) {
|
|
491
|
+
if (process.platform !== 'win32') process.kill(-child.pid, 'SIGINT');
|
|
492
|
+
else child.kill('SIGINT');
|
|
493
|
+
}
|
|
494
|
+
} catch {
|
|
495
|
+
// ignore
|
|
496
|
+
}
|
|
497
|
+
process.stdout.write('\x1b[2J\x1b[H\x1b[?25h');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
process.stdin.setRawMode(true);
|
|
501
|
+
process.stdin.resume();
|
|
502
|
+
process.stdin.on('data', (d) => {
|
|
503
|
+
const s = d.toString('utf-8');
|
|
504
|
+
if (s === '\u0003' || s === 'q') {
|
|
505
|
+
shutdown();
|
|
506
|
+
process.exit(0);
|
|
507
|
+
}
|
|
508
|
+
if (s === '\t') return focusNext(+1);
|
|
509
|
+
if (s === '\x1b[Z') return focusNext(-1);
|
|
510
|
+
if (s >= '1' && s <= '9') {
|
|
511
|
+
const idx = Number(s) - 1;
|
|
512
|
+
if (idx >= 0 && idx < panes.length) {
|
|
513
|
+
if (panes[idx].visible) {
|
|
514
|
+
focused = idx;
|
|
515
|
+
scheduleRender();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (s === 'v') return cycleLayout();
|
|
521
|
+
if (s === 'm') return toggleFocusedVisibility();
|
|
522
|
+
if (s === 'c') return clearFocused();
|
|
523
|
+
if (s === 'p') {
|
|
524
|
+
paused = !paused;
|
|
525
|
+
if (!paused) {
|
|
526
|
+
void refreshSummary();
|
|
527
|
+
scheduleRender();
|
|
528
|
+
}
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (s === '\x1b[A') return scrollFocused(+1);
|
|
533
|
+
if (s === '\x1b[B') return scrollFocused(-1);
|
|
534
|
+
if (s === '\x1b[5~') return scrollFocused(+10);
|
|
535
|
+
if (s === '\x1b[6~') return scrollFocused(-10);
|
|
536
|
+
if (s === '\x1b[H') {
|
|
537
|
+
panes[focused].scroll = 1000000;
|
|
538
|
+
scheduleRender();
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (s === '\x1b[F') {
|
|
542
|
+
panes[focused].scroll = 0;
|
|
543
|
+
scheduleRender();
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
await refreshSummary();
|
|
549
|
+
render();
|
|
550
|
+
await new Promise(() => {});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
main().catch((err) => {
|
|
554
|
+
console.error('[tui] failed:', err);
|
|
555
|
+
process.exit(1);
|
|
556
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import './utils/env.mjs';
|
|
2
|
+
import { parseArgs } from './utils/cli/args.mjs';
|
|
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';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_COMPONENTS = ['happy', 'happy-cli', 'happy-server-light', 'happy-server'];
|
|
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
|
+
function pickTypecheckScript(scripts) {
|
|
33
|
+
if (!scripts) return null;
|
|
34
|
+
const candidates = [
|
|
35
|
+
'typecheck',
|
|
36
|
+
'type-check',
|
|
37
|
+
'check-types',
|
|
38
|
+
'check:types',
|
|
39
|
+
'tsc',
|
|
40
|
+
'typescript',
|
|
41
|
+
];
|
|
42
|
+
return candidates.find((k) => typeof scripts[k] === 'string' && scripts[k].trim()) ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function main() {
|
|
46
|
+
const argv = process.argv.slice(2);
|
|
47
|
+
const { flags } = parseArgs(argv);
|
|
48
|
+
const json = wantsJson(argv, { flags });
|
|
49
|
+
|
|
50
|
+
if (wantsHelp(argv, { flags })) {
|
|
51
|
+
printResult({
|
|
52
|
+
json,
|
|
53
|
+
data: { components: DEFAULT_COMPONENTS, flags: ['--json'] },
|
|
54
|
+
text: [
|
|
55
|
+
'[typecheck] usage:',
|
|
56
|
+
' happys typecheck [component...] [--json]',
|
|
57
|
+
'',
|
|
58
|
+
'components:',
|
|
59
|
+
` ${DEFAULT_COMPONENTS.join(' | ')}`,
|
|
60
|
+
'',
|
|
61
|
+
'examples:',
|
|
62
|
+
' happys typecheck',
|
|
63
|
+
' happys typecheck happy happy-cli',
|
|
64
|
+
].join('\n'),
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const positionals = argv.filter((a) => !a.startsWith('--'));
|
|
70
|
+
const requested = positionals.length ? positionals : ['all'];
|
|
71
|
+
const wantAll = requested.includes('all');
|
|
72
|
+
const components = wantAll ? DEFAULT_COMPONENTS : requested;
|
|
73
|
+
|
|
74
|
+
const rootDir = getRootDir(import.meta.url);
|
|
75
|
+
|
|
76
|
+
const results = [];
|
|
77
|
+
for (const component of components) {
|
|
78
|
+
if (!DEFAULT_COMPONENTS.includes(component)) {
|
|
79
|
+
results.push({ component, ok: false, skipped: false, error: `unknown component (expected one of: ${DEFAULT_COMPONENTS.join(', ')})` });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const dir = getComponentDir(rootDir, component);
|
|
84
|
+
if (!(await pathExists(dir))) {
|
|
85
|
+
results.push({ component, ok: false, skipped: false, dir, error: `missing component dir: ${dir}` });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const scripts = await readScripts(dir);
|
|
90
|
+
if (!scripts) {
|
|
91
|
+
results.push({ component, ok: true, skipped: true, dir, reason: 'no package.json' });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const script = pickTypecheckScript(scripts);
|
|
96
|
+
if (!script) {
|
|
97
|
+
results.push({ component, ok: true, skipped: true, dir, reason: 'no typecheck script found in package.json' });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await ensureDepsInstalled(dir, component);
|
|
102
|
+
const pm = await detectPackageManagerCmd(dir);
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
// eslint-disable-next-line no-console
|
|
106
|
+
console.log(`[typecheck] ${component}: running ${pm.name} ${script}`);
|
|
107
|
+
await run(pm.cmd, pm.argsForScript(script), { cwd: dir, env: process.env });
|
|
108
|
+
results.push({ component, ok: true, skipped: false, dir, pm: pm.name, script });
|
|
109
|
+
} catch (e) {
|
|
110
|
+
results.push({ component, ok: false, skipped: false, dir, pm: pm.name, script, error: String(e?.message ?? e) });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const ok = results.every((r) => r.ok);
|
|
115
|
+
if (json) {
|
|
116
|
+
printResult({ json, data: { ok, results } });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const lines = ['[typecheck] results:'];
|
|
121
|
+
for (const r of results) {
|
|
122
|
+
if (r.ok && r.skipped) {
|
|
123
|
+
lines.push(`- ↪ ${r.component}: skipped (${r.reason})`);
|
|
124
|
+
} else if (r.ok) {
|
|
125
|
+
lines.push(`- ✅ ${r.component}: ok (${r.pm} ${r.script})`);
|
|
126
|
+
} else {
|
|
127
|
+
lines.push(`- ❌ ${r.component}: failed (${r.pm ?? 'unknown'} ${r.script ?? ''})`);
|
|
128
|
+
if (r.error) lines.push(` - ${r.error}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!ok) {
|
|
132
|
+
lines.push('');
|
|
133
|
+
lines.push('[typecheck] failed');
|
|
134
|
+
}
|
|
135
|
+
printResult({ json: false, text: lines.join('\n') });
|
|
136
|
+
if (!ok) {
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
main().catch((err) => {
|
|
142
|
+
console.error('[typecheck] failed:', err);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
});
|
|
145
|
+
|