@bli-cockpit/cli 0.2.41 → 0.2.43

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.
@@ -0,0 +1,289 @@
1
+ /**
2
+ * `cockpit brief edit` — rewrite the page like a document (BLI-3458, S2.7).
3
+ *
4
+ * The browser has had this since BLI-3120: click into a line, fix the words,
5
+ * press save, and the edit IS the correction — the exact wrong words, the exact
6
+ * right words, and optionally why, which is strictly more than any complaint
7
+ * could carry. This is the same door from a terminal.
8
+ *
9
+ * Four steps, and the order matters:
10
+ *
11
+ * 1. `GET /api/jarvis/brief?editable=1` — the page as a file, one
12
+ * `[claimId] the prose` line per rewritable claim. The server builds it
13
+ * from the SAME `indexClaims` the edit door diffs against, so an untouched
14
+ * line comes back byte-identical instead of being filed as an edit.
15
+ * 2. Open `$VISUAL`/`$EDITOR` on a temp copy (`editor.ts` owns that, and it is
16
+ * the one place in this package that spawns a program a PERSON chose). No
17
+ * editor, or nothing to open one on — piped input, a script, CI — and the
18
+ * edited document is read from stdin instead.
19
+ * 3. Diff HERE, not on the server. Only lines that really changed are sent, so
20
+ * an accidental save writes nothing and publishes no version.
21
+ * 4. `POST /api/jarvis/edit` — the same door the browser's save posts to.
22
+ *
23
+ * Two refusals happen before anything is sent, both by name:
24
+ *
25
+ * - **A claim id that vanished.** The document is a set of lines, not a text
26
+ * file: deleting one is not "delete that sentence", it is "I have no opinion
27
+ * about it", and the two are indistinguishable to a diff. Sending it as an
28
+ * emptied line would publish a page with a hole in it.
29
+ * - **A claim id that was invented.** Filing a correction against a clause
30
+ * nobody can find is how a ledger fills with rows that mean nothing.
31
+ */
32
+ import { colorEnabled, dim, isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
33
+ import { editInEditor, resolveEditorCommand } from "./editor.js";
34
+ import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
35
+ const REQUEST_DEADLINE_MS = 120_000;
36
+ /** A page is long; a document that arrives bigger than this is not one of ours. */
37
+ const MAX_DOCUMENT_CHARS = 400_000;
38
+ export async function runBriefEdit(command, io) {
39
+ const session = await loadPairedSession("brief edit", command.homeDir);
40
+ const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
41
+ const log = (line) => writeLine(io.stderr, line);
42
+ const startedAt = Date.now();
43
+ // ── 1. The page, as a document ────────────────────────────────────────────
44
+ const read = await towerJsonRequest({
45
+ dashboardUrl,
46
+ path: `/api/jarvis/brief${briefQuery(command)}`,
47
+ deviceToken: session.device_token,
48
+ fetch: io.fetch,
49
+ method: "GET",
50
+ label: "brief:edit",
51
+ timeoutMs: REQUEST_DEADLINE_MS,
52
+ log,
53
+ });
54
+ if (!read.ok)
55
+ return fail(command, io, read.reason, read.detail);
56
+ const brief = read.body;
57
+ const personId = brief.page?.personId;
58
+ const original = brief.editable?.document;
59
+ const lines = (brief.editable?.lines ?? []).filter((line) => typeof line.claimId === "string" && typeof line.text === "string");
60
+ if (!brief.ok || !personId || typeof original !== "string" || lines.length === 0) {
61
+ return fail(command, io, brief.error ?? "no_page", brief.reply ?? "There is no page to edit.");
62
+ }
63
+ // ── 2. Their editor, or their stdin ───────────────────────────────────────
64
+ const edited = await collectEditedDocument(io, original, log);
65
+ if (!edited.ok)
66
+ return fail(command, io, edited.reason, edited.detail);
67
+ // ── 3. The diff, here ─────────────────────────────────────────────────────
68
+ const diff = diffDocument(lines, edited.text);
69
+ if (diff.invented.length > 0) {
70
+ return fail(command, io, "invented_claim", `The page has no line called “${diff.invented[0]}”. Change the words after an id, never the id ` +
71
+ "itself — nothing has been sent to Tower.");
72
+ }
73
+ if (diff.deleted.length > 0) {
74
+ return fail(command, io, "deleted_claim", `The line [${diff.deleted[0]}] is missing from what you saved. Put it back — a line you delete ` +
75
+ "reads as no opinion at all, not as a sentence to remove, and Tower cannot tell those apart. " +
76
+ "Nothing has been sent.");
77
+ }
78
+ if (diff.edits.length === 0) {
79
+ const styled = colorEnabled(io);
80
+ if (command.json) {
81
+ writeLine(io.stdout, JSON.stringify({ ok: true, saved: 0, reason: "nothing_changed" }));
82
+ }
83
+ else {
84
+ writeLine(io.stdout, dim("Nothing changed, so nothing was written.", styled));
85
+ }
86
+ writeLine(io.stderr, `[brief edit] nothing changed ${JSON.stringify({
87
+ lines: lines.length,
88
+ elapsed_ms: Date.now() - startedAt,
89
+ })}`);
90
+ return 0;
91
+ }
92
+ // ── 4. The same door the browser's save posts to ──────────────────────────
93
+ const saved = await towerJsonRequest({
94
+ dashboardUrl,
95
+ path: "/api/jarvis/edit",
96
+ deviceToken: session.device_token,
97
+ fetch: io.fetch,
98
+ label: "brief:edit:save",
99
+ timeoutMs: REQUEST_DEADLINE_MS,
100
+ log,
101
+ body: {
102
+ personId,
103
+ pageId: brief.page?.pageId ?? null,
104
+ edits: diff.edits,
105
+ ...(command.reason ? { reason: command.reason } : {}),
106
+ },
107
+ });
108
+ if (!saved.ok)
109
+ return fail(command, io, saved.reason, saved.detail);
110
+ const answer = saved.body;
111
+ writeOutcome(command, io, answer, diff.edits.length);
112
+ writeLine(io.stderr, `[brief edit] saved ${JSON.stringify({
113
+ sent: diff.edits.length,
114
+ saved: answer.saved ?? null,
115
+ versions: answer.versions ?? null,
116
+ skipped: answer.skipped?.length ?? 0,
117
+ overruled: answer.overruled?.length ?? 0,
118
+ reason: answer.reason ?? null,
119
+ had_reason: command.reason != null,
120
+ subject: command.subject ? "selected" : "caller",
121
+ elapsed_ms: Date.now() - startedAt,
122
+ })}`);
123
+ return 0;
124
+ }
125
+ function briefQuery(command) {
126
+ const params = new URLSearchParams({ editable: "1" });
127
+ if (command.subject)
128
+ params.set("p", command.subject);
129
+ if (command.version)
130
+ params.set("v", command.version);
131
+ return `?${params.toString()}`;
132
+ }
133
+ /**
134
+ * The edited document.
135
+ *
136
+ * Piped stdin wins, and deliberately: somebody who prepared a file has already
137
+ * done the editing, and opening an editor on top of it would be the command
138
+ * ignoring what it was handed. Otherwise the editor opens — and when there is
139
+ * neither, the refusal names both ways out rather than guessing `vi` at
140
+ * somebody who has never used it.
141
+ */
142
+ async function collectEditedDocument(io, original, log) {
143
+ if (!isInteractiveStdin(io)) {
144
+ let piped;
145
+ try {
146
+ piped = await readPipedText(io.stdin, {
147
+ maxChars: MAX_DOCUMENT_CHARS,
148
+ overflowMessage: `An edited page is limited to ${MAX_DOCUMENT_CHARS} characters.`,
149
+ });
150
+ }
151
+ catch (error) {
152
+ return {
153
+ ok: false,
154
+ reason: "stdin_unreadable",
155
+ detail: error instanceof Error ? error.message : String(error),
156
+ };
157
+ }
158
+ if (piped.trim() === "") {
159
+ return {
160
+ ok: false,
161
+ reason: "nothing_on_stdin",
162
+ detail: "Nothing arrived on stdin. Run `cockpit brief edit` in a terminal to open your editor, " +
163
+ "or pipe the edited document in: `cockpit brief --editable > page.md; … ; " +
164
+ "cockpit brief edit < page.md`.",
165
+ };
166
+ }
167
+ log(`[brief edit] read from stdin ${JSON.stringify({ chars: piped.length })}`);
168
+ return { ok: true, text: piped, via: "stdin" };
169
+ }
170
+ if (!resolveEditorCommand(io.env)) {
171
+ return {
172
+ ok: false,
173
+ reason: "no_editor_configured",
174
+ detail: "No editor is configured. Set EDITOR (or VISUAL) — `export EDITOR=nano` — or pipe the " +
175
+ "edited document in on stdin instead.",
176
+ };
177
+ }
178
+ const session = await editInEditor({
179
+ contents: original,
180
+ suffix: ".md",
181
+ env: io.env,
182
+ log,
183
+ });
184
+ if (!session.ok)
185
+ return { ok: false, reason: session.reason, detail: session.detail };
186
+ return { ok: true, text: session.text, via: "editor" };
187
+ }
188
+ /**
189
+ * What actually changed, decided here rather than on the server.
190
+ *
191
+ * The parse mirrors `apps/dashboard/src/lib/jarvis/serialize/editable-document.ts`,
192
+ * which produced the document: `#` is a comment, a `[id]` starts a line, a
193
+ * following un-prefixed line is that line wrapped rather than a new one. It only
194
+ * ever has to read what the server wrote, and the `unchanged` case proves it
195
+ * did — a parser that got this wrong would report every line as an edit, which
196
+ * is exactly the failure `editable-document.test.ts` holds the server side to.
197
+ */
198
+ export function diffDocument(original, edited) {
199
+ const before = new Map(original.filter((line) => line.rewritable).map((line) => [line.claimId, normalize(line.text)]));
200
+ const seen = new Set();
201
+ const diff = { edits: [], invented: [], deleted: [] };
202
+ for (const parsed of parseDocument(edited)) {
203
+ if (seen.has(parsed.claimId))
204
+ continue;
205
+ seen.add(parsed.claimId);
206
+ const stored = before.get(parsed.claimId);
207
+ if (stored === undefined) {
208
+ diff.invented.push(parsed.claimId);
209
+ continue;
210
+ }
211
+ const after = normalize(parsed.text);
212
+ if (after !== stored)
213
+ diff.edits.push({ claimId: parsed.claimId, after });
214
+ }
215
+ for (const claimId of before.keys()) {
216
+ if (!seen.has(claimId))
217
+ diff.deleted.push(claimId);
218
+ }
219
+ return diff;
220
+ }
221
+ /** The same collapse `normalizeEditedText` does server-side, so the two agree. */
222
+ function normalize(text) {
223
+ return text.replace(/ /g, " ").replace(/\s+/g, " ").trim();
224
+ }
225
+ const CLAIM_LINE = /^\[([^\]\s]+)\]\s?(.*)$/;
226
+ export function parseDocument(text) {
227
+ const out = [];
228
+ let current = null;
229
+ for (const raw of text.split(/\r?\n/)) {
230
+ const line = raw.trim();
231
+ if (line.startsWith("#")) {
232
+ current = null;
233
+ continue;
234
+ }
235
+ const match = CLAIM_LINE.exec(line);
236
+ if (match) {
237
+ current = { claimId: match[1], text: (match[2] ?? "").trim() };
238
+ out.push(current);
239
+ continue;
240
+ }
241
+ if (line === "") {
242
+ current = null;
243
+ continue;
244
+ }
245
+ if (current)
246
+ current.text = `${current.text} ${line}`.trim();
247
+ }
248
+ return out;
249
+ }
250
+ function writeOutcome(command, io, answer, sent) {
251
+ if (command.json) {
252
+ writeLine(io.stdout, JSON.stringify({ ok: true, sent, ...answer }));
253
+ return;
254
+ }
255
+ const styled = colorEnabled(io);
256
+ const saved = answer.saved ?? 0;
257
+ const versions = answer.versions;
258
+ writeLine(io.stdout, saved === 1
259
+ ? "One line rewritten, and Tower has it."
260
+ : `${saved} lines rewritten, and Tower has them.`);
261
+ if (typeof versions === "number") {
262
+ writeLine(io.stdout, dim(`Your page now has ${versions} versions.`, styled));
263
+ }
264
+ else if (answer.reason) {
265
+ // The edit is on the record and the page did not move. Never silent: the
266
+ // server named which of the four reasons it was and it goes straight out.
267
+ writeLine(io.stdout, dim(`The page itself was not republished (${answer.reason}); your words are on the record.`, styled));
268
+ }
269
+ for (const skip of answer.skipped ?? []) {
270
+ writeLine(io.stdout, dim(`Not applied: [${skip.claimId ?? "?"}] — ${skip.reason ?? "no reason given"}.`, styled));
271
+ }
272
+ // The push-back is the point, not an error path: the record disagreed with
273
+ // what they typed, their words were kept anyway, and the disagreement is said
274
+ // out loud rather than quietly stored.
275
+ for (const overruled of answer.overruled ?? []) {
276
+ if (overruled.pushBack)
277
+ writeLine(io.stdout, overruled.pushBack);
278
+ }
279
+ }
280
+ function fail(command, io, reason, detail) {
281
+ if (command.json) {
282
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
283
+ }
284
+ else {
285
+ writeLine(io.stderr, detail);
286
+ }
287
+ writeLine(io.stderr, `[brief edit] not saved ${JSON.stringify({ reason })}`);
288
+ return 1;
289
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * `cockpit brief rewrite` — ask for the page to be written again (BLI-3458, S2.8).
3
+ *
4
+ * The website has a button; this is that button. What is different here is not
5
+ * the permission — an admin may ask about anybody, everyone else about their own
6
+ * page, decided by the row policy on `jarvis_recompile_requests` and nowhere
7
+ * else — it is the WAITING.
8
+ *
9
+ * **Why this polls instead of holding the connection.** Writing a page is a
10
+ * multi-minute job: `/api/jarvis/recompile` is budgeted to 800 seconds and a
11
+ * real run has taken seven attempts. A terminal's own fetch wall is 300. So a
12
+ * synchronous ask would abort ON THIS SIDE with the compile still running, and
13
+ * "it timed out" would be indistinguishable from "it failed" — the exact
14
+ * confusion this repo keeps paying for. Instead `mode: "queue"` answers 202 the
15
+ * moment the ask exists, and this command watches the person's own page until a
16
+ * new version shows up.
17
+ *
18
+ * Three outcomes, and they are kept apart on purpose:
19
+ *
20
+ * - **written** — a new page id appeared. Exit 0.
21
+ * - **still running** — the wait ran out. The compile is very likely still
22
+ * going; this is NOT a failure and says so, with the one command that will
23
+ * show the answer later. Exit non-zero, because what was asked for has not
24
+ * happened yet.
25
+ * - **queue only** — the deployment could not schedule the compile at all
26
+ * (`compileScheduled: false`). The ask is on the record and the daily job
27
+ * will reach it; nothing is pretended.
28
+ */
29
+ import { colorEnabled, dim, writeLine } from "./cli-io.js";
30
+ import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
31
+ const REQUEST_DEADLINE_MS = 60_000;
32
+ /**
33
+ * How long to watch. The server's own compile budget is 800s; this allows for
34
+ * that plus the queue's own latency, and then says plainly that it stopped
35
+ * watching rather than that anything failed.
36
+ */
37
+ const WATCH_CEILING_MS = 900_000;
38
+ const POLL_EVERY_MS = 10_000;
39
+ export async function runBriefRewrite(command, io, watchOptions = {}) {
40
+ const session = await loadPairedSession("brief rewrite", command.homeDir);
41
+ const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
42
+ const log = (line) => writeLine(io.stderr, line);
43
+ const styled = colorEnabled(io);
44
+ const startedAt = Date.now();
45
+ // Which page, and what it looks like NOW — the "before" the poll compares
46
+ // against. Read first for a second reason: it is the only way this command
47
+ // learns the person id, and it is the same read that decides whether this
48
+ // caller may see that page at all.
49
+ const before = await readPage(command, io, session.device_token, dashboardUrl, log);
50
+ if (!before.ok)
51
+ return fail(command, io, before.reason, before.detail);
52
+ const personId = before.page.page?.personId;
53
+ if (!personId) {
54
+ return fail(command, io, before.page.error ?? "no_page", before.page.reply ??
55
+ "There is no page here to write again yet. The first one is compiled by the daily job.");
56
+ }
57
+ const beforePageId = before.page.page?.pageId ?? null;
58
+ const queued = await towerJsonRequest({
59
+ dashboardUrl,
60
+ path: "/api/jarvis/recompile",
61
+ deviceToken: session.device_token,
62
+ fetch: io.fetch,
63
+ label: "brief:rewrite",
64
+ timeoutMs: REQUEST_DEADLINE_MS,
65
+ log,
66
+ body: { personIds: [personId], mode: "queue" },
67
+ });
68
+ if (!queued.ok)
69
+ return fail(command, io, queued.reason, queued.detail);
70
+ const answer = queued.body;
71
+ const request = (answer.queued ?? [])[0];
72
+ if (!answer.ok || !request?.requestId) {
73
+ return fail(command, io, "not_queued", answer.reply ?? "Tower did not accept that ask.");
74
+ }
75
+ const whose = request.name ?? before.page.page?.displayName ?? "that page";
76
+ const scheduled = answer.compileScheduled !== false;
77
+ writeLine(io.stderr, `[brief rewrite] queued ${JSON.stringify({
78
+ request_id: request.requestId,
79
+ compile_scheduled: scheduled,
80
+ compile_reason: answer.compileReason ?? null,
81
+ waiting: command.wait !== false,
82
+ })}`);
83
+ if (!scheduled) {
84
+ // Queue-only, said as queue-only. The ask is a real row; what is missing is
85
+ // somebody to run it in the next minute, and pretending otherwise would
86
+ // leave a person watching a page that was never going to change.
87
+ const sentence = `${whose} is on the list to be written again, but this Tower could not start the work now ` +
88
+ `(${answer.compileReason ?? "no reason given"}). The daily compile will reach it. Nothing is lost.`;
89
+ if (command.json) {
90
+ writeLine(io.stdout, JSON.stringify({
91
+ ok: true,
92
+ outcome: "queued_only",
93
+ requestId: request.requestId,
94
+ personId,
95
+ compileScheduled: false,
96
+ compileReason: answer.compileReason ?? null,
97
+ }));
98
+ }
99
+ else {
100
+ writeLine(io.stdout, sentence);
101
+ }
102
+ return 0;
103
+ }
104
+ if (command.wait === false) {
105
+ if (command.json) {
106
+ writeLine(io.stdout, JSON.stringify({
107
+ ok: true,
108
+ outcome: "queued",
109
+ requestId: request.requestId,
110
+ personId,
111
+ compileScheduled: true,
112
+ }));
113
+ }
114
+ else {
115
+ writeLine(io.stdout, `Asked. ${whose} is being written again.`);
116
+ writeLine(io.stdout, dim("Run `cockpit brief` in a few minutes to read it.", styled));
117
+ }
118
+ return 0;
119
+ }
120
+ writeLine(io.stderr, dim(`Writing ${whose} again. This takes a few minutes; watching for the new version…`, styled));
121
+ const watched = await watchForNewVersion({
122
+ command,
123
+ io,
124
+ deviceToken: session.device_token,
125
+ dashboardUrl,
126
+ beforePageId,
127
+ log,
128
+ pollEveryMs: watchOptions.pollEveryMs ?? POLL_EVERY_MS,
129
+ ceilingMs: watchOptions.ceilingMs ?? WATCH_CEILING_MS,
130
+ });
131
+ if (watched.outcome === "written") {
132
+ if (command.json) {
133
+ writeLine(io.stdout, JSON.stringify({
134
+ ok: true,
135
+ outcome: "written",
136
+ requestId: request.requestId,
137
+ personId,
138
+ pageId: watched.pageId,
139
+ waitedMs: watched.waitedMs,
140
+ }));
141
+ }
142
+ else {
143
+ writeLine(io.stdout, `${whose} has been written again.`);
144
+ writeLine(io.stdout, dim("Run `cockpit brief` to read it.", styled));
145
+ }
146
+ writeLine(io.stderr, `[brief rewrite] written ${JSON.stringify({
147
+ request_id: request.requestId,
148
+ polls: watched.polls,
149
+ waited_ms: watched.waitedMs,
150
+ elapsed_ms: Date.now() - startedAt,
151
+ })}`);
152
+ return 0;
153
+ }
154
+ // Not a failure, and named so it cannot be read as one. Nothing observed here
155
+ // says the compile stopped — only that this command stopped watching.
156
+ const sentence = watched.outcome === "watch_failed"
157
+ ? `I stopped being able to read the page while waiting (${watched.detail}). The rewrite may ` +
158
+ "still finish; run `cockpit brief` in a few minutes."
159
+ : `Still running after ${Math.round(watched.waitedMs / 1000)}s. Tower has not finished writing ` +
160
+ `${whose} yet — that is normal for a long page, and the work is still going. Run ` +
161
+ "`cockpit brief` in a few minutes.";
162
+ if (command.json) {
163
+ writeLine(io.stdout, JSON.stringify({
164
+ ok: false,
165
+ outcome: watched.outcome,
166
+ requestId: request.requestId,
167
+ personId,
168
+ waitedMs: watched.waitedMs,
169
+ ...(watched.outcome === "watch_failed" ? { detail: watched.detail } : {}),
170
+ }));
171
+ }
172
+ else {
173
+ writeLine(io.stderr, sentence);
174
+ }
175
+ writeLine(io.stderr, `[brief rewrite] not confirmed ${JSON.stringify({
176
+ request_id: request.requestId,
177
+ reason: watched.outcome,
178
+ polls: watched.polls,
179
+ waited_ms: watched.waitedMs,
180
+ })}`);
181
+ return 1;
182
+ }
183
+ /**
184
+ * Watch the person's own page until its id changes.
185
+ *
186
+ * The page id, not `compiledAt`: a new version is a new row, and an id that
187
+ * differs is the one signal that cannot be produced by a clock skew or by a
188
+ * re-read of the same row. `compiledAt` is logged alongside because it is what a
189
+ * human reads, but the decision is the id.
190
+ */
191
+ async function watchForNewVersion(input) {
192
+ const startedAt = Date.now();
193
+ let polls = 0;
194
+ while (Date.now() - startedAt < input.ceilingMs) {
195
+ await sleep(input.pollEveryMs);
196
+ polls += 1;
197
+ const read = await readPage(input.command, input.io, input.deviceToken, input.dashboardUrl, input.log);
198
+ const waitedMs = Date.now() - startedAt;
199
+ if (!read.ok) {
200
+ // A single blip while waiting is not an answer about the compile. Only a
201
+ // refusal is, and that one stops the watch rather than spinning.
202
+ if (read.httpStatus === 403 || read.httpStatus === 401) {
203
+ return { outcome: "watch_failed", detail: read.detail, polls, waitedMs };
204
+ }
205
+ input.log(`[brief rewrite] poll failed ${JSON.stringify({ poll: polls, reason: read.reason })}`);
206
+ continue;
207
+ }
208
+ const pageId = read.page.page?.pageId ?? null;
209
+ input.log(`[brief rewrite] polled ${JSON.stringify({
210
+ poll: polls,
211
+ changed: pageId != null && pageId !== input.beforePageId,
212
+ compiled_at: read.page.page?.compiledAt ?? null,
213
+ waited_ms: waitedMs,
214
+ })}`);
215
+ if (pageId && pageId !== input.beforePageId) {
216
+ return { outcome: "written", pageId, polls, waitedMs };
217
+ }
218
+ }
219
+ return { outcome: "still_running", polls, waitedMs: Date.now() - startedAt };
220
+ }
221
+ async function readPage(command, io, deviceToken, dashboardUrl, log) {
222
+ const params = new URLSearchParams({ tldr: "1" });
223
+ if (command.subject)
224
+ params.set("p", command.subject);
225
+ const result = await towerJsonRequest({
226
+ dashboardUrl,
227
+ path: `/api/jarvis/brief?${params.toString()}`,
228
+ deviceToken,
229
+ fetch: io.fetch,
230
+ method: "GET",
231
+ label: "brief:rewrite:read",
232
+ timeoutMs: REQUEST_DEADLINE_MS,
233
+ log,
234
+ });
235
+ if (!result.ok) {
236
+ return {
237
+ ok: false,
238
+ reason: result.reason,
239
+ detail: result.detail,
240
+ ...(result.httpStatus === undefined ? {} : { httpStatus: result.httpStatus }),
241
+ };
242
+ }
243
+ return { ok: true, page: result.body };
244
+ }
245
+ /** Injected nowhere: a real pause. Tests drive the clock through `io.fetch`. */
246
+ function sleep(ms) {
247
+ return new Promise((resolve) => {
248
+ setTimeout(resolve, ms);
249
+ });
250
+ }
251
+ function fail(command, io, reason, detail) {
252
+ if (command.json) {
253
+ writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
254
+ }
255
+ else {
256
+ writeLine(io.stderr, detail);
257
+ }
258
+ writeLine(io.stderr, `[brief rewrite] not asked ${JSON.stringify({ reason })}`);
259
+ return 1;
260
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Opening the person's own editor on a temp file (BLI-3458, slice S2.7).
3
+ *
4
+ * This is the ONE place in the collector that starts a child process for a
5
+ * PERSON rather than for a tool we chose, so the rules are written here and
6
+ * nowhere else:
7
+ *
8
+ * 1. **`spawnSync` with an ARRAY of arguments, never a shell string.** A
9
+ * person's `$EDITOR` is theirs — `code -w`, `"C:\\Program Files\\…\\Code.exe"
10
+ * -w`, `emacsclient -nw`. Building `${editor} ${path}` and handing it to a
11
+ * shell would make a path with a space into two arguments and a path with a
12
+ * semicolon into two commands. `shell: false` is the default and stays the
13
+ * default; nothing here interpolates.
14
+ * 2. **`EDITOR` / `VISUAL` are taken VERBATIM as the program**, after one
15
+ * tokenisation that understands quotes — because that is how every other
16
+ * tool on the machine reads them, and a person who wrote `code -w` means the
17
+ * flag. The tokeniser splits on unquoted whitespace and nothing else: it
18
+ * performs no expansion, no globbing, and no substitution, so a token can
19
+ * never turn into a command.
20
+ * 3. **On Windows the program is spawned as given.** Node resolves `notepad`,
21
+ * `notepad.exe` and an absolute `.exe` path itself; a `.cmd`/`.bat` shim
22
+ * needs `shell: true`, which is exactly what rule 1 forbids, so it is
23
+ * REFUSED by name with the fix (point EDITOR at the `.exe`) rather than run
24
+ * through cmd.exe with a person's own path interpolated into a command line.
25
+ * That refusal is deliberate and is the only thing this module will not do.
26
+ *
27
+ * The file itself carries page text, which is not a secret — but it is somebody
28
+ * else's writing, so it lives in the OS temp directory with a random name and is
29
+ * deleted in a `finally`, whatever happened.
30
+ */
31
+ import { spawnSync } from "node:child_process";
32
+ import { randomBytes } from "node:crypto";
33
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
34
+ import { tmpdir } from "node:os";
35
+ import { join } from "node:path";
36
+ import { stripLeadingBom } from "./cli-io.js";
37
+ /**
38
+ * The editor a person has actually chosen, or the platform's last resort.
39
+ *
40
+ * `VISUAL` wins over `EDITOR` — that is the POSIX order and every other tool on
41
+ * the machine follows it. Windows falls back to `notepad`, which is present on
42
+ * every install and needs no shell; macOS and Linux do NOT fall back, because
43
+ * guessing `vi` at somebody who has never used it strands them in a modal editor
44
+ * with no way out that they know. They get a named reason and a one-line fix.
45
+ */
46
+ export function resolveEditorCommand(env, platform = process.platform) {
47
+ const configured = (env["VISUAL"] ?? env["EDITOR"] ?? "").trim();
48
+ if (configured) {
49
+ const tokens = tokenizeCommand(configured);
50
+ const program = tokens[0];
51
+ if (program)
52
+ return { program, args: tokens.slice(1) };
53
+ }
54
+ if (platform === "win32")
55
+ return { program: "notepad", args: [] };
56
+ return null;
57
+ }
58
+ /**
59
+ * Splits a configured editor into program and arguments.
60
+ *
61
+ * Quotes are honoured because a Windows editor path routinely contains a space
62
+ * (`"C:\Program Files\Microsoft VS Code\Code.exe" -w`). NOTHING else is
63
+ * interpreted: no `$VAR`, no `~`, no globbing, no `&&`. A token comes out of
64
+ * this function exactly as it went in, minus the quotes that grouped it, and
65
+ * goes into `spawnSync`'s array — so there is no stage at which a shell could
66
+ * see any of it.
67
+ */
68
+ export function tokenizeCommand(command) {
69
+ const tokens = [];
70
+ let current = "";
71
+ let quote = null;
72
+ let started = false;
73
+ for (const char of command) {
74
+ if (quote) {
75
+ if (char === quote)
76
+ quote = null;
77
+ else
78
+ current += char;
79
+ continue;
80
+ }
81
+ if (char === '"' || char === "'") {
82
+ quote = char;
83
+ started = true;
84
+ continue;
85
+ }
86
+ if (/\s/u.test(char)) {
87
+ if (started)
88
+ tokens.push(current);
89
+ current = "";
90
+ started = false;
91
+ continue;
92
+ }
93
+ current += char;
94
+ started = true;
95
+ }
96
+ if (started)
97
+ tokens.push(current);
98
+ return tokens;
99
+ }
100
+ /**
101
+ * Writes `contents` to a temp file, opens the editor on it, and hands back what
102
+ * was there when the editor closed. Never throws.
103
+ */
104
+ export async function editInEditor(options) {
105
+ const platform = options.platform ?? process.platform;
106
+ const log = options.log ?? ((line) => console.error(line));
107
+ const chosen = resolveEditorCommand(options.env, platform);
108
+ if (!chosen) {
109
+ return {
110
+ ok: false,
111
+ reason: "no_editor_configured",
112
+ detail: "No editor is configured. Set EDITOR (or VISUAL) — for example `export EDITOR=nano` — " +
113
+ "or pipe the edited page in on stdin instead.",
114
+ };
115
+ }
116
+ // A `.cmd` or `.bat` is a batch script; Node cannot execute one without
117
+ // handing the whole line to cmd.exe, and that is the one thing this module
118
+ // will not do with a person's own path in it.
119
+ if (platform === "win32" && /\.(cmd|bat)$/i.test(chosen.program)) {
120
+ return {
121
+ ok: false,
122
+ reason: "editor_needs_a_shell",
123
+ detail: `EDITOR points at a .cmd/.bat wrapper (${basenameOf(chosen.program)}), which cannot be ` +
124
+ "started without a shell. Point EDITOR at the editor's .exe instead — for example " +
125
+ "notepad, or the full path to Code.exe.",
126
+ };
127
+ }
128
+ let directory;
129
+ let file;
130
+ try {
131
+ directory = await mkdtemp(join(tmpdir(), "cockpit-brief-"));
132
+ file = join(directory, `brief-${randomBytes(4).toString("hex")}${options.suffix ?? ".md"}`);
133
+ await writeFile(file, options.contents, "utf8");
134
+ }
135
+ catch (error) {
136
+ return {
137
+ ok: false,
138
+ reason: "scratch_file_failed",
139
+ detail: `The page could not be written to a scratch file (${messageOf(error)}).`,
140
+ };
141
+ }
142
+ try {
143
+ const spawn = options.spawn ?? spawnSync;
144
+ log(`[brief edit] editor opening ${JSON.stringify({
145
+ program: basenameOf(chosen.program),
146
+ extra_args: chosen.args.length,
147
+ bytes: Buffer.byteLength(options.contents, "utf8"),
148
+ })}`);
149
+ // `stdio: "inherit"` hands the terminal over: a full-screen editor needs the
150
+ // real tty. `shell: false` is the default and is stated to make the rule
151
+ // above impossible to lose in a refactor.
152
+ const result = spawn(chosen.program, [...chosen.args, file], {
153
+ stdio: "inherit",
154
+ shell: false,
155
+ env: options.env,
156
+ });
157
+ if (result.error) {
158
+ return {
159
+ ok: false,
160
+ reason: "editor_not_started",
161
+ detail: `Your editor (${basenameOf(chosen.program)}) could not be started ` +
162
+ `(${messageOf(result.error)}). Check EDITOR, or pipe the edited page in on stdin.`,
163
+ };
164
+ }
165
+ if (typeof result.status === "number" && result.status !== 0) {
166
+ return {
167
+ ok: false,
168
+ reason: "editor_exited_nonzero",
169
+ detail: `Your editor exited with status ${result.status}, so nothing was changed. ` +
170
+ "Nothing has been sent to Tower.",
171
+ };
172
+ }
173
+ const text = stripLeadingBom(await readFile(file, "utf8"));
174
+ log(`[brief edit] editor closed ${JSON.stringify({
175
+ program: basenameOf(chosen.program),
176
+ status: result.status ?? null,
177
+ bytes: Buffer.byteLength(text, "utf8"),
178
+ })}`);
179
+ return { ok: true, text, program: basenameOf(chosen.program) };
180
+ }
181
+ catch (error) {
182
+ return {
183
+ ok: false,
184
+ reason: "scratch_file_failed",
185
+ detail: `The edited page could not be read back (${messageOf(error)}).`,
186
+ };
187
+ }
188
+ finally {
189
+ // Whatever happened. The page is not a secret, but it is somebody's writing
190
+ // and it does not belong in /tmp after the command returns.
191
+ await rm(directory, { recursive: true, force: true }).catch(() => {
192
+ // Deliberately silent: the temp directory is the OS's to reap, and a
193
+ // failure to remove it must not turn a saved edit into a failed command.
194
+ });
195
+ }
196
+ }
197
+ /** The program's own name, never the directories around it. */
198
+ function basenameOf(program) {
199
+ const parts = program.split(/[\\/]/u);
200
+ return parts[parts.length - 1] || program;
201
+ }
202
+ function messageOf(error) {
203
+ return error instanceof Error ? error.message.split("\n")[0] ?? error.name : String(error);
204
+ }
@@ -1009,16 +1009,45 @@ function parseBriefArgs(args) {
1009
1009
  "--full",
1010
1010
  "--versions",
1011
1011
  "--claims",
1012
+ "--reason",
1013
+ "--wait",
1014
+ "--no-wait",
1012
1015
  "--json",
1013
1016
  ],
1014
- valueFlags: ["--home", "--dashboard-url", "--for", "--as", "--version"],
1017
+ valueFlags: ["--home", "--dashboard-url", "--for", "--as", "--version", "--reason"],
1015
1018
  });
1016
- assertNoPositionals(values.positionals, "brief");
1019
+ // Bare `cockpit brief` reads the page, which is what somebody typing it almost
1020
+ // always wants — the same shape `cockpit notes` and `cockpit scout` have.
1021
+ const first = values.positionals[0];
1022
+ const action = (first === undefined ? "read" : first);
1023
+ if (!["read", "edit", "rewrite"].includes(action)) {
1024
+ throw new Error(`Unknown brief command: ${first}. Try edit or rewrite, or nothing to read it.`);
1025
+ }
1026
+ if (values.positionals.length > (first === undefined ? 0 : 1)) {
1027
+ throw new Error(`brief ${action} does not take "${values.positionals[1]}".`);
1028
+ }
1017
1029
  const tldr = values.booleans.has("--tldr");
1018
1030
  const full = values.booleans.has("--full");
1019
1031
  if (tldr && full) {
1020
1032
  throw new Error("brief accepts either --tldr or --full, not both.");
1021
1033
  }
1034
+ const wait = values.booleans.has("--wait");
1035
+ const noWait = values.booleans.has("--no-wait");
1036
+ if (wait && noWait) {
1037
+ throw new Error("brief rewrite accepts either --wait or --no-wait, not both.");
1038
+ }
1039
+ const reason = optionalNonEmpty(values.flags.get("--reason"));
1040
+ if (reason && reason.length > 280) {
1041
+ // The server's own ceiling (`REASON_MAX`). Said here so the person is told
1042
+ // before the page is opened rather than after they have finished editing.
1043
+ throw new Error("A reason is limited to 280 characters.");
1044
+ }
1045
+ if (action !== "edit" && reason) {
1046
+ throw new Error("--reason belongs to `cockpit brief edit`.");
1047
+ }
1048
+ if (action !== "rewrite" && (wait || noWait)) {
1049
+ throw new Error("--wait and --no-wait belong to `cockpit brief rewrite`.");
1050
+ }
1022
1051
  // `--as` is accepted as an alias so the two conversational commands read the
1023
1052
  // same way; `cockpit jarvis --as <person>` has meant this since BLI-3380.
1024
1053
  const forPerson = optionalNonEmpty(values.flags.get("--for"));
@@ -1028,6 +1057,7 @@ function parseBriefArgs(args) {
1028
1057
  }
1029
1058
  return {
1030
1059
  kind: "brief",
1060
+ action,
1031
1061
  homeDir: optionalNonEmpty(values.flags.get("--home")),
1032
1062
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
1033
1063
  subject: forPerson ?? asPerson,
@@ -1035,6 +1065,10 @@ function parseBriefArgs(args) {
1035
1065
  tldr,
1036
1066
  versions: values.booleans.has("--versions"),
1037
1067
  claims: values.booleans.has("--claims"),
1068
+ ...(reason ? { reason } : {}),
1069
+ // Waiting is the default; only an explicit `--no-wait` turns it off. A
1070
+ // person who typed `rewrite` wants the page, not a receipt.
1071
+ ...(noWait ? { wait: false } : {}),
1038
1072
  json: values.booleans.has("--json"),
1039
1073
  };
1040
1074
  }
@@ -60,7 +60,7 @@ export function localCommandHelp(command) {
60
60
  " cockpit settings [personal [--chat-model <key>] [--brief-model <key>] | switches [set <key> <value>] | models [set --chat <key>] [--memory <id>] | env list|set --project <p> --file <f> --content-stdin|delete --id <uuid> [--yes]] [--json]",
61
61
  " cockpit team [members | invite <email> --role <role> [--team-id <uuid>] | role <userId> --role <role> [--yes]] [--json]",
62
62
  " cockpit workbook [<project> [<doc>]] [--section <id>] [--markdown] [--width <n>] [--dashboard-url <url>] [--json]",
63
- " cockpit brief [--for <person>] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--dashboard-url <url>] [--json]",
63
+ " cockpit brief [edit|rewrite] [--for <person>] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--reason \"<why>\"] [--wait|--no-wait] [--dashboard-url <url>] [--json]",
64
64
  " cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
65
65
  " cockpit notes [list|show <id>|shelf|shelves|upload <paths...>|paste|share <id>|unshare <id>|move <id>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--yes] [--dashboard-url <url>] [--json]",
66
66
  " cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
@@ -326,7 +326,7 @@ function localSubcommandHelp(command) {
326
326
  [
327
327
  "brief",
328
328
  [
329
- "Usage: cockpit brief [--for <person>] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--dashboard-url <url>] [--json]",
329
+ "Usage: cockpit brief [edit|rewrite] [--for <person>] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--reason \"<why>\"] [--wait|--no-wait] [--dashboard-url <url>] [--json]",
330
330
  "",
331
331
  "Prints the TODAY page — the same page the Tower website shows, rendered for a terminal.",
332
332
  "--for opens somebody else's page; the website's own rule decides whether you may, and it refuses in plain words when you may not.",
@@ -334,6 +334,17 @@ function localSubcommandHelp(command) {
334
334
  "--version <pageId> steps back to one exact earlier version; --versions lists them with their ids.",
335
335
  "--claims prints the [claimId] beside every line, which is what `cockpit correct --claim` takes.",
336
336
  "Reading it here counts as opening it, exactly as opening it in a browser does.",
337
+ "",
338
+ "edit — opens the page in $VISUAL/$EDITOR as a document: one [claimId] line per sentence you may rewrite, everything else a # comment. Change the words after an id, save, close. Only the lines that really changed are sent, so an accidental save writes nothing.",
339
+ " An id you delete or invent is refused by name BEFORE anything is sent: a missing line reads as no opinion at all, not as a sentence to remove, and Tower cannot tell those apart.",
340
+ " --reason \"<why>\" rides on every row, exactly like a commit message. Optional; the before-and-after already teaches on its own.",
341
+ " With no editor, or with something piped in, the edited document is read from stdin instead — `cockpit brief edit < page.md`.",
342
+ " The edit IS the correction: each changed line is filed in the ledger before the page is republished, so a publish that falls over never costs you the edit.",
343
+ "",
344
+ "rewrite — asks Tower to compile the page again and waits for the new version. Writing a page takes minutes, so this queues the work and watches your page rather than holding one long request open.",
345
+ " --no-wait returns as soon as the ask is on the record. Waiting is the default.",
346
+ " Running out of patience is not a failure and says so: the work is still going, and `cockpit brief` will show it when it lands.",
347
+ " Whose page you may ask about is the website's own rule — your own always, anybody's if you are an admin.",
337
348
  "The command uses the existing paired device identity. Run `cockpit login` first if this machine is not paired.",
338
349
  ],
339
350
  ],
@@ -22,6 +22,9 @@
22
22
  * workbook.ts `cockpit workbook` — the project document library
23
23
  * (+ workbook-render.ts, the pure layout half)
24
24
  * brief.ts `cockpit brief` — the TODAY page in the terminal
25
+ * brief-edit.ts `cockpit brief edit` — rewrite it like a document
26
+ * brief-rewrite.ts `cockpit brief rewrite` — have Tower compile it again
27
+ * editor.ts the one place a person's own $EDITOR is spawned
25
28
  * correct.ts `cockpit correct` — one line on it is wrong
26
29
  * notes.ts `cockpit notes` — the meeting-notes surface, typed
27
30
  * notes-file.ts the local screen a note file passes before it is sent
@@ -52,6 +55,8 @@ import { runTeam } from "./team.js";
52
55
  import { asRecord, callTower, openTower } from "./tower-command.js";
53
56
  import { runWorkbook } from "./workbook.js";
54
57
  import { runBrief } from "./brief.js";
58
+ import { runBriefEdit } from "./brief-edit.js";
59
+ import { runBriefRewrite } from "./brief-rewrite.js";
55
60
  import { runCorrect } from "./correct.js";
56
61
  import { runNotes } from "./notes.js";
57
62
  import { createCollectorServer } from "../server.js";
@@ -133,6 +138,14 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
133
138
  case "workbook":
134
139
  return await runWorkbook(command, io);
135
140
  case "brief":
141
+ // BLI-3458 S2.7/S2.8: `edit` and `rewrite` are the two PANEL actions
142
+ // reaching the terminal. Dispatched here rather than inside `brief.ts`
143
+ // so each one stays its own module — `brief.ts` owns reading and
144
+ // nothing else.
145
+ if (command.action === "edit")
146
+ return await runBriefEdit(command, io);
147
+ if (command.action === "rewrite")
148
+ return await runBriefRewrite(command, io);
136
149
  return await runBrief(command, io);
137
150
  case "correct":
138
151
  return await runCorrect(command, io);
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.41");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.43");
19
19
  return 0;
20
20
  }
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.41",
3
+ "version": "0.2.43",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {