happy-stacks 0.0.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 (67) hide show
  1. package/README.md +314 -0
  2. package/bin/happys.mjs +168 -0
  3. package/docs/menubar.md +186 -0
  4. package/docs/mobile-ios.md +134 -0
  5. package/docs/remote-access.md +43 -0
  6. package/docs/server-flavors.md +79 -0
  7. package/docs/stacks.md +218 -0
  8. package/docs/tauri.md +62 -0
  9. package/docs/worktrees-and-forks.md +395 -0
  10. package/extras/swiftbar/auth-login.sh +31 -0
  11. package/extras/swiftbar/happy-stacks.5s.sh +218 -0
  12. package/extras/swiftbar/icons/happy-green.png +0 -0
  13. package/extras/swiftbar/icons/happy-orange.png +0 -0
  14. package/extras/swiftbar/icons/happy-red.png +0 -0
  15. package/extras/swiftbar/icons/logo-white.png +0 -0
  16. package/extras/swiftbar/install.sh +191 -0
  17. package/extras/swiftbar/lib/git.sh +330 -0
  18. package/extras/swiftbar/lib/icons.sh +105 -0
  19. package/extras/swiftbar/lib/render.sh +774 -0
  20. package/extras/swiftbar/lib/system.sh +190 -0
  21. package/extras/swiftbar/lib/utils.sh +205 -0
  22. package/extras/swiftbar/pnpm-term.sh +125 -0
  23. package/extras/swiftbar/pnpm.sh +21 -0
  24. package/extras/swiftbar/set-interval.sh +62 -0
  25. package/extras/swiftbar/set-server-flavor.sh +57 -0
  26. package/extras/swiftbar/wt-pr.sh +95 -0
  27. package/package.json +58 -0
  28. package/scripts/auth.mjs +272 -0
  29. package/scripts/build.mjs +204 -0
  30. package/scripts/cli-link.mjs +58 -0
  31. package/scripts/completion.mjs +364 -0
  32. package/scripts/daemon.mjs +349 -0
  33. package/scripts/dev.mjs +181 -0
  34. package/scripts/doctor.mjs +342 -0
  35. package/scripts/happy.mjs +79 -0
  36. package/scripts/init.mjs +232 -0
  37. package/scripts/install.mjs +379 -0
  38. package/scripts/menubar.mjs +107 -0
  39. package/scripts/mobile.mjs +305 -0
  40. package/scripts/run.mjs +236 -0
  41. package/scripts/self.mjs +298 -0
  42. package/scripts/server_flavor.mjs +125 -0
  43. package/scripts/service.mjs +526 -0
  44. package/scripts/stack.mjs +815 -0
  45. package/scripts/tailscale.mjs +278 -0
  46. package/scripts/uninstall.mjs +190 -0
  47. package/scripts/utils/args.mjs +17 -0
  48. package/scripts/utils/cli.mjs +24 -0
  49. package/scripts/utils/cli_registry.mjs +262 -0
  50. package/scripts/utils/config.mjs +40 -0
  51. package/scripts/utils/dotenv.mjs +30 -0
  52. package/scripts/utils/env.mjs +138 -0
  53. package/scripts/utils/env_file.mjs +59 -0
  54. package/scripts/utils/env_local.mjs +25 -0
  55. package/scripts/utils/fs.mjs +11 -0
  56. package/scripts/utils/paths.mjs +184 -0
  57. package/scripts/utils/pm.mjs +294 -0
  58. package/scripts/utils/ports.mjs +66 -0
  59. package/scripts/utils/proc.mjs +66 -0
  60. package/scripts/utils/runtime.mjs +30 -0
  61. package/scripts/utils/server.mjs +41 -0
  62. package/scripts/utils/smoke_help.mjs +45 -0
  63. package/scripts/utils/validate.mjs +47 -0
  64. package/scripts/utils/wizard.mjs +69 -0
  65. package/scripts/utils/worktrees.mjs +78 -0
  66. package/scripts/where.mjs +105 -0
  67. package/scripts/worktrees.mjs +1721 -0
