@bli-cockpit/cli 0.1.8 → 0.1.9

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.
@@ -0,0 +1,154 @@
1
+ import { mkdir, rm, stat, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "./local-state.js";
5
+ /** launchd LaunchAgent label; matches docs/runbooks/cockpit-launchd-sync.md. */
6
+ export const AUTOSTART_LABEL = "com.bli.cockpit.sync";
7
+ const DEFAULT_INTERVAL_SECONDS = 1800;
8
+ const UNSUPPORTED_MESSAGE = "macOS-only for now; see docs/runbooks/cockpit-launchd-sync.md";
9
+ function plistPathFor(homeDir) {
10
+ return path.join(homeDir, "Library", "LaunchAgents", `${AUTOSTART_LABEL}.plist`);
11
+ }
12
+ function unsupportedResult(homeDir) {
13
+ return {
14
+ status: "unsupported",
15
+ label: AUTOSTART_LABEL,
16
+ plist_path: plistPathFor(homeDir ?? os.homedir()),
17
+ message: UNSUPPORTED_MESSAGE,
18
+ };
19
+ }
20
+ /**
21
+ * Installs (or refreshes) the launchd LaunchAgent that keeps `cockpit sync`
22
+ * running at login and every `intervalSeconds`. Mirrors the plist in
23
+ * docs/runbooks/cockpit-launchd-sync.md exactly, but writes resolved absolute
24
+ * log paths (launchd does not expand `$HOME`). The unload before load makes the
25
+ * install idempotent — re-running picks up a changed repo/url/interval.
26
+ */
27
+ export async function installAutostartAgent(options) {
28
+ const platform = options.platform ?? process.platform;
29
+ if (platform !== "darwin")
30
+ return unsupportedResult(options.homeDir);
31
+ const homeDir = options.homeDir ?? os.homedir();
32
+ const workDir = path.resolve(options.repoRoot ?? process.cwd());
33
+ const dashboardUrl = options.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
34
+ const intervalSeconds = options.intervalSeconds ?? DEFAULT_INTERVAL_SECONDS;
35
+ const paths = getCollectorRuntimePaths(homeDir);
36
+ const plistPath = plistPathFor(homeDir);
37
+ const stdoutPath = path.join(paths.state_dir, "sync.log");
38
+ const stderrPath = path.join(paths.state_dir, "sync.err.log");
39
+ await mkdir(path.dirname(plistPath), { recursive: true });
40
+ await mkdir(paths.state_dir, { recursive: true });
41
+ await writeFile(plistPath, renderPlist({
42
+ workDir,
43
+ dashboardUrl,
44
+ intervalSeconds,
45
+ stdoutPath,
46
+ stderrPath,
47
+ }), "utf8");
48
+ // Unload first so a changed plist is actually picked up; a not-yet-loaded
49
+ // agent makes unload fail harmlessly, so the error is ignored.
50
+ await options.exec("launchctl", ["unload", plistPath]).catch(() => undefined);
51
+ const load = await options.exec("launchctl", ["load", plistPath]);
52
+ const loaded = load.code === 0;
53
+ return {
54
+ status: "installed",
55
+ label: AUTOSTART_LABEL,
56
+ plist_path: plistPath,
57
+ loaded,
58
+ interval_seconds: intervalSeconds,
59
+ work_dir: workDir,
60
+ dashboard_url: dashboardUrl,
61
+ ...(loaded
62
+ ? {}
63
+ : {
64
+ message: `launchctl load exited ${load.code}: ${load.stderr.trim() || "unknown error"}`,
65
+ }),
66
+ };
67
+ }
68
+ /**
69
+ * Removes the LaunchAgent. Reports `absent` when there was nothing to remove so
70
+ * the command is safe to run repeatedly.
71
+ */
72
+ export async function uninstallAutostartAgent(options) {
73
+ const platform = options.platform ?? process.platform;
74
+ if (platform !== "darwin")
75
+ return unsupportedResult(options.homeDir);
76
+ const homeDir = options.homeDir ?? os.homedir();
77
+ const plistPath = plistPathFor(homeDir);
78
+ if (!(await fileExists(plistPath))) {
79
+ return { status: "absent", label: AUTOSTART_LABEL, plist_path: plistPath };
80
+ }
81
+ await options.exec("launchctl", ["unload", plistPath]).catch(() => undefined);
82
+ await rm(plistPath, { force: true });
83
+ return { status: "uninstalled", label: AUTOSTART_LABEL, plist_path: plistPath };
84
+ }
85
+ /**
86
+ * Reports whether the agent is installed and loaded. `absent` when no plist
87
+ * exists; otherwise `launchctl list <label>` exit code distinguishes `loaded`
88
+ * (0) from `not_loaded` (non-zero).
89
+ */
90
+ export async function autostartStatus(options) {
91
+ const platform = options.platform ?? process.platform;
92
+ if (platform !== "darwin")
93
+ return unsupportedResult(options.homeDir);
94
+ const homeDir = options.homeDir ?? os.homedir();
95
+ const plistPath = plistPathFor(homeDir);
96
+ if (!(await fileExists(plistPath))) {
97
+ return { status: "absent", label: AUTOSTART_LABEL, plist_path: plistPath };
98
+ }
99
+ const list = await options.exec("launchctl", ["list", AUTOSTART_LABEL]);
100
+ return {
101
+ status: list.code === 0 ? "loaded" : "not_loaded",
102
+ label: AUTOSTART_LABEL,
103
+ plist_path: plistPath,
104
+ };
105
+ }
106
+ function renderPlist(options) {
107
+ // The repo path is shell-quoted because it lands inside a `/bin/zsh -lc "…"`
108
+ // command string; the whole command is then XML-escaped for the <string>.
109
+ const command = `npm exec --yes --package=@bli-cockpit/cli@latest -- cockpit sync --repo ${shellQuote(options.workDir)} --dashboard-url ${shellQuote(options.dashboardUrl)} --json`;
110
+ return [
111
+ '<?xml version="1.0" encoding="UTF-8"?>',
112
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
113
+ '<plist version="1.0">',
114
+ "<dict>",
115
+ " <key>Label</key>",
116
+ ` <string>${xmlEscape(AUTOSTART_LABEL)}</string>`,
117
+ " <key>ProgramArguments</key>",
118
+ " <array>",
119
+ " <string>/bin/zsh</string>",
120
+ " <string>-lc</string>",
121
+ ` <string>${xmlEscape(command)}</string>`,
122
+ " </array>",
123
+ " <key>StartInterval</key>",
124
+ ` <integer>${options.intervalSeconds}</integer>`,
125
+ " <key>RunAtLoad</key>",
126
+ " <true/>",
127
+ " <key>EnvironmentVariables</key>",
128
+ " <dict>",
129
+ " <key>PATH</key>",
130
+ " <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>",
131
+ " </dict>",
132
+ " <key>StandardOutPath</key>",
133
+ ` <string>${xmlEscape(options.stdoutPath)}</string>`,
134
+ " <key>StandardErrorPath</key>",
135
+ ` <string>${xmlEscape(options.stderrPath)}</string>`,
136
+ "</dict>",
137
+ "</plist>",
138
+ "",
139
+ ].join("\n");
140
+ }
141
+ function shellQuote(value) {
142
+ return `'${value.replace(/'/g, "'\\''")}'`;
143
+ }
144
+ function xmlEscape(value) {
145
+ return value
146
+ .replace(/&/g, "&amp;")
147
+ .replace(/</g, "&lt;")
148
+ .replace(/>/g, "&gt;")
149
+ .replace(/"/g, "&quot;")
150
+ .replace(/'/g, "&apos;");
151
+ }
152
+ async function fileExists(filePath) {
153
+ return stat(filePath).then(() => true, () => false);
154
+ }
@@ -1,6 +1,8 @@
1
+ import { execFile } from "node:child_process";
1
2
  import os from "node:os";
2
3
  import path from "node:path";
3
4
  import { createCollectorServer } from "../server.js";
5
+ import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
4
6
  import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
5
7
  import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelope, } from "../upload.js";
6
8
  import { scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
@@ -21,6 +23,7 @@ export const rootCommandNames = new Set([
21
23
  "status",
22
24
  "sessions",
23
25
  "serve",
26
+ "autostart",
24
27
  ]);
25
28
  export async function runLocalCockpitCli(argv, io = defaultIo()) {
26
29
  if (isLocalHelpRequest(argv)) {
@@ -57,6 +60,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
57
60
  return await runSessions(command, io);
58
61
  case "serve":
59
62
  return await runServe(command, io);
63
+ case "autostart":
64
+ return await runAutostart(command, io);
60
65
  }
61
66
  }
62
67
  catch (error) {
@@ -78,6 +83,7 @@ export function localCommandHelp(command) {
78
83
  " cockpit status [--repo <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
79
84
  " cockpit sessions [--source codex|claude] [--repo <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
80
85
  " cockpit serve [--port <port>] [--repo <path>]",
86
+ " cockpit autostart [install|uninstall|status] [--repo <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
81
87
  ].join("\n");
82
88
  }
83
89
  function localSubcommandHelp(command) {
@@ -169,6 +175,17 @@ function localSubcommandHelp(command) {
169
175
  "Starts the local collector HTTP status server.",
170
176
  ],
171
177
  ],
178
+ [
179
+ "autostart",
180
+ [
181
+ "Usage: cockpit autostart [install|uninstall|status] [--repo <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
182
+ "",
183
+ "Installs a macOS launchd LaunchAgent that runs `cockpit sync` at login and",
184
+ "every 30 min (default), surviving reboots — so machines never drift to Stale.",
185
+ "Action defaults to `install`. `--repo` is the parent work folder to sync.",
186
+ "macOS-only for now; see docs/runbooks/cockpit-launchd-sync.md.",
187
+ ],
188
+ ],
172
189
  ]);
173
190
  return (helpByCommand.get(command) ?? [localCommandHelp()]).join("\n");
174
191
  }
