@bli-cockpit/cli 0.2.240 → 0.2.242
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-view-settings.js +61 -0
- package/dist/commands/cal.js +4 -0
- package/dist/commands/local-arg-values.js +90 -19
- package/dist/commands/local-args-tower-cal.js +52 -3
- package/dist/commands/local-args-tower-manager.js +128 -28
- package/dist/commands/local-args-tower-msg.js +59 -2
- package/dist/commands/local-args-tower.js +1 -0
- package/dist/commands/local-args.js +2 -0
- package/dist/commands/local-help-commands-tower-ops.js +32 -18
- package/dist/commands/local-help-commands-tower.js +4 -4
- package/dist/commands/local-help-commands.js +1 -0
- package/dist/commands/local-help.js +3 -1
- package/dist/commands/local.js +2 -0
- package/dist/commands/manager-fields-file.js +208 -0
- package/dist/commands/manager-hints.js +88 -0
- package/dist/commands/manager-limits.json +269 -0
- package/dist/commands/manager-read-reasons.js +78 -0
- package/dist/commands/manager-render-home.js +213 -37
- package/dist/commands/manager-render.js +119 -21
- package/dist/commands/manager-requests.js +7 -3
- package/dist/commands/manager.js +67 -43
- package/dist/commands/msg-draft.js +193 -0
- package/dist/commands/msg.js +15 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/push.js +50 -0
- package/package.json +2 -2
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
2
|
+
import { readPipedText, isInteractiveStdin, writeLine } from "./cli-io.js";
|
|
3
|
+
const FAIL_TAG = "[cal cli]";
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 30000;
|
|
5
|
+
const PATH = "/api/cal/view-settings";
|
|
6
|
+
/** `cal view-settings` (read) and `cal view-settings-change` (partial patch). Same door the browser uses. */
|
|
7
|
+
export async function runCalViewSettings(command, door) {
|
|
8
|
+
if (command.action === "view-settings-change")
|
|
9
|
+
return change(command, door);
|
|
10
|
+
const answer = await askAgentDoor(door, { method: "GET", path: PATH, label: "cal view-settings", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
11
|
+
if (!answer.ok)
|
|
12
|
+
return failAgentDoor(door, FAIL_TAG, answer.reason, answer.detail);
|
|
13
|
+
if (door.json)
|
|
14
|
+
return emitAgentDoor(door, answer.body);
|
|
15
|
+
writeSettings(door, answer.body, true);
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
async function change(command, door) {
|
|
19
|
+
let body = command.viewSettings ?? {};
|
|
20
|
+
if (!Object.keys(body).length && !isInteractiveStdin(door.io)) {
|
|
21
|
+
const piped = await readPipedSettings(door);
|
|
22
|
+
if (piped === null)
|
|
23
|
+
return failAgentDoor(door, FAIL_TAG, "invalid_body", "Pipe a JSON object of settings, or use --view, --days, --week-start, --hide-weekends, --density, --zone.");
|
|
24
|
+
body = piped;
|
|
25
|
+
}
|
|
26
|
+
if (!Object.keys(body).length) {
|
|
27
|
+
return failAgentDoor(door, FAIL_TAG, "invalid_body", "Name a setting: --view month, --days 4, --week-start sunday, --hide-weekends on, --density compact, --zone America/Toronto (or --zone none).");
|
|
28
|
+
}
|
|
29
|
+
const answer = await askAgentDoor(door, { method: "PATCH", path: PATH, body, label: "cal view-settings-change", timeoutMs: REQUEST_TIMEOUT_MS });
|
|
30
|
+
if (!answer.ok)
|
|
31
|
+
return failAgentDoor(door, FAIL_TAG, answer.reason, answer.detail);
|
|
32
|
+
if (door.json)
|
|
33
|
+
return emitAgentDoor(door, answer.body);
|
|
34
|
+
writeLine(door.io.stdout, "View settings saved.");
|
|
35
|
+
writeSettings(door, answer.body, false);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
function writeSettings(door, settings, withActions) {
|
|
39
|
+
const out = door.io.stdout;
|
|
40
|
+
writeLine(out, settings.saved ? `Saved ${settings.updated_at ?? ""}`.trim() : "Not saved yet: these are the defaults.");
|
|
41
|
+
writeLine(out, `default view: ${settings.default_view}${settings.default_view === "days" ? ` (${settings.day_count} days)` : ` · N days: ${settings.day_count}`}`);
|
|
42
|
+
writeLine(out, `week starts: ${settings.week_start} · weekends: ${settings.hide_weekends ? "hidden in week and N-day views" : "shown"} · density: ${settings.density}`);
|
|
43
|
+
writeLine(out, `extra time zones: ${settings.extra_zones.length ? settings.extra_zones.join(", ") : "none"}`);
|
|
44
|
+
if (withActions) {
|
|
45
|
+
writeLine(out, "Keyboard shortcuts:");
|
|
46
|
+
for (const action of settings.actions ?? [])
|
|
47
|
+
writeLine(out, ` ${action.keys.join(" ")} ${action.label}: ${action.description}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Piped JSON settings, or null when the pipe is unreadable or not a JSON object. */
|
|
51
|
+
async function readPipedSettings(door) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(await readPipedText(door.io.stdin, { maxChars: 4000, overflowMessage: "Settings are too large." }));
|
|
54
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
55
|
+
return null;
|
|
56
|
+
return parsed;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
package/dist/commands/cal.js
CHANGED
|
@@ -24,6 +24,7 @@ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./ag
|
|
|
24
24
|
import { runCalAddIcal, runCalDetach, runCalShare, runCalSync } from "./cal-calendars-write.js";
|
|
25
25
|
import { runCalDetail } from "./cal-detail.js";
|
|
26
26
|
import { runCalOverlay } from "./cal-overlay.js";
|
|
27
|
+
import { runCalViewSettings } from "./cal-view-settings.js";
|
|
27
28
|
import { runCalDelete } from "./cal-delete.js";
|
|
28
29
|
import { detailFields, runCalRsvp, scopeFields, writeScopeFacts } from "./cal-scope.js";
|
|
29
30
|
import { runCalDirect } from "./cal-direct.js";
|
|
@@ -46,6 +47,9 @@ export async function runCal(command, io) {
|
|
|
46
47
|
case "conflicts":
|
|
47
48
|
case "calendar-change":
|
|
48
49
|
return runCalOverlay(command, door);
|
|
50
|
+
case "view-settings":
|
|
51
|
+
case "view-settings-change":
|
|
52
|
+
return runCalViewSettings(command, door);
|
|
49
53
|
case "today":
|
|
50
54
|
case "week":
|
|
51
55
|
case "next":
|
|
@@ -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
|
-
* `
|
|
13
|
-
*
|
|
14
|
-
* command line at
|
|
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
|
-
|
|
30
|
+
rejectSupabaseServiceCredential(arg);
|
|
27
31
|
if (!arg.startsWith("--")) {
|
|
28
32
|
positionals.push(arg);
|
|
29
33
|
continue;
|
|
30
34
|
}
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
|
|
22
22
|
const CAL_ACTIONS = new Set([
|
|
23
|
-
"event", "export", "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",
|
|
@@ -72,6 +72,7 @@ const CAL_ALLOWED_FLAGS = [
|
|
|
72
72
|
"--expected-version",
|
|
73
73
|
"--reason",
|
|
74
74
|
"--scope", "--occurrence", "--response", "--comment", "--reminder", "--visibility", "--transparency", "--key",
|
|
75
|
+
"--view", "--days", "--week-start", "--hide-weekends", "--density", "--zone",
|
|
75
76
|
];
|
|
76
77
|
const CAL_VALUE_FLAGS = [
|
|
77
78
|
"--home",
|
|
@@ -96,6 +97,7 @@ const CAL_VALUE_FLAGS = [
|
|
|
96
97
|
"--expected-version",
|
|
97
98
|
"--reason",
|
|
98
99
|
"--scope", "--occurrence", "--response", "--comment", "--reminder", "--visibility", "--transparency", "--key",
|
|
100
|
+
"--view", "--days", "--week-start", "--hide-weekends", "--density", "--zone",
|
|
99
101
|
];
|
|
100
102
|
export function parseCalArgs(args) {
|
|
101
103
|
const values = parseNamedArgs(args, { allowedFlags: CAL_ALLOWED_FLAGS, valueFlags: CAL_VALUE_FLAGS });
|
|
@@ -109,13 +111,13 @@ export function parseCalArgs(args) {
|
|
|
109
111
|
const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
|
|
110
112
|
const offset = readCalOffset(values);
|
|
111
113
|
const command = buildCalCommand(values, action, subject, query, title, startsAt, endsAt, offset, limit);
|
|
112
|
-
return { ...command, ...readCalEventScope(action, values) };
|
|
114
|
+
return { ...command, ...readCalEventScope(action, values), ...readViewSettingsPatch(action, values) };
|
|
113
115
|
}
|
|
114
116
|
function readCalAction(values) {
|
|
115
117
|
const first = values.positionals[0];
|
|
116
118
|
const action = (first === undefined ? "today" : first);
|
|
117
119
|
if (!CAL_ACTIONS.has(action)) {
|
|
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.`);
|
|
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.`);
|
|
119
121
|
}
|
|
120
122
|
return { action, rest: values.positionals.slice(first === undefined ? 0 : 1) };
|
|
121
123
|
}
|
|
@@ -321,4 +323,51 @@ function readReminders(raw) {
|
|
|
321
323
|
throw new Error("A reminder method is popup or email.");
|
|
322
324
|
return { minutes, ...(method ? { method } : {}) };
|
|
323
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 } : {};
|
|
324
373
|
}
|
|
@@ -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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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})
|
|
405
|
-
|
|
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
|
-
|
|
413
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
}
|