@@ -0,0 +1,526 @@
1
+ import './utils/env.mjs';
2
+ import { run, runCapture } from './utils/proc.mjs';
3
+ import { getDefaultAutostartPaths, getRootDir, resolveStackEnvPath } from './utils/paths.mjs';
4
+ import { ensureMacAutostartDisabled, ensureMacAutostartEnabled } from './utils/pm.mjs';
5
+ import { spawn } from 'node:child_process';
6
+ import { homedir } from 'node:os';
7
+ import { existsSync } from 'node:fs';
8
+ import { rm } from 'node:fs/promises';
9
+ import { dirname, join, resolve } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { printResult, wantsHelp, wantsJson } from './utils/cli.mjs';
12
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
13
+
14
+ /**
15
+ * Manage the macOS LaunchAgent installed by `happys bootstrap -- --autostart`.
16
+ *
17
+ * Commands:
18
+ * - install | uninstall
19
+ * - status
20
+ * - start | stop | restart
21
+ * - enable | disable (same as start/stop but explicitly persistent)
22
+ * - logs (print last N lines)
23
+ * - tail (follow logs)
24
+ */
25
+
26
+ function getUid() {
27
+ // Prefer env var if present; otherwise fall back.
28
+ // (LaunchAgents run in a user context so this is fine.)
29
+ const n = Number(process.env.UID);
30
+ return Number.isFinite(n) ? n : null;
31
+ }
32
+
33
+ function getInternalUrl() {
34
+ const port = process.env.HAPPY_LOCAL_SERVER_PORT?.trim() ? Number(process.env.HAPPY_LOCAL_SERVER_PORT) : 3005;
35
+ return `http://127.0.0.1:${port}`;
36
+ }
37
+
38
+ function getAutostartEnv({ rootDir }) {
39
+ // IMPORTANT:
40
+ // LaunchAgents should NOT bake the entire config into the plist, because that would require
41
+ // reinstalling the service for any config change (server flavor, worktrees, ports, etc).
42
+ //
43
+ // Instead, persist only the env file path; `scripts/utils/env.mjs` will load it on every start.
44
+ //
45
+ // Stack installs:
46
+ // - `happys stack service <name> ...` runs under a stack env already, so we persist that pointer.
47
+ //
48
+ // Main installs:
49
+ // - default to the main stack env (outside the repo): ~/.happy/stacks/main/env
50
+
51
+ const stacksEnvFile = process.env.HAPPY_STACKS_ENV_FILE?.trim() ? process.env.HAPPY_STACKS_ENV_FILE.trim() : '';
52
+ const localEnvFile = process.env.HAPPY_LOCAL_ENV_FILE?.trim() ? process.env.HAPPY_LOCAL_ENV_FILE.trim() : '';
53
+ const envFile = stacksEnvFile || localEnvFile || resolveStackEnvPath('main').envPath;
54
+
55
+ // Persist both prefixes for backwards compatibility.
56
+ return {
57
+ HAPPY_STACKS_ENV_FILE: envFile,
58
+ HAPPY_LOCAL_ENV_FILE: envFile,
59
+ };
60
+ }
61
+
62
+ export async function installService() {
63
+ if (process.platform !== 'darwin') {
64
+ throw new Error('[local] service install is only supported on macOS (LaunchAgents).');
65
+ }
66
+ const rootDir = getRootDir(import.meta.url);
67
+ const { primaryLabel: label } = getDefaultAutostartPaths();
68
+ const env = getAutostartEnv({ rootDir });
69
+ // Ensure the env file exists so the service never points at a missing path.
70
+ try {
71
+ const envFile = env.HAPPY_STACKS_ENV_FILE;
72
+ await mkdir(dirname(envFile), { recursive: true });
73
+ if (!existsSync(envFile)) {
74
+ await writeFile(envFile, '', { flag: 'a' });
75
+ }
76
+ } catch {
77
+ // ignore
78
+ }
79
+ await ensureMacAutostartEnabled({ rootDir, label, env });
80
+ console.log('[local] service installed (macOS LaunchAgent)');
81
+ }
82
+
83
+ export async function uninstallService() {
84
+ if (process.platform !== 'darwin') {
85
+ return;
86
+ }
87
+ const { primaryPlistPath, legacyPlistPath, primaryLabel, legacyLabel } = getDefaultAutostartPaths();
88
+
89
+ // Disable both labels (primary + legacy) best-effort.
90
+ await ensureMacAutostartDisabled({ label: primaryLabel });
91
+ await ensureMacAutostartDisabled({ label: legacyLabel });
92
+ try {
93
+ await rm(primaryPlistPath, { force: true });
94
+ } catch {
95
+ // ignore
96
+ }
97
+ try {
98
+ await rm(legacyPlistPath, { force: true });
99
+ } catch {
100
+ // ignore
101
+ }
102
+ console.log('[local] service uninstalled (plist removed)');
103
+ }
104
+
105
+ async function launchctlTry(args) {
106
+ try {
107
+ await runCapture('launchctl', args);
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ async function restartLaunchAgentBestEffort() {
115
+ const { plistPath, label } = getDefaultAutostartPaths();
116
+ if (!existsSync(plistPath)) {
117
+ throw new Error(`[local] LaunchAgent plist not found at ${plistPath}. Run: happys service:install (or happys bootstrap -- --autostart)`);
118
+ }
119
+ const uid = getUid();
120
+ if (uid == null) {
121
+ return false;
122
+ }
123
+ // Prefer kickstart -k to avoid overlapping stop/start windows (which can stop a freshly started daemon).
124
+ return await launchctlTry(['kickstart', '-k', `gui/${uid}/${label}`]);
125
+ }
126
+
127
+ async function startLaunchAgent({ persistent }) {
128
+ const { plistPath } = getDefaultAutostartPaths();
129
+ if (!existsSync(plistPath)) {
130
+ throw new Error(`[local] LaunchAgent plist not found at ${plistPath}. Run: happys service:install (or happys bootstrap -- --autostart)`);
131
+ }
132
+
133
+ const { label } = getDefaultAutostartPaths();
134
+
135
+ // Old-style (works on many systems)
136
+ if (persistent) {
137
+ if (await launchctlTry(['load', '-w', plistPath])) {
138
+ return;
139
+ }
140
+ } else {
141
+ if (await launchctlTry(['load', plistPath])) {
142
+ return;
143
+ }
144
+ }
145
+
146
+ // Modern fallback (more reliable on newer macOS)
147
+ const uid = getUid();
148
+ if (uid == null) {
149
+ throw new Error('[local] Unable to determine UID for launchctl bootstrap.');
150
+ }
151
+
152
+ // bootstrap requires the plist
153
+ await run('launchctl', ['bootstrap', `gui/${uid}`, plistPath]);
154
+ await launchctlTry(['enable', `gui/${uid}/${label}`]);
155
+ await launchctlTry(['kickstart', '-k', `gui/${uid}/${label}`]);
156
+ }
157
+
158
+ async function postStartDiagnostics() {
159
+ const rootDir = getRootDir(import.meta.url);
160
+ const internalUrl = getInternalUrl();
161
+
162
+ const cliHomeDir = process.env.HAPPY_LOCAL_CLI_HOME_DIR?.trim()
163
+ ? process.env.HAPPY_LOCAL_CLI_HOME_DIR.trim().replace(/^~(?=\/)/, homedir())
164
+ : join(getDefaultAutostartPaths().baseDir, 'cli');
165
+
166
+ const publicUrl =
167
+ process.env.HAPPY_LOCAL_SERVER_URL?.trim()
168
+ ? process.env.HAPPY_LOCAL_SERVER_URL.trim()
169
+ : internalUrl.replace('127.0.0.1', 'localhost');
170
+
171
+ const cliDir = join(rootDir, 'components', 'happy-cli');
172
+ const cliBin = join(cliDir, 'bin', 'happy.mjs');
173
+
174
+ const accessKey = join(cliHomeDir, 'access.key');
175
+ const stateFile = join(cliHomeDir, 'daemon.state.json');
176
+ const lockFile = join(cliHomeDir, 'daemon.state.json.lock');
177
+ const logsDir = join(cliHomeDir, 'logs');
178
+
179
+ const readLastLines = async (path, lines = 60) => {
180
+ try {
181
+ const raw = await readFile(path, 'utf-8');
182
+ const parts = raw.split('\n');
183
+ return parts.slice(Math.max(0, parts.length - lines)).join('\n');
184
+ } catch {
185
+ return null;
186
+ }
187
+ };
188
+
189
+ const latestDaemonLog = async () => {
190
+ try {
191
+ const ls = await runCapture('bash', ['-lc', `ls -1t "${logsDir}"/*-daemon.log 2>/dev/null | head -1 || true`]);
192
+ const p = ls.trim();
193
+ return p || null;
194
+ } catch {
195
+ return null;
196
+ }
197
+ };
198
+
199
+ const checkOnce = async () => {
200
+ // If state exists, trust it.
201
+ if (existsSync(stateFile)) {
202
+ try {
203
+ const raw = await readFile(stateFile, 'utf-8');
204
+ const s = JSON.parse(raw);
205
+ const pid = Number(s?.pid);
206
+ if (Number.isFinite(pid) && pid > 0) {
207
+ try {
208
+ process.kill(pid, 0);
209
+ return { ok: true, kind: 'running', pid };
210
+ } catch {
211
+ return { ok: false, kind: 'stale_state', pid };
212
+ }
213
+ }
214
+ } catch {
215
+ return { ok: false, kind: 'bad_state' };
216
+ }
217
+ }
218
+
219
+ // No state yet: check lock PID (daemon may be starting or waiting for auth).
220
+ if (existsSync(lockFile)) {
221
+ try {
222
+ const raw = (await readFile(lockFile, 'utf-8')).trim();
223
+ const pid = Number(raw);
224
+ if (Number.isFinite(pid) && pid > 0) {
225
+ try {
226
+ process.kill(pid, 0);
227
+ const logPath = await latestDaemonLog();
228
+ const tail = logPath ? await readLastLines(logPath, 120) : null;
229
+ if (tail && (tail.includes('No credentials found') || tail.includes('authentication flow') || tail.includes('Waiting for credentials'))) {
230
+ return { ok: false, kind: 'auth_required', pid, logPath };
231
+ }
232
+ return { ok: false, kind: 'starting', pid, logPath };
233
+ } catch {
234
+ return { ok: false, kind: 'stale_lock', pid };
235
+ }
236
+ }
237
+ } catch {
238
+ // ignore
239
+ }
240
+ }
241
+
242
+ return { ok: false, kind: 'stopped' };
243
+ };
244
+
245
+ // Wait briefly for the daemon to settle after a restart.
246
+ let res = await checkOnce();
247
+ for (let i = 0; i < 12 && !res.ok; i++) {
248
+ if (res.kind === 'auth_required') {
249
+ break;
250
+ }
251
+ await new Promise((r) => setTimeout(r, 650));
252
+ // eslint-disable-next-line no-await-in-loop
253
+ res = await checkOnce();
254
+ if (res.ok) {
255
+ break;
256
+ }
257
+ }
258
+
259
+ if (res.ok && res.kind === 'running') {
260
+ console.log(`[local] daemon: running (pid=${res.pid})`);
261
+ return;
262
+ }
263
+
264
+ // Not running: print actionable diagnostics (without referencing SwiftBar).
265
+ if (res.kind === 'starting') {
266
+ console.log(`[local] daemon: starting (pid=${res.pid ?? 'unknown'})`);
267
+ if (res.logPath) {
268
+ console.log(`[local] daemon log: ${res.logPath}`);
269
+ }
270
+ return;
271
+ }
272
+ if (!existsSync(accessKey)) {
273
+ console.log(`[local] daemon: not running (auth required; missing credentials at ${accessKey})`);
274
+ console.log('[local] authenticate for this stack home with:');
275
+ console.log(
276
+ getDefaultAutostartPaths().stackName === 'main'
277
+ ? 'happys auth login'
278
+ : `happys stack auth ${getDefaultAutostartPaths().stackName} login`
279
+ );
280
+ } else if (res.kind === 'auth_required') {
281
+ console.log(`[local] daemon: waiting for auth (pid=${res.pid})`);
282
+ console.log('[local] authenticate for this stack home with:');
283
+ console.log(
284
+ getDefaultAutostartPaths().stackName === 'main'
285
+ ? 'happys auth login'
286
+ : `happys stack auth ${getDefaultAutostartPaths().stackName} login`
287
+ );
288
+ } else {
289
+ console.log('[local] daemon: not running');
290
+ }
291
+
292
+ const logPath = res.logPath ? res.logPath : await latestDaemonLog();
293
+ if (logPath) {
294
+ const tail = await readLastLines(logPath, 80);
295
+ console.log(`[local] last daemon log: ${logPath}`);
296
+ if (tail) {
297
+ console.log('--- last 80 daemon log lines ---');
298
+ console.log(tail);
299
+ console.log('--- end ---');
300
+ }
301
+ } else {
302
+ console.log(`[local] daemon logs dir: ${logsDir}`);
303
+ }
304
+ }
305
+
306
+ async function stopLaunchAgent({ persistent }) {
307
+ const { plistPath } = getDefaultAutostartPaths();
308
+ if (!existsSync(plistPath)) {
309
+ throw new Error(`[local] LaunchAgent plist not found at ${plistPath}. Run: happys service:install (or happys bootstrap -- --autostart)`);
310
+ }
311
+
312
+ const { label } = getDefaultAutostartPaths();
313
+
314
+ // Old-style
315
+ if (persistent) {
316
+ if (await launchctlTry(['unload', '-w', plistPath])) {
317
+ return;
318
+ }
319
+ } else {
320
+ if (await launchctlTry(['unload', plistPath])) {
321
+ return;
322
+ }
323
+ }
324
+
325
+ // Modern fallback
326
+ const uid = getUid();
327
+ if (uid == null) {
328
+ return;
329
+ }
330
+ await launchctlTry(['bootout', `gui/${uid}/${label}`]);
331
+ }
332
+
333
+ async function waitForLaunchAgentStopped({ timeoutMs = 8000 } = {}) {
334
+ const { label } = getDefaultAutostartPaths();
335
+ const deadline = Date.now() + timeoutMs;
336
+ while (Date.now() < deadline) {
337
+ try {
338
+ const list = await runCapture('launchctl', ['list']);
339
+ const still = list.split('\n').some((l) => l.includes(`\t${label}`) || l.trim().endsWith(` ${label}`) || l.trim() === label);
340
+ if (!still) {
341
+ return true;
342
+ }
343
+ } catch {
344
+ // ignore
345
+ }
346
+ await new Promise((r) => setTimeout(r, 250));
347
+ }
348
+ return false;
349
+ }
350
+
351
+ async function showStatus() {
352
+ const { plistPath, stdoutPath, stderrPath, label } = getDefaultAutostartPaths();
353
+ const internalUrl = getInternalUrl();
354
+
355
+ console.log(`label: ${label}`);
356
+ console.log(`plist: ${plistPath} ${existsSync(plistPath) ? '(present)' : '(missing)'}`);
357
+ console.log(`logs:`);
358
+ console.log(` stdout: ${stdoutPath}`);
359
+ console.log(` stderr: ${stderrPath}`);
360
+
361
+ try {
362
+ const list = await runCapture('launchctl', ['list']);
363
+ const line = list
364
+ .split('\n')
365
+ .map((l) => l.trim())
366
+ .find((l) => l.endsWith(` ${label}`) || l === label || l.includes(`\t${label}`));
367
+ console.log(`launchctl: ${line ? line : '(not listed)'}`);
368
+ } catch {
369
+ console.log('launchctl: (unable to query)');
370
+ }
371
+
372
+ // Health can briefly be unavailable right after install/restart; retry a bit.
373
+ const deadline = Date.now() + 10_000;
374
+ while (true) {
375
+ try {
376
+ const res = await fetch(`${internalUrl}/health`, { method: 'GET' });
377
+ const body = await res.text();
378
+ console.log(`health: ${res.status} ${body.trim()}`);
379
+ break;
380
+ } catch {
381
+ if (Date.now() >= deadline) {
382
+ console.log(`health: unreachable (${internalUrl})`);
383
+ break;
384
+ }
385
+ await new Promise((r) => setTimeout(r, 500));
386
+ }
387
+ }
388
+ }
389
+
390
+ async function showLogs(lines = 120) {
391
+ const { stdoutPath, stderrPath } = getDefaultAutostartPaths();
392
+ // Use tail if available.
393
+ await run('tail', ['-n', String(lines), stderrPath, stdoutPath]);
394
+ }
395
+
396
+ async function tailLogs() {
397
+ const { stdoutPath, stderrPath } = getDefaultAutostartPaths();
398
+ const child = spawn('tail', ['-f', stderrPath, stdoutPath], { stdio: 'inherit' });
399
+ await new Promise((resolve) => child.on('exit', resolve));
400
+ }
401
+
402
+ async function main() {
403
+ if (process.platform !== 'darwin') {
404
+ throw new Error('[local] service commands are only supported on macOS (LaunchAgents).');
405
+ }
406
+
407
+ const argv = process.argv.slice(2);
408
+ const positionals = argv.filter((a) => !a.startsWith('--'));
409
+ const cmd = positionals[0] ?? 'help';
410
+ const json = wantsJson(argv);
411
+ if (wantsHelp(argv) || cmd === 'help') {
412
+ printResult({
413
+ json,
414
+ data: { commands: ['install', 'uninstall', 'status', 'start', 'stop', 'restart', 'enable', 'disable', 'logs', 'tail'] },
415
+ text: [
416
+ '[service] usage:',
417
+ ' happys service install|uninstall [--json]',
418
+ ' happys service status [--json]',
419
+ ' happys service start|stop|restart [--json]',
420
+ ' happys service enable|disable [--json]',
421
+ ' happys service logs [--json]',
422
+ ' happys service tail',
423
+ '',
424
+ 'legacy aliases:',
425
+ ' happys service:install|uninstall|status|start|stop|restart|enable|disable',
426
+ ' happys logs | happys logs:tail',
427
+ ].join('\n'),
428
+ });
429
+ return;
430
+ }
431
+ switch (cmd) {
432
+ case 'install':
433
+ await installService();
434
+ if (json) printResult({ json, data: { ok: true, action: 'install' } });
435
+ return;
436
+ case 'uninstall':
437
+ await uninstallService();
438
+ if (json) printResult({ json, data: { ok: true, action: 'uninstall' } });
439
+ return;
440
+ case 'status':
441
+ if (json) {
442
+ const { plistPath, stdoutPath, stderrPath, label } = getDefaultAutostartPaths();
443
+ let launchctlLine = null;
444
+ try {
445
+ const list = await runCapture('launchctl', ['list']);
446
+ launchctlLine =
447
+ list
448
+ .split('\n')
449
+ .map((l) => l.trim())
450
+ .find((l) => l.endsWith(` ${label}`) || l === label || l.includes(`\t${label}`)) ?? null;
451
+ } catch {
452
+ launchctlLine = null;
453
+ }
454
+
455
+ const internalUrl = getInternalUrl();
456
+ let health = null;
457
+ try {
458
+ const res = await fetch(`${internalUrl}/health`, { method: 'GET' });
459
+ const body = await res.text();
460
+ health = { ok: res.ok, status: res.status, body: body.trim() };
461
+ } catch {
462
+ health = { ok: false, status: null, body: null };
463
+ }
464
+
465
+ printResult({
466
+ json,
467
+ data: { label, plistPath, stdoutPath, stderrPath, internalUrl, launchctlLine, health },
468
+ });
469
+ } else {
470
+ await showStatus();
471
+ }
472
+ return;
473
+ case 'start':
474
+ await startLaunchAgent({ persistent: false });
475
+ await postStartDiagnostics();
476
+ if (json) printResult({ json, data: { ok: true, action: 'start' } });
477
+ return;
478
+ case 'stop':
479
+ await stopLaunchAgent({ persistent: false });
480
+ if (json) printResult({ json, data: { ok: true, action: 'stop' } });
481
+ return;
482
+ case 'restart':
483
+ if (!(await restartLaunchAgentBestEffort())) {
484
+ await stopLaunchAgent({ persistent: false });
485
+ await waitForLaunchAgentStopped();
486
+ await startLaunchAgent({ persistent: false });
487
+ }
488
+ await postStartDiagnostics();
489
+ if (json) printResult({ json, data: { ok: true, action: 'restart' } });
490
+ return;
491
+ case 'enable':
492
+ await startLaunchAgent({ persistent: true });
493
+ await postStartDiagnostics();
494
+ if (json) printResult({ json, data: { ok: true, action: 'enable' } });
495
+ return;
496
+ case 'disable':
497
+ await stopLaunchAgent({ persistent: true });
498
+ if (json) printResult({ json, data: { ok: true, action: 'disable' } });
499
+ return;
500
+ case 'logs':
501
+ await showLogs();
502
+ return;
503
+ case 'tail':
504
+ await tailLogs();
505
+ return;
506
+ default:
507
+ throw new Error(`[local] unknown command: ${cmd}`);
508
+ }
509
+ }
510
+
511
+ function isDirectExecution() {
512
+ try {
513
+ const selfPath = resolve(fileURLToPath(import.meta.url));
514
+ const argvPath = process.argv[1] ? resolve(process.argv[1]) : '';
515
+ return selfPath === argvPath;
516
+ } catch {
517
+ return false;
518
+ }
519
+ }
520
+
521
+ if (isDirectExecution()) {
522
+ main().catch((err) => {
523
+ console.error('[local] failed:', err);
524
+ process.exit(1);
525
+ });
526
+ }