@bli-cockpit/cli 0.2.239 → 0.2.241

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.
Files changed (33) hide show
  1. package/dist/commands/cal-delete.js +13 -3
  2. package/dist/commands/cal-scope.js +71 -0
  3. package/dist/commands/cal-view-settings.js +61 -0
  4. package/dist/commands/cal.js +11 -0
  5. package/dist/commands/docs-share.js +81 -0
  6. package/dist/commands/docs.js +5 -0
  7. package/dist/commands/drills-render.js +2 -2
  8. package/dist/commands/drills.js +4 -4
  9. package/dist/commands/issue-list-page.js +101 -0
  10. package/dist/commands/issue.js +23 -5
  11. package/dist/commands/local-arg-values.js +90 -19
  12. package/dist/commands/local-args-tower-cal.js +148 -8
  13. package/dist/commands/local-args-tower-docs-msg.js +30 -3
  14. package/dist/commands/local-args-tower-drills.js +1 -1
  15. package/dist/commands/local-args-tower-manager.js +128 -28
  16. package/dist/commands/local-args-tower-msg.js +43 -6
  17. package/dist/commands/local-args-tower-work.js +74 -6
  18. package/dist/commands/local-help-commands-tower-ops.js +35 -21
  19. package/dist/commands/local-help-commands-tower.js +15 -11
  20. package/dist/commands/local-help.js +1 -1
  21. package/dist/commands/manager-fields-file.js +208 -0
  22. package/dist/commands/manager-hints.js +88 -0
  23. package/dist/commands/manager-limits.json +269 -0
  24. package/dist/commands/manager-read-reasons.js +78 -0
  25. package/dist/commands/manager-render-home.js +213 -37
  26. package/dist/commands/manager-render.js +119 -21
  27. package/dist/commands/manager-requests.js +7 -3
  28. package/dist/commands/manager.js +67 -43
  29. package/dist/commands/msg-write.js +118 -0
  30. package/dist/commands/msg.js +20 -29
  31. package/dist/commands/project.js +15 -3
  32. package/dist/commands/public-root.js +1 -1
  33. package/package.json +2 -2
@@ -9,9 +9,13 @@
9
9
  * verbatim: no logic changed, and `normalizeUrl` is still re-exported from
10
10
  * `./local-args.js` for the callers that had it there.
11
11
  *
12
- * `rejectServiceRoleLikeArgument` runs on every token, flag or positional, and
13
- * is why a service-role credential cannot enter a collector command from the
14
- * command line at all.
12
+ * `rejectSupabaseServiceCredential` runs on every token, flag or positional of every command, and is
13
+ * why a Supabase service credential (a service-role JWT, or the newer `sb_secret_…` key) cannot enter a
14
+ * collector command from the command line. It looks at
15
+ * the SHAPE of a value, never at words: a note that says "the Supabase service-role key" is
16
+ * plain text. The wider telemetry-core secret rulebook (`rejectSecretLikeValue`) is NOT run
17
+ * here: it reads words like "password: x" or "M0-…-review" as secrets, which no other command
18
+ * may be refused for. Only `cockpit manager`'s free-text values go through it (BLI-5777).
15
19
  */
16
20
  const WORK_ROOT_FLAGS = ["--repo", "--workspace"];
