cronfish 0.12.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +541 -0
  3. package/examples/.cronfish.json +17 -0
  4. package/examples/README.md +28 -0
  5. package/examples/disk-space.sh +25 -0
  6. package/examples/healthcheck.ts +27 -0
  7. package/examples/hello.md +18 -0
  8. package/examples/one-time/cleanup.ts +25 -0
  9. package/examples/one-time/reminder.md +22 -0
  10. package/package.json +44 -0
  11. package/src/alerts/dispatch.ts +134 -0
  12. package/src/alerts/index.ts +21 -0
  13. package/src/alerts/registry.ts +34 -0
  14. package/src/alerts/safe.ts +26 -0
  15. package/src/alerts/shell.ts +63 -0
  16. package/src/alerts/slack.ts +98 -0
  17. package/src/alerts/types.ts +34 -0
  18. package/src/cli.ts +1097 -0
  19. package/src/db.ts +365 -0
  20. package/src/frontmatter.ts +654 -0
  21. package/src/jobs.ts +536 -0
  22. package/src/models.ts +54 -0
  23. package/src/oneTime.ts +259 -0
  24. package/src/parsers/friendly.ts +53 -0
  25. package/src/platform/index.ts +14 -0
  26. package/src/platform/launchd.ts +564 -0
  27. package/src/prune.ts +155 -0
  28. package/src/result.ts +111 -0
  29. package/src/runner.ts +827 -0
  30. package/src/schedule.ts +188 -0
  31. package/src/state.ts +55 -0
  32. package/src/ts-shim.ts +44 -0
  33. package/src/ui/server.ts +469 -0
  34. package/src/watchdog.ts +201 -0
  35. package/templates/_examples/one-time/echo-at.md +10 -0
  36. package/templates/plist.template +35 -0
  37. package/ui/README.md +73 -0
  38. package/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  39. package/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  40. package/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  41. package/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  42. package/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  43. package/ui/dist/assets/index-DNE046Zp.js +9 -0
  44. package/ui/dist/assets/index-DmWTmu9X.css +2 -0
  45. package/ui/dist/favicon.svg +1 -0
  46. package/ui/dist/icons.svg +24 -0
  47. package/ui/dist/index.html +14 -0
