@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.
- package/dist/commands/cal-delete.js +13 -3
- package/dist/commands/cal-direct.js +119 -0
- package/dist/commands/cal-scope.js +71 -0
- package/dist/commands/cal.js +33 -2
- package/dist/commands/docs-share.js +81 -0
- package/dist/commands/docs.js +43 -6
- package/dist/commands/drills-render.js +2 -2
- package/dist/commands/drills.js +4 -4
- package/dist/commands/issue-list-page.js +101 -0
- package/dist/commands/issue-versions.js +93 -0
- package/dist/commands/issue.js +28 -5
- package/dist/commands/local-args-tower-cal.js +113 -9
- package/dist/commands/local-args-tower-docs-msg.js +52 -7
- package/dist/commands/local-args-tower-drills.js +1 -1
- package/dist/commands/local-args-tower-msg.js +62 -7
- package/dist/commands/local-args-tower-setup.js +11 -4
- package/dist/commands/local-args-tower-work.js +97 -11
- package/dist/commands/local-help-commands-tower-ops.js +3 -3
- package/dist/commands/local-help-commands-tower.js +27 -14
- 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-write.js +118 -0
- package/dist/commands/msg.js +64 -31
- package/dist/commands/project.js +15 -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,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
|
@@ -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
|
|
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 ??
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
|
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,
|
|
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.`);
|