@bli-cockpit/cli 0.2.238 → 0.2.240

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -31,8 +31,10 @@ import { assigneeLabel, beginAssigneeDirectoryFetch, finishAssigneeDirectory, wi
31
31
  import { cacheAgeLabel, cacheCoverage, readThroughCache } from "./read-cache.js";
32
32
  import { readTicketBoard } from "./tickets-board.js";
33
33
  import { showIssue } from "./issue-show.js";
34
+ import { addIssuePageQuery, pageFieldsOf, pageFooter, streamAllIssues } from "./issue-list-page.js";
34
35
  import { READ_DEADLINE_MS, TAG, resolveProjectId, sizeForEstimate, withSizeLabel, } from "./issue-contracts.js";
35
36
  import { commentIssue, createIssue, moveIssue, updateIssue } from "./issue-write.js";
37
+ import { listIssueVersions, restoreIssueVersion } from "./issue-versions.js";
36
38
  export async function runIssue(command, io) {
37
39
  const door = await openAgentDoor("issue", command, io);
38
40
  switch (command.action) {
@@ -62,6 +64,10 @@ export async function runIssue(command, io) {
62
64
  return commentIssue(command, door);
63
65
  case "history":
64
66
  return issueHistory(command, door);
67
+ case "versions":
68
+ return listIssueVersions(command, door);
69
+ case "restore":
70
+ return restoreIssueVersion(command, door);
65
71
  }
66
72
  }
67
73
  function issueLine(issue, directory) {
@@ -80,7 +86,7 @@ export function listGapLines(body, requestedLimit) {
80
86
  const returned = body.issues?.length ?? 0;
81
87
  const completeness = body.completeness;
82
88
  if (completeness?.status === "partial") {
83
- lines.push(`${TAG} partial list: ${completeness.returned} of ${completeness.total} matching issues; narrow the filter or pass --limit (at most 1000).`);
89
+ lines.push(`${TAG} partial list: ${completeness.returned} of ${completeness.total} matching issues; narrow the filter, continue with --cursor, or use --all.`);
84
90
  }
85
91
  else if (completeness?.status === "unknown") {
86
92
  lines.push(`${TAG} list may be partial: ${completeness.returned} issues filled the limit and Tower could not count the rest.`);
@@ -105,6 +111,10 @@ async function listIssues(command, door) {
105
111
  const built = await buildIssueListQuery(command, door);
106
112
  if (!built.ok)
107
113
  return built.exitCode;
114
+ addIssuePageQuery(built.query, command);
115
+ // BLI-21315: a streamed export never goes through the read cache or buffers.
116
+ if (command.all)
117
+ return streamAllIssues(command, door, built.query);
108
118
  const suffix = built.query.toString();
109
119
  // BLI-10579: the assignee directory (`/api/team/members`) is the whole org
110
120
  // roster regardless of which issues come back — it never actually depended
@@ -126,7 +136,7 @@ async function listIssues(command, door) {
126
136
  const body = answer.body;
127
137
  const issues = body.issues ?? [];
128
138
  // BLI-10447: the gaps go to stderr in both modes, so `--json` stdout stays one object.
129
- for (const line of listGapLines(body, command.limit ?? 1000))
139
+ for (const line of listGapLines(body, command.limit ?? 100))
130
140
  writeLine(door.io.stderr, line);
131
141
  // BLI-10450: one extra call for the whole list, never one per row — the
132
142
  // uuid `assignee_id` alone forced a second `cockpit team members` call and
@@ -139,6 +149,7 @@ async function listIssues(command, door) {
139
149
  ...cacheCoverage(answer),
140
150
  issues: issues.map((issue) => withSizeLabel(withAssigneeIdentity(issue, directory))),
141
151
  ...(body.completeness ? { completeness: body.completeness } : {}),
152
+ ...pageFieldsOf(body),
142
153
  ...(body.outside_project ? { outside_project: body.outside_project } : {}),
143
154
  });
144
155
  }
@@ -152,6 +163,9 @@ async function listIssues(command, door) {
152
163
  writeLine(door.io.stdout, issueLine(issue, directory));
153
164
  writeLine(door.io.stdout, "");
154
165
  writeLine(door.io.stdout, `${issues.length} issue(s).`);
166
+ const footer = pageFooter(body, "issues");
167
+ if (footer)
168
+ writeLine(door.io.stdout, footer);
155
169
  return 0;
156
170
  }
157
171
  /** The list query for the filters given, or the exit code of the project lookup that failed. */
@@ -225,7 +239,12 @@ function historyChange(row) {
225
239
  }
226
240
  async function issueHistory(command, door) {
227
241
  const ref = command.issueRef ?? "";
228
- const query = command.limit === undefined ? "" : `?limit=${command.limit}`;
242
+ const params = new URLSearchParams();
243
+ if (command.limit !== undefined)
244
+ params.set("limit", String(command.limit));
245
+ if (command.cursor)
246
+ params.set("cursor", command.cursor);
247
+ const query = params.toString() ? `?${params.toString()}` : "";
229
248
  const answer = await askAgentDoor(door, {
230
249
  path: `/api/work/issues/${encodeURIComponent(ref)}/history${query}`,
231
250
  method: "GET",
@@ -234,9 +253,10 @@ async function issueHistory(command, door) {
234
253
  });
235
254
  if (!answer.ok)
236
255
  return failAgentDoor(door, TAG, answer.reason, answer.detail);
237
- const history = answer.body.history ?? [];
256
+ const historyBody = answer.body;
257
+ const history = historyBody.history ?? [];
238
258
  if (door.json)
239
- return emitAgentDoor(door, { ok: true, history });
259
+ return emitAgentDoor(door, { ok: true, history, ...pageFieldsOf(historyBody) });
240
260
  if (history.length === 0) {
241
261
  writeLine(door.io.stdout, `No recorded changes for ${ref}.`);
242
262
  return 0;
@@ -246,5 +266,8 @@ async function issueHistory(command, door) {
246
266
  }
247
267
  writeLine(door.io.stdout, "");
248
268
  writeLine(door.io.stdout, `${history.length} change(s).`);
269
+ const footer = pageFooter(historyBody, "changes");
270
+ if (footer)
271
+ writeLine(door.io.stdout, footer);
249
272
  return 0;
250
273
  }
@@ -20,14 +20,14 @@
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",
27
27
  "find",
28
28
  "calendars",
29
29
  "add-ical",
30
- "change", "cancel",
30
+ "change", "cancel", "rsvp",
31
31
  "create",
32
32
  "share",
33
33
  "detach",
@@ -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", "rsvp"]);
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,18 @@ 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",
74
+ "--scope", "--occurrence", "--response", "--comment", "--reminder", "--visibility", "--transparency", "--key",
69
75
  ];
70
76
  const CAL_VALUE_FLAGS = [
71
77
  "--home",
@@ -86,6 +92,10 @@ const CAL_VALUE_FLAGS = [
86
92
  "--until",
87
93
  "--location",
88
94
  "--attendee",
95
+ "--idempotency-key",
96
+ "--expected-version",
97
+ "--reason",
98
+ "--scope", "--occurrence", "--response", "--comment", "--reminder", "--visibility", "--transparency", "--key",
89
99
  ];
90
100
  export function parseCalArgs(args) {
91
101
  const values = parseNamedArgs(args, { allowedFlags: CAL_ALLOWED_FLAGS, valueFlags: CAL_VALUE_FLAGS });
@@ -98,13 +108,14 @@ export function parseCalArgs(args) {
98
108
  // two of them wrong is told about --limit first, as before.
99
109
  const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
100
110
  const offset = readCalOffset(values);
101
- return buildCalCommand(values, action, subject, query, title, startsAt, endsAt, offset, limit);
111
+ const command = buildCalCommand(values, action, subject, query, title, startsAt, endsAt, offset, limit);
112
+ return { ...command, ...readCalEventScope(action, values) };
102
113
  }
103
114
  function readCalAction(values) {
104
115
  const first = values.positionals[0];
105
116
  const action = (first === undefined ? "today" : first);
106
117
  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.`);
118
+ 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
119
  }
109
120
  return { action, rest: values.positionals.slice(first === undefined ? 0 : 1) };
110
121
  }
@@ -114,8 +125,8 @@ function readCalSubjectOrQuery(action, rest) {
114
125
  if (CAL_ACTIONS_NEEDING_A_SUBJECT.has(action)) {
115
126
  subject = optionalNonEmpty(rest[0]);
116
127
  if (!subject) {
117
- throw new Error(action === "delete"
118
- ? "cal delete needs an event id — take one from a window verb's output (today/week/next/find)."
128
+ throw new Error(action === "delete" || action === "duplicate" || action === "change" || action === "cancel" || action === "rsvp"
129
+ ? `cal ${action} needs an event id — take one from a window verb's output (today/week/next/find).`
119
130
  : `cal ${action} needs a calendar id — take one from \`cockpit cal calendars\`.`);
120
131
  }
121
132
  if (rest.length > 1)
@@ -174,7 +185,7 @@ function readCalOffset(values) {
174
185
  function buildCalCommand(values, action, subject, query, title, startsAt, endsAt, offset, limit) {
175
186
  return {
176
187
  kind: "cal",
177
- occurrenceStart: optionalNonEmpty(values.flags.get("--occurrence-start")),
188
+ occurrenceStart: optionalNonEmpty(values.flags.get("--occurrence") ?? values.flags.get("--occurrence-start")),
178
189
  color: optionalNonEmpty(values.flags.get("--color")),
179
190
  action,
180
191
  homeDir: optionalNonEmpty(values.flags.get("--home")),
@@ -201,6 +212,11 @@ function buildCalCommand(values, action, subject, query, title, startsAt, endsAt
201
212
  location: optionalNonEmpty(values.flags.get("--location")),
202
213
  attendees: splitAddresses(values.flags.get("--attendee")),
203
214
  allDay: values.booleans.has("--all-day"),
215
+ timed: values.booleans.has("--timed"),
216
+ keepInvitees: values.booleans.has("--keep-invitees"),
217
+ idempotencyKey: optionalNonEmpty(values.flags.get("--idempotency-key") ?? values.flags.get("--key")),
218
+ expectedVersion: optionalNonEmpty(values.flags.get("--expected-version")),
219
+ reason: optionalNonEmpty(values.flags.get("--reason")),
204
220
  meet: values.booleans.has("--meet"),
205
221
  orgVisible: values.booleans.has("--org-visible"),
206
222
  makePrivate: values.booleans.has("--private"),
@@ -217,4 +233,92 @@ function splitAddresses(raw) {
217
233
  .split(",")
218
234
  .map((value) => value.trim())
219
235
  .filter((value) => value !== "");
236
+ }
237
+ const SCOPES = ["instance", "following", "series"];
238
+ const RSVP_RESPONSES = ["accepted", "declined", "tentative"];
239
+ const VISIBILITIES = ["default", "public", "private", "confidential"];
240
+ const TRANSPARENCIES = ["opaque", "transparent"];
241
+ const WRITE_ACTIONS = new Set(["create", "change"]);
242
+ /**
243
+ * BLI-21311: the scope, RSVP and event-detail flags. Validated here so a typo
244
+ * never costs a request; the door still owns every rule that needs the event.
245
+ */
246
+ function readCalEventScope(action, values) {
247
+ const out = {};
248
+ const scope = optionalNonEmpty(values.flags.get("--scope"));
249
+ if (scope !== undefined) {
250
+ if (!SCOPES.includes(scope))
251
+ throw new Error("--scope takes instance, following or series.");
252
+ if (action !== "change" && action !== "cancel" && action !== "delete" && action !== "rsvp")
253
+ throw new Error(`cal ${action} does not take --scope.`);
254
+ if (action === "rsvp" && scope === "following")
255
+ throw new Error("An RSVP takes --scope instance or series.");
256
+ out.scope = scope;
257
+ }
258
+ if (action === "rsvp") {
259
+ const response = optionalNonEmpty(values.flags.get("--response"));
260
+ if (!response || !RSVP_RESPONSES.includes(response)) {
261
+ throw new Error("cal rsvp needs --response accepted, declined or tentative.");
262
+ }
263
+ out.rsvpResponse = response;
264
+ out.rsvpComment = optionalNonEmpty(values.flags.get("--comment"));
265
+ return out;
266
+ }
267
+ if (values.flags.has("--response") || values.flags.has("--comment"))
268
+ throw new Error("--response and --comment belong to cal rsvp.");
269
+ if (!WRITE_ACTIONS.has(action)) {
270
+ for (const flag of ["--reminder", "--visibility", "--transparency"]) {
271
+ if (values.flags.has(flag))
272
+ throw new Error(`cal ${action} does not take ${flag}.`);
273
+ }
274
+ return out;
275
+ }
276
+ const color = optionalNonEmpty(values.flags.get("--color"));
277
+ if (color !== undefined) {
278
+ if (color === "none")
279
+ out.colorId = null;
280
+ else if (/^(?:[1-9]|10|11)$/.test(color))
281
+ out.colorId = Number(color);
282
+ else
283
+ throw new Error("--color takes a colour number 1 to 11, or none.");
284
+ }
285
+ const reminders = readReminders(values.flagValues.get("--reminder") ?? []);
286
+ if (reminders !== undefined)
287
+ out.reminders = reminders;
288
+ const visibility = optionalNonEmpty(values.flags.get("--visibility"));
289
+ if (visibility !== undefined) {
290
+ if (!VISIBILITIES.includes(visibility))
291
+ throw new Error(`--visibility takes ${VISIBILITIES.join(", ")}.`);
292
+ out.visibility = visibility;
293
+ }
294
+ const transparency = optionalNonEmpty(values.flags.get("--transparency"));
295
+ if (transparency !== undefined) {
296
+ if (!TRANSPARENCIES.includes(transparency))
297
+ throw new Error(`--transparency takes ${TRANSPARENCIES.join(" or ")}.`);
298
+ out.transparency = transparency;
299
+ }
300
+ return out;
301
+ }
302
+ /** `--reminder 10 --reminder 60:email` or `--reminder default`. Max 5, 0..40320 minutes. */
303
+ function readReminders(raw) {
304
+ if (raw.length === 0)
305
+ return undefined;
306
+ if (raw.includes("default")) {
307
+ if (raw.length > 1)
308
+ throw new Error("--reminder default stands alone: it means the calendar's own reminders.");
309
+ return "default";
310
+ }
311
+ if (raw.length > 5)
312
+ throw new Error("An event holds at most 5 reminders.");
313
+ return raw.map((entry) => {
314
+ const [minutesText, method, extra] = entry.split(":");
315
+ if (extra !== undefined || !/^\d+$/.test(minutesText ?? ""))
316
+ throw new Error("--reminder takes minutes (0 to 40320), optionally minutes:popup or minutes:email, or default.");
317
+ const minutes = Number(minutesText);
318
+ if (minutes > 40320)
319
+ throw new Error("--reminder minutes run from 0 to 40320 (four weeks).");
320
+ if (method !== undefined && method !== "popup" && method !== "email")
321
+ throw new Error("A reminder method is popup or email.");
322
+ return { minutes, ...(method ? { method } : {}) };
323
+ });
220
324
  }
@@ -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", "share", "access"]);
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", "share", "access"]);
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",
@@ -34,6 +37,9 @@ const DOCS_ALLOWED_FLAGS = [
34
37
  "--offset",
35
38
  "--format",
36
39
  "--out",
40
+ "--add",
41
+ "--remove",
42
+ "--reason",
37
43
  "--json",
38
44
  ];
39
45
  const DOCS_VALUE_FLAGS = [
@@ -41,6 +47,8 @@ const DOCS_VALUE_FLAGS = [
41
47
  "--dashboard-url",
42
48
  "--title",
43
49
  "--parent",
50
+ "--expected-revision",
51
+ "--request-id",
44
52
  "--visibility",
45
53
  "--file",
46
54
  "--query",
@@ -49,21 +57,27 @@ const DOCS_VALUE_FLAGS = [
49
57
  "--offset",
50
58
  "--format",
51
59
  "--out",
60
+ "--add",
61
+ "--remove",
62
+ "--reason",
52
63
  ];
53
64
  export function parseDocsArgs(args) {
54
65
  const values = parseNamedArgs(args, { allowedFlags: DOCS_ALLOWED_FLAGS, valueFlags: DOCS_VALUE_FLAGS });
55
66
  const { action, rest } = parseDocsAction(values.positionals);
56
67
  const link = parseDocsLink(action, rest, values.booleans.has("--clear"));
57
68
  const { docRef, docRefs } = parseDocsDocumentReference(action, link.rest);
58
- const { visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset } = parseDocsFlags(action, values);
69
+ const { visibility, clearParent, parentId, allowEmpty, title, requestId, expectedRevision, query, wholeWord, limit, bodies, section, offset } = parseDocsFlags(action, values);
59
70
  if (action === "read")
60
71
  validateDocsReadTargets(docRefs, parentId, bodies);
72
+ if (action === "mutation-status" && !UUID_PATTERN.test(docRef ?? "")) {
73
+ throw new Error("docs mutation-status needs a request UUID (the --request-id you sent, or the one `docs update` printed).");
74
+ }
61
75
  // BLI‑10555: `delete` needs no `--parent`/`--bodies` shape, only at least
62
76
  // one reference — `read`'s emptier validation does not apply.
63
77
  if (action === "delete" && (!docRefs || docRefs.length === 0)) {
64
78
  throw new Error("docs delete needs one or more document ids/slugs.");
65
79
  }
66
- return buildDocsCommand(values, action, docRef, docRefs, visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset, link);
80
+ return buildDocsCommand(values, action, docRef, docRefs, visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset, link, { requestId, expectedRevision });
67
81
  }
68
82
  /**
69
83
  * BLI-10450: `read` takes either several document references OR
@@ -115,7 +129,7 @@ function parseDocsAction(positionals) {
115
129
  const first = positionals[0];
116
130
  const action = (first === undefined ? "list" : first);
117
131
  if (!DOCS_ACTIONS.has(action)) {
118
- throw new Error(`Unknown docs command: ${first}. Try list, tree, read, create, update, delete, link, sync-status, or export.`);
132
+ throw new Error(`Unknown docs command: ${first}. Try list, tree, read, create, update, delete, link, sync-status, export, mutation-status, share, or access.`);
119
133
  }
120
134
  return { action, rest: positionals.slice(first === undefined ? 0 : 1) };
121
135
  }
@@ -173,11 +187,19 @@ function parseDocsWriteFlags(action, values) {
173
187
  if (allowEmpty && action !== "update") {
174
188
  throw new Error("--allow-empty belongs to `cockpit docs update`.");
175
189
  }
190
+ // BLI-21298: the write-safety pair belongs to `update`, like the other save-shaping flags.
191
+ if ((values.flags.has("--request-id") || values.flags.has("--expected-revision")) && action !== "update") {
192
+ throw new Error("--request-id and --expected-revision belong to `cockpit docs update`.");
193
+ }
194
+ const requestId = optionalNonEmpty(values.flags.get("--request-id"));
195
+ if (requestId && !UUID_PATTERN.test(requestId))
196
+ throw new Error("--request-id must be a UUID.");
197
+ const expectedRevision = optionalPositiveInteger(values.flags.get("--expected-revision"), "--expected-revision");
176
198
  const title = optionalNonEmpty(values.flags.get("--title"));
177
199
  if (action === "create" && !title) {
178
200
  throw new Error("docs create needs --title.");
179
201
  }
180
- return { visibility, clearParent, parentId, allowEmpty, title };
202
+ return { visibility, clearParent, parentId, allowEmpty, title, requestId, expectedRevision };
181
203
  }
182
204
  /** The flags that narrow or slice what a list or read returns. */
183
205
  function parseDocsReadFlags(action, values) {
@@ -217,6 +239,26 @@ function parseDocsReadFlags(action, values) {
217
239
  throw new Error("--offset belongs to `cockpit docs read`.");
218
240
  return { query, wholeWord, limit, bodies, section, offset };
219
241
  }
242
+ /**
243
+ * BLI-21313: `docs share <doc> --add <email> | --remove <email> [--reason <text>]` names or removes a page editor;
244
+ * `docs access <doc>` reads who may edit and why. A person is an exact email address, never a fuzzy name.
245
+ */
246
+ function parseDocsShareFlags(action, values) {
247
+ const addPerson = optionalNonEmpty(values.flags.get("--add"));
248
+ const removePerson = optionalNonEmpty(values.flags.get("--remove"));
249
+ const reason = optionalNonEmpty(values.flags.get("--reason"));
250
+ if ((addPerson || removePerson || reason) && action !== "share") {
251
+ throw new Error("--add, --remove and --reason belong to `cockpit docs share`.");
252
+ }
253
+ if (action === "share") {
254
+ if (Boolean(addPerson) === Boolean(removePerson)) {
255
+ throw new Error("docs share needs exactly one of --add <email> or --remove <email>.");
256
+ }
257
+ if (reason && reason.length > 200)
258
+ throw new Error("--reason can be at most 200 characters.");
259
+ }
260
+ return { ...(addPerson ? { addPerson } : {}), ...(removePerson ? { removePerson } : {}), ...(reason ? { reason } : {}) };
261
+ }
220
262
  function parseDocsOffset(raw) {
221
263
  const offsetRaw = optionalNonEmpty(raw);
222
264
  if (offsetRaw === undefined)
@@ -226,7 +268,7 @@ function parseDocsOffset(raw) {
226
268
  throw new Error("--offset must be a whole number 0 or greater.");
227
269
  return offset;
228
270
  }
229
- function buildDocsCommand(values, action, docRef, docRefs, visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset, link) {
271
+ function buildDocsCommand(values, action, docRef, docRefs, visibility, clearParent, parentId, allowEmpty, title, query, wholeWord, limit, bodies, section, offset, link, safety) {
230
272
  return {
231
273
  kind: "docs",
232
274
  action,
@@ -238,6 +280,8 @@ function buildDocsCommand(values, action, docRef, docRefs, visibility, clearPare
238
280
  ...(parentId ? { parentId } : {}),
239
281
  clearParent,
240
282
  ...(allowEmpty ? { allowEmpty } : {}),
283
+ ...(safety.requestId ? { requestId: safety.requestId } : {}),
284
+ ...(safety.expectedRevision === undefined ? {} : { expectedRevision: safety.expectedRevision }),
241
285
  visibility: visibility,
242
286
  filePath: optionalNonEmpty(values.flags.get("--file")),
243
287
  bodyStdin: values.booleans.has("--body-stdin"),
@@ -250,6 +294,7 @@ function buildDocsCommand(values, action, docRef, docRefs, visibility, clearPare
250
294
  ...(link.notionPage ? { notionPage: link.notionPage } : {}),
251
295
  ...(link.clearLink ? { clearLink: true } : {}),
252
296
  ...parseDocsExportFlags(action, values),
297
+ ...parseDocsShareFlags(action, values),
253
298
  json: values.booleans.has("--json"),
254
299
  };
255
300
  }
@@ -54,7 +54,7 @@ export function parseDrillsArgs(args) {
54
54
  if (action === "theme-set") {
55
55
  theme = rest.join(" ").trim();
56
56
  if (!theme)
57
- throw new Error("`cockpit drills theme set` takes a theme: video-game, boxing, muay-thai, crime-drama, anime, sports, or your own words.");
57
+ throw new Error("`cockpit drills theme set` takes a theme: plain (no theme, the default), video-game, sports, movies-tv, music, anime, cooking, or anything you're into in your own words.");
58
58
  }
59
59
  else if (rest.length > 0)
60
60
  throw new Error(`\`cockpit drills ${action}\` takes no positional arguments.`);