@bli-cockpit/cli 0.2.47 → 0.2.49
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/dist/adapters/raw-evidence-attribution-gaps.js +133 -0
- package/dist/adapters/raw-evidence.js +360 -349
- package/dist/autostart-contract.js +79 -0
- package/dist/autostart-darwin-plist.js +265 -0
- package/dist/autostart-darwin.js +171 -0
- package/dist/autostart-windows-scripts.js +310 -0
- package/dist/autostart-windows-task-xml.js +260 -0
- package/dist/autostart-windows.js +237 -0
- package/dist/autostart-xml.js +23 -0
- package/dist/autostart.js +35 -1148
- package/dist/commands/agent-rules-command.js +55 -0
- package/dist/commands/agent-session-report.js +290 -0
- package/dist/commands/analyze.js +131 -0
- package/dist/commands/autostart-command.js +105 -0
- package/dist/commands/backfill.js +824 -551
- package/dist/commands/cli-io.js +13 -0
- package/dist/commands/heartbeat.js +18 -0
- package/dist/commands/install-receipts.js +34 -0
- package/dist/commands/jarvis.js +179 -3
- package/dist/commands/local-arg-values.js +169 -0
- package/dist/commands/local-args-collector.js +578 -0
- package/dist/commands/local-args-tower.js +870 -0
- package/dist/commands/local-args.js +8 -1549
- package/dist/commands/local-help.js +11 -3
- package/dist/commands/local.js +18 -1786
- package/dist/commands/login.js +53 -0
- package/dist/commands/logout.js +66 -0
- package/dist/commands/onboard-receipts.js +66 -0
- package/dist/commands/onboard-report.js +274 -0
- package/dist/commands/onboard.js +449 -0
- package/dist/commands/ops-render.js +36 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/serve.js +13 -0
- package/dist/commands/session-sync.js +513 -534
- package/dist/commands/settings-render.js +28 -0
- package/dist/commands/settings.js +66 -2
- package/dist/commands/start.js +47 -0
- package/dist/commands/sync-followups.js +203 -0
- package/dist/commands/sync.js +381 -0
- package/dist/dev-build.js +186 -0
- package/dist/tower-stream.js +20 -4
- package/package.json +2 -2
package/dist/commands/cli-io.js
CHANGED
|
@@ -10,6 +10,19 @@ export function writeRaw(stream, text) {
|
|
|
10
10
|
if (!text.endsWith("\n"))
|
|
11
11
|
stream.write("\n");
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Part of a line, and nothing else (BLI-3517).
|
|
15
|
+
*
|
|
16
|
+
* `writeRaw` finishes the line for you, which is right for a whole block of
|
|
17
|
+
* captured output and wrong for a sentence still being written: a streamed
|
|
18
|
+
* answer arrives as a dozen fragments and each one would land on its own row.
|
|
19
|
+
* This adds nothing at all; the caller ends the line when the thought ends.
|
|
20
|
+
*/
|
|
21
|
+
export function writeFragment(stream, text) {
|
|
22
|
+
if (!text)
|
|
23
|
+
return;
|
|
24
|
+
stream.write(text);
|
|
25
|
+
}
|
|
13
26
|
export function errorMessage(error) {
|
|
14
27
|
return error instanceof Error ? error.message : String(error);
|
|
15
28
|
}
|
|
@@ -25,6 +25,7 @@ import os from "node:os";
|
|
|
25
25
|
import path from "node:path";
|
|
26
26
|
import { COLLECTOR_HEARTBEAT_SCHEMA_VERSION, } from "@bli-cockpit/telemetry-core";
|
|
27
27
|
import { describeError } from "../health-detail.js";
|
|
28
|
+
import { shouldSuppressFleetReceipts, } from "../dev-build.js";
|
|
28
29
|
import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
|
|
29
30
|
const HEARTBEAT_TIMEOUT_MS = 5_000;
|
|
30
31
|
/**
|
|
@@ -112,6 +113,23 @@ export function buildCollectorHeartbeat(options) {
|
|
|
112
113
|
* `sync.err.log` alone when the dashboard says a device is quiet.
|
|
113
114
|
*/
|
|
114
115
|
export async function sendCollectorHeartbeatBestEffort(options) {
|
|
116
|
+
// BLI-3554. A checkout's heartbeat would move `last_seen_at` on a real fleet
|
|
117
|
+
// device row, so an agent's worktree could keep a genuinely dead machine
|
|
118
|
+
// looking alive to the BLI-3550 reader.
|
|
119
|
+
const suppression = shouldSuppressFleetReceipts({
|
|
120
|
+
dashboardUrl: options.dashboardUrl,
|
|
121
|
+
...(options.devBuildProbe ? { probe: options.devBuildProbe } : {}),
|
|
122
|
+
});
|
|
123
|
+
if (suppression.suppressed) {
|
|
124
|
+
console.error("[heartbeat] dev build; this tick is not checking in with the fleet", JSON.stringify({
|
|
125
|
+
reason: "receipt_suppressed:dev_build",
|
|
126
|
+
detected_by: suppression.reason,
|
|
127
|
+
root_count: options.roots.length,
|
|
128
|
+
sync_status: options.facts.status,
|
|
129
|
+
fix: "set COCKPIT_DEV=0 to heartbeat from a checkout deliberately",
|
|
130
|
+
}));
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
115
133
|
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
116
134
|
const session = await readLocalCollectorSessionFile(paths).catch(() => null);
|
|
117
135
|
if (!session ||
|
|
@@ -14,6 +14,7 @@ import { errorMessage, writeLine } from "./cli-io.js";
|
|
|
14
14
|
import { describeError, maskLocalIdentifiers, redactedHealthDetail, } from "../health-detail.js";
|
|
15
15
|
import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
|
|
16
16
|
import { redactSecretLikeContent } from "@bli-cockpit/telemetry-core";
|
|
17
|
+
import { shouldSuppressFleetReceipts, } from "../dev-build.js";
|
|
17
18
|
import { enqueueInstallEventEntry, readPendingInstallEventEntries, recordInstallEventAttemptFailure, removeInstallEventEntry, } from "../spool/install-event-outbox.js";
|
|
18
19
|
export function addInstallEvent(events, step, status, errorCode,
|
|
19
20
|
// BLI-2542: the bucket alone cannot be acted on. Callers that hold the reason
|
|
@@ -47,6 +48,25 @@ export function sanitizeInstallErrorCode(value) {
|
|
|
47
48
|
export async function reportInstallEventsBestEffort(options) {
|
|
48
49
|
if (options.events.length === 0)
|
|
49
50
|
return null;
|
|
51
|
+
// BLI-3554. Withheld BEFORE the outbox, not before the POST: an entry queued
|
|
52
|
+
// by a checkout survives in `~/.cockpit` and the next real scheduled tick
|
|
53
|
+
// would deliver it under the workspace version, which is exactly how 47
|
|
54
|
+
// sandbox receipts reached the production fleet table.
|
|
55
|
+
const suppression = shouldSuppressFleetReceipts({
|
|
56
|
+
dashboardUrl: options.dashboardUrl,
|
|
57
|
+
...(options.devBuildProbe ? { probe: options.devBuildProbe } : {}),
|
|
58
|
+
});
|
|
59
|
+
if (suppression.suppressed) {
|
|
60
|
+
console.error("[install-receipts] dev build; install receipts withheld from the fleet", JSON.stringify({
|
|
61
|
+
reason: "receipt_suppressed:dev_build",
|
|
62
|
+
detected_by: suppression.reason,
|
|
63
|
+
command: options.command,
|
|
64
|
+
event_count: options.events.length,
|
|
65
|
+
cli_version: LOCAL_COLLECTOR_VERSION,
|
|
66
|
+
fix: "set COCKPIT_DEV=0 to post receipts from a checkout deliberately",
|
|
67
|
+
}));
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
50
70
|
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
51
71
|
try {
|
|
52
72
|
await enqueueInstallEventEntry(paths, {
|
|
@@ -98,6 +118,7 @@ export async function reportInstallEventsBestEffort(options) {
|
|
|
98
118
|
}
|
|
99
119
|
const pending = (await readPendingInstallEventEntries(paths)).slice(0, 20);
|
|
100
120
|
const failures = [];
|
|
121
|
+
let delivered = 0;
|
|
101
122
|
let observedMinCliVersion = null;
|
|
102
123
|
for (let offset = 0; offset < pending.length; offset += 5) {
|
|
103
124
|
await Promise.all(pending.slice(offset, offset + 5).map(async (entry) => {
|
|
@@ -138,6 +159,7 @@ export async function reportInstallEventsBestEffort(options) {
|
|
|
138
159
|
observedMinCliVersion = receipt.min_cli_version.trim();
|
|
139
160
|
}
|
|
140
161
|
await removeInstallEventEntry(paths, entry.outbox_id);
|
|
162
|
+
delivered += 1;
|
|
141
163
|
}
|
|
142
164
|
catch (error) {
|
|
143
165
|
const failureReason = classifyInstallTelemetryError(error);
|
|
@@ -170,6 +192,18 @@ export async function reportInstallEventsBestEffort(options) {
|
|
|
170
192
|
}
|
|
171
193
|
}));
|
|
172
194
|
}
|
|
195
|
+
if (delivered > 0) {
|
|
196
|
+
// The success branch says so too (BLI-3554 / the logging contract): a log
|
|
197
|
+
// that only fires on failure cannot answer "did any receipt land at all
|
|
198
|
+
// today?", which is the question a suppressed dev build now provokes.
|
|
199
|
+
console.error("[install-receipts] install events delivered", JSON.stringify({
|
|
200
|
+
reason: "install_events_delivered",
|
|
201
|
+
command: options.command,
|
|
202
|
+
delivered_entries: delivered,
|
|
203
|
+
kept_for_retry: pending.length - delivered,
|
|
204
|
+
cli_version: LOCAL_COLLECTOR_VERSION,
|
|
205
|
+
}));
|
|
206
|
+
}
|
|
173
207
|
if (options.json && failures.length > 0) {
|
|
174
208
|
writeLine(options.io.stderr, `Install event telemetry queued for retry: ${[...new Set(failures)].join(",")}`);
|
|
175
209
|
}
|
package/dist/commands/jarvis.js
CHANGED
|
@@ -5,10 +5,67 @@
|
|
|
5
5
|
* existing paired device session, and every turn is executed by the dashboard
|
|
6
6
|
* through the same JARVIS runtime used by web chat and Slack.
|
|
7
7
|
*/
|
|
8
|
-
import { colorEnabled, dim, isInteractiveStdin, readLine, readPipedText, writeLine, } from "./cli-io.js";
|
|
8
|
+
import { colorEnabled, dim, isInteractiveStdin, readLine, readPipedText, writeFragment, writeLine, } from "./cli-io.js";
|
|
9
9
|
import { attachedFileRefusalSentence, readAttachedImage, } from "./jarvis-attachment.js";
|
|
10
10
|
import { loadPairedSession, towerFailureDetail, towerJsonRequest, towerRequest, } from "../tower-client.js";
|
|
11
11
|
import { readTowerTurn, streamFailureDetail, } from "../tower-stream.js";
|
|
12
|
+
/**
|
|
13
|
+
* A citation line's link, as the dashboard mints it (BLI-3570):
|
|
14
|
+
* `Source: BLI-1234 — Judge outage <https://linear.app/…>`. The words are the
|
|
15
|
+
* receipt; the link is not part of the sentence, so the terminal prints it
|
|
16
|
+
* underneath, dim, rather than dragging a 118-character URL through the answer.
|
|
17
|
+
*/
|
|
18
|
+
const CITATION_LINK = /^(\s*(?:Source|Receipt)s?:.*?)\s*<(https?:\/\/[^>\s]+)>\s*$/i;
|
|
19
|
+
/**
|
|
20
|
+
* The answer, one line at a time, so a receipt's link gets its own dim line.
|
|
21
|
+
* Every other line is printed exactly as it arrived — this never rewrites the
|
|
22
|
+
* words, only where the link sits.
|
|
23
|
+
*/
|
|
24
|
+
function writeReply(io, prefix, reply) {
|
|
25
|
+
const styled = colorEnabled(io);
|
|
26
|
+
const lines = reply.split("\n");
|
|
27
|
+
lines.forEach((line, index) => {
|
|
28
|
+
const head = index === 0 ? prefix : "";
|
|
29
|
+
const citation = line.match(CITATION_LINK);
|
|
30
|
+
if (!citation) {
|
|
31
|
+
writeLine(io.stdout, `${head}${line}`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
writeLine(io.stdout, `${head}${citation[1]}`);
|
|
35
|
+
writeLine(io.stdout, dim(` ${citation[2]}`, styled));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The same link rule, applied to text that CONTINUES a line already on screen
|
|
40
|
+
* (BLI-3517 + BLI-3570).
|
|
41
|
+
*
|
|
42
|
+
* When the answer streamed, the citation block arrives after the last token,
|
|
43
|
+
* so `settle` has a tail to print rather than a whole reply. The first fragment
|
|
44
|
+
* is deliberately never tested against `CITATION_LINK`: it is the rest of a
|
|
45
|
+
* sentence the person is already reading, not a line of its own, and a regex
|
|
46
|
+
* anchored at `^` would be matching against a boundary that is not there.
|
|
47
|
+
* Every COMPLETE line after it gets the dim link treatment as usual.
|
|
48
|
+
*
|
|
49
|
+
* A citation that was already streamed inline cannot be split — a terminal
|
|
50
|
+
* cannot unprint — and that is the honest limit of this, not a bug to chase.
|
|
51
|
+
*/
|
|
52
|
+
function writeReplyContinuation(io, tail) {
|
|
53
|
+
const styled = colorEnabled(io);
|
|
54
|
+
const [first = "", ...rest] = tail.split("\n");
|
|
55
|
+
writeFragment(io.stdout, first);
|
|
56
|
+
for (const line of rest) {
|
|
57
|
+
writeLine(io.stdout, "");
|
|
58
|
+
const citation = line.match(CITATION_LINK);
|
|
59
|
+
if (!citation) {
|
|
60
|
+
writeFragment(io.stdout, line);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
writeFragment(io.stdout, citation[1]);
|
|
64
|
+
writeLine(io.stdout, "");
|
|
65
|
+
writeFragment(io.stdout, dim(` ${citation[2]}`, styled));
|
|
66
|
+
}
|
|
67
|
+
writeLine(io.stdout, "");
|
|
68
|
+
}
|
|
12
69
|
/**
|
|
13
70
|
* The dashboard route caps a turn at 120s (`maxDuration = 120`). The client
|
|
14
71
|
* waits slightly longer so the server's own named failure wins the race
|
|
@@ -186,17 +243,22 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
186
243
|
}
|
|
187
244
|
// Live trace lines go to a person as they land, never to a `--json`
|
|
188
245
|
// consumer: that contract is exactly one object on stdout, so the events are
|
|
189
|
-
// buffered and folded into the final payload instead.
|
|
246
|
+
// buffered and folded into the final payload instead. The same rule governs
|
|
247
|
+
// the answer's own words (BLI-3517): `--json` stays exactly one object.
|
|
190
248
|
let liveTraceLines = 0;
|
|
249
|
+
const live = createLiveAnswer(context.command, io);
|
|
191
250
|
const turn = await readTowerTurn(requested.response, {
|
|
192
251
|
startedAt,
|
|
193
252
|
log,
|
|
194
253
|
onActivity: (event) => {
|
|
195
254
|
if (context.command.json)
|
|
196
255
|
return;
|
|
256
|
+
// A trace row must never land in the middle of a half-written sentence.
|
|
257
|
+
live.interrupt();
|
|
197
258
|
if (writeActivityLine(io, event))
|
|
198
259
|
liveTraceLines += 1;
|
|
199
260
|
},
|
|
261
|
+
onToken: (event) => live.token(event),
|
|
200
262
|
onNote: (reason, detail) => {
|
|
201
263
|
log(`[jarvis cli] stream note ${JSON.stringify({
|
|
202
264
|
reason,
|
|
@@ -205,12 +267,14 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
205
267
|
},
|
|
206
268
|
});
|
|
207
269
|
if (!turn.ok) {
|
|
270
|
+
live.abandon();
|
|
208
271
|
writeFailure(context.command, io, turn.reason, streamFailureDetail(turn.reason, turn.detail));
|
|
209
272
|
return 1;
|
|
210
273
|
}
|
|
211
274
|
const body = turn.final;
|
|
212
275
|
const httpStatus = typeof turn.final.httpStatus === "number" ? turn.final.httpStatus : requested.response.status;
|
|
213
276
|
if (httpStatus >= 400 || !body.ok || !body.reply) {
|
|
277
|
+
live.abandon();
|
|
214
278
|
const reason = body.error ?? body.reply ?? `http_${httpStatus}`;
|
|
215
279
|
writeFailure(context.command, io, "turn_failed", reason);
|
|
216
280
|
return 1;
|
|
@@ -231,7 +295,12 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
231
295
|
}
|
|
232
296
|
else {
|
|
233
297
|
const subject = body.subject?.displayName ? ` (${body.subject.displayName})` : "";
|
|
234
|
-
|
|
298
|
+
// BLI-3517: when the answer streamed, `settle` prints only what the live
|
|
299
|
+
// words did not already say — and says so out loud when the grounding gate
|
|
300
|
+
// took some of them back. When nothing streamed it prints the whole reply
|
|
301
|
+
// through `writeReply`, which is byte-for-byte what this command printed
|
|
302
|
+
// before, BLI-3570's dim link line included.
|
|
303
|
+
live.settle(body.reply, subject, body.revised === true);
|
|
235
304
|
// Only when nothing was drawn live — otherwise every tool would print twice.
|
|
236
305
|
if (liveTraceLines === 0)
|
|
237
306
|
writeTraceBlock(io, trace);
|
|
@@ -250,9 +319,116 @@ async function sendOneTurn(context, prompt, io) {
|
|
|
250
319
|
image_byte_size: attachment?.bytes.byteLength ?? null,
|
|
251
320
|
streamed: turn.streamed,
|
|
252
321
|
live_trace_lines: liveTraceLines,
|
|
322
|
+
// BLI-3517: whether the words arrived live, and whether the settled
|
|
323
|
+
// answer superseded them. `streamed_chars: 0` against a streaming
|
|
324
|
+
// dashboard means the answer landed all at once.
|
|
325
|
+
streamed_chars: turn.draft.length,
|
|
326
|
+
revised: body.revised === true,
|
|
253
327
|
})}`);
|
|
254
328
|
return 0;
|
|
255
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* The answer as it is written, in a terminal (BLI-3517).
|
|
332
|
+
*
|
|
333
|
+
* A terminal cannot unprint. That single fact decides everything here:
|
|
334
|
+
*
|
|
335
|
+
* - The words stream out under the usual `jarvis> ` prefix, printed the moment
|
|
336
|
+
* they arrive rather than after the whole turn (a measured 4-16 s of nothing
|
|
337
|
+
* before this ticket).
|
|
338
|
+
* - A `reset` — the server taking back a draft the model wrote before deciding
|
|
339
|
+
* to call a tool — cannot erase what is on screen, so it SAYS so on its own
|
|
340
|
+
* line and the replacement follows. Never a retracted sentence left standing
|
|
341
|
+
* with nothing marking it.
|
|
342
|
+
* - The settled reply is the authority. The grounding gate runs on the whole
|
|
343
|
+
* text once the stream ends, so the answer can grow (citations, a fallback
|
|
344
|
+
* notice) or change. A pure continuation is printed as the remainder; a
|
|
345
|
+
* genuine change is reprinted whole under a line saying it was revised.
|
|
346
|
+
* - `--json` never streams a fragment: that contract is exactly one object on
|
|
347
|
+
* stdout, and every method here is a no-op for it.
|
|
348
|
+
*/
|
|
349
|
+
function createLiveAnswer(command, io) {
|
|
350
|
+
const styled = colorEnabled(io);
|
|
351
|
+
const quiet = command.json === true;
|
|
352
|
+
let printed = "";
|
|
353
|
+
let opened = false;
|
|
354
|
+
const open = (subject) => {
|
|
355
|
+
if (opened)
|
|
356
|
+
return;
|
|
357
|
+
opened = true;
|
|
358
|
+
writeFragment(io.stdout, `jarvis${subject}> `);
|
|
359
|
+
};
|
|
360
|
+
return {
|
|
361
|
+
token(event) {
|
|
362
|
+
if (quiet)
|
|
363
|
+
return;
|
|
364
|
+
const text = event.text ?? "";
|
|
365
|
+
if (event.reset) {
|
|
366
|
+
if (printed) {
|
|
367
|
+
writeLine(io.stdout, "");
|
|
368
|
+
writeLine(io.stdout, dim(" — that draft was replaced —", styled));
|
|
369
|
+
opened = false;
|
|
370
|
+
}
|
|
371
|
+
printed = "";
|
|
372
|
+
}
|
|
373
|
+
if (!text)
|
|
374
|
+
return;
|
|
375
|
+
open("");
|
|
376
|
+
printed += text;
|
|
377
|
+
writeFragment(io.stdout, text);
|
|
378
|
+
},
|
|
379
|
+
interrupt() {
|
|
380
|
+
if (quiet || !opened)
|
|
381
|
+
return;
|
|
382
|
+
writeLine(io.stdout, "");
|
|
383
|
+
opened = false;
|
|
384
|
+
},
|
|
385
|
+
settle(reply, subject, revised) {
|
|
386
|
+
if (quiet)
|
|
387
|
+
return;
|
|
388
|
+
if (!printed) {
|
|
389
|
+
// Nothing streamed: a non-streaming dashboard, `--no-stream`, or a
|
|
390
|
+
// turn whose words never arrived. Exactly the pre-ticket line, dim
|
|
391
|
+
// citation link and all (BLI-3570).
|
|
392
|
+
writeReply(io, `jarvis${subject}> `, reply);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (reply === printed) {
|
|
396
|
+
if (opened)
|
|
397
|
+
writeLine(io.stdout, "");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (reply.startsWith(printed)) {
|
|
401
|
+
// The gate only ADDED — citations going back under the answer, a
|
|
402
|
+
// fallback notice. Print the tail and leave the words alone; the
|
|
403
|
+
// citation block is exactly the thing BLI-3570 puts its link under, and
|
|
404
|
+
// it is arriving here rather than in the stream.
|
|
405
|
+
if (!opened)
|
|
406
|
+
writeFragment(io.stdout, `jarvis${subject}> `);
|
|
407
|
+
writeReplyContinuation(io, reply.slice(printed.length));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (opened)
|
|
411
|
+
writeLine(io.stdout, "");
|
|
412
|
+
writeLine(io.stdout, dim(revised ? ` — ${ANSWER_REVISED_LINE} —` : " — corrected —", styled));
|
|
413
|
+
writeReply(io, `jarvis${subject}> `, reply);
|
|
414
|
+
},
|
|
415
|
+
abandon() {
|
|
416
|
+
// The turn died with words already on screen. End the line so the
|
|
417
|
+
// failure sentence does not run on from a half-written answer.
|
|
418
|
+
if (!quiet && opened)
|
|
419
|
+
writeLine(io.stdout, "");
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* What the terminal says when the grounding gate changed an answer it had
|
|
425
|
+
* already printed. The browser's own mark says the same thing in one word
|
|
426
|
+
* (`ANSWER_REVISED_NOTE`, `apps/dashboard/src/lib/webchat/tool-trace.ts`);
|
|
427
|
+
* this package cannot import from the dashboard, so the sentence is written
|
|
428
|
+
* here in the same register rather than shared through a dependency that does
|
|
429
|
+
* not exist.
|
|
430
|
+
*/
|
|
431
|
+
const ANSWER_REVISED_LINE = "revised: I checked that against its sources and changed what they did not back up";
|
|
256
432
|
/**
|
|
257
433
|
* The multipart body `/api/jarvis/cli` reads when a file is attached
|
|
258
434
|
* (BLI-3414) — same field names the request handler parses, mirroring the
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The value readers every subcommand's decision table is written in terms of:
|
|
3
|
+
* one pass over `argv` into named flags and positionals, then one small
|
|
4
|
+
* question per value — is it non-empty, is it a URL, is it an email, is it a
|
|
5
|
+
* positive integer, is it a value this schema accepts.
|
|
6
|
+
*
|
|
7
|
+
* Split out of commands/local-args.ts (BLI-3578) so each parser reads as its
|
|
8
|
+
* own decision table rather than as parsing mixed with coercion. Moved
|
|
9
|
+
* verbatim: no logic changed, and `normalizeUrl` is still re-exported from
|
|
10
|
+
* `./local-args.js` for the callers that had it there.
|
|
11
|
+
*
|
|
12
|
+
* `rejectServiceRoleLikeArgument` runs on every token, flag or positional, and
|
|
13
|
+
* is why a service-role credential cannot enter a collector command from the
|
|
14
|
+
* command line at all.
|
|
15
|
+
*/
|
|
16
|
+
const WORK_ROOT_FLAGS = ["--repo", "--workspace"];
|
|
17
|
+
export function parseNamedArgs(args, options) {
|
|
18
|
+
const allowed = new Set(options.allowedFlags);
|
|
19
|
+
const valueFlags = new Set(options.valueFlags);
|
|
20
|
+
const flags = new Map();
|
|
21
|
+
const flagValues = new Map();
|
|
22
|
+
const booleans = new Set();
|
|
23
|
+
const positionals = [];
|
|
24
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
25
|
+
const arg = args[index] ?? "";
|
|
26
|
+
rejectServiceRoleLikeArgument(arg);
|
|
27
|
+
if (!arg.startsWith("--")) {
|
|
28
|
+
positionals.push(arg);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const [flag, inlineValue] = arg.split("=", 2);
|
|
32
|
+
if (!allowed.has(flag))
|
|
33
|
+
throw new Error(`Unknown flag: ${flag}`);
|
|
34
|
+
if (valueFlags.has(flag)) {
|
|
35
|
+
const value = inlineValue ?? args[index + 1];
|
|
36
|
+
if (!value || value.startsWith("--")) {
|
|
37
|
+
throw new Error(`${flag} requires a value.`);
|
|
38
|
+
}
|
|
39
|
+
rejectServiceRoleLikeArgument(value);
|
|
40
|
+
flags.set(flag, value);
|
|
41
|
+
const existing = flagValues.get(flag) ?? [];
|
|
42
|
+
existing.push(value);
|
|
43
|
+
flagValues.set(flag, existing);
|
|
44
|
+
if (inlineValue === undefined)
|
|
45
|
+
index += 1;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
if (inlineValue !== undefined)
|
|
49
|
+
throw new Error(`${flag} does not accept a value.`);
|
|
50
|
+
booleans.add(flag);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { flags, flagValues, booleans, positionals };
|
|
54
|
+
}
|
|
55
|
+
export function workRootFlagValue(values) {
|
|
56
|
+
const provided = WORK_ROOT_FLAGS.filter((flag) => values.flags.has(flag));
|
|
57
|
+
if (provided.length === 0)
|
|
58
|
+
return undefined;
|
|
59
|
+
const uniqueValues = new Set(provided.map((flag) => values.flags.get(flag)).filter(Boolean));
|
|
60
|
+
if (uniqueValues.size > 1) {
|
|
61
|
+
throw new Error("--repo and --workspace must point to the same path.");
|
|
62
|
+
}
|
|
63
|
+
return values.flags.get("--workspace") ?? values.flags.get("--repo");
|
|
64
|
+
}
|
|
65
|
+
export function workRootFlagValues(values) {
|
|
66
|
+
const roots = [];
|
|
67
|
+
for (const flag of WORK_ROOT_FLAGS) {
|
|
68
|
+
roots.push(...(values.flagValues.get(flag) ?? []));
|
|
69
|
+
}
|
|
70
|
+
return roots;
|
|
71
|
+
}
|
|
72
|
+
export function optionalNonEmptyList(values) {
|
|
73
|
+
const filtered = values
|
|
74
|
+
.map((value) => optionalNonEmpty(value))
|
|
75
|
+
.filter((value) => Boolean(value));
|
|
76
|
+
return filtered.length > 0 ? filtered : undefined;
|
|
77
|
+
}
|
|
78
|
+
export function assertNoPositionals(positionals, command) {
|
|
79
|
+
if (positionals.length > 0) {
|
|
80
|
+
throw new Error(`${command} does not accept positional arguments.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function optionalNonEmpty(value) {
|
|
84
|
+
const trimmed = value?.trim();
|
|
85
|
+
return trimmed ? trimmed : undefined;
|
|
86
|
+
}
|
|
87
|
+
export function optionalUrl(value) {
|
|
88
|
+
return value === undefined ? undefined : normalizeUrl(value);
|
|
89
|
+
}
|
|
90
|
+
export function optionalEmail(value) {
|
|
91
|
+
const trimmed = value?.trim().toLowerCase();
|
|
92
|
+
if (!trimmed)
|
|
93
|
+
return undefined;
|
|
94
|
+
if (!trimmed.includes("@")) {
|
|
95
|
+
throw new Error("--email must be a valid email address.");
|
|
96
|
+
}
|
|
97
|
+
return trimmed;
|
|
98
|
+
}
|
|
99
|
+
export function optionalPositiveInteger(value, flag) {
|
|
100
|
+
if (value === undefined)
|
|
101
|
+
return undefined;
|
|
102
|
+
const parsed = Number(value);
|
|
103
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
104
|
+
throw new Error(`${flag} must be a positive integer.`);
|
|
105
|
+
}
|
|
106
|
+
return parsed;
|
|
107
|
+
}
|
|
108
|
+
export function optionalConfidence(value, flag) {
|
|
109
|
+
if (value === undefined)
|
|
110
|
+
return undefined;
|
|
111
|
+
const parsed = Number(value);
|
|
112
|
+
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
|
|
113
|
+
throw new Error(`${flag} must be a number between 0 and 1.`);
|
|
114
|
+
}
|
|
115
|
+
return parsed;
|
|
116
|
+
}
|
|
117
|
+
export function optionalSchemaValue(schema, value, flag) {
|
|
118
|
+
const trimmed = optionalNonEmpty(value);
|
|
119
|
+
if (!trimmed)
|
|
120
|
+
return undefined;
|
|
121
|
+
const parsed = schema.safeParse(trimmed);
|
|
122
|
+
if (!parsed.success || parsed.data === undefined) {
|
|
123
|
+
throw new Error(`${flag} has an unsupported value.`);
|
|
124
|
+
}
|
|
125
|
+
return parsed.data;
|
|
126
|
+
}
|
|
127
|
+
export function normalizeUrl(value) {
|
|
128
|
+
const trimmed = value.trim().replace(/\/+$/, "");
|
|
129
|
+
if (!trimmed)
|
|
130
|
+
throw new Error("URL value cannot be empty.");
|
|
131
|
+
return trimmed;
|
|
132
|
+
}
|
|
133
|
+
function rejectServiceRoleLikeArgument(value) {
|
|
134
|
+
if (!looksLikeServiceRoleSecret(value))
|
|
135
|
+
return;
|
|
136
|
+
throw new Error("Service-role credentials are not accepted by local collector commands.");
|
|
137
|
+
}
|
|
138
|
+
function looksLikeServiceRoleSecret(value) {
|
|
139
|
+
if (serviceCredentialNamePattern().test(value))
|
|
140
|
+
return true;
|
|
141
|
+
const parts = value.split(".");
|
|
142
|
+
if (parts.length !== 3)
|
|
143
|
+
return false;
|
|
144
|
+
try {
|
|
145
|
+
const payload = Buffer.from(base64UrlToBase64(parts[1] ?? ""), "base64").toString("utf8");
|
|
146
|
+
return serviceCredentialPayloadPattern().test(payload);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Deliberately silent (BLI-3238), and it must stay silent: this is the
|
|
150
|
+
// "is this argument a service-role JWT?" test, so a value that will not
|
|
151
|
+
// decode is simply not one. Anything logged here would be a fragment of a
|
|
152
|
+
// credential.
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function serviceCredentialNamePattern() {
|
|
157
|
+
return new RegExp([
|
|
158
|
+
["SUPABASE", "SERVICE", "ROLE", "KEY"].join("[_-]?"),
|
|
159
|
+
["service", "role"].join("[_-]?"),
|
|
160
|
+
].join("|"), "i");
|
|
161
|
+
}
|
|
162
|
+
function serviceCredentialPayloadPattern() {
|
|
163
|
+
const privilegedRole = ["service", "role"].join("_");
|
|
164
|
+
return new RegExp(`"role"\\s*:\\s*"${privilegedRole}"`);
|
|
165
|
+
}
|
|
166
|
+
function base64UrlToBase64(value) {
|
|
167
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
168
|
+
return `${normalized}${"=".repeat((4 - (normalized.length % 4)) % 4)}`;
|
|
169
|
+
}
|