happy-stacks 0.1.2 → 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.
Files changed (91) hide show
  1. package/README.md +121 -83
  2. package/bin/happys.mjs +70 -10
  3. package/docs/edison.md +381 -0
  4. package/docs/happy-development.md +733 -0
  5. package/docs/menubar.md +54 -0
  6. package/docs/paths-and-env.md +141 -0
  7. package/docs/stacks.md +39 -0
  8. package/extras/swiftbar/auth-login.sh +5 -2
  9. package/extras/swiftbar/git-cache-refresh.sh +130 -0
  10. package/extras/swiftbar/happy-stacks.5s.sh +131 -81
  11. package/extras/swiftbar/happys-term.sh +15 -38
  12. package/extras/swiftbar/happys.sh +15 -32
  13. package/extras/swiftbar/install.sh +99 -13
  14. package/extras/swiftbar/lib/git.sh +309 -1
  15. package/extras/swiftbar/lib/icons.sh +2 -2
  16. package/extras/swiftbar/lib/render.sh +209 -80
  17. package/extras/swiftbar/lib/system.sh +27 -4
  18. package/extras/swiftbar/lib/utils.sh +311 -28
  19. package/extras/swiftbar/pnpm.sh +2 -1
  20. package/extras/swiftbar/set-interval.sh +10 -5
  21. package/extras/swiftbar/set-server-flavor.sh +11 -2
  22. package/extras/swiftbar/wt-pr.sh +9 -2
  23. package/package.json +2 -1
  24. package/scripts/auth.mjs +560 -112
  25. package/scripts/build.mjs +24 -4
  26. package/scripts/cli-link.mjs +3 -3
  27. package/scripts/completion.mjs +15 -8
  28. package/scripts/daemon.mjs +130 -20
  29. package/scripts/dev.mjs +201 -133
  30. package/scripts/doctor.mjs +26 -21
  31. package/scripts/edison.mjs +1828 -0
  32. package/scripts/happy.mjs +3 -7
  33. package/scripts/init.mjs +43 -20
  34. package/scripts/install.mjs +14 -8
  35. package/scripts/lint.mjs +145 -0
  36. package/scripts/menubar.mjs +81 -8
  37. package/scripts/migrate.mjs +25 -15
  38. package/scripts/mobile.mjs +13 -7
  39. package/scripts/run.mjs +114 -27
  40. package/scripts/self.mjs +3 -7
  41. package/scripts/server_flavor.mjs +3 -3
  42. package/scripts/service.mjs +15 -2
  43. package/scripts/setup.mjs +790 -0
  44. package/scripts/setup_pr.mjs +182 -0
  45. package/scripts/stack.mjs +1792 -254
  46. package/scripts/stop.mjs +6 -3
  47. package/scripts/tailscale.mjs +17 -2
  48. package/scripts/test.mjs +144 -0
  49. package/scripts/tui.mjs +556 -0
  50. package/scripts/typecheck.mjs +2 -2
  51. package/scripts/ui_gateway.mjs +2 -2
  52. package/scripts/uninstall.mjs +18 -10
  53. package/scripts/utils/auth_files.mjs +58 -0
  54. package/scripts/utils/auth_login_ux.mjs +76 -0
  55. package/scripts/utils/auth_sources.mjs +12 -0
  56. package/scripts/utils/browser.mjs +22 -0
  57. package/scripts/utils/canonical_home.mjs +20 -0
  58. package/scripts/utils/{cli_registry.mjs → cli/cli_registry.mjs} +48 -0
  59. package/scripts/utils/{wizard.mjs → cli/wizard.mjs} +1 -1
  60. package/scripts/utils/config.mjs +6 -2
  61. package/scripts/utils/dev_auth_key.mjs +169 -0
  62. package/scripts/utils/dev_daemon.mjs +104 -0
  63. package/scripts/utils/dev_expo_web.mjs +112 -0
  64. package/scripts/utils/dev_server.mjs +183 -0
  65. package/scripts/utils/env.mjs +60 -11
  66. package/scripts/utils/env_file.mjs +36 -0
  67. package/scripts/utils/expo.mjs +4 -2
  68. package/scripts/utils/handy_master_secret.mjs +94 -0
  69. package/scripts/utils/happy_server_infra.mjs +100 -46
  70. package/scripts/utils/localhost_host.mjs +17 -0
  71. package/scripts/utils/ownership.mjs +135 -0
  72. package/scripts/utils/paths.mjs +5 -2
  73. package/scripts/utils/pm.mjs +121 -20
  74. package/scripts/utils/proc.mjs +29 -2
  75. package/scripts/utils/runtime.mjs +1 -3
  76. package/scripts/utils/sandbox.mjs +14 -0
  77. package/scripts/utils/server.mjs +24 -0
  78. package/scripts/utils/server_port.mjs +9 -0
  79. package/scripts/utils/server_urls.mjs +54 -0
  80. package/scripts/utils/stack_context.mjs +23 -0
  81. package/scripts/utils/stack_runtime_state.mjs +104 -0
  82. package/scripts/utils/stack_startup.mjs +208 -0
  83. package/scripts/utils/stack_stop.mjs +79 -30
  84. package/scripts/utils/stacks.mjs +38 -0
  85. package/scripts/utils/watch.mjs +63 -0
  86. package/scripts/utils/worktrees.mjs +57 -1
  87. package/scripts/where.mjs +14 -7
  88. package/scripts/worktrees.mjs +82 -8
  89. /package/scripts/utils/{args.mjs → cli/args.mjs} +0 -0
  90. /package/scripts/utils/{cli.mjs → cli/cli.mjs} +0 -0
  91. /package/scripts/utils/{smoke_help.mjs → cli/smoke_help.mjs} +0 -0
@@ -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
+ });
@@ -1,6 +1,6 @@
1
1
  import './utils/env.mjs';
2
- import { parseArgs } from './utils/args.mjs';
3
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
2
+ import { parseArgs } from './utils/cli/args.mjs';
3
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
4
4
  import { getComponentDir, getRootDir } from './utils/paths.mjs';
5
5
  import { ensureDepsInstalled, requirePnpm } from './utils/pm.mjs';
6
6
  import { pathExists } from './utils/fs.mjs';
@@ -4,8 +4,8 @@ import net from 'node:net';
4
4
  import { extname, resolve, sep } from 'node:path';
5
5
  import { readFile, stat } from 'node:fs/promises';
6
6
 
7
- import { parseArgs } from './utils/args.mjs';
8
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
7
+ import { parseArgs } from './utils/cli/args.mjs';
8
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
9
9
 
10
10
  function usage() {
11
11
  return [
@@ -2,18 +2,16 @@ import './utils/env.mjs';
2
2
 
3
3
  import { rm } from 'node:fs/promises';
4
4
  import { existsSync } from 'node:fs';
5
- import { homedir } from 'node:os';
6
5
  import { join } from 'node:path';
7
6
  import { spawnSync } from 'node:child_process';
8
7
 
9
- import { parseArgs } from './utils/args.mjs';
10
- import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
8
+ import { parseArgs } from './utils/cli/args.mjs';
9
+ import { printResult, wantsHelp, wantsJson } from './utils/cli/cli.mjs';
10
+ import { expandHome } from './utils/canonical_home.mjs';
11
11
  import { getHappyStacksHomeDir, getRootDir, getStacksStorageRoot } from './utils/paths.mjs';
12
12
  import { getRuntimeDir } from './utils/runtime.mjs';
13
-
14
- function expandHome(p) {
15
- return p.replace(/^~(?=\/)/, homedir());
16
- }
13
+ import { getCanonicalHomeEnvPath } from './utils/config.mjs';
14
+ import { isSandboxed, sandboxAllowsGlobalSideEffects } from './utils/sandbox.mjs';
17
15
 
18
16
  function resolveWorkspaceDir({ rootDir, homeDir }) {
19
17
  // Uninstall should never default to deleting the repo root (getWorkspaceDir() can fall back to cliRootDir).
@@ -69,13 +67,14 @@ async function main() {
69
67
  if (wantsHelp(argv, { flags }) || argv.includes('help')) {
70
68
  printResult({
71
69
  json,
72
- data: { flags: ['--remove-workspace', '--remove-stacks', '--yes'], json: true },
70
+ data: { flags: ['--remove-workspace', '--remove-stacks', '--yes', '--global'], json: true },
73
71
  text: [
74
72
  '[uninstall] usage:',
75
73
  ' happys uninstall [--json] # dry-run',
76
74
  ' happys uninstall --yes [--json]',
77
75
  ' happys uninstall --remove-workspace --yes',
78
76
  ' happys uninstall --remove-stacks --yes',
77
+ ' happys uninstall --global --yes # also remove global OS integrations (services/SwiftBar) even in sandbox mode',
79
78
  '',
80
79
  'notes:',
81
80
  ' - default removes: runtime, shims, cache, SwiftBar assets + plugin files, and LaunchAgent services',
@@ -93,11 +92,12 @@ async function main() {
93
92
  const yes = flags.has('--yes');
94
93
  const removeWorkspace = flags.has('--remove-workspace');
95
94
  const removeStacks = flags.has('--remove-stacks');
95
+ const allowGlobal = flags.has('--global') || sandboxAllowsGlobalSideEffects();
96
96
 
97
97
  const dryRun = !yes;
98
98
 
99
99
  // 1) Stop/uninstall services best-effort.
100
- if (!dryRun) {
100
+ if (!dryRun && (!isSandboxed() || allowGlobal)) {
101
101
  try {
102
102
  spawnSync(process.execPath, [join(rootDir, 'scripts', 'service.mjs'), 'uninstall'], {
103
103
  stdio: json ? 'ignore' : 'inherit',
@@ -110,9 +110,15 @@ async function main() {
110
110
  }
111
111
 
112
112
  // 2) Remove SwiftBar plugin files best-effort.
113
- const menubar = dryRun ? { ok: true, removed: 0, pluginsDir: resolveSwiftbarPluginsDir() } : await removeSwiftbarPluginFiles().catch(() => ({ ok: false, removed: 0, pluginsDir: null }));
113
+ const menubar =
114
+ isSandboxed() && !allowGlobal
115
+ ? { ok: true, removed: 0, pluginsDir: null, skipped: 'sandbox' }
116
+ : dryRun
117
+ ? { ok: true, removed: 0, pluginsDir: resolveSwiftbarPluginsDir() }
118
+ : await removeSwiftbarPluginFiles().catch(() => ({ ok: false, removed: 0, pluginsDir: null }));
114
119
 
115
120
  // 3) Remove home-managed runtime + shims + extras + cache + env pointers.
121
+ const canonicalEnv = getCanonicalHomeEnvPath();
116
122
  const toRemove = [
117
123
  join(homeDir, 'bin'),
118
124
  join(homeDir, 'runtime'),
@@ -120,6 +126,8 @@ async function main() {
120
126
  join(homeDir, 'cache'),
121
127
  join(homeDir, '.env'),
122
128
  join(homeDir, 'env.local'),
129
+ // Stable pointer file (can differ from homeDir for custom installs).
130
+ canonicalEnv,
123
131
  ];
124
132
  const removedPaths = [];
125
133
  for (const p of toRemove) {