@bli-cockpit/cli 0.2.238 → 0.2.239
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/commands/cal-direct.js +119 -0
- package/dist/commands/cal.js +26 -2
- package/dist/commands/docs.js +38 -6
- package/dist/commands/issue-versions.js +93 -0
- package/dist/commands/issue.js +5 -0
- package/dist/commands/local-args-tower-cal.js +19 -6
- package/dist/commands/local-args-tower-docs-msg.js +25 -7
- package/dist/commands/local-args-tower-msg.js +24 -6
- package/dist/commands/local-args-tower-setup.js +11 -4
- package/dist/commands/local-args-tower-work.js +25 -7
- package/dist/commands/local-help-commands-tower.js +16 -7
- package/dist/commands/local-help-commands.js +1 -1
- package/dist/commands/local-help.js +2 -2
- package/dist/commands/msg-read-line.js +32 -0
- package/dist/commands/msg.js +45 -3
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/setup.js +13 -0
- package/dist/commands/usage.js +3 -1
- package/dist/spool/local-spool-parse.js +13 -12
- package/package.json +4 -4
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit cal duplicate | batch | undo` (BLI-21296, calendar slice 4): the
|
|
3
|
+
* terminal in front of the same doors the browser's grid uses. `batch` reads
|
|
4
|
+
* its changes as JSON on stdin; every verb takes `--idempotency-key` so a retry
|
|
5
|
+
* writes once, and `--json` prints the door's answer untouched.
|
|
6
|
+
*/
|
|
7
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
8
|
+
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
9
|
+
const TAG = "[cal cli]";
|
|
10
|
+
const WRITE_DEADLINE_MS = 300_000;
|
|
11
|
+
export async function runCalDirect(command, door) {
|
|
12
|
+
if (command.action === "duplicate")
|
|
13
|
+
return duplicate(command, door);
|
|
14
|
+
if (command.action === "batch")
|
|
15
|
+
return batch(command, door);
|
|
16
|
+
return undo(command, door);
|
|
17
|
+
}
|
|
18
|
+
function keyed(command) {
|
|
19
|
+
return {
|
|
20
|
+
...(command.idempotencyKey ? { idempotency_key: command.idempotencyKey } : {}),
|
|
21
|
+
...(command.reason ? { reason: command.reason } : {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function writeReceiptLines(door, receipt) {
|
|
25
|
+
if (receipt.event?.account_name)
|
|
26
|
+
writeLine(door.io.stdout, `Account: ${receipt.event.account_name}.`);
|
|
27
|
+
if (receipt.event?.event_id)
|
|
28
|
+
writeLine(door.io.stdout, `Tower event: ${receipt.event.event_id}`);
|
|
29
|
+
if (receipt.change_id)
|
|
30
|
+
writeLine(door.io.stdout, `Change: ${receipt.change_id} (undo with: cockpit cal undo ${receipt.change_id})`);
|
|
31
|
+
else
|
|
32
|
+
writeLine(door.io.stdout, "Google took the change but Tower could not record it in the ledger, so there is no change id to undo.");
|
|
33
|
+
if (receipt.event?.version)
|
|
34
|
+
writeLine(door.io.stdout, `Version: ${receipt.event.version}`);
|
|
35
|
+
if (receipt.replayed)
|
|
36
|
+
writeLine(door.io.stdout, "This key was used before: nothing new was written; this is the first receipt.");
|
|
37
|
+
}
|
|
38
|
+
async function duplicate(command, door) {
|
|
39
|
+
const answer = await askAgentDoor(door, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
path: `/api/cal/events/${encodeURIComponent(command.subjectId ?? "")}/duplicate`,
|
|
42
|
+
label: "cal duplicate",
|
|
43
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
44
|
+
body: {
|
|
45
|
+
...(command.startsAt ? { starts_at: command.startsAt } : {}),
|
|
46
|
+
...(command.endsAt ? { ends_at: command.endsAt } : {}),
|
|
47
|
+
...(command.calendarId ? { calendar_id: command.calendarId } : {}),
|
|
48
|
+
...(command.accountName ? { account: command.accountName } : {}),
|
|
49
|
+
...(command.keepInvitees ? { keep_invitees: true } : {}),
|
|
50
|
+
...keyed(command),
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
if (!answer.ok)
|
|
54
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
55
|
+
if (door.json)
|
|
56
|
+
return emitAgentDoor(door, { ok: true, ...answer.body });
|
|
57
|
+
const receipt = answer.body;
|
|
58
|
+
writeLine(door.io.stdout, `Duplicated "${receipt.event?.title ?? "the event"}". ${receipt.invitees === "kept" ? "The same guests were invited (Google emailed them)." : "No guests were invited."}`);
|
|
59
|
+
writeReceiptLines(door, receipt);
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
async function readBatchBody(door) {
|
|
63
|
+
if (isInteractiveStdin(door.io))
|
|
64
|
+
return null;
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(await readPipedText(door.io.stdin, { maxChars: 200_000, overflowMessage: "The batch is too large." }));
|
|
67
|
+
if (Array.isArray(parsed))
|
|
68
|
+
return { changes: parsed };
|
|
69
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function batch(command, door) {
|
|
76
|
+
const body = await readBatchBody(door);
|
|
77
|
+
if (!body || !Array.isArray(body.changes)) {
|
|
78
|
+
return failAgentDoor(door, TAG, "invalid_body", 'Pipe JSON: {"changes":[{"event_id":"…","starts_at":"…","ends_at":"…","expected_version":"…"}]} (or a bare array). Up to 25 changes, each event once.');
|
|
79
|
+
}
|
|
80
|
+
const answer = await askAgentDoor(door, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
path: "/api/cal/events/batch",
|
|
83
|
+
label: "cal batch",
|
|
84
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
85
|
+
body: { ...body, ...keyed(command) },
|
|
86
|
+
});
|
|
87
|
+
if (!answer.ok)
|
|
88
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
89
|
+
const result = answer.body;
|
|
90
|
+
// A script must be able to tell "all of it" from "some of it" without parsing: exit 1 unless every item was written.
|
|
91
|
+
const code = result.outcome === "all_committed" ? 0 : 1;
|
|
92
|
+
if (door.json)
|
|
93
|
+
return emitAgentDoor(door, { ok: code === 0, ...result }, code);
|
|
94
|
+
for (const item of result.items) {
|
|
95
|
+
writeLine(door.io.stdout, `#${item.index + 1} ${item.event_id}: ${item.outcome}${item.reason ? ` (${item.reason})` : ""}${item.change_id ? ` change ${item.change_id}` : ""}`);
|
|
96
|
+
}
|
|
97
|
+
writeLine(door.io.stdout, `${result.committed} of ${result.items.length} changed (${result.outcome.replaceAll("_", " ")}).`);
|
|
98
|
+
if (result.unchanged.length > 0) {
|
|
99
|
+
writeLine(door.io.stdout, `Left as they were: ${result.unchanged.map((item) => `#${item.index + 1} ${item.event_id}`).join(", ")}.`);
|
|
100
|
+
}
|
|
101
|
+
return code;
|
|
102
|
+
}
|
|
103
|
+
async function undo(command, door) {
|
|
104
|
+
const answer = await askAgentDoor(door, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
path: "/api/cal/undo",
|
|
107
|
+
label: "cal undo",
|
|
108
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
109
|
+
body: { ...(command.subjectId ? { change_id: command.subjectId } : {}), ...keyed(command) },
|
|
110
|
+
});
|
|
111
|
+
if (!answer.ok)
|
|
112
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
113
|
+
if (door.json)
|
|
114
|
+
return emitAgentDoor(door, { ok: true, ...answer.body });
|
|
115
|
+
const receipt = answer.body;
|
|
116
|
+
writeLine(door.io.stdout, `Undid change ${receipt.undoes_change_id}. The earlier values are back (or the created event is gone); guests, if any, were told by Google.`);
|
|
117
|
+
writeReceiptLines(door, receipt);
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
package/dist/commands/cal.js
CHANGED
|
@@ -25,6 +25,7 @@ import { runCalAddIcal, runCalDetach, runCalShare, runCalSync } from "./cal-cale
|
|
|
25
25
|
import { runCalDetail } from "./cal-detail.js";
|
|
26
26
|
import { runCalOverlay } from "./cal-overlay.js";
|
|
27
27
|
import { runCalDelete } from "./cal-delete.js";
|
|
28
|
+
import { runCalDirect } from "./cal-direct.js";
|
|
28
29
|
import { runCalSyncStatus } from "./cal-sync-status.js";
|
|
29
30
|
import { writeLine } from "./cli-io.js";
|
|
30
31
|
import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
|
|
@@ -37,6 +38,10 @@ export async function runCal(command, io) {
|
|
|
37
38
|
case "event":
|
|
38
39
|
case "export":
|
|
39
40
|
return runCalDetail(command, door);
|
|
41
|
+
case "duplicate":
|
|
42
|
+
case "batch":
|
|
43
|
+
case "undo":
|
|
44
|
+
return runCalDirect(command, door);
|
|
40
45
|
case "conflicts":
|
|
41
46
|
case "calendar-change":
|
|
42
47
|
return runCalOverlay(command, door);
|
|
@@ -299,6 +304,8 @@ async function create(command, door) {
|
|
|
299
304
|
...(command.allDay ? { all_day: true } : {}),
|
|
300
305
|
...(command.attendees.length > 0 ? { attendees: command.attendees } : {}),
|
|
301
306
|
...(command.meet ? { meet: true } : {}),
|
|
307
|
+
...(command.idempotencyKey ? { idempotency_key: command.idempotencyKey } : {}),
|
|
308
|
+
...(command.reason ? { reason: command.reason } : {}),
|
|
302
309
|
time_zone: command.timeZone ?? localTimeZone(),
|
|
303
310
|
},
|
|
304
311
|
});
|
|
@@ -340,11 +347,28 @@ function meetingLinkText(event) {
|
|
|
340
347
|
}
|
|
341
348
|
async function changeEvent(command, door) {
|
|
342
349
|
const answer = await askAgentDoor(door, { path: `/api/cal/events/${encodeURIComponent(command.subjectId)}`, method: "PATCH", label: "cal change", timeoutMs: WRITE_DEADLINE_MS,
|
|
343
|
-
body: {
|
|
350
|
+
body: {
|
|
351
|
+
...(command.accountName ? { account: command.accountName } : {}),
|
|
352
|
+
...(command.title ? { title: command.title } : {}),
|
|
353
|
+
...(command.startsAt ? { starts_at: command.startsAt } : {}),
|
|
354
|
+
...(command.endsAt ? { ends_at: command.endsAt } : {}),
|
|
355
|
+
...(command.location ? { location: command.location } : {}),
|
|
356
|
+
// BLI-21296: all-day conversion either way, and the write controls.
|
|
357
|
+
...(command.allDay ? { all_day: true } : command.timed ? { all_day: false } : {}),
|
|
358
|
+
...(command.timeZone && (command.allDay || command.timed) ? { time_zone: command.timeZone } : {}),
|
|
359
|
+
...(command.expectedVersion ? { expected_version: command.expectedVersion } : {}),
|
|
360
|
+
...(command.idempotencyKey ? { idempotency_key: command.idempotencyKey } : {}),
|
|
361
|
+
...(command.reason ? { reason: command.reason } : {}),
|
|
362
|
+
} });
|
|
344
363
|
if (!answer.ok)
|
|
345
364
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
346
365
|
if (door.json)
|
|
347
366
|
return emitAgentDoor(door, { ok: true, ...answer.body });
|
|
348
|
-
|
|
367
|
+
const changed = answer.body;
|
|
368
|
+
writeLine(door.io.stdout, `Changed event in ${changed.event?.account_name ?? "its account"}.`);
|
|
369
|
+
if (changed.change_id)
|
|
370
|
+
writeLine(door.io.stdout, `Change: ${changed.change_id} (undo with: cockpit cal undo ${changed.change_id})`);
|
|
371
|
+
if (changed.version)
|
|
372
|
+
writeLine(door.io.stdout, `Version: ${changed.version}`);
|
|
349
373
|
return 0;
|
|
350
374
|
}
|
package/dist/commands/docs.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* `document_not_writable`, `title_too_long`, `content_too_long`,
|
|
28
28
|
* `circular_parent`, and so on — never rephrased here.
|
|
29
29
|
*/
|
|
30
|
+
import { randomUUID } from "node:crypto";
|
|
30
31
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
31
32
|
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
32
33
|
import { readDoc } from "./docs-read.js";
|
|
@@ -59,6 +60,8 @@ export async function runDocs(command, io) {
|
|
|
59
60
|
return syncStatusDocs(command, door);
|
|
60
61
|
case "export":
|
|
61
62
|
return exportDoc(command, door);
|
|
63
|
+
case "mutation-status":
|
|
64
|
+
return mutationStatusDocs(command, door);
|
|
62
65
|
}
|
|
63
66
|
}
|
|
64
67
|
/** `--parent`, `--query`, `--word`, `--limit`, `--bodies` (BLI-10450) as the door's own query parameters (BLI-3737). */
|
|
@@ -248,14 +251,41 @@ async function updateDoc(command, door) {
|
|
|
248
251
|
&& !command.clearParent) {
|
|
249
252
|
return failAgentDoor(door, TAG, "invalid_body", "docs update needs at least one of --title, --visibility, --parent/--clear-parent, or a body on --body-stdin/--file.");
|
|
250
253
|
}
|
|
251
|
-
|
|
254
|
+
// BLI-21298: every save carries a request id, so a lost response can be checked and a retry applies once.
|
|
255
|
+
const requestId = command.requestId ?? randomUUID();
|
|
256
|
+
writeLine(door.io.stderr, `${TAG} request ${JSON.stringify({ request_id: requestId, expected_revision: command.expectedRevision })}`);
|
|
257
|
+
const answer = await updateDocument(command, door, resolved.id, bodyMarkdown, requestId);
|
|
258
|
+
if (!answer.ok)
|
|
259
|
+
return refuseDocsWrite(door, answer, requestId);
|
|
260
|
+
const { document = {}, mutation } = answer.body;
|
|
261
|
+
writeLine(door.io.stderr, `${TAG} updated ${JSON.stringify({ document_id: document["id"] ?? resolved.id, request_id: requestId, revision: mutation?.["revision"] ?? document["revision"] })}`);
|
|
262
|
+
if (door.json)
|
|
263
|
+
return emitAgentDoor(door, { ok: true, document, mutation });
|
|
264
|
+
writeLine(door.io.stdout, `Updated ${String(document["title"] ?? "")} (${String(document["id"] ?? resolved.id)}), revision ${String(mutation?.["revision"] ?? document["revision"] ?? "?")}.`);
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
/** Says where the save stands, then fails with the door's own reason (a conflict names the current revision). */
|
|
268
|
+
function refuseDocsWrite(door, answer, requestId) {
|
|
269
|
+
const mutation = answer.body?.mutation;
|
|
270
|
+
writeLine(door.io.stderr, `${TAG} check the save with cockpit docs mutation-status ${requestId}; retry unchanged input with --request-id ${requestId}.`);
|
|
271
|
+
if (mutation?.current) {
|
|
272
|
+
writeLine(door.io.stderr, `${TAG} conflict ${JSON.stringify({ request_id: requestId, current_revision: mutation.current.revision ?? mutation.revision })}`);
|
|
273
|
+
}
|
|
274
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail, mutation ? { mutation } : {});
|
|
275
|
+
}
|
|
276
|
+
/** BLI-21298: reads only your own save receipt. A missing receipt is unknown_check_again, never "nothing changed". */
|
|
277
|
+
async function mutationStatusDocs(command, door) {
|
|
278
|
+
const answer = await askAgentDoor(door, {
|
|
279
|
+
path: `/api/docs/mutations/${encodeURIComponent(command.docRef ?? "")}`,
|
|
280
|
+
method: "GET",
|
|
281
|
+
label: "docs mutation status",
|
|
282
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
283
|
+
});
|
|
252
284
|
if (!answer.ok)
|
|
253
285
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
254
|
-
const document = answer.body.document ?? {};
|
|
255
|
-
writeLine(door.io.stderr, `${TAG} updated ${JSON.stringify({ document_id: document["id"] ?? resolved.id })}`);
|
|
256
286
|
if (door.json)
|
|
257
|
-
return emitAgentDoor(door, { ok: true,
|
|
258
|
-
writeLine(door.io.stdout,
|
|
287
|
+
return emitAgentDoor(door, { ok: true, ...answer.body });
|
|
288
|
+
writeLine(door.io.stdout, JSON.stringify(answer.body));
|
|
259
289
|
return 0;
|
|
260
290
|
}
|
|
261
291
|
/**
|
|
@@ -327,13 +357,15 @@ async function deleteOneDocument(door, ref, id) {
|
|
|
327
357
|
return { ref, ok: false, reason: answer.reason, detail: answer.detail };
|
|
328
358
|
return { ref, ok: true, id };
|
|
329
359
|
}
|
|
330
|
-
async function updateDocument(command, door, documentId, bodyMarkdown) {
|
|
360
|
+
async function updateDocument(command, door, documentId, bodyMarkdown, requestId) {
|
|
331
361
|
return askAgentDoor(door, {
|
|
332
362
|
path: `/api/docs/documents/${encodeURIComponent(documentId)}`,
|
|
333
363
|
method: "PATCH",
|
|
334
364
|
label: "docs update",
|
|
335
365
|
timeoutMs: WRITE_DEADLINE_MS,
|
|
336
366
|
body: {
|
|
367
|
+
request_id: requestId,
|
|
368
|
+
...(command.expectedRevision !== undefined ? { expected_revision: command.expectedRevision } : {}),
|
|
337
369
|
...(command.title !== undefined ? { title: command.title } : {}),
|
|
338
370
|
...(bodyMarkdown !== undefined ? { body_markdown: bodyMarkdown } : {}),
|
|
339
371
|
...(command.visibility !== undefined ? { visibility: command.visibility } : {}),
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* `cockpit ticket versions <id>` and `cockpit ticket restore <id> --version <n>`
|
|
4
|
+
* (BLI-21300): the saved descriptions of one ticket, and the write that puts an
|
|
5
|
+
* older one back as a NEW version. Same doors the browser uses
|
|
6
|
+
* (`GET .../versions`, `POST .../versions/restore`).
|
|
7
|
+
*
|
|
8
|
+
* `restore` takes the version NUMBER the person read in `versions`, looks up
|
|
9
|
+
* that version's id in the same list, and sends the id: the door never guesses
|
|
10
|
+
* "the previous one". A number the list does not carry is refused here with
|
|
11
|
+
* the numbers that do exist, before anything is written.
|
|
12
|
+
*/
|
|
13
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
14
|
+
import { writeLine } from "./cli-io.js";
|
|
15
|
+
import { READ_DEADLINE_MS, TAG, WRITE_DEADLINE_MS } from "./issue-contracts.js";
|
|
16
|
+
export async function listIssueVersions(command, door) {
|
|
17
|
+
const ref = command.issueRef ?? "";
|
|
18
|
+
const query = command.limit === undefined ? "" : `?limit=${command.limit}`;
|
|
19
|
+
const answer = await askAgentDoor(door, {
|
|
20
|
+
path: `/api/work/issues/${encodeURIComponent(ref)}/versions${query}`,
|
|
21
|
+
method: "GET",
|
|
22
|
+
label: "issue versions",
|
|
23
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
24
|
+
});
|
|
25
|
+
if (!answer.ok)
|
|
26
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
27
|
+
const versions = answer.body.versions ?? [];
|
|
28
|
+
console.error(`${TAG} versions ${JSON.stringify({ count: versions.length })}`);
|
|
29
|
+
if (door.json)
|
|
30
|
+
return emitAgentDoor(door, { ok: true, versions });
|
|
31
|
+
if (versions.length === 0) {
|
|
32
|
+
writeLine(door.io.stdout, `No saved description versions for ${ref}.`);
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
for (const row of versions) {
|
|
36
|
+
const from = row.restored_from_version ? ` (restored from ${row.restored_from_version})` : "";
|
|
37
|
+
writeLine(door.io.stdout, `v${row.version} ${row.id} ${row.created_at} ${row.source}${from} ${row.markdown.length} chars`);
|
|
38
|
+
}
|
|
39
|
+
writeLine(door.io.stdout, "");
|
|
40
|
+
writeLine(door.io.stdout, `${versions.length} version(s), newest first. Restore one with: cockpit ticket restore ${ref} --version <n>`);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
export async function restoreIssueVersion(command, door) {
|
|
44
|
+
const ref = command.issueRef ?? "";
|
|
45
|
+
const wanted = command.versionNumber;
|
|
46
|
+
const listed = await askAgentDoor(door, {
|
|
47
|
+
path: `/api/work/issues/${encodeURIComponent(ref)}/versions?limit=500`,
|
|
48
|
+
method: "GET",
|
|
49
|
+
label: "issue versions",
|
|
50
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
51
|
+
});
|
|
52
|
+
if (!listed.ok)
|
|
53
|
+
return failAgentDoor(door, TAG, listed.reason, listed.detail);
|
|
54
|
+
const versions = listed.body.versions ?? [];
|
|
55
|
+
const chosen = versions.find(row => row.version === wanted);
|
|
56
|
+
if (!chosen) {
|
|
57
|
+
const have = versions.map(row => row.version).join(", ") || "none";
|
|
58
|
+
console.error(`${TAG} restore refused ${JSON.stringify({ reason: "version_not_found", requested: wanted ?? null, available: versions.length })}`);
|
|
59
|
+
return failAgentDoor(door, TAG, "version_not_found", `${ref} has no version ${wanted}. Versions: ${have}.`);
|
|
60
|
+
}
|
|
61
|
+
const requestId = command.requestId ?? randomUUID();
|
|
62
|
+
writeLine(door.io.stderr, `${TAG} request ${JSON.stringify({ request_id: requestId, expected_revision: command.expectedRevision, version: chosen.version })}`);
|
|
63
|
+
let answer;
|
|
64
|
+
try {
|
|
65
|
+
answer = await askAgentDoor(door, {
|
|
66
|
+
path: `/api/work/issues/${encodeURIComponent(ref)}/versions/restore`,
|
|
67
|
+
method: "POST",
|
|
68
|
+
label: "issue restore",
|
|
69
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
70
|
+
body: { request_id: requestId, expected_revision: command.expectedRevision, version_id: chosen.id },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
console.error(`${TAG} response lost`, JSON.stringify({ reason: "mutation_unknown" }));
|
|
75
|
+
writeLine(door.io.stderr, `${TAG} check local commit with cockpit ticket mutation-status ${requestId}; retry unchanged input with --request-id ${requestId}.`);
|
|
76
|
+
return failAgentDoor(door, TAG, "mutation_unknown", "Result unknown. Check this request ID before retrying with identical input.");
|
|
77
|
+
}
|
|
78
|
+
if (!answer.ok) {
|
|
79
|
+
writeLine(door.io.stderr, `${TAG} check local commit with cockpit ticket mutation-status ${requestId}; retry unchanged input with --request-id ${requestId}.`);
|
|
80
|
+
const mutation = answer.body?.mutation ?? { request_id: requestId, outcome: "unknown_check_again" };
|
|
81
|
+
if (door.json)
|
|
82
|
+
return emitAgentDoor(door, { ok: false, error: answer.reason, detail: answer.detail, mutation }, 1);
|
|
83
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
84
|
+
}
|
|
85
|
+
const body = answer.body;
|
|
86
|
+
const newVersion = body.mutation?.description_version;
|
|
87
|
+
writeLine(door.io.stderr, `${TAG} restored ${JSON.stringify({ restored_version: chosen.version, new_version: newVersion ?? null })}`);
|
|
88
|
+
writeLine(door.io.stderr, `${TAG} local commit receipt ${JSON.stringify(body.mutation ?? { outcome: "unknown_check_again", request_id: requestId })}`);
|
|
89
|
+
if (door.json)
|
|
90
|
+
return emitAgentDoor(door, { ok: true, issue: body.issue, mutation: body.mutation });
|
|
91
|
+
writeLine(door.io.stdout, `Restored version ${chosen.version} of ${ref} as new version ${newVersion ?? "?"}.`);
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
package/dist/commands/issue.js
CHANGED
|
@@ -33,6 +33,7 @@ import { readTicketBoard } from "./tickets-board.js";
|
|
|
33
33
|
import { showIssue } from "./issue-show.js";
|
|
34
34
|
import { READ_DEADLINE_MS, TAG, resolveProjectId, sizeForEstimate, withSizeLabel, } from "./issue-contracts.js";
|
|
35
35
|
import { commentIssue, createIssue, moveIssue, updateIssue } from "./issue-write.js";
|
|
36
|
+
import { listIssueVersions, restoreIssueVersion } from "./issue-versions.js";
|
|
36
37
|
export async function runIssue(command, io) {
|
|
37
38
|
const door = await openAgentDoor("issue", command, io);
|
|
38
39
|
switch (command.action) {
|
|
@@ -62,6 +63,10 @@ export async function runIssue(command, io) {
|
|
|
62
63
|
return commentIssue(command, door);
|
|
63
64
|
case "history":
|
|
64
65
|
return issueHistory(command, door);
|
|
66
|
+
case "versions":
|
|
67
|
+
return listIssueVersions(command, door);
|
|
68
|
+
case "restore":
|
|
69
|
+
return restoreIssueVersion(command, door);
|
|
65
70
|
}
|
|
66
71
|
}
|
|
67
72
|
function issueLine(issue, directory) {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
|
|
22
22
|
const CAL_ACTIONS = new Set([
|
|
23
|
-
"event", "export", "conflicts", "calendar-change",
|
|
23
|
+
"event", "export", "duplicate", "batch", "undo", "conflicts", "calendar-change",
|
|
24
24
|
"today",
|
|
25
25
|
"week",
|
|
26
26
|
"next",
|
|
@@ -36,9 +36,9 @@ const CAL_ACTIONS = new Set([
|
|
|
36
36
|
"delete",
|
|
37
37
|
]);
|
|
38
38
|
/** Every verb that names ONE thing first — a calendar id, or (delete) an event id. */
|
|
39
|
-
const CAL_ACTIONS_NEEDING_A_SUBJECT = new Set(["event", "export", "calendar-change", "sync", "detach", "share", "delete", "change", "cancel"]);
|
|
39
|
+
const CAL_ACTIONS_NEEDING_A_SUBJECT = new Set(["event", "export", "duplicate", "calendar-change", "sync", "detach", "share", "delete", "change", "cancel"]);
|
|
40
40
|
/** `sync-status [<calendar id>]` — every calendar, or one's. Ticket 10730. */
|
|
41
|
-
const CAL_ACTIONS_WITH_OPTIONAL_SUBJECT = new Set(["sync-status"]);
|
|
41
|
+
const CAL_ACTIONS_WITH_OPTIONAL_SUBJECT = new Set(["sync-status", "undo"]);
|
|
42
42
|
const CAL_ALLOWED_FLAGS = [
|
|
43
43
|
"--home",
|
|
44
44
|
"--dashboard-url",
|
|
@@ -60,12 +60,17 @@ const CAL_ALLOWED_FLAGS = [
|
|
|
60
60
|
"--location",
|
|
61
61
|
"--attendee",
|
|
62
62
|
"--all-day",
|
|
63
|
+
"--timed",
|
|
64
|
+
"--keep-invitees",
|
|
63
65
|
"--meet",
|
|
64
66
|
"--org-visible",
|
|
65
67
|
"--private",
|
|
66
68
|
"--full",
|
|
67
69
|
"--yes",
|
|
68
70
|
"--json",
|
|
71
|
+
"--idempotency-key",
|
|
72
|
+
"--expected-version",
|
|
73
|
+
"--reason",
|
|
69
74
|
];
|
|
70
75
|
const CAL_VALUE_FLAGS = [
|
|
71
76
|
"--home",
|
|
@@ -86,6 +91,9 @@ const CAL_VALUE_FLAGS = [
|
|
|
86
91
|
"--until",
|
|
87
92
|
"--location",
|
|
88
93
|
"--attendee",
|
|
94
|
+
"--idempotency-key",
|
|
95
|
+
"--expected-version",
|
|
96
|
+
"--reason",
|
|
89
97
|
];
|
|
90
98
|
export function parseCalArgs(args) {
|
|
91
99
|
const values = parseNamedArgs(args, { allowedFlags: CAL_ALLOWED_FLAGS, valueFlags: CAL_VALUE_FLAGS });
|
|
@@ -104,7 +112,7 @@ function readCalAction(values) {
|
|
|
104
112
|
const first = values.positionals[0];
|
|
105
113
|
const action = (first === undefined ? "today" : first);
|
|
106
114
|
if (!CAL_ACTIONS.has(action)) {
|
|
107
|
-
throw new Error(`Unknown cal command: ${first}. Try today, week, conflicts, calendar-change, next, find, calendars, add-ical, create, share, detach, sync, sync-status, or delete.`);
|
|
115
|
+
throw new Error(`Unknown cal command: ${first}. Try today, week, conflicts, calendar-change, next, find, calendars, add-ical, create, duplicate, batch, undo, share, detach, sync, sync-status, or delete.`);
|
|
108
116
|
}
|
|
109
117
|
return { action, rest: values.positionals.slice(first === undefined ? 0 : 1) };
|
|
110
118
|
}
|
|
@@ -114,8 +122,8 @@ function readCalSubjectOrQuery(action, rest) {
|
|
|
114
122
|
if (CAL_ACTIONS_NEEDING_A_SUBJECT.has(action)) {
|
|
115
123
|
subject = optionalNonEmpty(rest[0]);
|
|
116
124
|
if (!subject) {
|
|
117
|
-
throw new Error(action === "delete"
|
|
118
|
-
?
|
|
125
|
+
throw new Error(action === "delete" || action === "duplicate"
|
|
126
|
+
? `cal ${action} needs an event id — take one from a window verb's output (today/week/next/find).`
|
|
119
127
|
: `cal ${action} needs a calendar id — take one from \`cockpit cal calendars\`.`);
|
|
120
128
|
}
|
|
121
129
|
if (rest.length > 1)
|
|
@@ -201,6 +209,11 @@ function buildCalCommand(values, action, subject, query, title, startsAt, endsAt
|
|
|
201
209
|
location: optionalNonEmpty(values.flags.get("--location")),
|
|
202
210
|
attendees: splitAddresses(values.flags.get("--attendee")),
|
|
203
211
|
allDay: values.booleans.has("--all-day"),
|
|
212
|
+
timed: values.booleans.has("--timed"),
|
|
213
|
+
keepInvitees: values.booleans.has("--keep-invitees"),
|
|
214
|
+
idempotencyKey: optionalNonEmpty(values.flags.get("--idempotency-key")),
|
|
215
|
+
expectedVersion: optionalNonEmpty(values.flags.get("--expected-version")),
|
|
216
|
+
reason: optionalNonEmpty(values.flags.get("--reason")),
|
|
204
217
|
meet: values.booleans.has("--meet"),
|
|
205
218
|
orgVisible: values.booleans.has("--org-visible"),
|
|
206
219
|
makePrivate: values.booleans.has("--private"),
|
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
|
|
10
10
|
import { parseDocsExportFlags } from "./local-args-docs-export.js";
|
|
11
|
-
const DOCS_ACTIONS = new Set(["list", "tree", "read", "create", "update", "delete", "link", "sync-status", "export"]);
|
|
11
|
+
const DOCS_ACTIONS = new Set(["list", "tree", "read", "create", "update", "delete", "link", "sync-status", "export", "mutation-status"]);
|
|
12
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
13
|
// BLI-10450: `read` moved to its own branch in `parseDocsDocumentReference` —
|
|
13
14
|
// it takes SEVERAL references now, so it no longer shares the "exactly one"
|
|
14
15
|
// rule `update`/`link` still enforce. BLI‑10555: `delete` takes several too
|
|
15
16
|
// (the same door, called once per ref), so it joined `read`'s branch rather
|
|
16
17
|
// than this set.
|
|
17
|
-
const DOCS_ACTIONS_NEEDING_A_DOC = new Set(["update", "link", "export"]);
|
|
18
|
+
const DOCS_ACTIONS_NEEDING_A_DOC = new Set(["update", "link", "export", "mutation-status"]);
|
|
18
19
|
const DOCS_ALLOWED_FLAGS = [
|
|
19
20
|
"--home",
|
|
20
21
|
"--dashboard-url",
|
|
@@ -22,6 +23,8 @@ const DOCS_ALLOWED_FLAGS = [
|
|
|
22
23
|
"--parent",
|
|
23
24
|
"--clear-parent",
|
|
24
25
|
"--allow-empty",
|
|
26
|
+
"--expected-revision",
|
|
27
|
+
"--request-id",
|
|
25
28
|
"--visibility",
|
|
26
29
|
"--file",
|
|
27
30
|
"--body-stdin",
|
|
@@ -41,6 +44,8 @@ const DOCS_VALUE_FLAGS = [
|
|
|
41
44
|
"--dashboard-url",
|
|
42
45
|
"--title",
|
|
43
46
|
"--parent",
|
|
47
|
+
"--expected-revision",
|
|
48
|
+
"--request-id",
|
|
44
49
|
"--visibility",
|
|
45
50
|
"--file",
|
|
46
51
|
"--query",
|
|
@@ -55,15 +60,18 @@ export function parseDocsArgs(args) {
|
|
|
55
60
|
const { action, rest } = parseDocsAction(values.positionals);
|
|
56
61
|
const link = parseDocsLink(action, rest, values.booleans.has("--clear"));
|
|
57
62
|
const { docRef, docRefs } = parseDocsDocumentReference(action, link.rest);
|
|
58
|
-
const { visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset } = parseDocsFlags(action, values);
|
|
63
|
+
const { visibility, clearParent, parentId, allowEmpty, title, requestId, expectedRevision, query, wholeWord, limit, bodies, section, offset } = parseDocsFlags(action, values);
|
|
59
64
|
if (action === "read")
|
|
60
65
|
validateDocsReadTargets(docRefs, parentId, bodies);
|
|
66
|
+
if (action === "mutation-status" && !UUID_PATTERN.test(docRef ?? "")) {
|
|
67
|
+
throw new Error("docs mutation-status needs a request UUID (the --request-id you sent, or the one `docs update` printed).");
|
|
68
|
+
}
|
|
61
69
|
// BLI‑10555: `delete` needs no `--parent`/`--bodies` shape, only at least
|
|
62
70
|
// one reference — `read`'s emptier validation does not apply.
|
|
63
71
|
if (action === "delete" && (!docRefs || docRefs.length === 0)) {
|
|
64
72
|
throw new Error("docs delete needs one or more document ids/slugs.");
|
|
65
73
|
}
|
|
66
|
-
return buildDocsCommand(values, action, docRef, docRefs, visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset, link);
|
|
74
|
+
return buildDocsCommand(values, action, docRef, docRefs, visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset, link, { requestId, expectedRevision });
|
|
67
75
|
}
|
|
68
76
|
/**
|
|
69
77
|
* BLI-10450: `read` takes either several document references OR
|
|
@@ -115,7 +123,7 @@ function parseDocsAction(positionals) {
|
|
|
115
123
|
const first = positionals[0];
|
|
116
124
|
const action = (first === undefined ? "list" : first);
|
|
117
125
|
if (!DOCS_ACTIONS.has(action)) {
|
|
118
|
-
throw new Error(`Unknown docs command: ${first}. Try list, tree, read, create, update, delete, link, sync-status, or
|
|
126
|
+
throw new Error(`Unknown docs command: ${first}. Try list, tree, read, create, update, delete, link, sync-status, export, or mutation-status.`);
|
|
119
127
|
}
|
|
120
128
|
return { action, rest: positionals.slice(first === undefined ? 0 : 1) };
|
|
121
129
|
}
|
|
@@ -173,11 +181,19 @@ function parseDocsWriteFlags(action, values) {
|
|
|
173
181
|
if (allowEmpty && action !== "update") {
|
|
174
182
|
throw new Error("--allow-empty belongs to `cockpit docs update`.");
|
|
175
183
|
}
|
|
184
|
+
// BLI-21298: the write-safety pair belongs to `update`, like the other save-shaping flags.
|
|
185
|
+
if ((values.flags.has("--request-id") || values.flags.has("--expected-revision")) && action !== "update") {
|
|
186
|
+
throw new Error("--request-id and --expected-revision belong to `cockpit docs update`.");
|
|
187
|
+
}
|
|
188
|
+
const requestId = optionalNonEmpty(values.flags.get("--request-id"));
|
|
189
|
+
if (requestId && !UUID_PATTERN.test(requestId))
|
|
190
|
+
throw new Error("--request-id must be a UUID.");
|
|
191
|
+
const expectedRevision = optionalPositiveInteger(values.flags.get("--expected-revision"), "--expected-revision");
|
|
176
192
|
const title = optionalNonEmpty(values.flags.get("--title"));
|
|
177
193
|
if (action === "create" && !title) {
|
|
178
194
|
throw new Error("docs create needs --title.");
|
|
179
195
|
}
|
|
180
|
-
return { visibility, clearParent, parentId, allowEmpty, title };
|
|
196
|
+
return { visibility, clearParent, parentId, allowEmpty, title, requestId, expectedRevision };
|
|
181
197
|
}
|
|
182
198
|
/** The flags that narrow or slice what a list or read returns. */
|
|
183
199
|
function parseDocsReadFlags(action, values) {
|
|
@@ -226,7 +242,7 @@ function parseDocsOffset(raw) {
|
|
|
226
242
|
throw new Error("--offset must be a whole number 0 or greater.");
|
|
227
243
|
return offset;
|
|
228
244
|
}
|
|
229
|
-
function buildDocsCommand(values, action, docRef, docRefs, visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset, link) {
|
|
245
|
+
function buildDocsCommand(values, action, docRef, docRefs, visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset, link, safety) {
|
|
230
246
|
return {
|
|
231
247
|
kind: "docs",
|
|
232
248
|
action,
|
|
@@ -238,6 +254,8 @@ function buildDocsCommand(values, action, docRef, docRefs, visibility, clearPare
|
|
|
238
254
|
...(parentId ? { parentId } : {}),
|
|
239
255
|
clearParent,
|
|
240
256
|
...(allowEmpty ? { allowEmpty } : {}),
|
|
257
|
+
...(safety.requestId ? { requestId: safety.requestId } : {}),
|
|
258
|
+
...(safety.expectedRevision === undefined ? {} : { expectedRevision: safety.expectedRevision }),
|
|
241
259
|
visibility: visibility,
|
|
242
260
|
filePath: optionalNonEmpty(values.flags.get("--file")),
|
|
243
261
|
bodyStdin: values.booleans.has("--body-stdin"),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
2
|
-
const MSG_ACTIONS = new Set(["channels", "read", "send", "thread", "create", "dm", "show"]);
|
|
3
|
-
const MSG_ACTIONS_NEEDING_A_CHANNEL = new Set(["read", "send"]);
|
|
2
|
+
const MSG_ACTIONS = new Set(["channels", "read", "send", "thread", "create", "dm", "show", "unread"]);
|
|
3
|
+
const MSG_ACTIONS_NEEDING_A_CHANNEL = new Set(["read", "send", "unread"]);
|
|
4
4
|
/**
|
|
5
5
|
* `--members a@x.test,b@y.test` — addresses only, split on commas, never
|
|
6
6
|
* fuzzy (BLI-3749). The door resolves each one exactly, so a typo comes back
|
|
@@ -35,24 +35,40 @@ export function parseMsgArgs(args) {
|
|
|
35
35
|
"--private",
|
|
36
36
|
"--members",
|
|
37
37
|
"--description",
|
|
38
|
+
"--from",
|
|
39
|
+
"--mark-read",
|
|
38
40
|
"--json",
|
|
39
41
|
],
|
|
40
|
-
valueFlags: ["--home", "--dashboard-url", "--channel", "--thread", "--limit", "--members", "--description", "--before"],
|
|
42
|
+
valueFlags: ["--home", "--dashboard-url", "--channel", "--thread", "--limit", "--members", "--description", "--before", "--from"],
|
|
41
43
|
});
|
|
42
44
|
const { action, rest } = parseMsgAction(values.positionals);
|
|
43
45
|
if (values.flags.has("--before") && action !== "read" && action !== "thread") {
|
|
44
46
|
throw new Error("--before belongs to msg read or msg thread.");
|
|
45
47
|
}
|
|
48
|
+
const { markRead, unreadFrom } = parseMsgReadLineFlags(action, values);
|
|
46
49
|
const { channelName, dmEmail } = parseMsgCreateTarget(action, rest);
|
|
47
50
|
const { isPrivate, memberEmails, description } = parseMsgCreateFlags(action, values);
|
|
48
51
|
const { channelRef, threadId, limit } = parseMsgReadTarget(action, rest, values);
|
|
49
|
-
return buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit);
|
|
52
|
+
return buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit, markRead, unreadFrom);
|
|
53
|
+
}
|
|
54
|
+
/** `read --mark-read` and `unread --from <message-id>` (BLI-21299): the read line, moved on purpose. */
|
|
55
|
+
function parseMsgReadLineFlags(action, values) {
|
|
56
|
+
const markRead = values.booleans.has("--mark-read");
|
|
57
|
+
if (markRead && action !== "read")
|
|
58
|
+
throw new Error("--mark-read belongs to `cockpit msg read`.");
|
|
59
|
+
const unreadFrom = optionalNonEmpty(values.flags.get("--from"));
|
|
60
|
+
if (action === "unread" && !unreadFrom) {
|
|
61
|
+
throw new Error("msg unread needs --from <message-id>, e.g. `cockpit msg unread general --from <message-id>`.");
|
|
62
|
+
}
|
|
63
|
+
if (unreadFrom && action !== "unread")
|
|
64
|
+
throw new Error("--from belongs to `cockpit msg unread`.");
|
|
65
|
+
return { markRead, unreadFrom };
|
|
50
66
|
}
|
|
51
67
|
function parseMsgAction(positionals) {
|
|
52
68
|
const first = positionals[0];
|
|
53
69
|
const action = (first === undefined ? "channels" : first);
|
|
54
70
|
if (!MSG_ACTIONS.has(action)) {
|
|
55
|
-
throw new Error(`Unknown msg command: ${first}. Try channels, read, send, thread, create, dm, or show.`);
|
|
71
|
+
throw new Error(`Unknown msg command: ${first}. Try channels, read, unread, send, thread, create, dm, or show.`);
|
|
56
72
|
}
|
|
57
73
|
return { action, rest: positionals.slice(first === undefined ? 0 : 1) };
|
|
58
74
|
}
|
|
@@ -129,7 +145,7 @@ function parseMsgReadTarget(action, rest, values) {
|
|
|
129
145
|
}
|
|
130
146
|
return { channelRef, threadId, limit };
|
|
131
147
|
}
|
|
132
|
-
function buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit) {
|
|
148
|
+
function buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit, markRead, unreadFrom) {
|
|
133
149
|
return {
|
|
134
150
|
kind: "msg",
|
|
135
151
|
action,
|
|
@@ -144,6 +160,8 @@ function buildMsgCommand(values, action, channelRef, channelName, dmEmail, membe
|
|
|
144
160
|
isPrivate,
|
|
145
161
|
...(threadId ? { threadId } : {}),
|
|
146
162
|
...(limit === undefined ? {} : { limit }),
|
|
163
|
+
...(markRead ? { markRead } : {}),
|
|
164
|
+
...(unreadFrom ? { unreadFrom } : {}),
|
|
147
165
|
json: values.booleans.has("--json"),
|
|
148
166
|
};
|
|
149
167
|
}
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
2
2
|
export function parseSetupArgs(args) {
|
|
3
|
-
const values = parseNamedArgs(args, { allowedFlags: ["--person", "--compare", "--since", "--details", "--json", "--home", "--dashboard-url"], valueFlags: ["--since", "--person", "--compare", "--home", "--dashboard-url"] });
|
|
3
|
+
const values = parseNamedArgs(args, { allowedFlags: ["--until", "--machines", "--person", "--compare", "--since", "--details", "--json", "--home", "--dashboard-url"], valueFlags: ["--until", "--since", "--person", "--compare", "--home", "--dashboard-url"] });
|
|
4
4
|
if (values.positionals.length)
|
|
5
5
|
throw new Error("setup takes --person or --compare, not a subcommand.");
|
|
6
6
|
const compare = optionalNonEmpty(values.flags.get("--compare"));
|
|
7
7
|
if (compare && (compare.split(",").length !== 2 || compare.split(",").some(value => !value.trim())))
|
|
8
8
|
throw new Error("--compare takes two people (first names or emails) separated by a comma.");
|
|
9
9
|
const since = optionalNonEmpty(values.flags.get("--since"));
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
const until = optionalNonEmpty(values.flags.get("--until"));
|
|
11
|
+
const validDate = (value) => /^\d{4}-\d{2}-\d{2}(?:T.*)?$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(`${value.slice(0, 10)}T00:00:00Z`).toISOString().slice(0, 10) === value.slice(0, 10);
|
|
12
|
+
const end = until ? Date.parse(until) : Date.now();
|
|
13
|
+
const duration = since ? /^([1-9]\d*)([dh])$/.exec(since) : null;
|
|
14
|
+
const start = duration ? end - Number(duration[1]) * (duration[2] === "d" ? 86400000 : 3600000) : since ? Date.parse(since) : end - 30 * 86400000;
|
|
15
|
+
if (!Number.isFinite(end) || !Number.isFinite(start) || start >= end || (until && !validDate(until)) ||
|
|
16
|
+
(duration && Number(duration[1]) > (duration[2] === "d" ? 365 : 8760)) ||
|
|
17
|
+
(since && !duration && !validDate(since)))
|
|
18
|
+
throw new Error("--since takes a duration (1d, 7d, 30d, 90d) or an absolute start; --until takes an absolute end after the start.");
|
|
19
|
+
return { kind: "setup", since, until, machines: values.booleans.has("--machines"), details: values.booleans.has("--details"), person: optionalNonEmpty(values.flags.get("--person")), compare, json: values.booleans.has("--json"), homeDir: optionalNonEmpty(values.flags.get("--home")), dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")) };
|
|
13
20
|
}
|
|
@@ -27,6 +27,8 @@ const ISSUE_ACTIONS = new Set([
|
|
|
27
27
|
"move",
|
|
28
28
|
"comment",
|
|
29
29
|
"history",
|
|
30
|
+
"versions",
|
|
31
|
+
"restore",
|
|
30
32
|
]);
|
|
31
33
|
/** Every verb except `list` and `create` names ONE issue first. */
|
|
32
34
|
const ISSUE_ACTIONS_NEEDING_AN_ISSUE = new Set([
|
|
@@ -36,6 +38,8 @@ const ISSUE_ACTIONS_NEEDING_AN_ISSUE = new Set([
|
|
|
36
38
|
"move",
|
|
37
39
|
"comment",
|
|
38
40
|
"history",
|
|
41
|
+
"versions",
|
|
42
|
+
"restore",
|
|
39
43
|
]);
|
|
40
44
|
/**
|
|
41
45
|
* The closed state vocabulary, copied verbatim from
|
|
@@ -63,6 +67,7 @@ const ISSUE_ALLOWED_FLAGS = [
|
|
|
63
67
|
"--size",
|
|
64
68
|
"--parent",
|
|
65
69
|
"--file",
|
|
70
|
+
"--version",
|
|
66
71
|
"--body-stdin",
|
|
67
72
|
"--json",
|
|
68
73
|
];
|
|
@@ -82,6 +87,7 @@ const ISSUE_VALUE_FLAGS = [
|
|
|
82
87
|
"--size",
|
|
83
88
|
"--parent",
|
|
84
89
|
"--file",
|
|
90
|
+
"--version",
|
|
85
91
|
];
|
|
86
92
|
export function parseIssueArgs(args) {
|
|
87
93
|
const values = parseNamedArgs(args, {
|
|
@@ -96,8 +102,8 @@ export function parseIssueArgs(args) {
|
|
|
96
102
|
throw new Error("--request-id must be a UUID.");
|
|
97
103
|
if (action === "mutation-status" && !UUID_PATTERN.test(issueRef ?? ""))
|
|
98
104
|
throw new Error("mutation-status needs a request UUID.");
|
|
99
|
-
if ((requestId || expectedRevision !== undefined) && !["create", "update", "move", "comment"].includes(action)) {
|
|
100
|
-
throw new Error("Write-safety flags belong to create, update, move or
|
|
105
|
+
if ((requestId || expectedRevision !== undefined) && !["create", "update", "move", "comment", "restore"].includes(action)) {
|
|
106
|
+
throw new Error("Write-safety flags belong to create, update, move, comment or restore.");
|
|
101
107
|
}
|
|
102
108
|
validateIssueState(moveState, "issue move state must be one of: ");
|
|
103
109
|
const stateFilter = readIssueStateFilter(action, values);
|
|
@@ -112,13 +118,14 @@ export function parseIssueArgs(args) {
|
|
|
112
118
|
const estimate = readIssueSize(values);
|
|
113
119
|
const assignee = readIssueAssignee(action, values);
|
|
114
120
|
const limit = readIssueLimit(action, values);
|
|
115
|
-
|
|
121
|
+
const versionNumber = readIssueVersion(action, values);
|
|
122
|
+
return buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, estimate, assignee, limit, versionNumber);
|
|
116
123
|
}
|
|
117
124
|
function readIssueAction(values) {
|
|
118
125
|
const first = values.positionals[0];
|
|
119
126
|
const action = (first === undefined ? "list" : first);
|
|
120
127
|
if (!ISSUE_ACTIONS.has(action)) {
|
|
121
|
-
throw new Error(`Unknown issue command: ${first}. Try board, list, show, create, update, move, comment, history, or mutation-status.`);
|
|
128
|
+
throw new Error(`Unknown issue command: ${first}. Try board, list, show, create, update, move, comment, history, versions, restore, or mutation-status.`);
|
|
122
129
|
}
|
|
123
130
|
return { action, rest: values.positionals.slice(first === undefined ? 0 : 1) };
|
|
124
131
|
}
|
|
@@ -206,14 +213,24 @@ function readIssueAssignee(action, values) {
|
|
|
206
213
|
}
|
|
207
214
|
return assignee;
|
|
208
215
|
}
|
|
216
|
+
/** `restore` names the version it wants; there is no "restore the previous one" (BLI-21300). */
|
|
217
|
+
function readIssueVersion(action, values) {
|
|
218
|
+
const version = optionalPositiveInteger(values.flags.get("--version"), "--version");
|
|
219
|
+
if (action === "restore" && version === undefined) {
|
|
220
|
+
throw new Error("issue restore needs --version <n>; list them with `cockpit ticket versions <id>`.");
|
|
221
|
+
}
|
|
222
|
+
if (version !== undefined && action !== "restore")
|
|
223
|
+
throw new Error("--version belongs to `cockpit ticket restore`.");
|
|
224
|
+
return version;
|
|
225
|
+
}
|
|
209
226
|
function readIssueLimit(action, values) {
|
|
210
227
|
const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
|
|
211
|
-
if (limit !== undefined && action !== "list" && action !== "history") {
|
|
212
|
-
throw new Error("--limit belongs to `cockpit issue list` or `
|
|
228
|
+
if (limit !== undefined && action !== "list" && action !== "history" && action !== "versions") {
|
|
229
|
+
throw new Error("--limit belongs to `cockpit issue list`, `history` or `versions`.");
|
|
213
230
|
}
|
|
214
231
|
return limit;
|
|
215
232
|
}
|
|
216
|
-
function buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, estimate, assignee, limit) {
|
|
233
|
+
function buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, estimate, assignee, limit, versionNumber) {
|
|
217
234
|
return {
|
|
218
235
|
kind: "issue",
|
|
219
236
|
requestId: optionalNonEmpty(values.flags.get("--request-id")),
|
|
@@ -230,6 +247,7 @@ function buildIssueCommand(values, action, issueRef, issueRefs, moveState, state
|
|
|
230
247
|
rows: optionalNonEmpty(values.flags.get("--rows")),
|
|
231
248
|
project: optionalNonEmpty(values.flags.get("--project")),
|
|
232
249
|
...(limit === undefined ? {} : { limit }),
|
|
250
|
+
...(versionNumber === undefined ? {} : { versionNumber }),
|
|
233
251
|
title,
|
|
234
252
|
...(priority === undefined ? {} : { priority }),
|
|
235
253
|
...(estimate === undefined ? {} : { estimate }),
|
|
@@ -47,7 +47,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
47
47
|
[
|
|
48
48
|
"docs",
|
|
49
49
|
[
|
|
50
|
-
"Usage: cockpit docs [list|tree|read <id|slug> [<id|slug>...]|read --parent <id> --bodies|create|update <id>|delete <id|slug> [<id|slug>...]|link <id|slug> <notion-page>|sync-status [<id|slug>]|export <id|slug> [--format md|pdf|docx|txt] [--out <path>]] [flags]",
|
|
50
|
+
"Usage: cockpit docs [list|tree|read <id|slug> [<id|slug>...]|read --parent <id> --bodies|create|update <id>|delete <id|slug> [<id|slug>...]|link <id|slug> <notion-page>|sync-status [<id|slug>]|export <id|slug> [--format md|pdf|docx|txt] [--out <path>]|mutation-status <request-uuid>] [flags]",
|
|
51
51
|
"",
|
|
52
52
|
"The Tower document library, typed. Bare `cockpit docs` lists every document you may read.",
|
|
53
53
|
"list [--parent <id>|root] [--query <text> [--word]] [--limit <n>] — every document you may see: id, visibility, body size, slug, title. Metadata only — a list never carries a body, so it stays cheap enough for an agent to call (--json was 604 KB for 155 documents before BLI-3737, and is now a few tens of KB). --json shape: { documents: [{ id, slug, title, visibility, parent_id, source, updated_at, body_chars }] }.",
|
|
@@ -62,7 +62,10 @@ export const TOWER_COMMAND_HELP = [
|
|
|
62
62
|
" --json shape: one ref answers { document }, unchanged from before BLI-10450. Several refs answer { documents: [{ ref, ok, document } | { ref, ok: false, error, detail }] }.",
|
|
63
63
|
"read --parent <id>|root --bodies [--limit <n>] — every document filed directly under that parent, bodies included, in ONE call (GET /api/docs/documents?parent_id=...&bodies=true). --limit here caps the DOCUMENT COUNT (1-50), not characters. --json shape: { documents: [{ id, ..., body_markdown }] }.",
|
|
64
64
|
"create --title \"<title>\" [--parent <id>] [--visibility org|private] [--file <path>|--body-stdin] — the body comes from --file, or stdin (`cat body.md | cockpit docs create --title \"...\"`); neither means an empty body, matching the browser's own default. --json shape: { document }.",
|
|
65
|
-
"update <id|slug> [--title \"<t>\"] [--visibility org|private] [--parent <id>|--clear-parent] [--file <path>|--body-stdin] [--allow-empty] — only the fields you pass change; a `--parent` change IS a move, there is no separate move verb. --json shape: { document }.",
|
|
65
|
+
"update <id|slug> [--title \"<t>\"] [--visibility org|private] [--parent <id>|--clear-parent] [--file <path>|--body-stdin] [--allow-empty] [--expected-revision <n>] [--request-id <uuid>] — only the fields you pass change; a `--parent` change IS a move, there is no separate move verb. --json shape: { document, mutation }.",
|
|
66
|
+
" --expected-revision <n> is the page revision you read (`docs read --json` shows document.revision). The save is one atomic step: if someone saved since, nothing is written and you get revision_conflict with the current page (--json: mutation.current). Omit it for an unconditional save. Every save advances the revision.",
|
|
67
|
+
" --request-id <uuid> makes a retry apply once (minted and printed when absent). Reusing it with different input is request_id_reused. If the response is lost, run `cockpit docs mutation-status <uuid>`, then retry the SAME input with the SAME id.",
|
|
68
|
+
"mutation-status <request-uuid> — your own save receipt: committed (with the stored revision), not_committed (with the reason) or unknown_check_again (no receipt yet; it never proves nothing changed). --json shape: { mutation }.",
|
|
66
69
|
" --allow-empty clears the page on purpose. A body that would empty a document that holds text is refused (refused_empty_body) unless you say so, because far more often it is a surface that lost the content than a person who meant it.",
|
|
67
70
|
"delete <id|slug> [<id|slug>...] — soft-deletes one or more documents (BLI-10555): the browser's own delete, sets deleted_at, never a real SQL DELETE. Recoverable — only a super_admin clearing deleted_at brings it back, there is no self-service undo. Deleting a parent does NOT delete its children; they keep their own parent_id and stay exactly where they were. Several refs resolve against ONE shared list call, then one DELETE each — one bad ref never loses the rest. --json shape: one ref answers { ok, document_id }; several answer { documents: [{ ref, ok, document_id } | { ref, ok: false, error, detail }] }.",
|
|
68
71
|
"link <id|slug> <notion-url|page-id> — ties the document to a Notion page so the two-way sync moves it both ways. The page is the truth from then on: the next sync pulls it over this copy and keeps the copy as a version. `link <id|slug> --clear` unties it. Refusals: invalid_notion_page, notion_page_already_linked, document_not_writable.",
|
|
@@ -77,13 +80,14 @@ export const TOWER_COMMAND_HELP = [
|
|
|
77
80
|
[
|
|
78
81
|
"msg",
|
|
79
82
|
[
|
|
80
|
-
"Usage: cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>|show <message-id>] [flags]",
|
|
83
|
+
"Usage: cockpit msg [channels|create <name>|dm <email>|read <channel>|unread <channel> --from <message-id>|send <channel>|thread <id> --channel <channel>|show <message-id>] [flags]",
|
|
81
84
|
"",
|
|
82
85
|
"Channels and messages, typed. <channel> is a channel id, or its name with or without a leading #.",
|
|
83
86
|
"channels — every channel you are a member of (or, as a super_admin, every channel).",
|
|
84
87
|
"create <name> [--private] [--members a@x.test,b@y.test] [--description \"<text>\"] — makes a channel and prints its id. A leading # is fine; --private means membership decides who may read it, and --members names who joins at birth BY EMAIL. An address Tower does not carry refuses the whole create — no half-built channel.",
|
|
85
88
|
"dm <email> — opens (or re-opens) the direct message with one person. Idempotent: the same address always resolves to the same channel, and you never have to name yourself.",
|
|
86
|
-
"read <channel> [--limit <n>] [--thread <id>] — the channel's most recent top-level messages, oldest first; --thread <id> reads one thread's replies instead.",
|
|
89
|
+
"read <channel> [--limit <n>] [--thread <id>] [--mark-read] — the channel's most recent top-level messages, oldest first; --thread <id> reads one thread's replies instead. The first page also says where the messages come from (for an imported Slack channel: how long ago the archive was checked, or that it is unknown) and where YOUR read line stands. Reading never moves the read line; --mark-read moves it to the newest message shown, and says plainly if that failed.",
|
|
90
|
+
"unread <channel> --from <message-id> — marks that message and everything after it unread, so the web sidebar badge and the NEW MESSAGES line start there. Opening or reading a channel never clears unseen history.",
|
|
87
91
|
"send <channel> [--thread <id>] — posts a message. The content is never accepted on the command line: pipe it in, e.g. `echo \"hello\" | cockpit msg send general`.",
|
|
88
92
|
"thread <id> --channel <channel> [--before <cursor>] [--limit <n>] — one thread's replies by the parent message's id.",
|
|
89
93
|
"show <message-id> — exact message, thread root and bounded neighbors. read/thread --before takes the preceding nextCursor; repeat until hasMore is false.",
|
|
@@ -95,7 +99,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
95
99
|
[
|
|
96
100
|
"issue",
|
|
97
101
|
[
|
|
98
|
-
"Usage: cockpit issue [board|list|show <id> [<id>...]|create|update <id>|move <id> <state>|comment <id>|history <id>|mutation-status <request-uuid>] [flags]",
|
|
102
|
+
"Usage: cockpit issue [board|list|show <id> [<id>...]|create|update <id>|move <id> <state>|comment <id>|history <id>|versions <id>|restore <id> --version <n>|mutation-status <request-uuid>] [flags]",
|
|
99
103
|
"",
|
|
100
104
|
"Tower's issue tracker. <id> is a BLI-#### identifier (BLI-3654) or an issue's uuid — both work everywhere an issue is named.",
|
|
101
105
|
"list [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] — the issues you may see, most recently updated first. Each row carries assignee_name/assignee_email alongside the bare assignee_id (BLI-10450, one extra `cockpit team members` call for the whole page, never per row), and a `size` label (S/M/L) alongside the raw `estimate` when it matches Tower's own scale (BLI-10575). --json shape: { issues: [{ id, identifier, title, state, priority, estimate, size, assignee_id, assignee_name, assignee_email, project_id, parent_id, updated_at }] }.",
|
|
@@ -109,6 +113,8 @@ export const TOWER_COMMAND_HELP = [
|
|
|
109
113
|
"Writes accept --request-id <uuid> and --expected-revision <n>. Keep the UUID and identical fields across retries; JSON includes mutation receipt and mirror delivery separately.",
|
|
110
114
|
"mutation-status <request-uuid> — check your own local commit and mirror delivery after a lost response; no receipt means unknown_check_again.",
|
|
111
115
|
"history <id> [--limit <n>] — every recorded state move and reassignment, oldest first.",
|
|
116
|
+
"versions <id> [--limit <n>] — every saved description, newest first, each with its number, id, author, source and time (BLI-21300). --json shape: { versions: [{ id, version, markdown, author_id, source, restored_from_version, created_at }] }.",
|
|
117
|
+
"restore <id> --version <n> [--request-id <uuid>] [--expected-revision <n>] — writes version <n>'s text back as a NEW version; nothing is rewritten, so the restore can itself be undone. A stale revision conflicts and changes nothing. --json shape: { issue, mutation }.",
|
|
112
118
|
"A project may be named instead of id'd; the name is matched exactly (case-insensitively) against `cockpit project list`.",
|
|
113
119
|
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
|
|
114
120
|
"A refusal keeps Tower's own reason label — needs_rls_client, issue_not_found_or_unreadable, issue_not_writable, invalid_state, comment_too_long, and so on.",
|
|
@@ -145,7 +151,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
145
151
|
[
|
|
146
152
|
"cal",
|
|
147
153
|
[
|
|
148
|
-
'Usage: cockpit cal [event <id>|export <id>|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|next|find "<words>"|calendars|add-ical|create|share <id>|sync <id>|sync-status [<id>]|detach <id>|delete <event id> --yes] [flags]',
|
|
154
|
+
'Usage: cockpit cal [event <id>|export <id>|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|next|find "<words>"|calendars|add-ical|create|duplicate <event id>|batch|undo [<change id>]|share <id>|sync <id>|sync-status [<id>]|detach <id>|delete <event id> --yes] [flags]',
|
|
149
155
|
"",
|
|
150
156
|
"Every calendar you attached, in one place — plus any calendar a colleague marked shared.",
|
|
151
157
|
"today [--offset 1] [--tz <zone>] — what is on today, in YOUR zone (this machine's, unless --tz says otherwise). --offset 1 is tomorrow, -1 yesterday.",
|
|
@@ -168,7 +174,10 @@ export const TOWER_COMMAND_HELP = [
|
|
|
168
174
|
"sync <id> [--full] — reads that calendar now instead of waiting for the cron. --full ignores the sync token / ETag and re-reads everything.",
|
|
169
175
|
"sync-status [<id>] — every calendar's health, or one's: last successful sync, the last pass's own reason, how many events it carried, failures in the last 24h, and the lag between an event's own provider clock and when Tower stored it (ticket 10730).",
|
|
170
176
|
"detach <id> — removes the calendar and its stored events from Tower. The calendar itself is untouched at Google.",
|
|
171
|
-
"change <event id> [--account <name>] [--title <title>] [--at <iso> --until <iso>] [--location <place>] — changes an event on its own account.",
|
|
177
|
+
"change <event id> [--account <name>] [--title <title>] [--at <iso> --until <iso>] [--location <place>] [--all-day|--timed] [--tz <zone>] [--expected-version <v>] [--idempotency-key <k>] [--reason <why>] — changes an event on its own account and prints the change id (undo it with `undo`). --all-day converts to an all-day event (--at/--until are dates, --until the day AFTER the last day); --timed converts back. --expected-version refuses (version_conflict) instead of overwriting an event that changed since you read it. Recurring events are refused until scoped edits ship.",
|
|
178
|
+
"duplicate <event id> [--at <iso> --until <iso>] [--calendar <id>|--account <name>] [--keep-invitees] [--idempotency-key <k>] — copies ONE non-recurring event. The copy has NO guests unless you pass --keep-invitees (Google then emails each one) and no Meet room. Same times unless --at/--until are given; --calendar/--account puts the copy on another of your calendars on purpose. The same --idempotency-key twice makes one copy.",
|
|
179
|
+
'batch [--idempotency-key <k>] [--reason <why>] — up to 25 changes from JSON on stdin: {"changes":[{"event_id":"…","starts_at":"…","ends_at":"…","expected_version":"…"}]} (or a bare array; fields as `change`: title, location, all_day, time_zone). Each is its own write: the output lists every item and the ones left unchanged, and the exit code is 1 unless all were written.',
|
|
180
|
+
"undo [<change id>] — a NEW write that puts the recorded earlier values back (or removes the event a create or duplicate made), never a deletion of history. No id means your latest change. Refuses when the event changed again since (undo_stale), when that change was already undone, and for guest changes, cancellations and calendar moves.",
|
|
172
181
|
"cancel <event id> --yes [--account <name>] — same door as delete; a named account must own the event.",
|
|
173
182
|
"delete <event id> --yes — removes ONE event, AT GOOGLE first, then here. Refuses without --yes: a delete can email everybody on it, exactly like create's invitations do. Google already having it gone (404/410) is answered as success, not a refusal.",
|
|
174
183
|
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
|
|
@@ -28,7 +28,7 @@ const COMMAND_HELP = new Map([
|
|
|
28
28
|
// BLI-3709: the Tower nouns live next door, split along the same seam the
|
|
29
29
|
// argument parsers already use — see `local-help-commands-tower.ts`.
|
|
30
30
|
...TOWER_COMMAND_HELP,
|
|
31
|
-
["setup", ["Usage: cockpit setup [--since <
|
|
31
|
+
["setup", ["Usage: cockpit setup [--since <duration|date>] [--until <date>] [--machines] [--details] [--person <name|email>] [--compare <name,name>] [--json]", "Read agent setups beside windowed outcomes (default 30 days), or compare two people by first name or email."]],
|
|
32
32
|
[
|
|
33
33
|
"onboard",
|
|
34
34
|
[
|
|
@@ -144,7 +144,7 @@ const COMMAND_SUMMARY_LINES = [
|
|
|
144
144
|
" cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>|show <message-id>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--before <cursor>] [--dashboard-url <url>] [--json]",
|
|
145
145
|
" cockpit issue [list|show <BLI-id>|create --title <t>|update <BLI-id>|move <BLI-id> <state>|comment <BLI-id>|history <BLI-id>] [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] [--priority 0-4] [--size S|M|L] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
|
|
146
146
|
" cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|draft --account <id> --to <a> --subject <s> [--reply-to <message id>]|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--attach <path>]... [--dashboard-url <url>] [--json]",
|
|
147
|
-
" cockpit cal [event <id> [--occurrence-start <iso>]|export <id> [--occurrence-start <iso>]|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
|
|
147
|
+
" cockpit cal [event <id> [--occurrence-start <iso>]|export <id> [--occurrence-start <iso>]|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|duplicate <event id> [--keep-invitees]|batch|undo [<change id>]|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
|
|
148
148
|
" cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
|
|
149
149
|
" cockpit github <pr|merge|retitle|rerun> <owner/repo> <n> [--claim <state>] [--method squash|merge|rebase] [--title \"<text>\"] [--run <id>] [--confirm <code>] [--dashboard-url <url>] [--json]",
|
|
150
150
|
` cockpit search "<words>" [--kind <category,...>] [--limit <n>] [--dashboard-url <url>] [--json]`,
|
|
@@ -168,7 +168,7 @@ const COMMAND_SUMMARY_LINES = [
|
|
|
168
168
|
" cockpit drills [list|start|answer --confidence <1-5> [--text <words>] [--attempt <id>]|theme|theme set <theme> [--play <t>] [--watch <t>] [--train <t>]|review] [--dashboard-url <url>] [--json]",
|
|
169
169
|
" cockpit manager <clients|client <client>|run <run-id>|list <client>|queue <client>|show <id>|add <client> --title <t>|edit <id> --version <v>|done|reopen|link|approve|dismiss|proposal|archive|unarchive> [--timeline] [--dashboard-url <url>] [--json]",
|
|
170
170
|
" cockpit image <describe <file> [--session <id>]|show <sha256-or-prefix>> [--dashboard-url <url>] [--json]",
|
|
171
|
-
" cockpit setup [--since <
|
|
171
|
+
" cockpit setup [--since <duration|date>] [--until <date>] [--machines] [--details] [--person <name|email>] [--compare <name,name>] [--json]",
|
|
172
172
|
" cockpit usage sessions [--person <exact-name>] [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--tool claude|codex] [--since <window>] [--until <iso>] [--cursor <cursor>] [--limit <n>] [--json]",
|
|
173
173
|
" cockpit usage session <id> [--from-window <n>] [--json]",
|
|
174
174
|
" --by-topic groups work types; --by-subject groups open subjects from session summaries.",
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read line, from a terminal (BLI-21299), split out of `msg.ts` to keep it
|
|
3
|
+
* under the file-size ratchet: the two fields every channel read now carries
|
|
4
|
+
* (`provenance`, `readBoundary`) and the `read --mark-read` step. The web page,
|
|
5
|
+
* MCP `msg_read` `mark_read` and this all call `POST /api/msg/channels/[id]/read`.
|
|
6
|
+
*/
|
|
7
|
+
import { askAgentDoor } from "./agent-door.js";
|
|
8
|
+
import { writeLine } from "./cli-io.js";
|
|
9
|
+
const TAG = "[msg cli]";
|
|
10
|
+
const WRITE_DEADLINE_MS = 30_000;
|
|
11
|
+
/** POST the read line to the newest top-level message of a page just read (newest first). */
|
|
12
|
+
export async function markReadThrough(door, channelId, messages) {
|
|
13
|
+
const newest = messages.find((message) => !message.thread_parent_id);
|
|
14
|
+
if (!newest) {
|
|
15
|
+
writeLine(door.io.stderr, `${TAG} mark-read skipped ${JSON.stringify({ channel_id: channelId, reason: "nothing_to_mark" })}`);
|
|
16
|
+
return { ok: false, reason: "nothing_to_mark", detail: "There was no top-level message on this page to mark read." };
|
|
17
|
+
}
|
|
18
|
+
const answer = await askAgentDoor(door, {
|
|
19
|
+
path: `/api/msg/channels/${encodeURIComponent(channelId)}/read`,
|
|
20
|
+
method: "POST",
|
|
21
|
+
label: "msg mark-read",
|
|
22
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
23
|
+
body: { message_id: newest.id },
|
|
24
|
+
});
|
|
25
|
+
if (!answer.ok) {
|
|
26
|
+
writeLine(door.io.stderr, `${TAG} mark-read failed ${JSON.stringify({ channel_id: channelId, reason: answer.reason })}`);
|
|
27
|
+
return { ok: false, reason: answer.reason, detail: answer.detail };
|
|
28
|
+
}
|
|
29
|
+
const changed = answer.body.move?.changed === true;
|
|
30
|
+
writeLine(door.io.stderr, `${TAG} mark-read ${JSON.stringify({ channel_id: channelId, message_id: newest.id, changed })}`);
|
|
31
|
+
return { ok: true, messageId: newest.id, changed };
|
|
32
|
+
}
|
package/dist/commands/msg.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `cockpit msg` — channels and messages, typed (BLI-3706, BLI-3749).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Eight verbs over `/api/msg/**` (BLI-3654 Wave 1a), which already accepts the
|
|
5
5
|
* collector device token through `resolveCaller({ allowDeviceToken: true })`
|
|
6
6
|
* on every route — no server-side door change was needed for this terminal.
|
|
7
7
|
*
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
19
19
|
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
20
20
|
import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
|
|
21
|
+
import { markReadThrough } from "./msg-read-line.js";
|
|
21
22
|
const TAG = "[msg cli]";
|
|
22
23
|
const READ_DEADLINE_MS = 30_000;
|
|
23
24
|
const WRITE_DEADLINE_MS = 30_000;
|
|
@@ -31,6 +32,8 @@ export async function runMsg(command, io) {
|
|
|
31
32
|
return listChannels(door);
|
|
32
33
|
case "read":
|
|
33
34
|
return readChannel(command, door);
|
|
35
|
+
case "unread":
|
|
36
|
+
return markUnread(command, door);
|
|
34
37
|
case "send":
|
|
35
38
|
return sendMessage(command, door);
|
|
36
39
|
case "thread":
|
|
@@ -151,11 +154,20 @@ async function readChannel(command, door) {
|
|
|
151
154
|
// BLI-10449: whether this channel is Tower's own or a Slack snapshot, and
|
|
152
155
|
// how fresh — the door's own sentence, printed as sent.
|
|
153
156
|
const provenance = body.provenance ?? null;
|
|
157
|
+
const readBoundary = body.readBoundary ?? null;
|
|
154
158
|
const limitApplied = body.limitApplied ?? null;
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
// `--mark-read` (BLI-21299): the person has just been shown these messages, so
|
|
160
|
+
// the read line moves to the newest top-level one - the only way a terminal
|
|
161
|
+
// moves it. Never implied by reading alone.
|
|
162
|
+
const readMove = command.markRead ? await markReadThrough(door, resolved.id, messages) : null;
|
|
163
|
+
const exitCode = readMove && !readMove.ok ? 1 : 0;
|
|
164
|
+
if (door.json) {
|
|
165
|
+
return emitAgentDoor(door, { ok: true, ...body, channelId: resolved.id, provenance, readBoundary, limitApplied, messages, nextCursor: body.nextCursor, hasMore: body.hasMore, coverage: body.coverage, ...(readMove ? { readMove } : {}) }, exitCode);
|
|
166
|
+
}
|
|
157
167
|
if (provenance?.sentence)
|
|
158
168
|
writeLine(door.io.stdout, provenance.sentence);
|
|
169
|
+
if (readBoundary?.sentence)
|
|
170
|
+
writeLine(door.io.stdout, readBoundary.sentence);
|
|
159
171
|
if (limitApplied !== null) {
|
|
160
172
|
writeLine(door.io.stdout, `Showing the newest ${limitApplied}: one read returns at most ${limitApplied} messages.`);
|
|
161
173
|
}
|
|
@@ -170,6 +182,36 @@ async function readChannel(command, door) {
|
|
|
170
182
|
const who = message.agent_label ?? message.user_id ?? message.slack_author_label ?? "someone";
|
|
171
183
|
writeLine(door.io.stdout, `${message.id} ${message.created_at} ${who} ${message.content ?? ""}`);
|
|
172
184
|
}
|
|
185
|
+
if (readMove) {
|
|
186
|
+
writeLine(readMove.ok ? door.io.stdout : door.io.stderr, readMove.ok
|
|
187
|
+
? `Read line moved to ${readMove.messageId} (${readMove.changed ? "advanced" : "already at or past it"}).`
|
|
188
|
+
: `Read line NOT moved (${readMove.reason}): ${readMove.detail} Run the same command again to retry.`);
|
|
189
|
+
}
|
|
190
|
+
return exitCode;
|
|
191
|
+
}
|
|
192
|
+
/** `cockpit msg unread <channel> --from <message-id>` — this message and everything after it counts as unread. */
|
|
193
|
+
async function markUnread(command, door) {
|
|
194
|
+
const ref = command.channelRef ?? "";
|
|
195
|
+
const messageId = command.unreadFrom ?? "";
|
|
196
|
+
const resolved = await resolveChannelId(door, ref);
|
|
197
|
+
if (resolved.status === "list_failed")
|
|
198
|
+
return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
|
|
199
|
+
if (resolved.status === "not_found")
|
|
200
|
+
return channelNotFound(door, ref);
|
|
201
|
+
const answer = await askAgentDoor(door, {
|
|
202
|
+
path: `/api/msg/channels/${encodeURIComponent(resolved.id)}/read`,
|
|
203
|
+
method: "POST",
|
|
204
|
+
label: "msg unread",
|
|
205
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
206
|
+
body: { message_id: messageId, action: "unread" },
|
|
207
|
+
});
|
|
208
|
+
if (!answer.ok)
|
|
209
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
210
|
+
const move = answer.body.move ?? {};
|
|
211
|
+
writeLine(door.io.stderr, `${TAG} unread ${JSON.stringify({ channel_id: resolved.id, message_id: messageId })}`);
|
|
212
|
+
if (door.json)
|
|
213
|
+
return emitAgentDoor(door, { ok: true, channelId: resolved.id, messageId, move });
|
|
214
|
+
writeLine(door.io.stdout, `Marked ${messageId} and everything after it unread in ${ref}. Read line now at ${move.lastReadAt ?? "the start"}.`);
|
|
173
215
|
return 0;
|
|
174
216
|
}
|
|
175
217
|
async function readThread(command, door) {
|
|
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
24
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
24
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.239");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
package/dist/commands/setup.js
CHANGED
|
@@ -3,6 +3,10 @@ import { writeLine } from "./cli-io.js";
|
|
|
3
3
|
export async function runSetup(command, io) {
|
|
4
4
|
const door = await openAgentDoor("setup", command, io);
|
|
5
5
|
const query = new URLSearchParams();
|
|
6
|
+
if (command.until)
|
|
7
|
+
query.set("until", command.until);
|
|
8
|
+
if (command.machines)
|
|
9
|
+
query.set("machines", "1");
|
|
6
10
|
if (command.since)
|
|
7
11
|
query.set("since", command.since);
|
|
8
12
|
if (command.person)
|
|
@@ -18,6 +22,10 @@ export async function runSetup(command, io) {
|
|
|
18
22
|
const number = (value) => value === null ? "n/a" : value.toLocaleString(undefined, { maximumFractionDigits: 1 });
|
|
19
23
|
// BLI-13066: the server writes what each number rests on (the share of sessions counted, the sessions judged), so the CLI, the web page and JARVIS say the same words. An older server sends none.
|
|
20
24
|
const coverage = (words) => words ? ` (${words})` : "";
|
|
25
|
+
if (body.window)
|
|
26
|
+
writeLine(io.stdout, `Requested window: ${body.window.since} to ${body.window.until}. ${body.automation_basis ?? "Includes automated work"}`);
|
|
27
|
+
if (body.freshness)
|
|
28
|
+
writeLine(io.stdout, body.freshness.words);
|
|
21
29
|
for (const person of body.people ?? []) {
|
|
22
30
|
writeLine(io.stdout, `${person.rank ? `#${person.rank} ` : "Unranked · "}${person.display_name ?? person.email ?? "Unknown person"}: ${number(person.prs_per_week)} merged PRs/week · ${number(person.tokens_per_pr)} tokens/merged PR${coverage(person.coverage_words?.tokens)} · ${person.yield === null ? "no session judged yet" : `${number(person.yield * 100)}% session yield`}${coverage(person.coverage_words?.yield)}`);
|
|
23
31
|
if (person.tokens_basis)
|
|
@@ -28,6 +36,11 @@ export async function runSetup(command, io) {
|
|
|
28
36
|
if (!person.machines.length)
|
|
29
37
|
writeLine(io.stdout, " No setup received.");
|
|
30
38
|
for (const machine of person.machines) {
|
|
39
|
+
if (machine.health) {
|
|
40
|
+
writeLine(io.stdout, ` Machine reading: ${machine.health.words ?? machine.health.reason}. ${machine.health.next_action?.words ?? ""}`);
|
|
41
|
+
for (const piece of machine.health.pieces ?? [])
|
|
42
|
+
writeLine(io.stdout, ` ${piece.key}: ${piece.status}${piece.reason ? ` (${piece.reason})` : ""}. ${piece.next_action?.words ?? ""}`);
|
|
43
|
+
}
|
|
31
44
|
if (!machine.setup) {
|
|
32
45
|
writeLine(io.stdout, " No readable setup from this machine yet.");
|
|
33
46
|
continue;
|
package/dist/commands/usage.js
CHANGED
|
@@ -5,6 +5,8 @@ import { writeLine } from "./cli-io.js";
|
|
|
5
5
|
export async function runUsage(command, io) {
|
|
6
6
|
const door = await openAgentDoor("usage", command, io);
|
|
7
7
|
const query = new URLSearchParams({ since: command.since });
|
|
8
|
+
if (command.person)
|
|
9
|
+
query.set("person", command.person);
|
|
8
10
|
addUsageGroupingFilters(command, query);
|
|
9
11
|
if (command.action === "sessions")
|
|
10
12
|
return runUsageSessions(command, io, door, query);
|
|
@@ -19,7 +21,7 @@ export async function runUsage(command, io) {
|
|
|
19
21
|
writeLine(io.stdout, String(answer.body.text ?? ""));
|
|
20
22
|
return 0;
|
|
21
23
|
}
|
|
22
|
-
if (command.person) {
|
|
24
|
+
if (command.person && !body.person_filter && !(body.people ?? []).some(person => person.identity)) {
|
|
23
25
|
const email = command.person === "me" ? (await loadPairedSession("usage", command.homeDir)).email : command.person;
|
|
24
26
|
if (!email)
|
|
25
27
|
return failAgentDoor(door, "[usage]", "caller_email_unavailable", "The paired session has no email. Sign in again or pass --person <email>.");
|
|
@@ -65,24 +65,25 @@ function parseSourceRetryEntry(value, fieldPath) {
|
|
|
65
65
|
function parsePendingSessionReport(value, fieldPath) {
|
|
66
66
|
const record = requireRecord(value, fieldPath);
|
|
67
67
|
const sessions = parseMetadataOnlySessionReportRows(record["sessions"], `${fieldPath}.sessions`);
|
|
68
|
+
const text = (key) => requireString(record[key], `${fieldPath}.${key}`);
|
|
68
69
|
return {
|
|
69
|
-
report_id:
|
|
70
|
-
created_at:
|
|
71
|
-
last_attempt_at:
|
|
72
|
-
dashboard_url:
|
|
73
|
-
generated_at:
|
|
74
|
-
work_context_id:
|
|
75
|
-
repo_label:
|
|
76
|
-
branch:
|
|
77
|
-
repo_fingerprint:
|
|
70
|
+
report_id: text("report_id"),
|
|
71
|
+
created_at: text("created_at"),
|
|
72
|
+
last_attempt_at: text("last_attempt_at"),
|
|
73
|
+
dashboard_url: text("dashboard_url"),
|
|
74
|
+
generated_at: text("generated_at"),
|
|
75
|
+
work_context_id: text("work_context_id"),
|
|
76
|
+
repo_label: text("repo_label"),
|
|
77
|
+
branch: text("branch"),
|
|
78
|
+
repo_fingerprint: text("repo_fingerprint"),
|
|
78
79
|
repo_origin_url: record["repo_origin_url"] === undefined
|
|
79
80
|
? null
|
|
80
81
|
: requireNullableString(record["repo_origin_url"], `${fieldPath}.repo_origin_url`),
|
|
81
|
-
worktree_label:
|
|
82
|
-
worktree_fingerprint:
|
|
82
|
+
worktree_label: text("worktree_label"),
|
|
83
|
+
worktree_fingerprint: text("worktree_fingerprint"),
|
|
83
84
|
worktree_is_primary: requireBoolean(record["worktree_is_primary"], `${fieldPath}.worktree_is_primary`),
|
|
84
85
|
sessions,
|
|
85
|
-
failure_reason:
|
|
86
|
+
failure_reason: text("failure_reason"),
|
|
86
87
|
};
|
|
87
88
|
}
|
|
88
89
|
export function parseMetadataOnlySessionReportRows(value, fieldPath) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.239",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/memory-mcp": "0.1.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.70",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.144",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.77"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|