@@ -0,0 +1,564 @@
1
+ // launchd backend. Everything that knows about plists, launchctl, and
2
+ // ~/Library/LaunchAgents lives here. cli.ts talks to this via the Platform
3
+ // interface (see ./index.ts).
4
+
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { homedir, networkInterfaces } from "node:os";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import { dispatchSchedule, type Dispatched } from "../schedule.ts";
16
+ import type { JobMeta } from "../jobs.ts";
17
+
18
+ const LAUNCH_AGENTS = join(homedir(), "Library", "LaunchAgents");
19
+ const TEMPLATE = resolve(
20
+ import.meta.dir,
21
+ "..",
22
+ "..",
23
+ "templates",
24
+ "plist.template",
25
+ );
26
+ const RUNNER_TS = resolve(import.meta.dir, "..", "runner.ts");
27
+
28
+ // PATH candidates baked into every plist so `/usr/bin/env bun` works under
29
+ // launchd's minimal default PATH. Resolved bun dir gets prepended at sync.
30
+ const DEFAULT_PATH_DIRS = [
31
+ "/opt/homebrew/bin",
32
+ "/usr/local/bin",
33
+ "/usr/bin",
34
+ "/bin",
35
+ ];
36
+
37
+ function gui(): string {
38
+ const uid = process.getuid?.() ?? 501;
39
+ return `gui/${uid}`;
40
+ }
41
+
42
+ function sh(cmd: string[]): { code: number; out: string; err: string } {
43
+ const proc = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "pipe" });
44
+ return {
45
+ code: proc.exitCode ?? 0,
46
+ out: new TextDecoder().decode(proc.stdout),
47
+ err: new TextDecoder().decode(proc.stderr),
48
+ };
49
+ }
50
+
51
+ // --- .env preservation ---
52
+ //
53
+ // Per spec: every plist's EnvironmentVariables block should carry the
54
+ // consumer's .env so .md/.sh runs (which don't go through bun's
55
+ // auto-loader) can still reach postgres / Linear / Slack tokens. Failure
56
+ // to parse .env is non-fatal — we surface to stderr and continue.
57
+
58
+ function parseDotEnv(text: string): Record<string, string> {
59
+ const out: Record<string, string> = {};
60
+ for (const raw of text.split(/\r?\n/)) {
61
+ const line = raw.replace(/^\s+/, "");
62
+ if (!line || line.startsWith("#")) continue;
63
+ const eq = line.indexOf("=");
64
+ if (eq < 0) continue;
65
+ const key = line.slice(0, eq).trim();
66
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
67
+ let val = line.slice(eq + 1);
68
+ // strip surrounding quotes (single or double) if balanced
69
+ const trimmed = val.trim();
70
+ if (
71
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
72
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
73
+ ) {
74
+ val = trimmed.slice(1, -1);
75
+ } else {
76
+ // strip inline `#` comments only for unquoted values
77
+ const hash = val.search(/\s#/);
78
+ if (hash >= 0) val = val.slice(0, hash);
79
+ val = val.trim();
80
+ }
81
+ out[key] = val;
82
+ }
83
+ return out;
84
+ }
85
+
86
+ function loadConsumerEnv(consumerRoot: string): Record<string, string> {
87
+ const path = join(consumerRoot, ".env");
88
+ if (!existsSync(path)) return {};
89
+ try {
90
+ return parseDotEnv(readFileSync(path, "utf-8"));
91
+ } catch (e) {
92
+ console.error(
93
+ `[cronfish] .env parse failed (${path}): ${(e as Error).message}`,
94
+ );
95
+ return {};
96
+ }
97
+ }
98
+
99
+ // Scope a parsed .env to a job's declared `env: [...]` allowlist. `undefined`
100
+ // (no declaration) → full env, backward compatible. An empty list → no
101
+ // secrets at all. A declared key absent from .env is silently skipped (and
102
+ // warned) rather than failing the sync — the job simply won't see it.
103
+ function scopeEnv(
104
+ env: Record<string, string>,
105
+ allow: string[] | undefined,
106
+ ): Record<string, string> {
107
+ if (allow === undefined) return env;
108
+ const out: Record<string, string> = {};
109
+ const missing: string[] = [];
110
+ for (const key of allow) {
111
+ if (key in env) out[key] = env[key];
112
+ else missing.push(key);
113
+ }
114
+ if (missing.length > 0) {
115
+ console.error(
116
+ `[cronfish] env: declares ${missing.join(", ")} not found in .env — skipped`,
117
+ );
118
+ }
119
+ return out;
120
+ }
121
+
122
+ function escapeXml(s: string): string {
123
+ return s
124
+ .replace(/&/g, "&amp;")
125
+ .replace(/</g, "&lt;")
126
+ .replace(/>/g, "&gt;");
127
+ }
128
+
129
+ function renderEnvBlock(env: Record<string, string>): string {
130
+ const lines: string[] = [];
131
+ for (const [k, v] of Object.entries(env)) {
132
+ lines.push(` <key>${escapeXml(k)}</key>`);
133
+ lines.push(` <string>${escapeXml(v)}</string>`);
134
+ }
135
+ return lines.join("\n");
136
+ }
137
+
138
+ function calendarBlock(cronExpr: string): string {
139
+ const [m, h, dom, mon, dow] = cronExpr.split(/\s+/);
140
+ const fields: [string, string][] = [
141
+ ["Minute", m],
142
+ ["Hour", h],
143
+ ["Day", dom],
144
+ ["Month", mon],
145
+ ["Weekday", dow],
146
+ ];
147
+ const inner = fields
148
+ .filter(([, v]) => v !== "*")
149
+ .map(
150
+ ([k, v]) =>
151
+ ` <key>${k}</key>\n <integer>${parseInt(v, 10)}</integer>`,
152
+ )
153
+ .join("\n");
154
+ return ` <key>StartCalendarInterval</key>\n <dict>\n${inner}\n </dict>`;
155
+ }
156
+
157
+ function intervalBlock(seconds: number): string {
158
+ return ` <key>StartInterval</key>\n <integer>${Math.floor(seconds)}</integer>`;
159
+ }
160
+
161
+ function scheduleBlock(d: Dispatched): string {
162
+ if (d.kind === "cron") return calendarBlock(d.expr);
163
+ if (d.kind === "seconds") return intervalBlock(d.value);
164
+ throw new Error(`schedule kind "${d.kind}" should not produce a plist`);
165
+ }
166
+
167
+ function findBunDir(bunPathOverride?: string): string | null {
168
+ // Resolve `bun` once at sync time; bake its directory into the plist's PATH.
169
+ // Priority: explicit bun_path → $BUN_INSTALL/bin → common install dirs → PATH.
170
+ if (bunPathOverride) {
171
+ if (!existsSync(bunPathOverride)) return null;
172
+ return dirname(bunPathOverride);
173
+ }
174
+ const bunInstall = process.env.BUN_INSTALL;
175
+ const candidates = [
176
+ bunInstall ? join(bunInstall, "bin") : null,
177
+ "/opt/homebrew/bin",
178
+ join(homedir(), ".bun", "bin"),
179
+ "/usr/local/bin",
180
+ ].filter((d): d is string => !!d);
181
+ for (const dir of candidates) {
182
+ if (existsSync(join(dir, "bun"))) return dir;
183
+ }
184
+ const { out, code } = sh(["/usr/bin/env", "which", "bun"]);
185
+ if (code === 0 && out.trim()) return dirname(out.trim());
186
+ return null;
187
+ }
188
+
189
+ export interface LaunchdRender {
190
+ label: string;
191
+ plistPath: string;
192
+ contents: string;
193
+ }
194
+
195
+ export interface LaunchdConfig {
196
+ bundlePrefix: string;
197
+ consumerRoot: string;
198
+ bunPath?: string;
199
+ }
200
+
201
+ // launchd labels can't contain `/`, so nested slugs (`email/triage`) get their
202
+ // separators flattened to `.` on the wire (`<prefix>.email.triage`). The
203
+ // reverse mapping isn't unique when a filename contains a literal `.`, so we
204
+ // only ever go slug → label; comparisons against the installed set happen in
205
+ // label space (see labelSuffixOf).
206
+ function slugToLabelComponent(slug: string): string {
207
+ return slug.split("/").join(".");
208
+ }
209
+
210
+ function labelFor(prefix: string, slug: string): string {
211
+ return `${prefix}.${slugToLabelComponent(slug)}`;
212
+ }
213
+
214
+ export function labelSuffixOf(slug: string): string {
215
+ return slugToLabelComponent(slug);
216
+ }
217
+
218
+ function plistPathFor(label: string): string {
219
+ return join(LAUNCH_AGENTS, `${label}.plist`);
220
+ }
221
+
222
+ function oneTimeScheduleBlock(job: JobMeta): { block: string; runAtLoad: boolean } {
223
+ if (job.runAtMs === undefined) {
224
+ throw new Error(`one-time job ${job.slug} missing runAtMs`);
225
+ }
226
+ const ageMs = Date.now() - job.runAtMs;
227
+ if (ageMs >= -1000) {
228
+ // Within grace — fire on bootstrap. Omit StartCalendarInterval entirely
229
+ // so launchd doesn't repeat on the next calendar match.
230
+ return { block: "", runAtLoad: true };
231
+ }
232
+ // Future — match the exact minute/hour/day/month. Annual recurrence is
233
+ // theoretically possible but the runner's flock + executed_at guard
234
+ // (and archive on success) prevents a second fire.
235
+ const d = new Date(job.runAtMs);
236
+ const inner =
237
+ ` <key>Minute</key>\n <integer>${d.getMinutes()}</integer>\n` +
238
+ ` <key>Hour</key>\n <integer>${d.getHours()}</integer>\n` +
239
+ ` <key>Day</key>\n <integer>${d.getDate()}</integer>\n` +
240
+ ` <key>Month</key>\n <integer>${d.getMonth() + 1}</integer>`;
241
+ return {
242
+ block: ` <key>StartCalendarInterval</key>\n <dict>\n${inner}\n </dict>`,
243
+ runAtLoad: false,
244
+ };
245
+ }
246
+
247
+ export function render(job: JobMeta, cfg: LaunchdConfig): LaunchdRender {
248
+ const bunDir = findBunDir(cfg.bunPath);
249
+ if (!bunDir) {
250
+ if (cfg.bunPath) {
251
+ throw new Error(
252
+ `.cronfish.json bun_path "${cfg.bunPath}" not found on disk.`,
253
+ );
254
+ }
255
+ throw new Error(
256
+ "bun not found in $BUN_INSTALL/bin, /opt/homebrew/bin, ~/.bun/bin, /usr/local/bin, or PATH. Install: https://bun.sh (or set bun_path in .cronfish.json)",
257
+ );
258
+ }
259
+ const pathEnv = [
260
+ bunDir,
261
+ ...DEFAULT_PATH_DIRS.filter((d) => d !== bunDir),
262
+ ].join(":");
263
+ const label = labelFor(cfg.bundlePrefix, job.slug);
264
+
265
+ let schedBlock: string;
266
+ let runAtLoad: boolean;
267
+ if (job.oneTime) {
268
+ const r = oneTimeScheduleBlock(job);
269
+ schedBlock = r.block;
270
+ runAtLoad = r.runAtLoad;
271
+ } else {
272
+ const d = dispatchSchedule(job.schedule);
273
+ if (d.kind === "manual") {
274
+ throw new Error(`render: ${job.slug} is manual — should not be installed`);
275
+ }
276
+ schedBlock = scheduleBlock(d);
277
+ runAtLoad = false;
278
+ }
279
+
280
+ // Build EnvironmentVariables block: required keys + consumer .env merged
281
+ // in. Required keys win over .env if a collision occurs (HOME etc.).
282
+ // When the job declares `env: [...]`, scope the injected .env to just those
283
+ // keys (scoped secrets); unset → whole .env (backward compatible).
284
+ const required: Record<string, string> = {
285
+ HOME: homedir(),
286
+ CRONFISH_CONSUMER_ROOT: cfg.consumerRoot,
287
+ PATH: pathEnv,
288
+ };
289
+ const consumerEnv = scopeEnv(loadConsumerEnv(cfg.consumerRoot), job.env);
290
+ const merged: Record<string, string> = { ...consumerEnv, ...required };
291
+ const envBlock = renderEnvBlock(merged);
292
+
293
+ const tmpl = readFileSync(TEMPLATE, "utf-8");
294
+ const contents = tmpl
295
+ .replace(/__LABEL__/g, label)
296
+ .replace(/__CONSUMER_ROOT__/g, cfg.consumerRoot)
297
+ .replace(/__JOB_PATH__/g, job.path)
298
+ .replace(/__SLUG__/g, job.slug)
299
+ .replace(/__RUNNER_TS__/g, RUNNER_TS)
300
+ .replace("__SCHEDULE_BLOCK__", schedBlock)
301
+ .replace("__RUN_AT_LOAD__", runAtLoad ? "true" : "false")
302
+ .replace("__ENV_BLOCK__", envBlock);
303
+ return { label, plistPath: plistPathFor(label), contents };
304
+ }
305
+
306
+ export function listInstalled(prefix: string): string[] {
307
+ if (!existsSync(LAUNCH_AGENTS)) return [];
308
+ const dot = `${prefix}.`;
309
+ return readdirSync(LAUNCH_AGENTS)
310
+ .filter((f) => f.startsWith(dot) && f.endsWith(".plist"))
311
+ .map((f) => f.replace(dot, "").replace(/\.plist$/, ""));
312
+ }
313
+
314
+ export function isLoaded(label: string): boolean {
315
+ const { code, out } = sh(["launchctl", "print", `${gui()}/${label}`]);
316
+ return code === 0 && out.includes(label);
317
+ }
318
+
319
+ function bootout(label: string): void {
320
+ const dest = plistPathFor(label);
321
+ if (existsSync(dest)) {
322
+ sh(["launchctl", "bootout", gui(), dest]);
323
+ } else {
324
+ sh(["launchctl", "bootout", `${gui()}/${label}`]);
325
+ }
326
+ }
327
+
328
+ function bootstrap(dest: string): void {
329
+ const { code, err, out } = sh(["launchctl", "bootstrap", gui(), dest]);
330
+ if (code !== 0) {
331
+ throw new Error(`launchctl bootstrap failed (${code}): ${err || out}`);
332
+ }
333
+ }
334
+
335
+ export interface InstallResult {
336
+ changed: boolean;
337
+ }
338
+
339
+ export function install(job: JobMeta, cfg: LaunchdConfig): InstallResult {
340
+ mkdirSync(LAUNCH_AGENTS, { recursive: true });
341
+ const r = render(job, cfg);
342
+ const prev = existsSync(r.plistPath)
343
+ ? readFileSync(r.plistPath, "utf-8")
344
+ : "";
345
+ if (prev === r.contents && isLoaded(r.label)) {
346
+ return { changed: false };
347
+ }
348
+ if (existsSync(r.plistPath)) bootout(r.label);
349
+ writeFileSync(r.plistPath, r.contents, "utf-8");
350
+ bootstrap(r.plistPath);
351
+ return { changed: true };
352
+ }
353
+
354
+ export function uninstall(prefix: string, slug: string): boolean {
355
+ const label = labelFor(prefix, slug);
356
+ const dest = plistPathFor(label);
357
+ const existed = existsSync(dest) || isLoaded(label);
358
+ if (existsSync(dest)) bootout(label);
359
+ if (existsSync(dest)) rmSync(dest);
360
+ return existed;
361
+ }
362
+
363
+ export function statusOf(prefix: string, slug: string): string {
364
+ const label = labelFor(prefix, slug);
365
+ const { code, out, err } = sh(["launchctl", "print", `${gui()}/${label}`]);
366
+ if (code !== 0) return "(not loaded)";
367
+ return out || err;
368
+ }
369
+
370
+ export function getLabel(prefix: string, slug: string): string {
371
+ return labelFor(prefix, slug);
372
+ }
373
+
374
+ // --- UI daemon (cronfish ui install/uninstall/status) ---
375
+
376
+ const CLI_TS = resolve(import.meta.dir, "..", "cli.ts");
377
+
378
+ function uiLabelFor(prefix: string): string {
379
+ return `${prefix}.ui`;
380
+ }
381
+
382
+ export interface UiDaemonConfig {
383
+ bundlePrefix: string;
384
+ consumerRoot: string;
385
+ port: number;
386
+ host: string;
387
+ bunPath?: string;
388
+ }
389
+
390
+ function lanIpv4(): string | null {
391
+ const ifs = networkInterfaces();
392
+ for (const addrs of Object.values(ifs)) {
393
+ if (!addrs) continue;
394
+ for (const a of addrs) {
395
+ if (a.family === "IPv4" && !a.internal) return a.address;
396
+ }
397
+ }
398
+ return null;
399
+ }
400
+
401
+ function urlFor(host: string, port: number): string {
402
+ if (host === "0.0.0.0" || host === "::") {
403
+ const ip = lanIpv4();
404
+ return ip ? `http://${ip}:${port}` : `http://127.0.0.1:${port}`;
405
+ }
406
+ return `http://${host}:${port}`;
407
+ }
408
+
409
+ function renderUi(cfg: UiDaemonConfig): LaunchdRender {
410
+ const bunDir = findBunDir(cfg.bunPath);
411
+ if (!bunDir) {
412
+ if (cfg.bunPath) {
413
+ throw new Error(
414
+ `.cronfish.json bun_path "${cfg.bunPath}" not found on disk.`,
415
+ );
416
+ }
417
+ throw new Error(
418
+ "bun not found in $BUN_INSTALL/bin, /opt/homebrew/bin, ~/.bun/bin, /usr/local/bin, or PATH. Install: https://bun.sh (or set bun_path in .cronfish.json)",
419
+ );
420
+ }
421
+ const pathEnv = [
422
+ bunDir,
423
+ ...DEFAULT_PATH_DIRS.filter((d) => d !== bunDir),
424
+ ].join(":");
425
+ const label = uiLabelFor(cfg.bundlePrefix);
426
+ const logPath = join(cfg.consumerRoot, ".cronfish", "logs", "ui.log");
427
+ const contents = `<?xml version="1.0" encoding="UTF-8"?>
428
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
429
+ <plist version="1.0">
430
+ <dict>
431
+ <key>Label</key>
432
+ <string>${label}</string>
433
+
434
+ <key>ProgramArguments</key>
435
+ <array>
436
+ <string>/usr/bin/env</string>
437
+ <string>bun</string>
438
+ <string>${CLI_TS}</string>
439
+ <string>ui</string>
440
+ <string>--port</string>
441
+ <string>${cfg.port}</string>
442
+ <string>--host</string>
443
+ <string>${cfg.host}</string>
444
+ <string>--no-open</string>
445
+ </array>
446
+
447
+ <key>WorkingDirectory</key>
448
+ <string>${cfg.consumerRoot}</string>
449
+
450
+ <key>RunAtLoad</key>
451
+ <true/>
452
+
453
+ <key>KeepAlive</key>
454
+ <true/>
455
+
456
+ <key>StandardOutPath</key>
457
+ <string>${logPath}</string>
458
+
459
+ <key>StandardErrorPath</key>
460
+ <string>${logPath}</string>
461
+
462
+ <key>EnvironmentVariables</key>
463
+ <dict>
464
+ <key>HOME</key>
465
+ <string>${homedir()}</string>
466
+ <key>CRONFISH_CONSUMER_ROOT</key>
467
+ <string>${cfg.consumerRoot}</string>
468
+ <key>PATH</key>
469
+ <string>${pathEnv}</string>
470
+ </dict>
471
+ </dict>
472
+ </plist>
473
+ `;
474
+ return { label, plistPath: plistPathFor(label), contents };
475
+ }
476
+
477
+ export interface UiInstallResult {
478
+ changed: boolean;
479
+ label: string;
480
+ plistPath: string;
481
+ logPath: string;
482
+ url: string;
483
+ }
484
+
485
+ export function installUi(cfg: UiDaemonConfig): UiInstallResult {
486
+ mkdirSync(LAUNCH_AGENTS, { recursive: true });
487
+ mkdirSync(join(cfg.consumerRoot, ".cronfish", "logs"), { recursive: true });
488
+ const r = renderUi(cfg);
489
+ const logPath = join(cfg.consumerRoot, ".cronfish", "logs", "ui.log");
490
+ const url = urlFor(cfg.host, cfg.port);
491
+ const prev = existsSync(r.plistPath)
492
+ ? readFileSync(r.plistPath, "utf-8")
493
+ : "";
494
+ if (prev === r.contents && isLoaded(r.label)) {
495
+ return {
496
+ changed: false,
497
+ label: r.label,
498
+ plistPath: r.plistPath,
499
+ logPath,
500
+ url,
501
+ };
502
+ }
503
+ if (existsSync(r.plistPath)) bootout(r.label);
504
+ writeFileSync(r.plistPath, r.contents, "utf-8");
505
+ bootstrap(r.plistPath);
506
+ return {
507
+ changed: true,
508
+ label: r.label,
509
+ plistPath: r.plistPath,
510
+ logPath,
511
+ url,
512
+ };
513
+ }
514
+
515
+ export function uninstallUi(prefix: string): {
516
+ existed: boolean;
517
+ label: string;
518
+ } {
519
+ const label = uiLabelFor(prefix);
520
+ const dest = plistPathFor(label);
521
+ const existed = existsSync(dest) || isLoaded(label);
522
+ if (existsSync(dest)) bootout(label);
523
+ if (existsSync(dest)) rmSync(dest);
524
+ return { existed, label };
525
+ }
526
+
527
+ export interface UiStatusInfo {
528
+ installed: boolean;
529
+ loaded: boolean;
530
+ label: string;
531
+ plistPath: string;
532
+ pid: number | null;
533
+ url: string | null;
534
+ }
535
+
536
+ function parsePlistHostPort(
537
+ plistPath: string,
538
+ ): { host: string; port: number } | null {
539
+ if (!existsSync(plistPath)) return null;
540
+ const xml = readFileSync(plistPath, "utf-8");
541
+ const args = [...xml.matchAll(/<string>([^<]*)<\/string>/g)].map((m) => m[1]);
542
+ const portIdx = args.indexOf("--port");
543
+ const hostIdx = args.indexOf("--host");
544
+ const portStr = portIdx >= 0 ? args[portIdx + 1] : null;
545
+ const host = hostIdx >= 0 ? args[hostIdx + 1] : "127.0.0.1";
546
+ if (!portStr || !/^\d+$/.test(portStr)) return null;
547
+ return { host, port: parseInt(portStr, 10) };
548
+ }
549
+
550
+ export function uiStatus(prefix: string): UiStatusInfo {
551
+ const label = uiLabelFor(prefix);
552
+ const plistPath = plistPathFor(label);
553
+ const installed = existsSync(plistPath);
554
+ const { code, out } = sh(["launchctl", "print", `${gui()}/${label}`]);
555
+ const loaded = code === 0 && out.includes(label);
556
+ let pid: number | null = null;
557
+ if (loaded) {
558
+ const m = out.match(/\bpid\s*=\s*(\d+)/);
559
+ if (m) pid = parseInt(m[1], 10);
560
+ }
561
+ const hp = parsePlistHostPort(plistPath);
562
+ const url = hp ? urlFor(hp.host, hp.port) : null;
563
+ return { installed, loaded, label, plistPath, pid, url };
564
+ }