17
21
  export function parseNamedArgs(args, options) {
@@ -23,12 +27,15 @@ export function parseNamedArgs(args, options) {
23
27
  const positionals = [];
24
28
  for (let index = 0; index < args.length; index += 1) {
25
29
  const arg = args[index] ?? "";
26
- rejectServiceRoleLikeArgument(arg);
30
+ rejectSupabaseServiceCredential(arg);
27
31
  if (!arg.startsWith("--")) {
28
32
  positionals.push(arg);
29
33
  continue;
30
34
  }
31
- const [flag, inlineValue] = arg.split("=", 2);
35
+ // Split on the FIRST "=" only: the value may hold more of them ("ARR = 1M", a URL query).
36
+ const equalsAt = arg.indexOf("=");
37
+ const flag = equalsAt === -1 ? arg : arg.slice(0, equalsAt);
38
+ const inlineValue = equalsAt === -1 ? undefined : arg.slice(equalsAt + 1);
32
39
  if (!allowed.has(flag))
33
40
  throw new Error(`Unknown flag: ${flag}`);
34
41
  if (valueFlags.has(flag)) {
@@ -36,7 +43,7 @@ export function parseNamedArgs(args, options) {
36
43
  if (!value || value.startsWith("--")) {
37
44
  throw new Error(`${flag} requires a value.`);
38
45
  }
39
- rejectServiceRoleLikeArgument(value);
46
+ rejectSupabaseServiceCredential(value);
40
47
  flags.set(flag, value);
41
48
  const existing = flagValues.get(flag) ?? [];
42
49
  existing.push(value);
@@ -139,14 +146,84 @@ export function normalizeUrl(value) {
139
146
  throw new Error("URL value cannot be empty.");
140
147
  return trimmed;
141
148
  }
142
- function rejectServiceRoleLikeArgument(value) {
143
- if (!looksLikeServiceRoleSecret(value))
144
- return;
145
- throw new Error("Service-role credentials are not accepted by local collector commands.");
149
+ /**
150
+ * Supabase's newer secret key format (`SUPABASE_SECRET_KEY`, admin-contracts.ts): not a JWT, so it needs its own shape.
151
+ * A provider prefix plus a key-length body: prose about the key ("SUPABASE_SECRET_KEY docs") never matches.
152
+ */
153
+ const SUPABASE_SECRET_KEY_SHAPE = /\bsb_secret_[A-Za-z0-9_-]{16}[A-Za-z0-9_-]*/;
154
+ /**
155
+ * Every command, every token: refuses a Supabase service credential, by SHAPE: a JWT whose payload says it is the
156
+ * privileged role, or an `sb_secret_…` key. The words "service-role key" are plain text (BLI-5777: a meeting note that
157
+ * named one was refused, and an agent reworded the client's words to get through).
158
+ */
159
+ export function rejectSupabaseServiceCredential(value) {
160
+ if (looksLikeServiceRoleJwt(value) || containsServiceRoleJwt(value) || SUPABASE_SECRET_KEY_SHAPE.test(value)) {
161
+ throw new Error("Service-role credentials are not accepted by local collector commands.");
162
+ }
163
+ }
164
+ /**
165
+ * The key SHAPES `cockpit manager` free text is refused for: only high-precision ones, each a provider's own key format, so a
166
+ * sentence about a key, a commit sha or a session name is never refused. Copied from the telemetry-core rulebook
167
+ * (`packages/cockpit-telemetry-core/src/secret-guards-content-rules.ts`, `SECRET_VALUE_PATTERNS` and the password rule), which is
168
+ * built to over-match for redaction (it masks and uploads, never refuses) and is NOT used here: its mem0 rule `m0[-_]…` reads
169
+ * "M0-deliverable-review-session" as a key. Left out on purpose: that rule, `name=value` assignments other than a password, and
170
+ * any generic entropy, token or session-id shape.
171
+ */
172
+ export const MANAGER_KEY_SHAPES = [
173
+ // Any JWT (a Supabase anon, service-role or user token): three base64url parts, the first starting `eyJ`.
174
+ /\beyJ[A-Za-z0-9_-]{10}[A-Za-z0-9_-]*\.[A-Za-z0-9_-]{10}[A-Za-z0-9_-]*\.[A-Za-z0-9_-]{10}[A-Za-z0-9_-]*\b/,
175
+ // Real keys are long and hold digits; prose ("Upgrade sk-learn-pipeline-improvements", "rk_quarterly_review_notes_final") does not: a digit is required,
176
+ // and sk- needs a 32+ character body, rk_ and the _live_/_test_ keys 24+. (Not pk_live_: a publishable key is public.)
177
+ /\bsk-(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{32,}/,
178
+ /\b(?:sk|rk)_(?:live|test)_(?=[A-Za-z0-9]*\d)[A-Za-z0-9]{24,}/,
179
+ /\brk_(?=[A-Za-z0-9_]*\d)[A-Za-z0-9_]{24,}/,
180
+ SUPABASE_SECRET_KEY_SHAPE,
181
+ /\bgh[pousr]_(?=[A-Za-z0-9_]*\d)[A-Za-z0-9_]{16,}/,
182
+ /\bgithub_pat_(?=[A-Za-z0-9_]*\d)[A-Za-z0-9_]{20,}/,
183
+ /\blin_api_(?=[A-Za-z0-9]*\d)[A-Za-z0-9]{16,}/,
184
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/,
185
+ /\bxox[abprs]-(?=[A-Za-z0-9-]*\d)[A-Za-z0-9-]{16,}/,
186
+ /\bBearer\s+(?=[A-Za-z0-9._~+/=-]*\d)[A-Za-z0-9._~+/=-]{16,}/,
187
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
188
+ ];
189
+ /** A candidate password: the word, then `:` or `=` and a value (its quotes aside). */
190
+ const PASSWORD_ASSIGNMENT = /(?<![A-Za-z0-9])(?:password|passwd|passcode)(?:[_-][A-Za-z0-9_-]{0,40})?["']?\s*[:=]\s*["']?([^\s"'`,;<>(){}\[\]]{6,})/gi;
191
+ /**
192
+ * A password ASSIGNED a value is a secret when the value holds a digit or a symbol ("password: hunter2", `passwd="x1y2z3"`, "admin password: Tr0ub4dor&3").
193
+ * Prose is text: the word alone ("password reset flow"), a value of letters ("Password: rotated."), sentence punctuation after it (".,!?;:"), a hyphen or
194
+ * apostrophe between letters ("password: on-site", "Client password: e-mailed to Jo") and the product name 1Password ("Shared password: 1Password vault").
195
+ */
196
+ export function looksLikePasswordAssignment(value) {
197
+ for (const match of value.matchAll(PASSWORD_ASSIGNMENT)) {
198
+ const plain = match[1].replace(/[.,!?;:]+$/, "").replace(/1password/gi, "").replace(/(?<=[A-Za-z])[-'’](?=[A-Za-z])/g, "");
199
+ if (/[^A-Za-z]/.test(plain))
200
+ return true;
201
+ }
202
+ return false;
203
+ }
204
+ /** The whole manager free-text question: does this text hold a key shape or an assigned password? */
205
+ export function looksLikeManagerSecret(value) {
206
+ return MANAGER_KEY_SHAPES.some((shape) => shape.test(value)) || looksLikePasswordAssignment(value);
207
+ }
208
+ /**
209
+ * `cockpit manager` free text only (its text flags and every `--fields-file` value): the service-role JWT, plus the key shapes above.
210
+ * `where` names the flag or file key, so the sentence says which one; the value is never echoed.
211
+ */
212
+ export function rejectSecretLikeValue(value, where) {
213
+ // The named sentence first (KEY_SHAPES holds the JWT and sb_secret_ shapes too); the service-role JWT check stays as the floor.
214
+ if (looksLikeManagerSecret(value)) {
215
+ throw new Error(`The value of ${where} looks like a secret (a key, token or password), and secrets are not accepted there. Take the secret out and run it again; text that only talks about a key is fine.`);
216
+ }
217
+ rejectSupabaseServiceCredential(value);
146
218
  }
147
- function looksLikeServiceRoleSecret(value) {
148
- if (serviceCredentialNamePattern().test(value))
149
- return true;
219
+ /**
220
+ * A service-role JWT anywhere inside the value ("the service-role key=<jwt> for staging."): every token that starts at a JWT header (`eyJ`, which every
221
+ * Supabase JWT does) is decoded. Anchored on the header, not on any three dotted words, so a JWT glued after a dotted word ("staging.eyJ….….…") is found whole.
222
+ */
223
+ function containsServiceRoleJwt(value) {
224
+ return (value.match(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g) ?? []).some((token) => looksLikeServiceRoleJwt(token));
225
+ }
226
+ function looksLikeServiceRoleJwt(value) {
150
227
  const parts = value.split(".");
151
228
  if (parts.length !== 3)
152
229
  return false;
@@ -162,12 +239,6 @@ function looksLikeServiceRoleSecret(value) {
162
239
  return false;
163
240
  }
164
241
  }
165
- function serviceCredentialNamePattern() {
166
- return new RegExp([
167
- ["SUPABASE", "SERVICE", "ROLE", "KEY"].join("[_-]?"),
168
- ["service", "role"].join("[_-]?"),
169
- ].join("|"), "i");
170
- }
171
242
  function serviceCredentialPayloadPattern() {
172
243
  const privilegedRole = ["service", "role"].join("_");
173
244
  return new RegExp(`"role"\\s*:\\s*"${privilegedRole}"`);
@@ -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", "duplicate", "batch", "undo", "conflicts", "calendar-change",
23
+ "event", "export", "duplicate", "batch", "undo", "conflicts", "calendar-change", "view-settings", "view-settings-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,7 +36,7 @@ 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", "duplicate", "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
41
  const CAL_ACTIONS_WITH_OPTIONAL_SUBJECT = new Set(["sync-status", "undo"]);
42
42
  const CAL_ALLOWED_FLAGS = [
@@ -71,6 +71,8 @@ const CAL_ALLOWED_FLAGS = [
71
71
  "--idempotency-key",
72
72
  "--expected-version",
73
73
  "--reason",
74
+ "--scope", "--occurrence", "--response", "--comment", "--reminder", "--visibility", "--transparency", "--key",
75
+ "--view", "--days", "--week-start", "--hide-weekends", "--density", "--zone",
74
76
  ];
75
77
  const CAL_VALUE_FLAGS = [
76
78
  "--home",
@@ -94,6 +96,8 @@ const CAL_VALUE_FLAGS = [
94
96
  "--idempotency-key",
95
97
  "--expected-version",
96
98
  "--reason",
99
+ "--scope", "--occurrence", "--response", "--comment", "--reminder", "--visibility", "--transparency", "--key",
100
+ "--view", "--days", "--week-start", "--hide-weekends", "--density", "--zone",
97
101
  ];
98
102
  export function parseCalArgs(args) {
99
103
  const values = parseNamedArgs(args, { allowedFlags: CAL_ALLOWED_FLAGS, valueFlags: CAL_VALUE_FLAGS });
@@ -106,13 +110,14 @@ export function parseCalArgs(args) {
106
110
  // two of them wrong is told about --limit first, as before.
107
111
  const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
108
112
  const offset = readCalOffset(values);
109
- return buildCalCommand(values, action, subject, query, title, startsAt, endsAt, offset, limit);
113
+ const command = buildCalCommand(values, action, subject, query, title, startsAt, endsAt, offset, limit);
114
+ return { ...command, ...readCalEventScope(action, values), ...readViewSettingsPatch(action, values) };
110
115
  }
111
116
  function readCalAction(values) {
112
117
  const first = values.positionals[0];
113
118
  const action = (first === undefined ? "today" : first);
114
119
  if (!CAL_ACTIONS.has(action)) {
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.`);
120
+ throw new Error(`Unknown cal command: ${first}. Try today, week, conflicts, calendar-change, view-settings, view-settings-change, next, find, calendars, add-ical, create, duplicate, batch, undo, share, detach, sync, sync-status, or delete.`);
116
121
  }
117
122
  return { action, rest: values.positionals.slice(first === undefined ? 0 : 1) };
118
123
  }
@@ -122,7 +127,7 @@ function readCalSubjectOrQuery(action, rest) {
122
127
  if (CAL_ACTIONS_NEEDING_A_SUBJECT.has(action)) {
123
128
  subject = optionalNonEmpty(rest[0]);
124
129
  if (!subject) {
125
- throw new Error(action === "delete" || action === "duplicate"
130
+ throw new Error(action === "delete" || action === "duplicate" || action === "change" || action === "cancel" || action === "rsvp"
126
131
  ? `cal ${action} needs an event id — take one from a window verb's output (today/week/next/find).`
127
132
  : `cal ${action} needs a calendar id — take one from \`cockpit cal calendars\`.`);
128
133
  }
@@ -182,7 +187,7 @@ function readCalOffset(values) {
182
187
  function buildCalCommand(values, action, subject, query, title, startsAt, endsAt, offset, limit) {
183
188
  return {
184
189
  kind: "cal",
185
- occurrenceStart: optionalNonEmpty(values.flags.get("--occurrence-start")),
190
+ occurrenceStart: optionalNonEmpty(values.flags.get("--occurrence") ?? values.flags.get("--occurrence-start")),
186
191
  color: optionalNonEmpty(values.flags.get("--color")),
187
192
  action,
188
193
  homeDir: optionalNonEmpty(values.flags.get("--home")),
@@ -211,7 +216,7 @@ function buildCalCommand(values, action, subject, query, title, startsAt, endsAt
211
216
  allDay: values.booleans.has("--all-day"),
212
217
  timed: values.booleans.has("--timed"),
213
218
  keepInvitees: values.booleans.has("--keep-invitees"),
214
- idempotencyKey: optionalNonEmpty(values.flags.get("--idempotency-key")),
219
+ idempotencyKey: optionalNonEmpty(values.flags.get("--idempotency-key") ?? values.flags.get("--key")),
215
220
  expectedVersion: optionalNonEmpty(values.flags.get("--expected-version")),
216
221
  reason: optionalNonEmpty(values.flags.get("--reason")),
217
222
  meet: values.booleans.has("--meet"),
@@ -230,4 +235,139 @@ function splitAddresses(raw) {
230
235
  .split(",")
231
236
  .map((value) => value.trim())
232
237
  .filter((value) => value !== "");
238
+ }
239
+ const SCOPES = ["instance", "following", "series"];
240
+ const RSVP_RESPONSES = ["accepted", "declined", "tentative"];
241
+ const VISIBILITIES = ["default", "public", "private", "confidential"];
242
+ const TRANSPARENCIES = ["opaque", "transparent"];
243
+ const WRITE_ACTIONS = new Set(["create", "change"]);
244
+ /**
245
+ * BLI-21311: the scope, RSVP and event-detail flags. Validated here so a typo
246
+ * never costs a request; the door still owns every rule that needs the event.
247
+ */
248
+ function readCalEventScope(action, values) {
249
+ const out = {};
250
+ const scope = optionalNonEmpty(values.flags.get("--scope"));
251
+ if (scope !== undefined) {
252
+ if (!SCOPES.includes(scope))
253
+ throw new Error("--scope takes instance, following or series.");
254
+ if (action !== "change" && action !== "cancel" && action !== "delete" && action !== "rsvp")
255
+ throw new Error(`cal ${action} does not take --scope.`);
256
+ if (action === "rsvp" && scope === "following")
257
+ throw new Error("An RSVP takes --scope instance or series.");
258
+ out.scope = scope;
259
+ }
260
+ if (action === "rsvp") {
261
+ const response = optionalNonEmpty(values.flags.get("--response"));
262
+ if (!response || !RSVP_RESPONSES.includes(response)) {
263
+ throw new Error("cal rsvp needs --response accepted, declined or tentative.");
264
+ }
265
+ out.rsvpResponse = response;
266
+ out.rsvpComment = optionalNonEmpty(values.flags.get("--comment"));
267
+ return out;
268
+ }
269
+ if (values.flags.has("--response") || values.flags.has("--comment"))
270
+ throw new Error("--response and --comment belong to cal rsvp.");
271
+ if (!WRITE_ACTIONS.has(action)) {
272
+ for (const flag of ["--reminder", "--visibility", "--transparency"]) {
273
+ if (values.flags.has(flag))
274
+ throw new Error(`cal ${action} does not take ${flag}.`);
275
+ }
276
+ return out;
277
+ }
278
+ const color = optionalNonEmpty(values.flags.get("--color"));
279
+ if (color !== undefined) {
280
+ if (color === "none")
281
+ out.colorId = null;
282
+ else if (/^(?:[1-9]|10|11)$/.test(color))
283
+ out.colorId = Number(color);
284
+ else
285
+ throw new Error("--color takes a colour number 1 to 11, or none.");
286
+ }
287
+ const reminders = readReminders(values.flagValues.get("--reminder") ?? []);
288
+ if (reminders !== undefined)
289
+ out.reminders = reminders;
290
+ const visibility = optionalNonEmpty(values.flags.get("--visibility"));
291
+ if (visibility !== undefined) {
292
+ if (!VISIBILITIES.includes(visibility))
293
+ throw new Error(`--visibility takes ${VISIBILITIES.join(", ")}.`);
294
+ out.visibility = visibility;
295
+ }
296
+ const transparency = optionalNonEmpty(values.flags.get("--transparency"));
297
+ if (transparency !== undefined) {
298
+ if (!TRANSPARENCIES.includes(transparency))
299
+ throw new Error(`--transparency takes ${TRANSPARENCIES.join(" or ")}.`);
300
+ out.transparency = transparency;
301
+ }
302
+ return out;
303
+ }
304
+ /** `--reminder 10 --reminder 60:email` or `--reminder default`. Max 5, 0..40320 minutes. */
305
+ function readReminders(raw) {
306
+ if (raw.length === 0)
307
+ return undefined;
308
+ if (raw.includes("default")) {
309
+ if (raw.length > 1)
310
+ throw new Error("--reminder default stands alone: it means the calendar's own reminders.");
311
+ return "default";
312
+ }
313
+ if (raw.length > 5)
314
+ throw new Error("An event holds at most 5 reminders.");
315
+ return raw.map((entry) => {
316
+ const [minutesText, method, extra] = entry.split(":");
317
+ if (extra !== undefined || !/^\d+$/.test(minutesText ?? ""))
318
+ throw new Error("--reminder takes minutes (0 to 40320), optionally minutes:popup or minutes:email, or default.");
319
+ const minutes = Number(minutesText);
320
+ if (minutes > 40320)
321
+ throw new Error("--reminder minutes run from 0 to 40320 (four weeks).");
322
+ if (method !== undefined && method !== "popup" && method !== "email")
323
+ throw new Error("A reminder method is popup or email.");
324
+ return { minutes, ...(method ? { method } : {}) };
325
+ });
326
+ }
327
+ const VIEW_FLAG_KEYS = ["--view", "--days", "--week-start", "--hide-weekends", "--density", "--zone"];
328
+ /**
329
+ * BLI-21423 `view-settings-change`: the flags become the same partial patch the
330
+ * door takes. Types are read here; the door owns the ranges and the zone list.
331
+ */
332
+ function readViewSettingsPatch(action, values) {
333
+ if (action !== "view-settings-change") {
334
+ for (const flag of VIEW_FLAG_KEYS) {
335
+ if (values.flags.has(flag))
336
+ throw new Error(`cal ${action} does not take ${flag}; it belongs to cal view-settings-change.`);
337
+ }
338
+ return {};
339
+ }
340
+ const patch = {};
341
+ const view = optionalNonEmpty(values.flags.get("--view"));
342
+ if (view !== undefined)
343
+ patch.default_view = view;
344
+ const days = optionalNonEmpty(values.flags.get("--days"));
345
+ if (days !== undefined) {
346
+ if (!/^\d+$/.test(days))
347
+ throw new Error("--days takes a whole number from 2 to 9.");
348
+ patch.day_count = Number(days);
349
+ }
350
+ const weekStart = optionalNonEmpty(values.flags.get("--week-start"));
351
+ if (weekStart !== undefined)
352
+ patch.week_start = weekStart;
353
+ const hide = optionalNonEmpty(values.flags.get("--hide-weekends"));
354
+ if (hide !== undefined) {
355
+ if (hide !== "on" && hide !== "off")
356
+ throw new Error("--hide-weekends takes on or off.");
357
+ patch.hide_weekends = hide === "on";
358
+ }
359
+ const density = optionalNonEmpty(values.flags.get("--density"));
360
+ if (density !== undefined)
361
+ patch.density = density;
362
+ const zones = values.flagValues.get("--zone") ?? [];
363
+ if (zones.length) {
364
+ if (zones.includes("none")) {
365
+ if (zones.length > 1)
366
+ throw new Error("--zone none stands alone: it clears the extra zones.");
367
+ patch.extra_zones = [];
368
+ }
369
+ else
370
+ patch.extra_zones = zones;
371
+ }
372
+ return Object.keys(patch).length ? { viewSettings: patch } : {};
233
373
  }
@@ -8,14 +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", "mutation-status"]);
11
+ const DOCS_ACTIONS = new Set(["list", "tree", "read", "create", "update", "delete", "link", "sync-status", "export", "mutation-status", "share", "access"]);
12
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;
13
13
  // BLI-10450: `read` moved to its own branch in `parseDocsDocumentReference` —
14
14
  // it takes SEVERAL references now, so it no longer shares the "exactly one"
15
15
  // rule `update`/`link` still enforce. BLI‑10555: `delete` takes several too
16
16
  // (the same door, called once per ref), so it joined `read`'s branch rather
17
17
  // than this set.
18
- const DOCS_ACTIONS_NEEDING_A_DOC = new Set(["update", "link", "export", "mutation-status"]);
18
+ const DOCS_ACTIONS_NEEDING_A_DOC = new Set(["update", "link", "export", "mutation-status", "share", "access"]);
19
19
  const DOCS_ALLOWED_FLAGS = [
20
20
  "--home",
21
21
  "--dashboard-url",
@@ -37,6 +37,9 @@ const DOCS_ALLOWED_FLAGS = [
37
37
  "--offset",
38
38
  "--format",
39
39
  "--out",
40
+ "--add",
41
+ "--remove",
42
+ "--reason",
40
43
  "--json",
41
44
  ];
42
45
  const DOCS_VALUE_FLAGS = [
@@ -54,6 +57,9 @@ const DOCS_VALUE_FLAGS = [
54
57
  "--offset",
55
58
  "--format",
56
59
  "--out",
60
+ "--add",
61
+ "--remove",
62
+ "--reason",
57
63
  ];
58
64
  export function parseDocsArgs(args) {
59
65
  const values = parseNamedArgs(args, { allowedFlags: DOCS_ALLOWED_FLAGS, valueFlags: DOCS_VALUE_FLAGS });
@@ -123,7 +129,7 @@ function parseDocsAction(positionals) {
123
129
  const first = positionals[0];
124
130
  const action = (first === undefined ? "list" : first);
125
131
  if (!DOCS_ACTIONS.has(action)) {
126
- throw new Error(`Unknown docs command: ${first}. Try list, tree, read, create, update, delete, link, sync-status, export, or mutation-status.`);
132
+ throw new Error(`Unknown docs command: ${first}. Try list, tree, read, create, update, delete, link, sync-status, export, mutation-status, share, or access.`);
127
133
  }
128
134
  return { action, rest: positionals.slice(first === undefined ? 0 : 1) };
129
135
  }
@@ -233,6 +239,26 @@ function parseDocsReadFlags(action, values) {
233
239
  throw new Error("--offset belongs to `cockpit docs read`.");
234
240
  return { query, wholeWord, limit, bodies, section, offset };
235
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
+ }
236
262
  function parseDocsOffset(raw) {
237
263
  const offsetRaw = optionalNonEmpty(raw);
238
264
  if (offsetRaw === undefined)
@@ -268,6 +294,7 @@ function buildDocsCommand(values, action, docRef, docRefs, visibility, clearPare
268
294
  ...(link.notionPage ? { notionPage: link.notionPage } : {}),
269
295
  ...(link.clearLink ? { clearLink: true } : {}),
270
296
  ...parseDocsExportFlags(action, values),
297
+ ...parseDocsShareFlags(action, values),
271
298
  json: values.booleans.has("--json"),
272
299
  };
273
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.`);