@bli-cockpit/cli 0.1.8 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/dist/adapters/agent-image-evidence.js +97 -0
- package/dist/adapters/agent-image-records.js +147 -0
- package/dist/adapters/agent-image-validation.js +88 -0
- package/dist/adapters/raw-evidence.js +145 -16
- package/dist/autostart.js +197 -0
- package/dist/commands/local-args.js +383 -0
- package/dist/commands/local.js +135 -851
- package/dist/commands/session-sync.js +504 -0
- package/dist/evidence-upload-client.js +7 -0
- package/dist/local-state.js +1 -1
- package/dist/upload.js +152 -3
- package/package.json +2 -2
|
@@ -0,0 +1,197 @@
|
|
|
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
|
+
// launchd will not reliably watch a path that does not exist at load time, so
|
|
40
|
+
// only feed it the transcript dirs that are present right now. A missing dir
|
|
41
|
+
// is fine — the StartInterval floor still covers it.
|
|
42
|
+
const watchPaths = await existingWatchPaths(homeDir);
|
|
43
|
+
await mkdir(path.dirname(plistPath), { recursive: true });
|
|
44
|
+
await mkdir(paths.state_dir, { recursive: true });
|
|
45
|
+
await writeFile(plistPath, renderPlist({
|
|
46
|
+
workDir,
|
|
47
|
+
dashboardUrl,
|
|
48
|
+
intervalSeconds,
|
|
49
|
+
stdoutPath,
|
|
50
|
+
stderrPath,
|
|
51
|
+
watchPaths,
|
|
52
|
+
}), "utf8");
|
|
53
|
+
// Unload first so a changed plist is actually picked up; a not-yet-loaded
|
|
54
|
+
// agent makes unload fail harmlessly, so the error is ignored.
|
|
55
|
+
await options.exec("launchctl", ["unload", plistPath]).catch(() => undefined);
|
|
56
|
+
const load = await options.exec("launchctl", ["load", plistPath]);
|
|
57
|
+
const loaded = load.code === 0;
|
|
58
|
+
return {
|
|
59
|
+
status: "installed",
|
|
60
|
+
label: AUTOSTART_LABEL,
|
|
61
|
+
plist_path: plistPath,
|
|
62
|
+
loaded,
|
|
63
|
+
interval_seconds: intervalSeconds,
|
|
64
|
+
work_dir: workDir,
|
|
65
|
+
dashboard_url: dashboardUrl,
|
|
66
|
+
...(loaded
|
|
67
|
+
? {}
|
|
68
|
+
: {
|
|
69
|
+
message: `launchctl load exited ${load.code}: ${load.stderr.trim() || "unknown error"}`,
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Removes the LaunchAgent. Reports `absent` when there was nothing to remove so
|
|
75
|
+
* the command is safe to run repeatedly.
|
|
76
|
+
*/
|
|
77
|
+
export async function uninstallAutostartAgent(options) {
|
|
78
|
+
const platform = options.platform ?? process.platform;
|
|
79
|
+
if (platform !== "darwin")
|
|
80
|
+
return unsupportedResult(options.homeDir);
|
|
81
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
82
|
+
const plistPath = plistPathFor(homeDir);
|
|
83
|
+
if (!(await fileExists(plistPath))) {
|
|
84
|
+
return { status: "absent", label: AUTOSTART_LABEL, plist_path: plistPath };
|
|
85
|
+
}
|
|
86
|
+
await options.exec("launchctl", ["unload", plistPath]).catch(() => undefined);
|
|
87
|
+
await rm(plistPath, { force: true });
|
|
88
|
+
return { status: "uninstalled", label: AUTOSTART_LABEL, plist_path: plistPath };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Reports whether the agent is installed and loaded. `absent` when no plist
|
|
92
|
+
* exists; otherwise `launchctl list <label>` exit code distinguishes `loaded`
|
|
93
|
+
* (0) from `not_loaded` (non-zero).
|
|
94
|
+
*/
|
|
95
|
+
export async function autostartStatus(options) {
|
|
96
|
+
const platform = options.platform ?? process.platform;
|
|
97
|
+
if (platform !== "darwin")
|
|
98
|
+
return unsupportedResult(options.homeDir);
|
|
99
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
100
|
+
const plistPath = plistPathFor(homeDir);
|
|
101
|
+
if (!(await fileExists(plistPath))) {
|
|
102
|
+
return { status: "absent", label: AUTOSTART_LABEL, plist_path: plistPath };
|
|
103
|
+
}
|
|
104
|
+
const list = await options.exec("launchctl", ["list", AUTOSTART_LABEL]);
|
|
105
|
+
return {
|
|
106
|
+
status: list.code === 0 ? "loaded" : "not_loaded",
|
|
107
|
+
label: AUTOSTART_LABEL,
|
|
108
|
+
plist_path: plistPath,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The transcript directories whose changes should re-trigger a sync, in the
|
|
113
|
+
* order they appear in WatchPaths. Resolved from the same homeDir as the plist
|
|
114
|
+
* and log paths so `--home` redirects them together.
|
|
115
|
+
*/
|
|
116
|
+
function watchPathCandidates(homeDir) {
|
|
117
|
+
return [
|
|
118
|
+
path.join(homeDir, ".claude", "projects"),
|
|
119
|
+
path.join(homeDir, ".codex", "sessions"),
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
/** Subset of the transcript dirs that exist now (see the WatchPaths comment). */
|
|
123
|
+
async function existingWatchPaths(homeDir) {
|
|
124
|
+
const candidates = watchPathCandidates(homeDir);
|
|
125
|
+
const present = await Promise.all(candidates.map((dir) => fileExists(dir)));
|
|
126
|
+
return candidates.filter((_, i) => present[i]);
|
|
127
|
+
}
|
|
128
|
+
function renderPlist(options) {
|
|
129
|
+
// The repo path is shell-quoted because it lands inside a `/bin/zsh -lc "…"`
|
|
130
|
+
// command string; the whole command is then XML-escaped for the <string>.
|
|
131
|
+
const command = `npm exec --yes --package=@bli-cockpit/cli@latest -- cockpit sync --repo ${shellQuote(options.workDir)} --dashboard-url ${shellQuote(options.dashboardUrl)} --json`;
|
|
132
|
+
return [
|
|
133
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
134
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
135
|
+
'<plist version="1.0">',
|
|
136
|
+
"<dict>",
|
|
137
|
+
" <key>Label</key>",
|
|
138
|
+
` <string>${xmlEscape(AUTOSTART_LABEL)}</string>`,
|
|
139
|
+
" <key>ProgramArguments</key>",
|
|
140
|
+
" <array>",
|
|
141
|
+
" <string>/bin/zsh</string>",
|
|
142
|
+
" <string>-lc</string>",
|
|
143
|
+
` <string>${xmlEscape(command)}</string>`,
|
|
144
|
+
" </array>",
|
|
145
|
+
" <key>StartInterval</key>",
|
|
146
|
+
` <integer>${options.intervalSeconds}</integer>`,
|
|
147
|
+
" <key>RunAtLoad</key>",
|
|
148
|
+
" <true/>",
|
|
149
|
+
// WatchPaths makes a transcript write fire `cockpit sync` within seconds, so
|
|
150
|
+
// captures land near-real-time; StartInterval above is the safety-net floor.
|
|
151
|
+
// It fires often, but launchd single-flights the job, sync holds its own
|
|
152
|
+
// lock, and growing-transcript re-upload is damped — so cost stays bounded
|
|
153
|
+
// and no extra debounce is needed.
|
|
154
|
+
...watchPathsBlock(options.watchPaths),
|
|
155
|
+
" <key>EnvironmentVariables</key>",
|
|
156
|
+
" <dict>",
|
|
157
|
+
" <key>PATH</key>",
|
|
158
|
+
" <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>",
|
|
159
|
+
" </dict>",
|
|
160
|
+
" <key>StandardOutPath</key>",
|
|
161
|
+
` <string>${xmlEscape(options.stdoutPath)}</string>`,
|
|
162
|
+
" <key>StandardErrorPath</key>",
|
|
163
|
+
` <string>${xmlEscape(options.stderrPath)}</string>`,
|
|
164
|
+
"</dict>",
|
|
165
|
+
"</plist>",
|
|
166
|
+
"",
|
|
167
|
+
].join("\n");
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Renders the `<key>WatchPaths</key><array>…</array>` lines, or nothing when no
|
|
171
|
+
* transcript dir exists yet (an empty array would tell launchd to watch
|
|
172
|
+
* everything-and-nothing; omitting the key leaves StartInterval as the floor).
|
|
173
|
+
*/
|
|
174
|
+
function watchPathsBlock(watchPaths) {
|
|
175
|
+
if (watchPaths.length === 0)
|
|
176
|
+
return [];
|
|
177
|
+
return [
|
|
178
|
+
" <key>WatchPaths</key>",
|
|
179
|
+
" <array>",
|
|
180
|
+
...watchPaths.map((dir) => ` <string>${xmlEscape(dir)}</string>`),
|
|
181
|
+
" </array>",
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
function shellQuote(value) {
|
|
185
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
186
|
+
}
|
|
187
|
+
function xmlEscape(value) {
|
|
188
|
+
return value
|
|
189
|
+
.replace(/&/g, "&")
|
|
190
|
+
.replace(/</g, "<")
|
|
191
|
+
.replace(/>/g, ">")
|
|
192
|
+
.replace(/"/g, """)
|
|
193
|
+
.replace(/'/g, "'");
|
|
194
|
+
}
|
|
195
|
+
async function fileExists(filePath) {
|
|
196
|
+
return stat(filePath).then(() => true, () => false);
|
|
197
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// CLI argument parsing for the local cockpit collector. Split out of
|
|
2
|
+
// commands/local.ts so the command runners read as a table of contents and
|
|
3
|
+
// the parser (pure, independently testable) can change at its own rate.
|
|
4
|
+
//
|
|
5
|
+
// Behavior-preserving extraction: functions moved verbatim, no logic change.
|
|
6
|
+
import { DEFAULT_DASHBOARD_URL } from "../local-state.js";
|
|
7
|
+
export function parseLocalArgs(argv) {
|
|
8
|
+
const command = argv[0];
|
|
9
|
+
switch (command) {
|
|
10
|
+
case "onboard":
|
|
11
|
+
return parseOnboardArgs(argv.slice(1));
|
|
12
|
+
case "install":
|
|
13
|
+
return parseInstallArgs(argv.slice(1));
|
|
14
|
+
case "login":
|
|
15
|
+
case "pair":
|
|
16
|
+
return parseLoginArgs(argv.slice(1));
|
|
17
|
+
case "logout":
|
|
18
|
+
return parseLogoutArgs(argv.slice(1));
|
|
19
|
+
case "start":
|
|
20
|
+
return parseStartArgs(argv.slice(1));
|
|
21
|
+
case "sync":
|
|
22
|
+
return parseSyncArgs(argv.slice(1));
|
|
23
|
+
case "status":
|
|
24
|
+
return parseStatusArgs(argv.slice(1));
|
|
25
|
+
case "sessions":
|
|
26
|
+
return parseSessionsArgs(argv.slice(1));
|
|
27
|
+
case "serve":
|
|
28
|
+
return parseServeArgs(argv.slice(1));
|
|
29
|
+
case "autostart":
|
|
30
|
+
return parseAutostartArgs(argv.slice(1));
|
|
31
|
+
default:
|
|
32
|
+
throw new Error(`Unknown local command: ${command ?? ""}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function parseOnboardArgs(args) {
|
|
36
|
+
const values = parseNamedArgs(args, {
|
|
37
|
+
allowedFlags: [
|
|
38
|
+
"--home",
|
|
39
|
+
"--repo",
|
|
40
|
+
"--dashboard-url",
|
|
41
|
+
"--email",
|
|
42
|
+
"--device-name",
|
|
43
|
+
"--ticket",
|
|
44
|
+
"--branch",
|
|
45
|
+
"--json",
|
|
46
|
+
"--poll-interval-ms",
|
|
47
|
+
"--timeout-ms",
|
|
48
|
+
"--max-depth",
|
|
49
|
+
"--max-repos",
|
|
50
|
+
],
|
|
51
|
+
valueFlags: [
|
|
52
|
+
"--home",
|
|
53
|
+
"--repo",
|
|
54
|
+
"--dashboard-url",
|
|
55
|
+
"--email",
|
|
56
|
+
"--device-name",
|
|
57
|
+
"--ticket",
|
|
58
|
+
"--branch",
|
|
59
|
+
"--poll-interval-ms",
|
|
60
|
+
"--timeout-ms",
|
|
61
|
+
"--max-depth",
|
|
62
|
+
"--max-repos",
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
assertNoPositionals(values.positionals, "onboard");
|
|
66
|
+
return {
|
|
67
|
+
kind: "onboard",
|
|
68
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
69
|
+
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
70
|
+
dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
|
|
71
|
+
claimedOwnerEmail: optionalEmail(values.flags.get("--email")),
|
|
72
|
+
deviceName: optionalNonEmpty(values.flags.get("--device-name")),
|
|
73
|
+
activeTicketId: optionalNonEmpty(values.flags.get("--ticket")),
|
|
74
|
+
branch: optionalNonEmpty(values.flags.get("--branch")),
|
|
75
|
+
json: values.booleans.has("--json"),
|
|
76
|
+
pollIntervalMs: optionalPositiveInteger(values.flags.get("--poll-interval-ms"), "--poll-interval-ms"),
|
|
77
|
+
timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
|
|
78
|
+
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
79
|
+
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function parseInstallArgs(args) {
|
|
83
|
+
const values = parseNamedArgs(args, {
|
|
84
|
+
allowedFlags: [
|
|
85
|
+
"--home",
|
|
86
|
+
"--repo",
|
|
87
|
+
"--dashboard-url",
|
|
88
|
+
"--supabase-url",
|
|
89
|
+
"--json",
|
|
90
|
+
],
|
|
91
|
+
valueFlags: ["--home", "--repo", "--dashboard-url", "--supabase-url"],
|
|
92
|
+
});
|
|
93
|
+
assertNoPositionals(values.positionals, "install");
|
|
94
|
+
return {
|
|
95
|
+
kind: "install",
|
|
96
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
97
|
+
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
98
|
+
dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
|
|
99
|
+
supabaseUrl: optionalNonEmpty(values.flags.get("--supabase-url")),
|
|
100
|
+
json: values.booleans.has("--json"),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function parseLoginArgs(args) {
|
|
104
|
+
const values = parseNamedArgs(args, {
|
|
105
|
+
allowedFlags: [
|
|
106
|
+
"--home",
|
|
107
|
+
"--dashboard-url",
|
|
108
|
+
"--email",
|
|
109
|
+
"--device-name",
|
|
110
|
+
"--json",
|
|
111
|
+
"--poll-interval-ms",
|
|
112
|
+
"--timeout-ms",
|
|
113
|
+
],
|
|
114
|
+
valueFlags: [
|
|
115
|
+
"--home",
|
|
116
|
+
"--dashboard-url",
|
|
117
|
+
"--email",
|
|
118
|
+
"--device-name",
|
|
119
|
+
"--poll-interval-ms",
|
|
120
|
+
"--timeout-ms",
|
|
121
|
+
],
|
|
122
|
+
});
|
|
123
|
+
assertNoPositionals(values.positionals, "login");
|
|
124
|
+
return {
|
|
125
|
+
kind: "login",
|
|
126
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
127
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
128
|
+
claimedOwnerEmail: optionalEmail(values.flags.get("--email")),
|
|
129
|
+
deviceName: optionalNonEmpty(values.flags.get("--device-name")),
|
|
130
|
+
json: values.booleans.has("--json"),
|
|
131
|
+
pollIntervalMs: optionalPositiveInteger(values.flags.get("--poll-interval-ms"), "--poll-interval-ms"),
|
|
132
|
+
timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function parseLogoutArgs(args) {
|
|
136
|
+
const values = parseNamedArgs(args, {
|
|
137
|
+
allowedFlags: ["--home", "--json"],
|
|
138
|
+
valueFlags: ["--home"],
|
|
139
|
+
});
|
|
140
|
+
assertNoPositionals(values.positionals, "logout");
|
|
141
|
+
return {
|
|
142
|
+
kind: "logout",
|
|
143
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
144
|
+
json: values.booleans.has("--json"),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function parseStartArgs(args) {
|
|
148
|
+
const values = parseNamedArgs(args, {
|
|
149
|
+
allowedFlags: [
|
|
150
|
+
"--home",
|
|
151
|
+
"--repo",
|
|
152
|
+
"--branch",
|
|
153
|
+
"--ticket",
|
|
154
|
+
"--operator-id",
|
|
155
|
+
"--session-id",
|
|
156
|
+
"--json",
|
|
157
|
+
"--max-depth",
|
|
158
|
+
"--max-repos",
|
|
159
|
+
],
|
|
160
|
+
valueFlags: [
|
|
161
|
+
"--home",
|
|
162
|
+
"--repo",
|
|
163
|
+
"--branch",
|
|
164
|
+
"--ticket",
|
|
165
|
+
"--operator-id",
|
|
166
|
+
"--session-id",
|
|
167
|
+
"--max-depth",
|
|
168
|
+
"--max-repos",
|
|
169
|
+
],
|
|
170
|
+
});
|
|
171
|
+
assertNoPositionals(values.positionals, "start");
|
|
172
|
+
return {
|
|
173
|
+
kind: "start",
|
|
174
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
175
|
+
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
176
|
+
branch: optionalNonEmpty(values.flags.get("--branch")),
|
|
177
|
+
activeTicketId: optionalNonEmpty(values.flags.get("--ticket")),
|
|
178
|
+
operatorId: optionalNonEmpty(values.flags.get("--operator-id")),
|
|
179
|
+
sessionId: optionalNonEmpty(values.flags.get("--session-id")),
|
|
180
|
+
json: values.booleans.has("--json"),
|
|
181
|
+
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
182
|
+
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function parseSyncArgs(args) {
|
|
186
|
+
const values = parseNamedArgs(args, {
|
|
187
|
+
allowedFlags: ["--home", "--repo", "--dashboard-url", "--json", "--max-depth", "--max-repos"],
|
|
188
|
+
valueFlags: ["--home", "--repo", "--dashboard-url", "--max-depth", "--max-repos"],
|
|
189
|
+
});
|
|
190
|
+
assertNoPositionals(values.positionals, "sync");
|
|
191
|
+
return {
|
|
192
|
+
kind: "sync",
|
|
193
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
194
|
+
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
195
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
196
|
+
json: values.booleans.has("--json"),
|
|
197
|
+
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
198
|
+
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function parseStatusArgs(args) {
|
|
202
|
+
const values = parseNamedArgs(args, {
|
|
203
|
+
allowedFlags: ["--home", "--repo", "--json", "--max-depth", "--max-repos"],
|
|
204
|
+
valueFlags: ["--home", "--repo", "--max-depth", "--max-repos"],
|
|
205
|
+
});
|
|
206
|
+
assertNoPositionals(values.positionals, "status");
|
|
207
|
+
return {
|
|
208
|
+
kind: "status",
|
|
209
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
210
|
+
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
211
|
+
json: values.booleans.has("--json"),
|
|
212
|
+
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
213
|
+
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function parseSessionsArgs(args) {
|
|
217
|
+
const values = parseNamedArgs(args, {
|
|
218
|
+
allowedFlags: ["--home", "--repo", "--source", "--json", "--max-depth", "--max-repos"],
|
|
219
|
+
valueFlags: ["--home", "--repo", "--source", "--max-depth", "--max-repos"],
|
|
220
|
+
});
|
|
221
|
+
assertNoPositionals(values.positionals, "sessions");
|
|
222
|
+
const source = values.flags.get("--source");
|
|
223
|
+
if (source !== undefined && source !== "codex" && source !== "claude") {
|
|
224
|
+
throw new Error("--source must be 'codex' or 'claude'.");
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
kind: "sessions",
|
|
228
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
229
|
+
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
230
|
+
source,
|
|
231
|
+
json: values.booleans.has("--json"),
|
|
232
|
+
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
233
|
+
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
function parseServeArgs(args) {
|
|
237
|
+
const values = parseNamedArgs(args, {
|
|
238
|
+
allowedFlags: ["--home", "--repo", "--port"],
|
|
239
|
+
valueFlags: ["--home", "--repo", "--port"],
|
|
240
|
+
});
|
|
241
|
+
assertNoPositionals(values.positionals, "serve");
|
|
242
|
+
const port = Number(values.flags.get("--port") ?? "4174");
|
|
243
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
244
|
+
throw new Error("--port must be a TCP port between 1 and 65535.");
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
kind: "serve",
|
|
248
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
249
|
+
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
250
|
+
port,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function parseAutostartArgs(args) {
|
|
254
|
+
const values = parseNamedArgs(args, {
|
|
255
|
+
allowedFlags: [
|
|
256
|
+
"--home",
|
|
257
|
+
"--repo",
|
|
258
|
+
"--dashboard-url",
|
|
259
|
+
"--interval-seconds",
|
|
260
|
+
"--json",
|
|
261
|
+
],
|
|
262
|
+
valueFlags: ["--home", "--repo", "--dashboard-url", "--interval-seconds"],
|
|
263
|
+
});
|
|
264
|
+
if (values.positionals.length > 1) {
|
|
265
|
+
throw new Error("autostart accepts at most one action (install|uninstall|status).");
|
|
266
|
+
}
|
|
267
|
+
const action = values.positionals[0] ?? "install";
|
|
268
|
+
if (action !== "install" && action !== "uninstall" && action !== "status") {
|
|
269
|
+
throw new Error("autostart action must be install, uninstall, or status.");
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
kind: "autostart",
|
|
273
|
+
action,
|
|
274
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
275
|
+
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
276
|
+
dashboardUrl: normalizeUrl(values.flags.get("--dashboard-url") ?? DEFAULT_DASHBOARD_URL),
|
|
277
|
+
intervalSeconds: optionalPositiveInteger(values.flags.get("--interval-seconds"), "--interval-seconds") ?? 1800,
|
|
278
|
+
json: values.booleans.has("--json"),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function parseNamedArgs(args, options) {
|
|
282
|
+
const allowed = new Set(options.allowedFlags);
|
|
283
|
+
const valueFlags = new Set(options.valueFlags);
|
|
284
|
+
const flags = new Map();
|
|
285
|
+
const booleans = new Set();
|
|
286
|
+
const positionals = [];
|
|
287
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
288
|
+
const arg = args[index] ?? "";
|
|
289
|
+
rejectServiceRoleLikeArgument(arg);
|
|
290
|
+
if (!arg.startsWith("--")) {
|
|
291
|
+
positionals.push(arg);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const [flag, inlineValue] = arg.split("=", 2);
|
|
295
|
+
if (!allowed.has(flag))
|
|
296
|
+
throw new Error(`Unknown flag: ${flag}`);
|
|
297
|
+
if (valueFlags.has(flag)) {
|
|
298
|
+
const value = inlineValue ?? args[index + 1];
|
|
299
|
+
if (!value || value.startsWith("--")) {
|
|
300
|
+
throw new Error(`${flag} requires a value.`);
|
|
301
|
+
}
|
|
302
|
+
rejectServiceRoleLikeArgument(value);
|
|
303
|
+
flags.set(flag, value);
|
|
304
|
+
if (inlineValue === undefined)
|
|
305
|
+
index += 1;
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
if (inlineValue !== undefined)
|
|
309
|
+
throw new Error(`${flag} does not accept a value.`);
|
|
310
|
+
booleans.add(flag);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { flags, booleans, positionals };
|
|
314
|
+
}
|
|
315
|
+
function assertNoPositionals(positionals, command) {
|
|
316
|
+
if (positionals.length > 0) {
|
|
317
|
+
throw new Error(`${command} does not accept positional arguments.`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function optionalNonEmpty(value) {
|
|
321
|
+
const trimmed = value?.trim();
|
|
322
|
+
return trimmed ? trimmed : undefined;
|
|
323
|
+
}
|
|
324
|
+
function optionalUrl(value) {
|
|
325
|
+
return value === undefined ? undefined : normalizeUrl(value);
|
|
326
|
+
}
|
|
327
|
+
function optionalEmail(value) {
|
|
328
|
+
const trimmed = value?.trim().toLowerCase();
|
|
329
|
+
if (!trimmed)
|
|
330
|
+
return undefined;
|
|
331
|
+
if (!trimmed.includes("@")) {
|
|
332
|
+
throw new Error("--email must be a valid email address.");
|
|
333
|
+
}
|
|
334
|
+
return trimmed;
|
|
335
|
+
}
|
|
336
|
+
function optionalPositiveInteger(value, flag) {
|
|
337
|
+
if (value === undefined)
|
|
338
|
+
return undefined;
|
|
339
|
+
const parsed = Number(value);
|
|
340
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
341
|
+
throw new Error(`${flag} must be a positive integer.`);
|
|
342
|
+
}
|
|
343
|
+
return parsed;
|
|
344
|
+
}
|
|
345
|
+
export function normalizeUrl(value) {
|
|
346
|
+
const trimmed = value.trim().replace(/\/+$/, "");
|
|
347
|
+
if (!trimmed)
|
|
348
|
+
throw new Error("URL value cannot be empty.");
|
|
349
|
+
return trimmed;
|
|
350
|
+
}
|
|
351
|
+
function rejectServiceRoleLikeArgument(value) {
|
|
352
|
+
if (!looksLikeServiceRoleSecret(value))
|
|
353
|
+
return;
|
|
354
|
+
throw new Error("Service-role credentials are not accepted by local collector commands.");
|
|
355
|
+
}
|
|
356
|
+
function looksLikeServiceRoleSecret(value) {
|
|
357
|
+
if (serviceCredentialNamePattern().test(value))
|
|
358
|
+
return true;
|
|
359
|
+
const parts = value.split(".");
|
|
360
|
+
if (parts.length !== 3)
|
|
361
|
+
return false;
|
|
362
|
+
try {
|
|
363
|
+
const payload = Buffer.from(base64UrlToBase64(parts[1] ?? ""), "base64").toString("utf8");
|
|
364
|
+
return serviceCredentialPayloadPattern().test(payload);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function serviceCredentialNamePattern() {
|
|
371
|
+
return new RegExp([
|
|
372
|
+
["SUPABASE", "SERVICE", "ROLE", "KEY"].join("[_-]?"),
|
|
373
|
+
["service", "role"].join("[_-]?"),
|
|
374
|
+
].join("|"), "i");
|
|
375
|
+
}
|
|
376
|
+
function serviceCredentialPayloadPattern() {
|
|
377
|
+
const privilegedRole = ["service", "role"].join("_");
|
|
378
|
+
return new RegExp(`"role"\\s*:\\s*"${privilegedRole}"`);
|
|
379
|
+
}
|
|
380
|
+
function base64UrlToBase64(value) {
|
|
381
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
382
|
+
return `${normalized}${"=".repeat((4 - (normalized.length % 4)) % 4)}`;
|
|
383
|
+
}
|