@bli-cockpit/cli 0.2.41 → 0.2.42
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/brief-edit.js +289 -0
- package/dist/commands/brief-rewrite.js +260 -0
- package/dist/commands/editor.js +204 -0
- package/dist/commands/local-args.js +36 -2
- package/dist/commands/local-help.js +13 -2
- package/dist/commands/local.js +13 -0
- package/dist/commands/public-root.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
],
|
package/dist/commands/local.js
CHANGED
|
@@ -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.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.42");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|