@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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
2
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
|
|
3
|
+
const MSG_DRAFT_VERBS = new Set(["list", "show", "save", "discard", "restore", "send"]);
|
|
4
|
+
const MSG_ACTIONS = new Set(["channels", "read", "send", "thread", "create", "dm", "show", "unread", "edit", "delete", "outcome", "draft"]);
|
|
4
5
|
const MSG_ACTIONS_NEEDING_A_CHANNEL = new Set(["read", "send", "unread"]);
|
|
5
6
|
/**
|
|
6
7
|
* `--members a@x.test,b@y.test` — addresses only, split on commas, never
|
|
@@ -48,6 +49,8 @@ export function parseMsgArgs(args) {
|
|
|
48
49
|
],
|
|
49
50
|
});
|
|
50
51
|
const { action, rest } = parseMsgAction(values.positionals);
|
|
52
|
+
if (action === "draft")
|
|
53
|
+
return parseMsgDraftArgs(values, rest);
|
|
51
54
|
if (values.flags.has("--before") && action !== "read" && action !== "thread") {
|
|
52
55
|
throw new Error("--before belongs to msg read or msg thread.");
|
|
53
56
|
}
|
|
@@ -58,6 +61,60 @@ export function parseMsgArgs(args) {
|
|
|
58
61
|
const writeSafety = parseMsgWriteSafety(action, rest, values);
|
|
59
62
|
return buildMsgCommand(values, action, channelRef, channelName, dmEmail, memberEmails, description, isPrivate, threadId, limit, markRead, unreadFrom, writeSafety);
|
|
60
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* `cockpit msg draft [list|show|save|discard|restore|send] <channel> [--thread <id>] [--expected-revision <n>]`
|
|
66
|
+
* (BLI-21414). `list` takes no channel; every other verb needs one. `--request-id` belongs to `send`.
|
|
67
|
+
*/
|
|
68
|
+
function parseMsgDraftArgs(values, rest) {
|
|
69
|
+
const first = rest[0];
|
|
70
|
+
let verb = "list";
|
|
71
|
+
let positional = rest;
|
|
72
|
+
if (first !== undefined) {
|
|
73
|
+
if (!MSG_DRAFT_VERBS.has(first)) {
|
|
74
|
+
throw new Error(`Unknown msg draft command: ${first}. Try list, show, save, discard, restore or send.`);
|
|
75
|
+
}
|
|
76
|
+
verb = first;
|
|
77
|
+
positional = rest.slice(1);
|
|
78
|
+
}
|
|
79
|
+
const stray = ["--limit", "--before", "--members", "--description", "--from", "--channel"].find((flag) => values.flags.has(flag));
|
|
80
|
+
if (stray || values.booleans.has("--private") || values.booleans.has("--mark-read")) {
|
|
81
|
+
throw new Error(`${stray ?? "That flag"} does not belong to \`cockpit msg draft\`. It takes a channel, --thread <id> and --expected-revision <n>.`);
|
|
82
|
+
}
|
|
83
|
+
let channelRef;
|
|
84
|
+
if (verb === "list") {
|
|
85
|
+
if (positional.length > 0 || values.flags.has("--thread"))
|
|
86
|
+
throw new Error("msg draft list takes no channel; it lists all your unsent drafts.");
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
channelRef = optionalNonEmpty(positional[0]);
|
|
90
|
+
if (!channelRef)
|
|
91
|
+
throw new Error(`msg draft ${verb} needs a channel id or name, e.g. \`cockpit msg draft ${verb} general\`.`);
|
|
92
|
+
if (positional.length > 1)
|
|
93
|
+
throw new Error(`msg draft ${verb} takes one channel, not ${positional.length}.`);
|
|
94
|
+
}
|
|
95
|
+
const requestId = optionalNonEmpty(values.flags.get("--request-id"));
|
|
96
|
+
if (requestId && (verb !== "send" || !UUID_PATTERN.test(requestId))) {
|
|
97
|
+
throw new Error(verb === "send" ? "--request-id must be a UUID." : "--request-id belongs to `cockpit msg draft send`.");
|
|
98
|
+
}
|
|
99
|
+
const expectedRevision = optionalPositiveInteger(values.flags.get("--expected-revision"), "--expected-revision");
|
|
100
|
+
if (expectedRevision !== undefined && (verb === "list" || verb === "show")) {
|
|
101
|
+
throw new Error("--expected-revision belongs to `msg draft save`, `discard`, `restore` or `send`.");
|
|
102
|
+
}
|
|
103
|
+
const threadId = optionalNonEmpty(values.flags.get("--thread"));
|
|
104
|
+
return {
|
|
105
|
+
kind: "msg",
|
|
106
|
+
action: "draft",
|
|
107
|
+
draftVerb: verb,
|
|
108
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
109
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
110
|
+
...(channelRef ? { channelRef } : {}),
|
|
111
|
+
...(threadId ? { threadId } : {}),
|
|
112
|
+
isPrivate: false,
|
|
113
|
+
...(requestId ? { requestId } : {}),
|
|
114
|
+
...(expectedRevision === undefined ? {} : { expectedRevision }),
|
|
115
|
+
json: values.booleans.has("--json"),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
61
118
|
/** `send|edit|delete --request-id`, `edit|delete --expected-revision`, and the id `edit`/`delete`/`outcome` name (BLI-21314). */
|
|
62
119
|
function parseMsgWriteSafety(action, rest, values) {
|
|
63
120
|
const requestId = optionalNonEmpty(values.flags.get("--request-id"));
|
|
@@ -102,7 +159,7 @@ function parseMsgAction(positionals) {
|
|
|
102
159
|
const first = positionals[0];
|
|
103
160
|
const action = (first === undefined ? "channels" : first);
|
|
104
161
|
if (!MSG_ACTIONS.has(action)) {
|
|
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.`);
|
|
162
|
+
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; for unsent text: draft.`);
|
|
106
163
|
}
|
|
107
164
|
return { action, rest: positionals.slice(first === undefined ? 0 : 1) };
|
|
108
165
|
}
|
|
@@ -54,4 +54,5 @@ export { parseCareersArgs } from './local-args-tower-careers.js';
|
|
|
54
54
|
export { parseRenameArgs } from './local-args-tower-rename.js';
|
|
55
55
|
export { parseGlossaryArgs } from "./glossary.js";
|
|
56
56
|
export { parseSpendArgs } from "./spend.js";
|
|
57
|
+
export { parsePushArgs } from "./push.js";
|
|
57
58
|
export { parseCiArgs } from "./ci.js";
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { parseGlossaryArgs } from "./glossary.js";
|
|
19
19
|
import { parseSpendArgs } from "./spend.js";
|
|
20
|
+
import { parsePushArgs } from "./push.js";
|
|
20
21
|
import { parseRootsArgs } from "./roots.js";
|
|
21
22
|
import { SURFACE_ALIASES } from "./local-help.js";
|
|
22
23
|
import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseSourcesArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
|
|
@@ -82,6 +83,7 @@ const COMMAND_PARSERS = new Map([
|
|
|
82
83
|
["sources", (_command, rest) => parseSourcesArgs(rest)],
|
|
83
84
|
["glossary", (_command, rest) => parseGlossaryArgs(rest)],
|
|
84
85
|
["spend", (_command, rest) => parseSpendArgs(rest)],
|
|
86
|
+
["push", (_command, rest) => parsePushArgs(rest)],
|
|
85
87
|
["docs", (_command, rest) => parseDocsArgs(rest)],
|
|
86
88
|
["msg", (_command, rest) => parseMsgArgs(rest)],
|
|
87
89
|
["issue", (_command, rest) => parseIssueArgs(rest)],
|
|
@@ -210,53 +210,67 @@ export const TOWER_OPS_COMMAND_HELP = [
|
|
|
210
210
|
[
|
|
211
211
|
"manager",
|
|
212
212
|
[
|
|
213
|
-
"Usage: cockpit manager <clients|client|home|summary|run|list|queue|show|add|edit|done|reopen|link|approve|dismiss|proposal|archive|unarchive> ... [--dashboard-url <url>] [--json]",
|
|
213
|
+
"Usage: cockpit manager <clients|client|home|summary|run|list|queue|show|add|edit|done|drop|reopen|link|approve|dismiss|proposal|archive|unarchive> ... [--dashboard-url <url>] [--json]",
|
|
214
214
|
"",
|
|
215
215
|
"Tower Manager's clients, from the terminal: each client's Home and \"Where we are\", its actions and its timeline items, the same things the manager page does.",
|
|
216
216
|
" cockpit manager clients every client, with its id",
|
|
217
|
-
" cockpit manager client <client> one client: state, last read, pages waiting; run <run-id> one run's receipt",
|
|
217
|
+
" cockpit manager client <client> one client: state, last read, pages waiting; run <run-id> one run's receipt (a summary check says what it did to the card, not \"0 pages seen\"). A run id is a UUID from `queue <client> --json` (a row's changedByRunId), `show <id>` (a suggested change's runId) or `client <client> --json` (lastBriefRebuild.runId); a read's workflow id (lastRead.workflowRunId, like sched_wf_…) is not one",
|
|
218
218
|
" cockpit manager home <client> the client's Home: how fresh its data is (last read, when Notion last changed), \"Where we are\" (health, headline, up to 5 dated bullets,",
|
|
219
|
-
" next step, blocker, who wrote it and when), live counts, what needs attention by owner (
|
|
220
|
-
" what the newest read that changed anything did, and how many sync changes
|
|
221
|
-
"
|
|
219
|
+
" next step, blocker, who wrote it and when), live counts, what needs attention by owner in two parts, \"Due this week\" first (today to six days ahead) then \"Overdue\" (rows sit under their owners, oldest first under each; a line \"Overdue longest: <title>, due <day>\" names the one overdue longest), each with its own total and, when cut, \"Showing X of N\" and the `list` command for the rest, what is coming up,",
|
|
220
|
+
" what the newest read that changed anything did (proposals first), \"In Tower, last 7 days\" (what people and agents added, closed, dropped and dismissed, and who, by name; a count of none is left out, and when all are none: \"nothing added, closed, dropped or dismissed by people or agents\"), and a What changed tally that likewise leaves out a kind with none (\"12 added, 2 proposed\"; \"nothing changed\" when none), and the review line (\"N suggested changes need a decision · M new items to check\"; `queue <client>` lists them), and how many sync changes are \"new from syncs\" (`queue <client>` lists them; `list` counts every unreviewed item as \"need review\", the first read's included).",
|
|
221
|
+
" Home also says how old the last check of the card is (\"Last checked 2 h ago\"), \"The last check failed; showing the card from Oct 5\" when it did, \"Last checked\" only once a check has looked at the card (a card a person wrote has none yet), a failed read's reason in the page\'s own sentence (\"Why the latest read failed: ...\"), and \"First read not done yet\" for a client no read has finished (\"First read failed\" when it failed: the manager page shows only that failure until a read succeeds, though writes made here are saved); a card computed from counts reads \"AI unavailable · computed from counts\".",
|
|
222
|
+
" cockpit manager summary <client> --version <v>|none [--health on_track|at_risk|blocked] [--headline <text>] [--bullet 'YYYY-MM-DD: text']... [--next <text>|none] [--blocker <text>|none]",
|
|
222
223
|
" edit \"Where we are\": any field alone, or several at once. --bullet is given 1 to 5 times and REPLACES the whole list; a leading",
|
|
223
224
|
" \"YYYY-MM-DD: \" dates a bullet, without it the bullet is undated. none clears --next or --blocker. blocked needs a blocker (given, or already on the card),",
|
|
224
|
-
" and a blocker
|
|
225
|
+
" and a blocker goes only on a Blocked card: sent with any other health it is refused (summary_blocker_needs_blocked); sending only a new health drops the stored one.",
|
|
225
226
|
" --version is summary.updatedAt from `home <client> --json`, or none when the client has no card yet (then --health, --headline and at least one --bullet are all needed).",
|
|
226
|
-
" Limits: headline 140 characters, each bullet 160, next and blocker 200. The edit is marked as yours and stands until the client's",
|
|
227
|
-
" data changes or an action
|
|
227
|
+
" Limits: headline 140 characters, each bullet 160, next and blocker 200; a longer text is refused here with its length. A day spelled with a month name at the start of a bullet (\"Oct 3: x\") is refused, never saved undated. --version none is for a client with no card: on one that has a card the write is refused with \"This client already has a card: pass its version\". The edit is marked as yours and stands until the client's",
|
|
228
|
+
" data changes, an action goes overdue or an open action passes 14 days overdue; the next check after that writes the card again (newest wins).",
|
|
229
|
+
" A saved card that says On track while an open action is more than 14 days overdue is saved anyway and prints a \"Warning: ...\" line naming the action overdue longest.",
|
|
230
|
+
" Write the card after your item writes: your own later item writes count as data changes, and the next check rewrites the card.",
|
|
231
|
+
" Health rule: an open action more than 14 days overdue, as `cockpit manager home` shows it (Tower's today, America/Vancouver), means the card is at_risk (or blocked, with a named blocker).",
|
|
232
|
+
" Do not set on_track over one; tell the person instead. The headline or a bullet must name the action overdue longest, with the day it was due.",
|
|
228
233
|
" 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]",
|
|
229
234
|
" actions (default), or with --timeline the timeline; --status, --owner, --no-owner and --overdue (open actions due before today) are for actions, --kind for the timeline.",
|
|
230
235
|
" The timeline reads top down: undated items (\"Needs a date\"), then upcoming, a Today line, then the past, newest first (today is Vancouver's day).",
|
|
231
|
-
" Dismissed and archived items are hidden: --review dismissed (or all) and --archived find them. A list holds at most 500 rows and says when it is cut.",
|
|
232
|
-
" cockpit manager queue <client> changes from syncs after onboarding that nobody has looked at yet (the onboarding's own items are not in it)",
|
|
236
|
+
" Dismissed and archived items are hidden: --review dismissed (or all) and --archived find them. A list holds at most 500 rows and says when it is cut. With --owner or --no-owner the counts under the heading are that filter's (\"N open actions match these filters (M overdue)\"), not the client's. Times print as Tower's day and time (America/Vancouver, \"Oct 16, 1:03 AM PDT\"); --json keeps the raw ISO.",
|
|
237
|
+
" cockpit manager queue <client> changes from syncs after onboarding that nobody has looked at yet (the onboarding's own items are not in it): \"Needs your decision\" first (a suggested change waits on each), then \"New items to check\"",
|
|
233
238
|
" an empty queue does not mean everything is reviewed: list <client> --needs-review --status all, and list <client> --timeline --needs-review",
|
|
234
|
-
" cockpit manager show <id> [--timeline] one item in full: detail, sources, suggested changes with their ids, what a sync changed",
|
|
235
|
-
" cockpit manager add <client> --title <text> [--owners
|
|
239
|
+
" cockpit manager show <id> [--timeline] one item in full: detail, sources, suggested changes with their ids and, beneath each, the page and quote the AI cited for it (or \"not found in the page\"), what a sync changed, and who added, edited, reviewed, closed or archived it (by name)",
|
|
240
|
+
" cockpit manager add <client> --title <text> [--owners 'A, B'] [--due YYYY-MM-DD] [--meeting <timeline-id>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]",
|
|
236
241
|
" cockpit manager add <client> --timeline --kind meeting|document|milestone|internal --title <text> [--date YYYY-MM-DD] [--owner <one name>] [--detail <text>] [--source-url <url>] [--source-note <text>] [--request-id <uuid>]",
|
|
237
242
|
" no --date makes an undated timeline item, which lands in \"Needs a date\"",
|
|
238
243
|
" --source-url (an http(s) link) and --source-note say where it came from, as the web form's Source does; show prints them",
|
|
239
|
-
" cockpit manager edit <id> --version <v> [--timeline] [--title <text>] [--detail <text>] [--due <date>|none] [--owners
|
|
244
|
+
" cockpit manager edit <id> --version <v> [--timeline] [--title <text>] [--detail <text>] [--due <date>|none] [--owners 'A, B'|none]",
|
|
240
245
|
" on a timeline item: [--kind <kind>] [--date <date>|none] [--owner <one name>|none]",
|
|
241
|
-
"
|
|
242
|
-
" cockpit manager
|
|
246
|
+
" an edit marks the item reviewed by you (an AI item's [needs review] becomes [reviewed], you the reviewer), even a detail-only one; the AI can only propose changes to it afterwards",
|
|
247
|
+
" cockpit manager done <action> --version <v> tick an action off (it marks the action reviewed by you, an AI action too, and suggested changes it makes moot close; on an action already done it writes nothing); reopen <action> --version <v> open it again",
|
|
248
|
+
" cockpit manager drop <action> --version <v> drop an action: the work no longer applies and was NOT done (a dropped action is not a done one: summaries and the page tell them apart). Add a dated line saying why to its detail first (`edit <id> --version <v> --detail <text>` replaces the detail: keep what `show <id>` printed, add \"YYYY-MM-DD: dropped because ...\"). Prints \"Dropped: <title>.\", or \"No change; nothing was written.\" when it was already dropped; reopen undoes it. Marks the action reviewed by you when the status changes. On an older Tower server it says it can\'t drop yet; mark it done with a dated line saying why instead.",
|
|
249
|
+
" cockpit manager link <action> --version <v> --meeting <timeline-id> link an action to the meeting it came from; --none unlinks it (either marks the action reviewed by you)",
|
|
243
250
|
" cockpit manager approve <id> --version <v> [--timeline] [--restore] the AI's item is right: mark it reviewed; --restore brings a DISMISSED item back into the lists, marked reviewed by you (without it, a dismissed item is refused)",
|
|
244
251
|
" cockpit manager dismiss <id> --version <v> [--timeline] the AI's item is wrong: dismiss it (kept, hidden from the lists)",
|
|
245
252
|
" cockpit manager proposal <id> <proposal-id> accept|dismiss|add-new --version <v> [--timeline] decide one suggested change; add-new makes it an item of its own",
|
|
246
253
|
" cockpit manager archive <id> --version <v> [--timeline] delete an item: it moves to the Deleted list (list --archived), kept; unarchive <id> --version <v> brings it back",
|
|
247
254
|
"approve, dismiss and proposal decide the AI's suggestion as YOU: your name is recorded as the reviewer, and the item leaves the review queue unless a suggested change still waits on it.",
|
|
255
|
+
"Marking reviewed sets the review state to reviewed, with you as the reviewer and the time of the write: link always does it, and edit does whenever it changes something, even a detail-only one (an edit whose fields all equal the stored ones prints \"No change; nothing was written.\", with no new version and no reviewed mark); done, drop and reopen do it only when the status changes (on an action already in that status nothing is written); approve and an accepted proposal do it too. Dismiss sets it to dismissed with you as the reviewer; archive, unarchive and a proposal you dismiss or add-new leave the item's review state as it was.",
|
|
256
|
+
"Free text without shell quoting: add, edit and summary take --fields-file <path> (or - for stdin), one JSON object whose keys are the flags in snake_case, so a $12k, an apostrophe, a double quote or an = reaches Tower exactly as written. The file is UTF-8 (a UTF-16 file with a BOM is read too; any other encoding is refused): in Windows PowerShell write it with `-Encoding utf8` (a `>` redirect makes UTF-16 and Set-Content makes the ANSI code page, which breaks a ’), and for stdin on 5.1 set `$OutputEncoding = [System.Text.UTF8Encoding]::new()` first.",
|
|
257
|
+
" add: title, detail, owners, owner, due, date, kind, meeting, source_url, source_note, request_id; edit: title, detail, owners, owner, due, date, kind; summary: health, headline, bullets, next, blocker.",
|
|
258
|
+
" owners is a list or \"A, B\"; bullets is a list of \"YYYY-MM-DD: text\" strings or {\"date\": \"YYYY-MM-DD\" or null, \"text\": ...} objects; null (or none) clears owners, owner, due, date, next or blocker.",
|
|
259
|
+
" --version and --timeline stay flags. A field given as a flag and in the file is refused, so is an unknown key. The file gets the flags' own checks. Example: cat fields.json | cockpit manager summary Acme --version none --fields-file -",
|
|
260
|
+
"Free text (the flags above and every value in a fields file) that holds a provider key (an sk-, rk_, sb_secret_, ghp_, github_pat_, AKIA/ASIA, xoxb- key, any JWT, a PEM private key) or a password given a value (\"password: x\") is refused, naming the flag or key; ordinary words, a commit sha, a URL and the words \"service-role key\" are plain text.",
|
|
261
|
+
"On a flag, free text (--headline, --bullet, --title, --detail, --next, --blocker, --owners, --source-note) goes in SINGLE quotes so the shell leaves $ alone: \"$12k\" in double quotes loses the $12k (zsh and bash read it as a variable). In bash and zsh write an apostrophe inside single quotes as '\\'' (end the quote, a backslash-escaped apostrophe, reopen); in PowerShell double the apostrophe ('Rowan''s deck'). The command prints what it saved: check it is your words.",
|
|
248
262
|
"<client> is the client's id, or its exact name (any case; `clients` lists them); a name that matches no client, or several, is refused with the names. Ids and versions are printed by list, queue and show. Items you add are reviewed already.",
|
|
249
263
|
"--version is the item's version exactly as list or show prints it, copied as it is. Every change to an existing item names it: if someone or a sync changed the item",
|
|
250
264
|
"since, the write is refused with a sentence saying so and nothing is written; run show again for the new version and retry.",
|
|
251
265
|
"--owners on an action takes, as the web form says, \"One or more people, separated by commas\" (\"Rowan Ash, Juniper Twig\"), and it REPLACES the whole list. Leave it out and the owners stay;",
|
|
252
266
|
"--owners none clears them. A timeline item has ONE owner: --owner <name> or --owner none. The same pattern clears a date: --due none, --date none.",
|
|
253
267
|
"Owner names are kept as written, not matched to accounts: use the spelling `list` shows, so one person stays one name. A flag you leave out is never sent, and a flag given twice is refused (all but summary's --bullet). A date is YYYY-MM-DD. `cockpit manager`, `manager help` and `manager --help` print this and change nothing.",
|
|
254
|
-
"add always sends a request id
|
|
255
|
-
"--request-id
|
|
268
|
+
"add always sends a request id and prints it: leave --request-id out on a first add (a fresh one is made). If Tower does not answer, nothing was confirmed: rerun the same command with the",
|
|
269
|
+
"--request-id the CLI printed and it retries without making a duplicate (\"Already added earlier under this request id; nothing new was written.\"). Never make an id up: reusing one for a different item is refused (request_reused): the request id IS the item's id, so `show <request-id>` shows it, a retry already made it, and a different item is added without --request-id. An add prints the detail (cut to 200 characters) and the source it saved, so you can check they are your words.",
|
|
256
270
|
"--json writes the door's body to stdout, verbatim, and nothing else (an add's body also carries requestId); a refusal is { ok: false, error, detail, refusal? } and exits 1.",
|
|
257
271
|
"Progress lines on stderr start with [tag]; stdout is the answer (2>/dev/null hides them). Creating a client, syncing, Rebuild brief and merging owners are on the manager page only. Teaching: docs/architecture/tower-manager.md.",
|
|
258
272
|
"The bli-tower MCP server has the twins: manager_clients, manager_client, manager_home, manager_set_summary (summary), manager_run, manager_list, manager_queue, manager_show, manager_add, manager_edit,",
|
|
259
|
-
"manager_done, manager_reopen, manager_link, manager_approve, manager_dismiss, manager_proposal, manager_archive, manager_unarchive.",
|
|
273
|
+
"manager_done, manager_drop, manager_reopen, manager_link, manager_approve, manager_dismiss, manager_proposal, manager_archive, manager_unarchive.",
|
|
260
274
|
],
|
|
261
275
|
],
|
|
262
276
|
[
|
|
@@ -81,7 +81,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
81
81
|
[
|
|
82
82
|
"msg",
|
|
83
83
|
[
|
|
84
|
-
"Usage: cockpit msg [channels|create <name>|dm <email>|read <channel>|unread <channel> --from <message-id>|send <channel>|edit <message-id>|delete <message-id>|outcome <request-uuid>|thread <id> --channel <channel>|show <message-id>] [flags]",
|
|
84
|
+
"Usage: cockpit msg [channels|create <name>|dm <email>|read <channel>|unread <channel> --from <message-id>|send <channel>|edit <message-id>|delete <message-id>|outcome <request-uuid>|draft [list|show|save|discard|restore|send] <channel>|thread <id> --channel <channel>|show <message-id>] [flags]",
|
|
85
85
|
"",
|
|
86
86
|
"Channels and messages, typed. <channel> is a channel id, or its name with or without a leading #.",
|
|
87
87
|
"channels — every channel you are a member of (or, as a super_admin, every channel).",
|
|
@@ -90,7 +90,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
90
90
|
"read <channel> [--limit <n>] [--thread <id>] [--mark-read] — the channel's most recent top-level messages, oldest first; --thread <id> reads one thread's replies instead. The first page also says where the messages come from (for an imported Slack channel: how long ago the archive was checked, or that it is unknown) and where YOUR read line stands. Reading never moves the read line; --mark-read moves it to the newest message shown, and says plainly if that failed.",
|
|
91
91
|
"unread <channel> --from <message-id> — marks that message and everything after it unread, so the web sidebar badge and the NEW MESSAGES line start there. Opening or reading a channel never clears unseen history.",
|
|
92
92
|
"send <channel> [--thread <id>] [--request-id <uuid>] — posts a message. The content is never accepted on the command line: pipe it in, e.g. `echo \"hello\" | cockpit msg send general`. Every send carries a request id (minted when you give none); retry a send whose answer was lost with the SAME --request-id and the same text and it posts once. The same id with different text is refused request_id_reused, not applied as an edit.",
|
|
93
|
-
"edit <message-id> [--expected-revision <n>] [--request-id <uuid>] — replaces your own message's text (piped in). With --expected-revision, an edit of a message that has moved on is refused revision_conflict and nothing is saved over it; the current revision is printed. A message you did not write, or one that was deleted, is refused by name. delete <message-id> [--expected-revision <n>] [--request-id <uuid>] — deletes by tombstone: the text is cleared and every surface reads \"This message was deleted\", but the row stays so its thread, replies and reply count survive. Search and unread counts ignore it. A repeat answers already_deleted. outcome <request-uuid> — your own write receipt for a send, edit or delete: committed, not_committed (with the reason) or unknown_check_again. Run it after a timeout before retrying.",
|
|
93
|
+
"edit <message-id> [--expected-revision <n>] [--request-id <uuid>] — replaces your own message's text (piped in). With --expected-revision, an edit of a message that has moved on is refused revision_conflict and nothing is saved over it; the current revision is printed. A message you did not write, or one that was deleted, is refused by name. delete <message-id> [--expected-revision <n>] [--request-id <uuid>] — deletes by tombstone: the text is cleared and every surface reads \"This message was deleted\", but the row stays so its thread, replies and reply count survive. Search and unread counts ignore it. A repeat answers already_deleted. outcome <request-uuid> — your own write receipt for a send, edit or delete: committed, not_committed (with the reason) or unknown_check_again. Run it after a timeout before retrying. draft [list|show|save|discard|restore|send] <channel> [--thread <id>] [--expected-revision <n>] [--request-id <uuid>] — the text you have not sent yet, one draft per channel and thread, the same drafts the browser autosaves. list shows all your unsent drafts (newest first, with coverage); show prints one; save takes the body on stdin (`echo \"half a thought\" | cockpit msg draft save general`) and with --expected-revision refuses draft_revision_conflict, naming the stored revision, instead of overwriting; discard keeps the text (state discarded) and restore brings it back; send posts the stored draft with a fresh request id (--request-id to retry the same send) and its revision, prints the receipt, and marks the draft sent only if it did not change meanwhile. Only you can read or change your drafts.",
|
|
94
94
|
"thread <id> --channel <channel> [--before <cursor>] [--limit <n>] — one thread's replies by the parent message's id.",
|
|
95
95
|
"show <message-id> — exact message, thread root and bounded neighbors. read/thread --before takes the preceding nextCursor; repeat until hasMore is false.",
|
|
96
96
|
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
|
|
@@ -153,7 +153,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
153
153
|
[
|
|
154
154
|
"cal",
|
|
155
155
|
[
|
|
156
|
-
'Usage: cockpit cal [event <id>|export <id>|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|next|find "<words>"|calendars|add-ical|create|duplicate <event id>|batch|undo [<change id>]|rsvp <event id> --response <accepted|declined|tentative>|change <event id>|cancel <event id> --yes|share <id>|sync <id>|sync-status [<id>]|detach <id>|delete <event id> --yes] [flags]',
|
|
156
|
+
'Usage: cockpit cal [event <id>|export <id>|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|view-settings|view-settings-change|next|find "<words>"|calendars|add-ical|create|duplicate <event id>|batch|undo [<change id>]|rsvp <event id> --response <accepted|declined|tentative>|change <event id>|cancel <event id> --yes|share <id>|sync <id>|sync-status [<id>]|detach <id>|delete <event id> --yes] [flags]',
|
|
157
157
|
"",
|
|
158
158
|
"Every calendar you attached, in one place — plus any calendar a colleague marked shared.",
|
|
159
159
|
"today [--offset 1] [--tz <zone>] — what is on today, in YOUR zone (this machine's, unless --tz says otherwise). --offset 1 is tomorrow, -1 yesterday.",
|
|
@@ -163,7 +163,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
163
163
|
"conflicts [--from <iso> --to <iso>] [--tz <IANA>] — own-account double-bookings with duplicate invitation merging, checked calendars and age. Visible calendars only by default; --all includes hidden calendars. All-day and free events never conflict.",
|
|
164
164
|
"event <id> [--occurrence-start <iso>] [--tz <zone>] [--json] — exact occurrence details, human recurrence, named guests/RSVP, organizer, source zone, HTTPS Join and edit capability. The selector must match a real occurrence; cancelled/missing instances are refused.",
|
|
165
165
|
"export <id> [--occurrence-start <iso>] [--json] — iCalendar text to stdout; redirect to event.ics. Recurring exports include the complete series and exceptions, keeping source zone/recurrence. No calendar credentials.",
|
|
166
|
-
"calendar-change <id> --color <hex|provider> — persistent owner colour override, or reset to provider colour. Also accepts stdin JSON {color_override, selected}. History is retained.",
|
|
166
|
+
"calendar-change <id> --color <hex|provider> — persistent owner colour override, or reset to provider colour. Also accepts stdin JSON {color_override, selected}. History is retained. view-settings [--json] reads your saved view settings (default view, N days, week start, weekends, density, up to 3 extra time zones) plus the calendar actions and keyboard shortcuts; never saved means the defaults, said so. view-settings-change [--view day|week|days|month] [--days 2-9] [--week-start monday|sunday] [--hide-weekends on|off] [--density normal|compact] [--zone <IANA>]... changes only what you name; --zone repeats (up to 3), --zone none clears; stdin JSON like {\"day_count\":4} works too.",
|
|
167
167
|
"calendars — every calendar, its provider (google_oauth or ical_url), whether it is shared, and when it last synced. The ids other verbs take are on the last line.",
|
|
168
168
|
"add-ical [--name \"<what to call it>\"] — attaches a personal calendar by its SECRET iCal ADDRESS, read from stdin. There is no flag for it:",
|
|
169
169
|
' macOS: printf "%s" "https://calendar.google.com/calendar/ical/…/basic.ics" | cockpit cal add-ical --name "Personal"',
|
|
@@ -23,6 +23,7 @@ import { TOWER_OPS_COMMAND_HELP } from "./local-help-commands-tower-ops.js";
|
|
|
23
23
|
/** One manual per collector command; the Tower nouns are spread in from their own file. */
|
|
24
24
|
const COMMAND_HELP = new Map([
|
|
25
25
|
["glossary", ["Usage: cockpit glossary list|add <wrong> --to <name>|remove <wrong> [--json]", "Quote phrases. Removing a form prevents future corrections of it."]],
|
|
26
|
+
["push", ["Usage: cockpit push status | test [--dashboard-url <url>] [--json]", "Phone notifications. status lists your saved devices and whether the server has its push keys; test sends a test push to every saved device. Turn a phone on from Settings on the phone."]],
|
|
26
27
|
["spend", ["Usage: cockpit spend memory [--days 1|7|30] [--dashboard-url <url>] [--json]", "Spend by memory operation (write, recall, dream, update_forget, backfill, label, exam, predictor, people, who, intentions, salience, chat, other), ranked top first with list and real dollars, share, trend against the previous window, and how much of the real OpenAI bill the ledger explains. What it does not explain is shown as untagged. A super_admin reads it."]],
|
|
27
28
|
["roots", ["Usage: cockpit roots add <dir> [--allow-home-root] [--json]", "Approves a work directory, saves an approval receipt, and adds it to collection. Run cockpit doctor afterward."]],
|
|
28
29
|
// BLI-3709: the Tower nouns live next door, split along the same seam the
|
|
@@ -72,6 +72,7 @@ export const rootCommandNames = new Set([
|
|
|
72
72
|
"sources",
|
|
73
73
|
"glossary",
|
|
74
74
|
"spend",
|
|
75
|
+
"push",
|
|
75
76
|
"docs",
|
|
76
77
|
"msg",
|
|
77
78
|
"issue",
|
|
@@ -144,7 +145,7 @@ const COMMAND_SUMMARY_LINES = [
|
|
|
144
145
|
" cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>|show <message-id>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--before <cursor>] [--dashboard-url <url>] [--json]",
|
|
145
146
|
" cockpit issue [list|show <BLI-id>|create --title <t>|update <BLI-id>|move <BLI-id> <state>|comment <BLI-id>|history <BLI-id>] [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] [--priority 0-4] [--size S|M|L] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
|
|
146
147
|
" cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|draft --account <id> --to <a> --subject <s> [--reply-to <message id>]|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--attach <path>]... [--dashboard-url <url>] [--json]",
|
|
147
|
-
" cockpit cal [event <id> [--occurrence-start <iso>]|export <id> [--occurrence-start <iso>]|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|duplicate <event id> [--keep-invitees]|batch|undo [<change id>]|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
|
|
148
|
+
" cockpit cal [event <id> [--occurrence-start <iso>]|export <id> [--occurrence-start <iso>]|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|view-settings|view-settings-change|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|duplicate <event id> [--keep-invitees]|batch|undo [<change id>]|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
|
|
148
149
|
" cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
|
|
149
150
|
" cockpit github <pr|merge|retitle|rerun> <owner/repo> <n> [--claim <state>] [--method squash|merge|rebase] [--title \"<text>\"] [--run <id>] [--confirm <code>] [--dashboard-url <url>] [--json]",
|
|
150
151
|
` cockpit search "<words>" [--kind <category,...>] [--limit <n>] [--dashboard-url <url>] [--json]`,
|
|
@@ -153,6 +154,7 @@ const COMMAND_SUMMARY_LINES = [
|
|
|
153
154
|
" cockpit careers [list|show <id>|rescreen <id>|send-takehome <id>|decide <id> --decision <passed_on|archived|reopen>|followup <id> [--record-sent-at <iso>]|interview <id> <--at <iso>|--clear>|grade <id> [--now]|resume <id> [--out <path>]|takehome show <role>|takehome set <role> --body-file <path>|settings show|settings set --enabled <true|false> --followup-enabled <true|false> --followup-after-days <n> --takehome-window-hours <n>] [--role <slug>] [--min-score <n>] [--since <date>] [--stage <s>] [--force] [--json]",
|
|
154
155
|
" cockpit glossary list|add <wrong> --to <name>|remove <wrong> [--dashboard-url <url>] [--json]",
|
|
155
156
|
" cockpit spend memory [--days 1|7|30] [--dashboard-url <url>] [--json]",
|
|
157
|
+
" cockpit push status|test [--dashboard-url <url>] [--json]",
|
|
156
158
|
" cockpit rename [list|resolve <id> --scope <page|everywhere|undo>] [--dashboard-url <url>] [--json]",
|
|
157
159
|
" cockpit logs <collector|dashboard|railway|railway:<service>|-> [--since 15m|1h|2d|<iso>] [--raw <template-id> [--limit <n>]] [--all-levels] [--table <file>] [--json]",
|
|
158
160
|
" cockpit intentions [list [--status open|overdue|done|dropped|all] [--project <name>] [--limit <n>]|show <id>|done <id> --evidence <text>|drop <id> --evidence <text>|reopen <id>] [--dashboard-url <url>] [--json]",
|
package/dist/commands/local.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { runOpsCommand } from "./local-run-ops.js";
|
|
2
2
|
import { runGlossary } from "./glossary.js";
|
|
3
3
|
import { runSpend } from "./spend.js";
|
|
4
|
+
import { runPush } from "./push.js";
|
|
4
5
|
import { defaultIo, writeLine } from "./cli-io.js";
|
|
5
6
|
import { isLocalHelpRequest, localCommandHelp } from "./local-help.js";
|
|
6
7
|
import { beginStage, endStage } from "../crash-guard.js";
|
|
@@ -196,6 +197,7 @@ async function runTowerCommand(command, io) {
|
|
|
196
197
|
case "notes": return await runNotes(command, io);
|
|
197
198
|
case "glossary": return await runGlossary(command, io);
|
|
198
199
|
case "spend": return await runSpend(command, io);
|
|
200
|
+
case "push": return await runPush(command, io);
|
|
199
201
|
case "docs": return await runDocs(command, io);
|
|
200
202
|
case "msg": return await runMsg(command, io);
|
|
201
203
|
case "issue": return await runIssue(command, io);
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit manager add|edit|summary --fields-file <path>|-` (BLI-5777, findings Q-10 and Q-33): the free text of a write
|
|
3
|
+
* as one JSON object, read from a file or from stdin, so no shell quoting touches it (`$12k`, an apostrophe, a double
|
|
4
|
+
* quote on Windows PowerShell 5.1, an `=`).
|
|
5
|
+
*
|
|
6
|
+
* The keys are the flag names in snake_case. This module only READS: it checks the JSON's shape, refuses an unknown key
|
|
7
|
+
* or a key also given as a flag, runs the secret guard on every string, and turns each key into the same value the flag
|
|
8
|
+
* would have carried. The parser (`local-args-tower-manager.ts`) then runs the one set of field readers over the merged
|
|
9
|
+
* values, so a file value gets exactly the flag's validation (`none` clears, blank is refused, dates are real days).
|
|
10
|
+
*
|
|
11
|
+
* `--version` and `--timeline` stay flags: the version guards a write against a stale read, and `--timeline` picks the table.
|
|
12
|
+
*/
|
|
13
|
+
import { readFile, stat } from "node:fs/promises";
|
|
14
|
+
import { isInteractiveStdin } from "./cli-io.js";
|
|
15
|
+
import { rejectSecretLikeValue } from "./local-arg-values.js";
|
|
16
|
+
/** The keys a fields file may hold, per verb: every value flag the verb owns except `--version`. */
|
|
17
|
+
export const FIELDS_FILE_KEYS = {
|
|
18
|
+
add: ["title", "detail", "owners", "owner", "due", "date", "kind", "meeting", "source_url", "source_note", "request_id"],
|
|
19
|
+
edit: ["title", "detail", "owners", "owner", "due", "date", "kind"],
|
|
20
|
+
summary: ["health", "headline", "bullets", "next", "blocker"],
|
|
21
|
+
};
|
|
22
|
+
/** Keys whose JSON `null` means "clear it", the same as the flag's `none`. */
|
|
23
|
+
const NULLABLE = new Set(["owners", "owner", "due", "date", "next", "blocker"]);
|
|
24
|
+
/** A file is a few fields of text; this stops a wrong file (a log, a dump) being read whole. */
|
|
25
|
+
export const FIELDS_FILE_MAX_BYTES = 256 * 1024;
|
|
26
|
+
const flagOf = (key) => `--${key.replace(/_/g, "-")}`;
|
|
27
|
+
/** A key is the person's own spelling: cut and cleaned before it goes into a sentence. */
|
|
28
|
+
const shown = (key) => key.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").slice(0, 40);
|
|
29
|
+
/** The sentence for bytes that are not UTF-8 text (a Windows PowerShell 5.1 `>` file is UTF-16, a `Set-Content` file is the ANSI code page). */
|
|
30
|
+
export const NOT_UTF8 = "The file is not UTF-8. Save the file as UTF-8 (PowerShell: -Encoding utf8) and run it again.";
|
|
31
|
+
/**
|
|
32
|
+
* The bytes of a fields file as text: a UTF-8 BOM is dropped, a UTF-16 LE or BE BOM is decoded (PowerShell 5.1's `>` and Out-File write UTF-16 LE with a
|
|
33
|
+
* BOM), and anything else must be well-formed UTF-8: a file in the ANSI code page (a `’` saved by Set-Content) is refused, never read as "�".
|
|
34
|
+
*/
|
|
35
|
+
export function decodeFieldsBytes(bytes) {
|
|
36
|
+
// UTF-16 without a BOM (a program that wrote it raw) is full of NUL bytes, which no UTF-8 text holds: say what is wrong, not "not valid JSON (near character 1)".
|
|
37
|
+
const utf16Bom = bytes.length >= 2 && ((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff));
|
|
38
|
+
if (!utf16Bom && bytes.includes(0))
|
|
39
|
+
throw new Error(NOT_UTF8);
|
|
40
|
+
try {
|
|
41
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe)
|
|
42
|
+
return new TextDecoder("utf-16le", { fatal: true }).decode(bytes.subarray(2));
|
|
43
|
+
if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff)
|
|
44
|
+
return new TextDecoder("utf-16be", { fatal: true }).decode(bytes.subarray(2));
|
|
45
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes.subarray(bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ? 3 : 0));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new Error(NOT_UTF8);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Everything the stream holds, as bytes (a string chunk, as a test gives, is its UTF-8), refused over `max` bytes. */
|
|
52
|
+
async function readStreamBytes(stream, max, overflow) {
|
|
53
|
+
const chunks = [];
|
|
54
|
+
let total = 0;
|
|
55
|
+
for await (const chunk of stream) {
|
|
56
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
57
|
+
total += bytes.length;
|
|
58
|
+
if (total > max)
|
|
59
|
+
throw new Error(overflow);
|
|
60
|
+
chunks.push(bytes);
|
|
61
|
+
}
|
|
62
|
+
return Buffer.concat(chunks);
|
|
63
|
+
}
|
|
64
|
+
/** The text of the fields file: `-` is stdin (piped), anything else a path. Every failure is a sentence that says what to do. */
|
|
65
|
+
export async function readFieldsText(source, io) {
|
|
66
|
+
if (source === "-") {
|
|
67
|
+
if (isInteractiveStdin(io)) {
|
|
68
|
+
throw new Error("--fields-file - reads the JSON from stdin, but stdin is a terminal. Pipe it in (`cat fields.json | cockpit manager ... --fields-file -`) or give a file path.");
|
|
69
|
+
}
|
|
70
|
+
const bytes = await readStreamBytes(io.stdin, FIELDS_FILE_MAX_BYTES, `The fields JSON on stdin is longer than ${FIELDS_FILE_MAX_BYTES / 1024} KB. It holds a few fields of text; check you piped the right file.`);
|
|
71
|
+
let text;
|
|
72
|
+
try {
|
|
73
|
+
text = decodeFieldsBytes(bytes);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// From a pipe, Windows PowerShell 5.1 encodes with $OutputEncoding (ASCII by default): the way out is its setting, not the file.
|
|
77
|
+
throw new Error("The JSON on stdin is not UTF-8. In Windows PowerShell 5.1 set `$OutputEncoding = [System.Text.UTF8Encoding]::new()` before piping, or save the JSON as UTF-8 (PowerShell: -Encoding utf8) and pass its path.");
|
|
78
|
+
}
|
|
79
|
+
if (text.trim() === "")
|
|
80
|
+
throw new Error("--fields-file - got nothing on stdin. Pipe in a JSON object, like {\"title\": \"Send the SOW\"}.");
|
|
81
|
+
return text;
|
|
82
|
+
}
|
|
83
|
+
let info;
|
|
84
|
+
try {
|
|
85
|
+
info = await stat(source);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
const code = error.code;
|
|
89
|
+
throw new Error(code === "ENOENT" ? `--fields-file ${source}: no such file.` : `--fields-file ${source}: cannot be read (${code ?? "unknown error"}).`);
|
|
90
|
+
}
|
|
91
|
+
if (!info.isFile())
|
|
92
|
+
throw new Error(`--fields-file ${source} is not a file.`);
|
|
93
|
+
if (info.size > FIELDS_FILE_MAX_BYTES) {
|
|
94
|
+
throw new Error(`--fields-file ${source} is ${Math.ceil(info.size / 1024)} KB; the limit is ${FIELDS_FILE_MAX_BYTES / 1024} KB. It holds a few fields of text; check it is the right file.`);
|
|
95
|
+
}
|
|
96
|
+
let bytes;
|
|
97
|
+
try {
|
|
98
|
+
bytes = await readFile(source);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
throw new Error(`--fields-file ${source}: cannot be read (${error.code ?? "unknown error"}).`);
|
|
102
|
+
}
|
|
103
|
+
const text = decodeFieldsBytes(bytes);
|
|
104
|
+
if (text.trim() === "")
|
|
105
|
+
throw new Error(`--fields-file ${source} is empty. Put a JSON object in it, like {"title": "Send the SOW"}.`);
|
|
106
|
+
return text;
|
|
107
|
+
}
|
|
108
|
+
function parseObject(raw) {
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(raw);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
// The engine's message quotes the file's own words: only the position is kept.
|
|
115
|
+
const at = /position (\d+)/.exec(error instanceof Error ? error.message : "")?.[1];
|
|
116
|
+
throw new Error(`The fields file is not valid JSON${at ? ` (near character ${at})` : ""}. It is one object: {"title": "Send the SOW", "detail": "Mo said \\"ok\\""}. A quote inside a value is written \\", a new line \\n.`);
|
|
117
|
+
}
|
|
118
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
119
|
+
throw new Error("The fields file must be one JSON object, like {\"title\": \"Send the SOW\"}, not a list or a single value.");
|
|
120
|
+
}
|
|
121
|
+
return parsed;
|
|
122
|
+
}
|
|
123
|
+
/** A string value: of the right type, and not a secret. */
|
|
124
|
+
function text(key, value) {
|
|
125
|
+
if (typeof value !== "string")
|
|
126
|
+
throw new Error(`The fields file's "${key}" must be text${NULLABLE.has(key) ? " (or null to clear it)" : ""}.`);
|
|
127
|
+
rejectSecretLikeValue(value, `the fields file's "${key}"`);
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Folds the file's fields into `values` as the flags they stand for, and returns the file's bullets (they have a
|
|
132
|
+
* shape of their own). Throws a sentence for: bad JSON, an unknown key, a key also given as a flag, a wrong type, a secret.
|
|
133
|
+
*/
|
|
134
|
+
export function mergeFieldsFile(values, verb, fileText) {
|
|
135
|
+
const fields = parseObject(fileText);
|
|
136
|
+
const allowed = FIELDS_FILE_KEYS[verb];
|
|
137
|
+
const keys = Object.keys(fields);
|
|
138
|
+
for (const key of keys) {
|
|
139
|
+
if (key === "version" || key === "timeline") {
|
|
140
|
+
throw new Error(`"${key}" is not a field: give ${flagOf(key)} as a flag. It is not read from the fields file.`);
|
|
141
|
+
}
|
|
142
|
+
if (!allowed.includes(key)) {
|
|
143
|
+
throw new Error(`The fields file has a key "${shown(key)}" that \`cockpit manager ${verb}\` does not take. It takes: ${allowed.join(", ")}.`);
|
|
144
|
+
}
|
|
145
|
+
const flag = key === "bullets" ? "--bullet" : flagOf(key);
|
|
146
|
+
if (values.flags.has(flag) || values.flagValues.has(flag)) {
|
|
147
|
+
throw new Error(`${key} is given both as ${flag} and in the fields file. Give it once, in one place.`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const out = { keys: keys.length };
|
|
151
|
+
for (const key of keys) {
|
|
152
|
+
const value = fields[key];
|
|
153
|
+
if (key === "bullets") {
|
|
154
|
+
out.bullets = readBulletList(value);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const flag = flagOf(key);
|
|
158
|
+
if (value === null) {
|
|
159
|
+
if (!NULLABLE.has(key))
|
|
160
|
+
throw new Error(`The fields file's "${key}" must be text; null clears only ${[...NULLABLE].join(", ")}.`);
|
|
161
|
+
values.flags.set(flag, "none");
|
|
162
|
+
}
|
|
163
|
+
else if (key === "owners" && Array.isArray(value)) {
|
|
164
|
+
values.flags.set(flag, ownersText(value));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
// An empty string is allowed from a file (it can clear a detail); the shell parser refuses an empty flag value.
|
|
168
|
+
values.flags.set(flag, text(key, value));
|
|
169
|
+
}
|
|
170
|
+
values.flagValues.set(flag, [values.flags.get(flag)]);
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
/** `["Pip Moss", "Rowan Ash"]` as the flag's `Pip Moss, Rowan Ash`; `[]` clears (`none`), like the flag's `none`. */
|
|
175
|
+
function ownersText(list) {
|
|
176
|
+
if (list.length === 0)
|
|
177
|
+
return "none";
|
|
178
|
+
const names = list.map((entry) => {
|
|
179
|
+
const name = text("owners", entry).trim();
|
|
180
|
+
if (name === "")
|
|
181
|
+
throw new Error("The fields file's \"owners\" has an empty name. Give names, or [] or null to clear them.");
|
|
182
|
+
if (name.includes(","))
|
|
183
|
+
throw new Error("A name in the fields file's \"owners\" cannot hold a comma: Tower splits people on commas. Write the name without it.");
|
|
184
|
+
return name;
|
|
185
|
+
});
|
|
186
|
+
return names.join(", ");
|
|
187
|
+
}
|
|
188
|
+
function readBulletList(value) {
|
|
189
|
+
if (!Array.isArray(value))
|
|
190
|
+
throw new Error("The fields file's \"bullets\" must be a list: [\"2026-10-14: Signed the SOW\", {\"date\": null, \"text\": \"Export still missing\"}].");
|
|
191
|
+
return value.map((entry, index) => {
|
|
192
|
+
if (typeof entry === "string")
|
|
193
|
+
return text("bullets", entry);
|
|
194
|
+
if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) {
|
|
195
|
+
const { date, text: words, ...rest } = entry;
|
|
196
|
+
const extra = Object.keys(rest)[0];
|
|
197
|
+
if (extra !== undefined)
|
|
198
|
+
throw new Error(`Bullet ${index + 1} in the fields file has a key "${shown(extra)}"; a bullet is {"date": "YYYY-MM-DD" or null, "text": "..."}.`);
|
|
199
|
+
if (date !== null && date !== undefined && typeof date !== "string")
|
|
200
|
+
throw new Error(`Bullet ${index + 1}'s "date" must be "YYYY-MM-DD" or null.`);
|
|
201
|
+
if (typeof words !== "string")
|
|
202
|
+
throw new Error(`Bullet ${index + 1}'s "text" must be text.`);
|
|
203
|
+
rejectSecretLikeValue(words, `the fields file's "bullets"`);
|
|
204
|
+
return { date: date ?? null, text: words };
|
|
205
|
+
}
|
|
206
|
+
throw new Error(`Bullet ${index + 1} in the fields file must be a "YYYY-MM-DD: text" string or a {"date", "text"} object.`);
|
|
207
|
+
});
|
|
208
|
+
}
|