@@ -200,6 +217,8 @@ function parseLocalArgs(argv) {
200
217
  return parseSessionsArgs(argv.slice(1));
201
218
  case "serve":
202
219
  return parseServeArgs(argv.slice(1));
220
+ case "autostart":
221
+ return parseAutostartArgs(argv.slice(1));
203
222
  default:
204
223
  throw new Error(`Unknown local command: ${command ?? ""}`);
205
224
  }
@@ -422,6 +441,34 @@ function parseServeArgs(args) {
422
441
  port,
423
442
  };
424
443
  }
444
+ function parseAutostartArgs(args) {
445
+ const values = parseNamedArgs(args, {
446
+ allowedFlags: [
447
+ "--home",
448
+ "--repo",
449
+ "--dashboard-url",
450
+ "--interval-seconds",
451
+ "--json",
452
+ ],
453
+ valueFlags: ["--home", "--repo", "--dashboard-url", "--interval-seconds"],
454
+ });
455
+ if (values.positionals.length > 1) {
456
+ throw new Error("autostart accepts at most one action (install|uninstall|status).");
457
+ }
458
+ const action = values.positionals[0] ?? "install";
459
+ if (action !== "install" && action !== "uninstall" && action !== "status") {
460
+ throw new Error("autostart action must be install, uninstall, or status.");
461
+ }
462
+ return {
463
+ kind: "autostart",
464
+ action,
465
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
466
+ repoRoot: optionalNonEmpty(values.flags.get("--repo")),
467
+ dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
468
+ intervalSeconds: optionalPositiveInteger(values.flags.get("--interval-seconds"), "--interval-seconds") ?? 1800,
469
+ json: values.booleans.has("--json"),
470
+ };
471
+ }
425
472
  function parseNamedArgs(args, options) {
426
473
  const allowed = new Set(options.allowedFlags);
427
474
  const valueFlags = new Set(options.valueFlags);
@@ -473,17 +520,14 @@ function isInteractiveStdin(io) {
473
520
  return Boolean(io.stdin.isTTY);
474
521
  }
475
522
  /**
476
- * Reads one line from stdin so `cockpit onboard` (no flags) can ask for the
477
- * dashboard email instead of forcing a `--email` flag. Only called when stdin
478
- * is a TTY and we're not in --json mode, so headless / piped / spawned runs
479
- * never block on input — they keep the existing behaviour (email optional; the
480
- * approving admin's account owns the device). An empty answer or a non-email
481
- * skips rather than failing, matching `optionalEmail`'s leniency.
523
+ * Reads one line from stdin after writing a prompt. Shared by the onboard email
524
+ * prompt and the autostart prompt; callers gate on `isInteractiveStdin` first so
525
+ * headless / piped / spawned runs never block on input.
482
526
  */
483
- async function promptOnboardEmail(io) {
484
- io.stdout.write("Dashboard email (press enter to skip): ");
527
+ async function readLine(io, prompt) {
528
+ io.stdout.write(prompt);
485
529
  io.stdin.setEncoding("utf8");
486
- const raw = await new Promise((resolve) => {
530
+ return new Promise((resolve) => {
487
531
  const onData = (chunk) => {
488
532
  io.stdin.removeListener("data", onData);
489
533
  io.stdin.pause();
@@ -492,6 +536,15 @@ async function promptOnboardEmail(io) {
492
536
  io.stdin.resume();
493
537
  io.stdin.on("data", onData);
494
538
  });
539
+ }
540
+ /**
541
+ * Asks for the dashboard email so `cockpit onboard` (no flags) need not force a
542
+ * `--email`. Only called when stdin is a TTY and not in --json mode. An empty
543
+ * answer or a non-email skips rather than failing, matching `optionalEmail`'s
544
+ * leniency (the approving admin's account then owns the device).
545
+ */
546
+ async function promptOnboardEmail(io) {
547
+ const raw = await readLine(io, "Dashboard email (press enter to skip): ");
495
548
  const answer = raw.trim().toLowerCase();
496
549
  if (!answer)
497
550
  return undefined;
@@ -501,6 +554,38 @@ async function promptOnboardEmail(io) {
501
554
  }
502
555
  return answer;
503
556
  }
557
+ /**
558
+ * After a successful onboard, offers to install the launchd autostart agent so a
559
+ * Mac mini keeps syncing without anyone re-running cockpit — this is the fix for
560
+ * interns drifting to Stale. Only in an interactive, non-JSON run with a real
561
+ * exec runner (`io.exec`): headless / piped / spawned onboards and tests that
562
+ * pass no exec skip it entirely. Declining leaves onboarding's success untouched.
563
+ */
564
+ async function maybeOfferAutostart(command, io) {
565
+ if (command.json || !isInteractiveStdin(io) || !io.exec)
566
+ return;
567
+ const answer = (await readLine(io, "Keep Cockpit syncing in the background, even after restart? [Y/n] "))
568
+ .trim()
569
+ .toLowerCase();
570
+ if (answer === "n" || answer === "no") {
571
+ writeLine(io.stdout, "Skipped background autostart. Run `cockpit autostart install` anytime.");
572
+ return;
573
+ }
574
+ const result = await installAutostartAgent({
575
+ homeDir: command.homeDir,
576
+ repoRoot: command.repoRoot,
577
+ dashboardUrl: command.dashboardUrl,
578
+ exec: io.exec,
579
+ });
580
+ if (result.status === "unsupported") {
581
+ writeLine(io.stdout, `Background autostart unsupported: ${result.message}`);
582
+ return;
583
+ }
584
+ writeLine(io.stdout, result.loaded
585
+ ? "Background autostart installed; Cockpit syncs at login and every 30 min."
586
+ : "Background autostart installed, but launchctl load reported a problem; check `cockpit autostart status`.");
587
+ writeLine(io.stdout, `Plist: ${result.plist_path}`);
588
+ }
504
589
  async function runOnboard(command, io) {
505
590
  let install = null;
506
591
  let pair = null;
@@ -574,6 +659,8 @@ async function runOnboard(command, io) {
574
659
  codex_sessions: multi.codex_sessions,
575
660
  }, null, 2));
576
661
  }
662
+ if (multi.ok)
663
+ await maybeOfferAutostart(command, io);
577
664
  return multi.ok ? 0 : 1;
578
665
  }
579
666
  const context = await startLocalWorkContext({
@@ -638,6 +725,7 @@ async function runOnboard(command, io) {
638
725
  writeLine(io.stdout, `Upload state: ${status.upload_state}`);
639
726
  writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
640
727
  writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
728
+ await maybeOfferAutostart(command, io);
641
729
  return 0;
642
730
  }
643
731
  catch (error) {
@@ -1685,6 +1773,61 @@ async function runServe(command, io) {
1685
1773
  });
1686
1774
  return 0;
1687
1775
  }
1776
+ async function runAutostart(command, io) {
1777
+ const exec = io.exec ?? defaultExec();
1778
+ const result = command.action === "install"
1779
+ ? await installAutostartAgent({
1780
+ homeDir: command.homeDir,
1781
+ repoRoot: command.repoRoot,
1782
+ dashboardUrl: command.dashboardUrl,
1783
+ intervalSeconds: command.intervalSeconds,
1784
+ exec,
1785
+ })
1786
+ : command.action === "uninstall"
1787
+ ? await uninstallAutostartAgent({ homeDir: command.homeDir, exec })
1788
+ : await autostartStatus({ homeDir: command.homeDir, exec });
1789
+ if (command.json) {
1790
+ writeLine(io.stdout, JSON.stringify(result, null, 2));
1791
+ return result.status === "unsupported" ? 1 : 0;
1792
+ }
1793
+ writeAutostartResult(io, result);
1794
+ return result.status === "unsupported" ? 1 : 0;
1795
+ }
1796
+ function writeAutostartResult(io, result) {
1797
+ switch (result.status) {
1798
+ case "installed":
1799
+ writeLine(io.stdout, result.loaded
1800
+ ? "Cockpit autostart installed and loaded."
1801
+ : "Cockpit autostart installed (launchctl load reported a problem).");
1802
+ writeLine(io.stdout, `Label: ${result.label}`);
1803
+ writeLine(io.stdout, `Plist: ${result.plist_path}`);
1804
+ writeLine(io.stdout, `Interval: every ${result.interval_seconds}s`);
1805
+ writeLine(io.stdout, `Repo: ${result.work_dir}`);
1806
+ writeLine(io.stdout, `Dashboard: ${result.dashboard_url}`);
1807
+ if (!result.loaded && result.message)
1808
+ writeLine(io.stderr, result.message);
1809
+ return;
1810
+ case "uninstalled":
1811
+ writeLine(io.stdout, "Cockpit autostart removed.");
1812
+ writeLine(io.stdout, `Plist: ${result.plist_path}`);
1813
+ return;
1814
+ case "absent":
1815
+ writeLine(io.stdout, "Cockpit autostart is not installed.");
1816
+ writeLine(io.stdout, `Plist: ${result.plist_path}`);
1817
+ return;
1818
+ case "loaded":
1819
+ writeLine(io.stdout, "Cockpit autostart is installed and loaded.");
1820
+ writeLine(io.stdout, `Plist: ${result.plist_path}`);
1821
+ return;
1822
+ case "not_loaded":
1823
+ writeLine(io.stdout, "Cockpit autostart plist exists but is not loaded; run `cockpit autostart install` to reload.");
1824
+ writeLine(io.stdout, `Plist: ${result.plist_path}`);
1825
+ return;
1826
+ case "unsupported":
1827
+ writeLine(io.stderr, `Autostart unsupported: ${result.message}`);
1828
+ return;
1829
+ }
1830
+ }
1688
1831
  function assertNoPositionals(positionals, command) {
1689
1832
  if (positionals.length > 0) {
1690
1833
  throw new Error(`${command} does not accept positional arguments.`);
@@ -1754,6 +1897,16 @@ function base64UrlToBase64(value) {
1754
1897
  const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
1755
1898
  return `${normalized}${"=".repeat((4 - (normalized.length % 4)) % 4)}`;
1756
1899
  }
1900
+ function defaultExec() {
1901
+ return (cmd, args) => new Promise((resolve) => {
1902
+ execFile(cmd, args, { encoding: "utf8" }, (err, stdout, stderr) => {
1903
+ // execFile's error `.code` is the exit code when numeric, but a string
1904
+ // (e.g. "ENOENT") for spawn failures — treat those as a generic 1.
1905
+ const code = err == null ? 0 : typeof err.code === "number" ? err.code : 1;
1906
+ resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
1907
+ });
1908
+ });
1909
+ }
1757
1910
  function defaultIo() {
1758
1911
  if (!globalThis.fetch) {
1759
1912
  throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
@@ -1764,6 +1917,7 @@ function defaultIo() {
1764
1917
  stderr: process.stderr,
1765
1918
  env: process.env,
1766
1919
  fetch: globalThis.fetch.bind(globalThis),
1920
+ exec: defaultExec(),
1767
1921
  };
1768
1922
  }
1769
1923
  function writeLine(stream, text) {
@@ -5,7 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { resolveRepoWorktreeIdentity, } from "./repo-identity.js";
7
7
  import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
8
- export const LOCAL_COLLECTOR_VERSION = "0.1.8";
8
+ export const LOCAL_COLLECTOR_VERSION = "0.1.9";
9
9
  export const DEFAULT_DASHBOARD_URL = "https://bli-cockpit-dashboard.vercel.app";
10
10
  export function getCollectorRuntimePaths(homeDir = os.homedir()) {
11
11
  const paths = getUserLocalCockpitPaths(homeDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {