@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
@@ -4,27 +4,33 @@
4
4
  * cockpit manager clients
5
5
  * cockpit manager client <client>
6
6
  * cockpit manager home <client>
7
- * cockpit manager summary <client> --version <v>|none [--health on_track|at_risk|blocked] [--headline <t>] [--bullet "YYYY-MM-DD: t"|"t"]... [--next <t>|none] [--blocker <t>|none]
7
+ * cockpit manager summary <client> --version <v>|none [--health on_track|at_risk|blocked] [--headline <t>] [--bullet 'YYYY-MM-DD: t'|'t']... [--next <t>|none] [--blocker <t>|none]
8
8
  * cockpit manager run <run-id>
9
9
  * cockpit manager list <client> [--timeline] [--needs-review] [--status open|done|dropped|closed|all] [--owner <name> | --no-owner] [--overdue] [--kind <kind>] [--search <text>] [--archived] [--review dismissed|all]
10
10
  * cockpit manager queue <client>
11
11
  * cockpit manager show <id> [--timeline]
12
- * cockpit manager add <client> --title <t> [--owners "A, B"] [--due YYYY-MM-DD] [--meeting <timeline-id>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]
12
+ * cockpit manager add <client> --title <t> [--owners 'A, B'] [--due YYYY-MM-DD] [--meeting <timeline-id>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]
13
13
  * cockpit manager add <client> --timeline --kind <kind> --title <t> [--date YYYY-MM-DD] [--owner <one name>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]
14
- * cockpit manager edit <id> --version <v> [--timeline] [--title] [--detail] [--due <date>|none] [--owners "A, B"|none]
14
+ * cockpit manager edit <id> --version <v> [--timeline] [--title] [--detail] [--due <date>|none] [--owners 'A, B'|none]
15
15
  * (timeline: --kind, --date <date>|none, --owner <one name>|none)
16
- * cockpit manager done|reopen <action> --version <v>
16
+ * cockpit manager done|drop|reopen <action> --version <v>
17
17
  * cockpit manager link <action> --version <v> --meeting <timeline-id> | --none
18
18
  * cockpit manager approve <id> --version <v> [--timeline] [--restore] (--restore brings a dismissed item back)
19
19
  * cockpit manager dismiss|archive|unarchive <id> --version <v> [--timeline]
20
20
  * cockpit manager proposal <id> <proposal-id> accept|dismiss|add-new --version <v> [--timeline]
21
21
  *
22
+ * Free text goes in a JSON file or on stdin with `--fields-file <path>|-` on add, edit and summary (no shell quoting at all,
23
+ * `manager-fields-file.ts`); on a flag it goes in SINGLE quotes (the shell leaves `$` alone) and the help says how to write
24
+ * an apostrophe. The parser is synchronous and a file is not: `--fields-file` parses to a command that carries `fieldsFile`
25
+ * and `rawArgs`, and `runManager` reads the file and parses the same args again with its text (`options.fieldsText`).
22
26
  * Every verb is one `/api/manager` door on this machine's device token. A bare `cockpit manager`,
23
27
  * `help` and `--help` are the help, never a list and never another command.
24
28
  */
25
- import { optionalNonEmpty, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
29
+ import { optionalNonEmpty, optionalUrl, parseNamedArgs, rejectSecretLikeValue } from "./local-arg-values.js";
30
+ import { mergeFieldsFile } from "./manager-fields-file.js";
31
+ import { runIdRefusal } from "./manager-hints.js";
26
32
  export const MANAGER_VERBS = [
27
- "clients", "client", "home", "summary", "run", "list", "queue", "show", "add", "edit", "done", "reopen", "link",
33
+ "clients", "client", "home", "summary", "run", "list", "queue", "show", "add", "edit", "done", "drop", "reopen", "link",
28
34
  "approve", "dismiss", "proposal", "archive", "unarchive",
29
35
  ];
30
36
  export const MANAGER_ACTION_STATUSES = ["open", "done", "dropped", "closed", "all"];
@@ -38,14 +44,16 @@ const ONE_VERB = `manager takes one verb: ${MANAGER_VERBS.join(", ")}. Example:
38
44
  const OWN_FLAGS = [
39
45
  "--timeline", "--needs-review", "--status", "--owner", "--owners", "--no-owner", "--kind", "--search", "--archived", "--review",
40
46
  "--title", "--detail", "--due", "--date", "--meeting", "--none", "--version", "--request-id", "--restore", "--overdue", "--source-url", "--source-note",
41
- "--health", "--headline", "--bullet", "--next", "--blocker",
47
+ "--health", "--headline", "--bullet", "--next", "--blocker", "--fields-file",
42
48
  ];
43
49
  const COMMON_FLAGS = ["--home", "--dashboard-url", "--json", "--help"];
44
50
  const VALUE_FLAGS = [
45
51
  "--status", "--owner", "--owners", "--kind", "--search", "--review", "--title", "--detail", "--due", "--date", "--meeting",
46
52
  "--version", "--request-id", "--source-url", "--source-note", "--home", "--dashboard-url",
47
- "--health", "--headline", "--bullet", "--next", "--blocker",
53
+ "--health", "--headline", "--bullet", "--next", "--blocker", "--fields-file",
48
54
  ];
55
+ /** The flags whose value is a person's own words (not an id, a version or a choice). */
56
+ const TEXT_FLAGS = ["--title", "--detail", "--headline", "--bullet", "--next", "--blocker", "--owners", "--owner", "--source-note", "--source-url"];
49
57
  /** `summary --bullet` is given once per bullet: the one value flag that may repeat. */
50
58
  const REPEATABLE_FLAGS = new Set(["--bullet"]);
51
59
  /** Which flags each verb owns, so a flag on the wrong verb is refused by name rather than ignored. */
@@ -53,14 +61,15 @@ export const FLAGS_BY_VERB = {
53
61
  clients: [],
54
62
  client: [],
55
63
  home: [],
56
- summary: ["--version", "--health", "--headline", "--bullet", "--next", "--blocker"],
64
+ summary: ["--version", "--health", "--headline", "--bullet", "--next", "--blocker", "--fields-file"],
57
65
  run: [],
58
66
  list: ["--timeline", "--needs-review", "--status", "--owner", "--no-owner", "--overdue", "--kind", "--search", "--archived", "--review"],
59
67
  queue: [],
60
68
  show: ["--timeline"],
61
- add: ["--timeline", "--title", "--detail", "--owners", "--owner", "--due", "--meeting", "--kind", "--date", "--source-url", "--source-note", "--request-id"],
62
- edit: ["--timeline", "--version", "--title", "--detail", "--owners", "--owner", "--due", "--kind", "--date"],
69
+ add: ["--timeline", "--title", "--detail", "--owners", "--owner", "--due", "--meeting", "--kind", "--date", "--source-url", "--source-note", "--request-id", "--fields-file"],
70
+ edit: ["--timeline", "--version", "--title", "--detail", "--owners", "--owner", "--due", "--kind", "--date", "--fields-file"],
63
71
  done: ["--version"],
72
+ drop: ["--version"],
64
73
  reopen: ["--version"],
65
74
  link: ["--version", "--meeting", "--none"],
66
75
  approve: ["--timeline", "--version", "--restore"],
@@ -82,6 +91,7 @@ const POSITIONALS = {
82
91
  add: ["client"],
83
92
  edit: ["id"],
84
93
  done: ["action id"],
94
+ drop: ["action id"],
85
95
  reopen: ["action id"],
86
96
  link: ["action id"],
87
97
  approve: ["id"],
@@ -103,9 +113,13 @@ function refuseRepeatedFlags(args) {
103
113
  }
104
114
  }
105
115
  }
106
- export function parseManagerArgs(args) {
116
+ export function parseManagerArgs(args, options = {}) {
107
117
  refuseRepeatedFlags(args);
108
118
  const values = parseNamedArgs(args, { allowedFlags: [...OWN_FLAGS, ...COMMON_FLAGS], valueFlags: VALUE_FLAGS });
119
+ // Free text is the one place a key could be pasted by mistake: the wider secret rulebook runs on these flags (and on a fields file's values), no other command's.
120
+ for (const flag of TEXT_FLAGS)
121
+ for (const value of values.flagValues.get(flag) ?? [])
122
+ rejectSecretLikeValue(value, flag);
109
123
  const base = {
110
124
  kind: "manager",
111
125
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
@@ -136,9 +150,9 @@ export function parseManagerArgs(args) {
136
150
  if (CLIENT_VERBS.has(verb))
137
151
  shape.client = rest[0];
138
152
  else if (verb === "run")
139
- shape.runId = readId(rest[0], "A run");
153
+ shape.runId = readRunId(rest[0]);
140
154
  else if (verb !== "clients")
141
- shape.id = readId(rest[0], verb === "done" || verb === "reopen" || verb === "link" ? "An action" : "An item");
155
+ shape.id = readId(rest[0], verb === "done" || verb === "drop" || verb === "reopen" || verb === "link" ? "An action" : "An item");
142
156
  if (verb === "proposal") {
143
157
  shape.proposalId = rest[1];
144
158
  shape.decision = readDecision(rest[2]);
@@ -146,8 +160,16 @@ export function parseManagerArgs(args) {
146
160
  const needsVersion = !["clients", "client", "home", "summary", "run", "list", "queue", "show", "add"].includes(verb);
147
161
  if (needsVersion)
148
162
  shape.version = readVersion(flag("--version"), verb);
163
+ let fileBullets;
164
+ const fieldsFile = flag("--fields-file");
165
+ if (fieldsFile !== undefined && (verb === "add" || verb === "edit" || verb === "summary")) {
166
+ // Not read yet: `runManager` reads the file (or stdin) and parses these same args again with its text.
167
+ if (options.fieldsText === undefined)
168
+ return { ...shape, fieldsFile, rawArgs: args };
169
+ fileBullets = mergeFieldsFile(values, verb, options.fieldsText).bullets;
170
+ }
149
171
  if (verb === "summary")
150
- readSummaryFields(values, shape);
172
+ readSummaryFields(values, shape, fileBullets);
151
173
  if (verb === "list")
152
174
  readListFilters(values, shape);
153
175
  if (verb === "add")
@@ -160,6 +182,12 @@ export function parseManagerArgs(args) {
160
182
  shape.restore = true;
161
183
  return shape;
162
184
  }
185
+ /** A run's receipt id: a uuid. Anything else is refused with where run ids really come from (a read's workflow id, `sched_wf_…`, is named for what it is). */
186
+ function readRunId(value) {
187
+ if (!ID.test(value))
188
+ throw new Error(runIdRefusal(value));
189
+ return value;
190
+ }
163
191
  function readId(value, what) {
164
192
  if (!ID.test(value))
165
193
  throw new Error(`${what} id looks like 8f14e45f-ceea-467a-9575-1d3a4e7b2c10 (\`cockpit manager list <client>\` prints them).`);
@@ -253,13 +281,24 @@ function refuseWrongItemFlags(values, shape) {
253
281
  }
254
282
  else {
255
283
  if (given("--owner"))
256
- throw new Error("An action has several owners: use --owners \"A, B\" (or --owners none). --owner is for a timeline item.");
284
+ throw new Error("An action has several owners: use --owners 'A, B' (or --owners none). --owner is for a timeline item.");
257
285
  const item = ["--kind", "--date"].find(given);
258
286
  if (item)
259
287
  throw new Error(`${item} belongs to a timeline item. Add --timeline, or use --due for an action's date.`);
260
288
  }
261
289
  }
290
+ /** A day written with a month name at the start of a bullet: "Oct 3:", "Sat, Oct 3rd, 2026:", "3 October:". */
291
+ const MONTH = "(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\\.?";
292
+ const MONTH_NAME_DAY = new RegExp(`^((?:(?:mon|tue|wed|thu|fri|sat|sun)[a-z]*\\.?,?\\s+)?(?:${MONTH}\\s+\\d{1,2}(?:st|nd|rd|th)?|\\d{1,2}(?:st|nd|rd|th)?\\s+${MONTH})(?:,?\\s+\\d{4})?)\\s*:`, "i");
262
293
  const isNone = (value) => value.trim().toLowerCase() === "none";
294
+ /**
295
+ * Nothing a person can read: empty, whitespace, or only invisible characters, the same set the server's "Where we are"
296
+ * blank test reads as invisible (`summary-answer.ts` INVISIBLE: default-ignorable code points, format characters,
297
+ * combining marks such as U+FE0F and U+034F, the Hangul fillers and the blank braille U+2800), plus control characters.
298
+ * `trim()` alone leaves U+FE0F and U+200B behind, so free text that looks empty used to be sent (and, as --next, cleared
299
+ * the field while the terminal said "Saved").
300
+ */
301
+ const isBlank = (value) => /^[\s\p{Z}\p{Cc}\p{Default_Ignorable_Code_Point}\p{Cf}\p{M}\u115F\u1160\u3164\uFFA0\u2800]*$/u.test(value);
263
302
  /** `--owners "A, B"` replaces the whole list; `none` is the only way to clear it; a list of blanks is neither. */
264
303
  function readOwnersList(raw) {
265
304
  if (isNone(raw))
@@ -282,7 +321,11 @@ function readOneOwner(raw) {
282
321
  }
283
322
  function readAddFields(values, shape) {
284
323
  refuseWrongItemFlags(values, shape);
285
- const title = optionalNonEmpty(values.flags.get("--title"));
324
+ const raw = values.flags.get("--title");
325
+ // A title of only invisible characters (U+200B, U+FE0F, U+3164 …) shows nothing: refused as blank, like edit's.
326
+ if (raw !== undefined && raw.trim() !== "" && isBlank(raw))
327
+ throw new Error("--title cannot be blank.");
328
+ const title = optionalNonEmpty(raw);
286
329
  if (!title)
287
330
  throw new Error("`cockpit manager add` needs --title <text>.");
288
331
  shape.title = title;
@@ -338,7 +381,7 @@ function readEditFields(values, shape) {
338
381
  let changes = 0;
339
382
  const title = values.flags.get("--title");
340
383
  if (title !== undefined) {
341
- if (title.trim() === "")
384
+ if (isBlank(title))
342
385
  throw new Error("--title cannot be blank.");
343
386
  shape.title = title.trim();
344
387
  changes += 1;
@@ -397,24 +440,69 @@ function readSummaryVersion(raw) {
397
440
  throw new Error("--version is the card's summary.updatedAt exactly as `cockpit manager home <client> --json` gives it, like 2026-10-09T17:03:11.123456+00:00, or none.");
398
441
  return raw;
399
442
  }
400
- /** `--bullet "2026-10-14: Signed the SOW"` is a dated bullet; without a leading `YYYY-MM-DD: ` it is undated. */
443
+ /**
444
+ * The card's text limits, in characters (code points), as the help and the server's `TOWER_MANAGER_SUMMARY_LIMITS` say
445
+ * (`manager-cli-limits.test.ts` in the dashboard fails when they differ). Checked here so a long text is refused by this
446
+ * sentence, not by the server's raw schema message ("String must contain at most 4000 character(s)") that a text over 4,000 reaches first.
447
+ */
448
+ export const SUMMARY_TEXT_LIMITS = { headline: 140, bullet: 160, next: 200, blocker: 200 };
449
+ function refuseTooLong(flag, text, max) {
450
+ const length = [...text].length;
451
+ if (length > max)
452
+ throw new Error(`${flag} is ${length.toLocaleString("en-US")} characters; at most ${max}. Shorten it.`);
453
+ }
454
+ /** `--bullet '2026-10-14: Signed the SOW'` is a dated bullet; without a leading `YYYY-MM-DD: ` it is undated. */
401
455
  export function readBullet(raw) {
402
456
  // A bullet pasted from a list keeps no "- " or "* " of its own.
403
457
  const value = raw.trim().replace(/^[-*•]\s+/u, "");
404
- const dated = /^(\d{4}-\d{2}-\d{2}):\s*(.*)$/s.exec(value);
405
- const dateLike = dated ? null : /^(\d{1,4}[-/.]\d{1,2}[-/.]\d{1,4})\s*:/.exec(value);
458
+ const dated = /^(\d{4}-\d{2}-\d{2})\s*:\s*(.*)$/s.exec(value);
459
+ // A shape of digits split by dashes or slashes was always read as a mistyped day ("26-10-01:", "10/14/2026:",
460
+ // "2026-10/14:"). A DOT only counts with a 4-digit year: first and then dash/slash or dot ("2026-10.14:",
461
+ // "2026.10.14:"), or last in a dotted-only shape ("14.10.2026:"). Short ranges ("1-2.5:", "9-10.30:") and
462
+ // versions ("2.1.0:") are plain text.
463
+ const dateLike = dated ? null : /^(\d{1,4}[-/]\d{1,2}[-/]\d{1,4}|\d{4}[-/]\d{1,2}\.\d{1,4}|\d{4}\.\d{1,2}\.\d{1,2}|\d{1,2}\.\d{1,2}\.\d{4})\s*:/.exec(value);
406
464
  if (dateLike)
407
465
  throw new Error(`--bullet "${raw}" starts with ${dateLike[1]}, which is not a YYYY-MM-DD day. Write it like 2026-10-14, or leave the date out for an undated bullet.`);
466
+ // A day spelled with a month name ("Oct 3: x", "3 October 2026: x") is a date too: saved undated it would lose the day without a word.
467
+ const worded = dated ? null : MONTH_NAME_DAY.exec(value);
468
+ if (worded)
469
+ throw new Error(`--bullet "${raw}" starts with ${worded[1].trim()}, which looks like a date but is not YYYY-MM-DD. Write it like 2026-10-14 to date the bullet, or reword the text so it does not start with a date.`);
408
470
  const date = dated ? dated[1] : null;
409
471
  const text = (dated ? dated[2] : value).trim();
410
472
  if (date !== null && !isCalendarDate(date))
411
473
  throw new Error(`--bullet "${raw}" starts with ${date}, which is not a real day. Write the date YYYY-MM-DD, like 2026-10-14, or leave it out for an undated bullet.`);
412
- if (text === "")
413
- throw new Error("--bullet needs words after its date. Example: --bullet \"2026-10-14: Signed the SOW\".");
474
+ return checkedBullet(date, text);
475
+ }
476
+ /** A bullet's words are never blank (the same rule for `--bullet` and a fields file's `{date, text}`). */
477
+ function checkedBullet(date, text) {
478
+ if (isBlank(text)) {
479
+ throw new Error(date === null
480
+ ? "--bullet needs some words, and this one is empty. Example: --bullet 'Export still missing' (a day first makes it dated: --bullet '2026-10-14: Signed the SOW')."
481
+ : "--bullet needs words after its date. Example: --bullet '2026-10-14: Signed the SOW'.");
482
+ }
414
483
  return { date, text };
415
484
  }
485
+ /**
486
+ * A fields file's `{date, text}` bullet. With a date, the date must be a real day and the text is the text. With none (null or
487
+ * left out), the text is read exactly like a `--bullet` value: a leading "YYYY-MM-DD: " dates it, a day spelled any other way is
488
+ * refused, never saved undated (the same rule MCP's manager_set_summary applies).
489
+ */
490
+ function readObjectBullet(bullet) {
491
+ if (bullet.date === null)
492
+ return readBullet(bullet.text);
493
+ if (!isCalendarDate(bullet.date)) {
494
+ throw new Error(`A bullet's date "${bullet.date}" is not a real day. Write the date YYYY-MM-DD, like 2026-10-14, or null for an undated bullet.`);
495
+ }
496
+ return checkedBullet(bullet.date, bullet.text.trim());
497
+ }
498
+ /** --next and --blocker: text, or `none` to clear. A value with nothing readable in it is refused, never sent as a silent clear. */
499
+ function readClearable(flag, raw) {
500
+ if (isBlank(raw))
501
+ throw new Error(`${flag} is blank, and a blank would clear it. To clear ${flag} write ${flag} none; otherwise give words.`);
502
+ return raw.trim();
503
+ }
416
504
  /** The card's fields. Counts, lengths and "blocked needs a blocker" are the server's rules, refused there by name. */
417
- function readSummaryFields(values, shape) {
505
+ function readSummaryFields(values, shape, fileBullets) {
418
506
  shape.version = readSummaryVersion(values.flags.get("--version"));
419
507
  let changes = 0;
420
508
  const health = values.flags.get("--health");
@@ -426,26 +514,38 @@ function readSummaryFields(values, shape) {
426
514
  }
427
515
  const headline = values.flags.get("--headline");
428
516
  if (headline !== undefined) {
429
- if (headline.trim() === "")
517
+ if (isBlank(headline))
430
518
  throw new Error("--headline cannot be blank.");
519
+ refuseTooLong("--headline", headline.trim(), SUMMARY_TEXT_LIMITS.headline);
431
520
  shape.headline = headline.trim();
432
521
  changes += 1;
433
522
  }
434
523
  const bullets = values.flagValues.get("--bullet");
435
- if (bullets !== undefined && bullets.length > 0) {
524
+ if (fileBullets !== undefined) {
525
+ // An empty list is a mistake, not "no change": the server wants 1 to 5 and refuses 0 by name.
526
+ shape.bullets = fileBullets.map((bullet) => (typeof bullet === "string" ? readBullet(bullet) : readObjectBullet(bullet)));
527
+ changes += 1;
528
+ }
529
+ else if (bullets !== undefined && bullets.length > 0) {
436
530
  shape.bullets = bullets.map(readBullet);
437
531
  changes += 1;
438
532
  }
533
+ for (const bullet of shape.bullets ?? [])
534
+ refuseTooLong("A bullet", bullet.text, SUMMARY_TEXT_LIMITS.bullet);
439
535
  const next = values.flags.get("--next");
440
536
  if (next !== undefined) {
441
- shape.next = isNone(next) ? null : next.trim();
537
+ shape.next = isNone(next) ? null : readClearable("--next", next);
442
538
  changes += 1;
443
539
  }
444
540
  const blocker = values.flags.get("--blocker");
445
541
  if (blocker !== undefined) {
446
- shape.blocker = isNone(blocker) ? null : blocker.trim();
542
+ shape.blocker = isNone(blocker) ? null : readClearable("--blocker", blocker);
447
543
  changes += 1;
448
544
  }
545
+ if (shape.next)
546
+ refuseTooLong("--next", shape.next, SUMMARY_TEXT_LIMITS.next);
547
+ if (shape.blocker)
548
+ refuseTooLong("--blocker", shape.blocker, SUMMARY_TEXT_LIMITS.blocker);
449
549
  if (changes === 0) {
450
550
  throw new Error("`cockpit manager summary` has nothing to change. Give at least one of --health, --headline, --bullet (1 to 5 of them, replacing the list), --next, --blocker. A flag you leave out keeps its value; none clears --next or --blocker.");
451
551
  }
@@ -1,5 +1,6 @@
1
1
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
2
- const MSG_ACTIONS = new Set(["channels", "read", "send", "thread", "create", "dm", "show", "unread"]);
2
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
+ const MSG_ACTIONS = new Set(["channels", "read", "send", "thread", "create", "dm", "show", "unread", "edit", "delete", "outcome"]);
3
4
  const MSG_ACTIONS_NEEDING_A_CHANNEL = new Set(["read", "send", "unread"]);
4
5
  /**
5
6
  * `--members a@x.test,b@y.test` — addresses only, split on commas, never
@@ -37,9 +38,14 @@ export function parseMsgArgs(args) {
37
38
  "--description",
38
39
  "--from",
39
40
  "--mark-read",
41
+ "--request-id",
42
+ "--expected-revision",
40
43
  "--json",
41
44
  ],
42
- valueFlags: ["--home", "--dashboard-url", "--channel", "--thread", "--limit", "--members", "--description", "--before", "--from"],
45
+ valueFlags: [
46
+ "--home", "--dashboard-url", "--channel", "--thread", "--limit", "--members", "--description", "--before", "--from",
47
+ "--request-id", "--expected-revision",
48
+ ],
43
49
  });
44
50
  const { action, rest } = parseMsgAction(values.positionals);
45
51
  if (values.flags.has("--before") && action !== "read" && action !== "thread") {
@@ -49,7 +55,35 @@ export function parseMsgArgs(args) {
49
55
  const { channelName, dmEmail } = parseMsgCreateTarget(action, rest);
50
56
  const { isPrivate, memberEmails, description } = parseMsgCreateFlags(action, values);
51
57
  const { channelRef, threadId, limit } = parseMsgReadTarget(action, rest, values);
52
- return buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit, markRead, unreadFrom);
58
+ const writeSafety = parseMsgWriteSafety(action, rest, values);
59
+ return buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit, markRead, unreadFrom, writeSafety);
60
+ }
61
+ /** `send|edit|delete --request-id`, `edit|delete --expected-revision`, and the id `edit`/`delete`/`outcome` name (BLI-21314). */
62
+ function parseMsgWriteSafety(action, rest, values) {
63
+ const requestId = optionalNonEmpty(values.flags.get("--request-id"));
64
+ if (requestId && !UUID_PATTERN.test(requestId))
65
+ throw new Error("--request-id must be a UUID.");
66
+ if (requestId && action !== "send" && action !== "edit" && action !== "delete") {
67
+ throw new Error("--request-id belongs to `cockpit msg send`, `edit` or `delete`.");
68
+ }
69
+ const expectedRevision = optionalPositiveInteger(values.flags.get("--expected-revision"), "--expected-revision");
70
+ if (expectedRevision !== undefined && action !== "edit" && action !== "delete") {
71
+ throw new Error("--expected-revision belongs to `cockpit msg edit` or `delete`.");
72
+ }
73
+ let targetId;
74
+ if (action === "edit" || action === "delete" || action === "outcome") {
75
+ targetId = optionalNonEmpty(rest[0]);
76
+ if (!targetId) {
77
+ throw new Error(action === "outcome"
78
+ ? "msg outcome needs the request UUID, e.g. `cockpit msg outcome <request-uuid>`."
79
+ : `msg ${action} needs a message id, e.g. \`cockpit msg ${action} <message-id>\`.`);
80
+ }
81
+ if (rest.length > 1)
82
+ throw new Error(`msg ${action} takes one id, not ${rest.length}.`);
83
+ if (action === "outcome" && !UUID_PATTERN.test(targetId))
84
+ throw new Error("msg outcome takes a request UUID.");
85
+ }
86
+ return { targetId, requestId, expectedRevision };
53
87
  }
54
88
  /** `read --mark-read` and `unread --from <message-id>` (BLI-21299): the read line, moved on purpose. */
55
89
  function parseMsgReadLineFlags(action, values) {
@@ -68,7 +102,7 @@ function parseMsgAction(positionals) {
68
102
  const first = positionals[0];
69
103
  const action = (first === undefined ? "channels" : first);
70
104
  if (!MSG_ACTIONS.has(action)) {
71
- throw new Error(`Unknown msg command: ${first}. Try channels, read, unread, send, thread, create, dm, or show.`);
105
+ throw new Error(`Unknown msg command: ${first}. Try channels, read, unread, send, thread, create, dm, or show; to change a message: edit, delete or outcome.`);
72
106
  }
73
107
  return { action, rest: positionals.slice(first === undefined ? 0 : 1) };
74
108
  }
@@ -129,7 +163,7 @@ function parseMsgReadTarget(action, rest, values) {
129
163
  if (rest.length > 1)
130
164
  throw new Error(`msg thread takes one thread id, not ${rest.length}.`);
131
165
  }
132
- else if (action !== "create" && action !== "dm" && rest.length > 0) {
166
+ else if (action !== "create" && action !== "dm" && action !== "edit" && action !== "delete" && action !== "outcome" && rest.length > 0) {
133
167
  throw new Error(`msg ${action} does not take "${rest[0]}".`);
134
168
  }
135
169
  const threadFlag = optionalNonEmpty(values.flags.get("--thread"));
@@ -145,7 +179,7 @@ function parseMsgReadTarget(action, rest, values) {
145
179
  }
146
180
  return { channelRef, threadId, limit };
147
181
  }
148
- function buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit, markRead, unreadFrom) {
182
+ function buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit, markRead, unreadFrom, writeSafety) {
149
183
  return {
150
184
  kind: "msg",
151
185
  action,
@@ -162,6 +196,9 @@ function buildMsgCommand(values, action, channelRef, channelName, dmEmail, membe
162
196
  ...(limit === undefined ? {} : { limit }),
163
197
  ...(markRead ? { markRead } : {}),
164
198
  ...(unreadFrom ? { unreadFrom } : {}),
199
+ ...(writeSafety.targetId ? { targetId: writeSafety.targetId } : {}),
200
+ ...(writeSafety.requestId ? { requestId: writeSafety.requestId } : {}),
201
+ ...(writeSafety.expectedRevision === undefined ? {} : { expectedRevision: writeSafety.expectedRevision }),
165
202
  json: values.booleans.has("--json"),
166
203
  };
167
204
  }
@@ -68,6 +68,15 @@ const ISSUE_ALLOWED_FLAGS = [
68
68
  "--parent",
69
69
  "--file",
70
70
  "--version",
71
+ "--cursor",
72
+ "--q",
73
+ "--created-after",
74
+ "--created-before",
75
+ "--updated-after",
76
+ "--updated-before",
77
+ "--sort",
78
+ "--dir",
79
+ "--all",
71
80
  "--body-stdin",
72
81
  "--json",
73
82
  ];
@@ -88,6 +97,14 @@ const ISSUE_VALUE_FLAGS = [
88
97
  "--parent",
89
98
  "--file",
90
99
  "--version",
100
+ "--cursor",
101
+ "--q",
102
+ "--created-after",
103
+ "--created-before",
104
+ "--updated-after",
105
+ "--updated-before",
106
+ "--sort",
107
+ "--dir",
91
108
  ];
92
109
  export function parseIssueArgs(args) {
93
110
  const values = parseNamedArgs(args, {
@@ -108,9 +125,13 @@ export function parseIssueArgs(args) {
108
125
  validateIssueState(moveState, "issue move state must be one of: ");
109
126
  const stateFilter = readIssueStateFilter(action, values);
110
127
  const title = readIssueTitle(action, values);
111
- if (action !== "board" && (values.flags.has("--label") || values.flags.has("--rows"))) {
112
- throw new Error("--label and --rows filter cockpit tickets board only.");
128
+ if (action !== "board" && values.flags.has("--rows")) {
129
+ throw new Error("--rows widens cockpit tickets board only.");
113
130
  }
131
+ if (action !== "board" && action !== "list" && values.flags.has("--label")) {
132
+ throw new Error("--label filters cockpit tickets board and cockpit tickets list.");
133
+ }
134
+ const page = readIssuePage(action, values);
114
135
  const boardProject = optionalNonEmpty(values.flags.get("--project"));
115
136
  if (action === "board" && boardProject && !UUID_PATTERN.test(boardProject))
116
137
  throw new Error("board --project requires a uuid.");
@@ -119,7 +140,51 @@ export function parseIssueArgs(args) {
119
140
  const assignee = readIssueAssignee(action, values);
120
141
  const limit = readIssueLimit(action, values);
121
142
  const versionNumber = readIssueVersion(action, values);
122
- return buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, estimate, assignee, limit, versionNumber);
143
+ return buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, estimate, assignee, limit, versionNumber, page);
144
+ }
145
+ const INSTANT = /^\d{4}-\d\d-\d\d(?:T\d\d:\d\d:\d\d(?:\.\d{1,6})?(?:Z|[+-]\d\d:\d\d))?$/;
146
+ /** BLI-21315: the cursor contract's flags. Filters and sort belong to `list`; `--cursor` also to `history`. */
147
+ function readIssuePage(action, values) {
148
+ const named = ["--q", "--created-after", "--created-before", "--updated-after", "--updated-before", "--sort", "--dir"];
149
+ for (const flag of named) {
150
+ if (values.flags.has(flag) && action !== "list")
151
+ throw new Error(`${flag} belongs to \`cockpit ticket list\`.`);
152
+ }
153
+ if (values.booleans.has("--all") && action !== "list")
154
+ throw new Error("--all belongs to `cockpit ticket list`.");
155
+ if (values.flags.has("--cursor") && action !== "list" && action !== "history") {
156
+ throw new Error("--cursor belongs to `cockpit ticket list` and `cockpit ticket history`.");
157
+ }
158
+ const instants = {};
159
+ for (const flag of ["--created-after", "--created-before", "--updated-after", "--updated-before"]) {
160
+ const value = optionalNonEmpty(values.flags.get(flag));
161
+ if (value !== undefined && (!INSTANT.test(value) || !Number.isFinite(Date.parse(value)))) {
162
+ throw new Error(`${flag} must be a UTC instant like 2026-10-01T00:00:00Z or a date like 2026-10-01.`);
163
+ }
164
+ instants[flag] = value;
165
+ }
166
+ const sort = optionalNonEmpty(values.flags.get("--sort"));
167
+ if (sort !== undefined && sort !== "updated_at" && sort !== "created_at")
168
+ throw new Error("--sort must be updated_at or created_at.");
169
+ const dir = optionalNonEmpty(values.flags.get("--dir"));
170
+ if (dir !== undefined && dir !== "asc" && dir !== "desc")
171
+ throw new Error("--dir must be asc or desc.");
172
+ const all = values.booleans.has("--all");
173
+ const cursor = optionalNonEmpty(values.flags.get("--cursor"));
174
+ if (all && cursor)
175
+ throw new Error("--all walks from the start; drop --cursor, or pass --cursor without --all to read one page.");
176
+ const q = optionalNonEmpty(values.flags.get("--q"));
177
+ return {
178
+ ...(cursor ? { cursor } : {}),
179
+ ...(q ? { q } : {}),
180
+ ...(instants["--created-after"] ? { createdAfter: instants["--created-after"] } : {}),
181
+ ...(instants["--created-before"] ? { createdBefore: instants["--created-before"] } : {}),
182
+ ...(instants["--updated-after"] ? { updatedAfter: instants["--updated-after"] } : {}),
183
+ ...(instants["--updated-before"] ? { updatedBefore: instants["--updated-before"] } : {}),
184
+ ...(sort ? { sort } : {}),
185
+ ...(dir ? { dir } : {}),
186
+ ...(all ? { all } : {}),
187
+ };
123
188
  }
124
189
  function readIssueAction(values) {
125
190
  const first = values.positionals[0];
@@ -230,7 +295,7 @@ function readIssueLimit(action, values) {
230
295
  }
231
296
  return limit;
232
297
  }
233
- function buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, estimate, assignee, limit, versionNumber) {
298
+ function buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, estimate, assignee, limit, versionNumber, page) {
234
299
  return {
235
300
  kind: "issue",
236
301
  requestId: optionalNonEmpty(values.flags.get("--request-id")),
@@ -247,6 +312,7 @@ function buildIssueCommand(values, action, issueRef, issueRefs, moveState, state
247
312
  rows: optionalNonEmpty(values.flags.get("--rows")),
248
313
  project: optionalNonEmpty(values.flags.get("--project")),
249
314
  ...(limit === undefined ? {} : { limit }),
315
+ ...page,
250
316
  ...(versionNumber === undefined ? {} : { versionNumber }),
251
317
  title,
252
318
  ...(priority === undefined ? {} : { priority }),
@@ -259,8 +325,8 @@ function buildIssueCommand(values, action, issueRef, issueRefs, moveState, state
259
325
  }
260
326
  export function parseProjectArgs(args) {
261
327
  const values = parseNamedArgs(args, {
262
- allowedFlags: ["--home", "--dashboard-url", "--archived", "--json"],
263
- valueFlags: ["--home", "--dashboard-url"],
328
+ allowedFlags: ["--home", "--dashboard-url", "--archived", "--cursor", "--limit", "--json"],
329
+ valueFlags: ["--home", "--dashboard-url", "--cursor", "--limit"],
264
330
  });
265
331
  const first = values.positionals[0];
266
332
  const action = (first === undefined ? "list" : first);
@@ -275,6 +341,8 @@ export function parseProjectArgs(args) {
275
341
  homeDir: optionalNonEmpty(values.flags.get("--home")),
276
342
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
277
343
  includeArchived: values.booleans.has("--archived"),
344
+ ...(optionalNonEmpty(values.flags.get("--cursor")) ? { cursor: optionalNonEmpty(values.flags.get("--cursor")) } : {}),
345
+ ...(values.flags.has("--limit") ? { limit: optionalPositiveInteger(values.flags.get("--limit"), "--limit") } : {}),
278
346
  json: values.booleans.has("--json"),
279
347
  };
280
348
  }