@bli-cockpit/cli 0.2.57 → 0.2.58
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/issue-contracts.js +99 -0
- package/dist/commands/issue-write.js +129 -0
- package/dist/commands/issue.js +189 -0
- package/dist/commands/local-args-tower-work.js +178 -0
- package/dist/commands/local-args-tower.js +4 -1
- package/dist/commands/local-args.js +6 -2
- package/dist/commands/local-help.js +34 -0
- package/dist/commands/local.js +6 -0
- package/dist/commands/memory-install-config.js +140 -0
- package/dist/commands/memory-install-report.js +89 -0
- package/dist/commands/memory-install.js +24 -275
- package/dist/commands/notes-door.js +120 -0
- package/dist/commands/notes-reads.js +134 -0
- package/dist/commands/notes-writes.js +208 -0
- package/dist/commands/notes.js +16 -442
- package/dist/commands/ops-render.js +12 -1
- package/dist/commands/ops.js +9 -2
- package/dist/commands/project.js +38 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/repo-identity-fingerprint.js +88 -0
- package/dist/repo-identity-git.js +76 -0
- package/dist/repo-identity-linked-worktrees.js +81 -0
- package/dist/repo-identity.js +5 -222
- package/package.json +6 -6
package/dist/commands/notes.js
CHANGED
|
@@ -22,20 +22,23 @@
|
|
|
22
22
|
* `--yes` without one. `--json` implies `--yes`, because a machine-readable
|
|
23
23
|
* run has nobody to ask and asking it to guess is worse than the flag it
|
|
24
24
|
* already typed the command for.
|
|
25
|
+
*
|
|
26
|
+
* This file is the dispatch table (BLI-3717 readability pass, 680 lines to a
|
|
27
|
+
* table of contents). Every responsibility lives in a `notes-*.ts` sibling:
|
|
28
|
+
*
|
|
29
|
+
* - `notes-reads.ts` — the library, shelves list, one note, your own shelf.
|
|
30
|
+
* - `notes-writes.ts` — upload, paste, share/unshare, move.
|
|
31
|
+
* - `notes-door.ts` — the one HTTP request, and the shared answer / refusal /
|
|
32
|
+
* emit / say-* rendering both halves above call.
|
|
33
|
+
*
|
|
34
|
+
* Every public name is still importable from this file.
|
|
25
35
|
*/
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
* model call over the whole file. The client waits slightly longer so the
|
|
33
|
-
* server's named failure wins the race whenever it manages to send one.
|
|
34
|
-
*/
|
|
35
|
-
const UPLOAD_DEADLINE_MS = 305_000;
|
|
36
|
-
const READ_DEADLINE_MS = 60_000;
|
|
37
|
-
const PASTE_MAX_CHARS = 2_000_000;
|
|
38
|
-
const TAG = "[notes cli]";
|
|
36
|
+
import { loadPairedSession } from "../tower-client.js";
|
|
37
|
+
import { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
|
|
38
|
+
import { uploadNotes, pasteNote, shareNote, moveNote } from "./notes-writes.js";
|
|
39
|
+
export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
|
|
40
|
+
export { uploadNotes, pasteNote, shareNote, moveNote } from "./notes-writes.js";
|
|
41
|
+
export { ask, emit, fail, sayUpload, sayScope, errorText, TAG, } from "./notes-door.js";
|
|
39
42
|
export async function runNotes(command, io) {
|
|
40
43
|
const session = await loadPairedSession("notes", command.homeDir);
|
|
41
44
|
const door = {
|
|
@@ -63,433 +66,4 @@ export async function runNotes(command, io) {
|
|
|
63
66
|
case "move":
|
|
64
67
|
return moveNote(command, door);
|
|
65
68
|
}
|
|
66
|
-
}
|
|
67
|
-
function libraryQuery(command) {
|
|
68
|
-
const query = new URLSearchParams();
|
|
69
|
-
if (command.series)
|
|
70
|
-
query.set("series", command.series);
|
|
71
|
-
if (command.meetingKind)
|
|
72
|
-
query.set("kind", command.meetingKind);
|
|
73
|
-
if (command.since)
|
|
74
|
-
query.set("since", command.since);
|
|
75
|
-
if (command.until)
|
|
76
|
-
query.set("until", command.until);
|
|
77
|
-
if (command.limit !== undefined)
|
|
78
|
-
query.set("limit", String(command.limit));
|
|
79
|
-
const rendered = query.toString();
|
|
80
|
-
return rendered === "" ? "" : `?${rendered}`;
|
|
81
|
-
}
|
|
82
|
-
async function listNotes(command, door) {
|
|
83
|
-
const answer = await ask(door, {
|
|
84
|
-
path: `/api/notes/library${libraryQuery(command)}`,
|
|
85
|
-
method: "GET",
|
|
86
|
-
label: "notes list",
|
|
87
|
-
timeoutMs: READ_DEADLINE_MS,
|
|
88
|
-
});
|
|
89
|
-
if (!answer.ok)
|
|
90
|
-
return fail(door, answer.reason, answer.detail);
|
|
91
|
-
const body = answer.body;
|
|
92
|
-
if (door.json)
|
|
93
|
-
return emit(door, body);
|
|
94
|
-
sayScope(door, body);
|
|
95
|
-
const series = body.series ?? [];
|
|
96
|
-
if (series.length === 0) {
|
|
97
|
-
writeLine(door.io.stdout, "No meeting notes matched.");
|
|
98
|
-
return 0;
|
|
99
|
-
}
|
|
100
|
-
for (const one of series) {
|
|
101
|
-
writeLine(door.io.stdout, "");
|
|
102
|
-
writeLine(door.io.stdout, `${one.heading} (${one.notes.length})`);
|
|
103
|
-
for (const note of one.notes) {
|
|
104
|
-
const who = note.participants.length > 0 ? ` — ${note.participants.join(", ")}` : "";
|
|
105
|
-
writeLine(door.io.stdout, ` ${note.meetingDate} ${note.id} ${note.fileName}${who}`);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
writeLine(door.io.stdout, "");
|
|
109
|
-
writeLine(door.io.stdout, `${body.count ?? 0} note(s)${body.more ? ", and more exist beyond the limit" : ""}.`);
|
|
110
|
-
return 0;
|
|
111
|
-
}
|
|
112
|
-
async function listShelves(command, door) {
|
|
113
|
-
const answer = await ask(door, {
|
|
114
|
-
path: `/api/notes/library${libraryQuery(command)}`,
|
|
115
|
-
method: "GET",
|
|
116
|
-
label: "notes shelves",
|
|
117
|
-
timeoutMs: READ_DEADLINE_MS,
|
|
118
|
-
});
|
|
119
|
-
if (!answer.ok)
|
|
120
|
-
return fail(door, answer.reason, answer.detail);
|
|
121
|
-
const body = answer.body;
|
|
122
|
-
const shelves = (body.series ?? []).map((one) => ({
|
|
123
|
-
shelf: one.heading,
|
|
124
|
-
notes: one.notes.length,
|
|
125
|
-
// A shelf somebody typed, rather than the one a kind implies. Only the
|
|
126
|
-
// first kind is reported: a shelf is free text and can hold any of them.
|
|
127
|
-
custom: (body.categories ?? []).includes(one.heading),
|
|
128
|
-
}));
|
|
129
|
-
if (door.json)
|
|
130
|
-
return emit(door, { scope: body.scope, shelves });
|
|
131
|
-
sayScope(door, body);
|
|
132
|
-
if (shelves.length === 0) {
|
|
133
|
-
writeLine(door.io.stdout, "No shelves yet.");
|
|
134
|
-
return 0;
|
|
135
|
-
}
|
|
136
|
-
for (const shelf of shelves) {
|
|
137
|
-
writeLine(door.io.stdout, `${String(shelf.notes).padStart(4)} ${shelf.shelf}${shelf.custom ? "" : " (from the meeting kind)"}`);
|
|
138
|
-
}
|
|
139
|
-
return 0;
|
|
140
|
-
}
|
|
141
|
-
async function showNote(command, door) {
|
|
142
|
-
const answer = await ask(door, {
|
|
143
|
-
path: `/api/notes/library/${encodeURIComponent(command.noteId ?? "")}`,
|
|
144
|
-
method: "GET",
|
|
145
|
-
label: "notes show",
|
|
146
|
-
timeoutMs: READ_DEADLINE_MS,
|
|
147
|
-
});
|
|
148
|
-
if (!answer.ok)
|
|
149
|
-
return fail(door, answer.reason, answer.detail);
|
|
150
|
-
const body = answer.body;
|
|
151
|
-
if (door.json)
|
|
152
|
-
return emit(door, body);
|
|
153
|
-
const note = body.note;
|
|
154
|
-
if (!note)
|
|
155
|
-
return fail(door, "no_note_in_answer", "Tower answered without a note.");
|
|
156
|
-
sayScope(door, body);
|
|
157
|
-
writeLine(door.io.stdout, note.title);
|
|
158
|
-
writeLine(door.io.stdout, `${note.meetingDate} · ${note.shelf} · ${note.fileName} · ${note.lineCount} lines`);
|
|
159
|
-
if (note.participants.length > 0) {
|
|
160
|
-
writeLine(door.io.stdout, `In the room: ${note.participants.join(", ")}`);
|
|
161
|
-
}
|
|
162
|
-
writeLine(door.io.stdout, note.visibility);
|
|
163
|
-
writeLine(door.io.stdout, "");
|
|
164
|
-
writeLine(door.io.stdout, note.content);
|
|
165
|
-
return 0;
|
|
166
|
-
}
|
|
167
|
-
async function showShelf(command, door) {
|
|
168
|
-
const query = command.limit === undefined ? "" : `?limit=${command.limit}`;
|
|
169
|
-
const answer = await ask(door, {
|
|
170
|
-
path: `/api/notes/shelf${query}`,
|
|
171
|
-
method: "GET",
|
|
172
|
-
label: "notes shelf",
|
|
173
|
-
timeoutMs: READ_DEADLINE_MS,
|
|
174
|
-
});
|
|
175
|
-
if (!answer.ok)
|
|
176
|
-
return fail(door, answer.reason, answer.detail);
|
|
177
|
-
const body = answer.body;
|
|
178
|
-
if (door.json)
|
|
179
|
-
return emit(door, body);
|
|
180
|
-
sayScope(door, body);
|
|
181
|
-
const notes = body.notes ?? [];
|
|
182
|
-
if (notes.length === 0) {
|
|
183
|
-
writeLine(door.io.stdout, "You have not put any notes in yet.");
|
|
184
|
-
return 0;
|
|
185
|
-
}
|
|
186
|
-
for (const note of notes) {
|
|
187
|
-
const counts = note.countsKnown
|
|
188
|
-
? `${note.statements} statements, ${note.openToTheTeam} open to the team, ${note.keptBack} kept back`
|
|
189
|
-
: "counts unknown on this server";
|
|
190
|
-
writeLine(door.io.stdout, `${note.meetingDate} ${note.id} ${note.shared ? "shared " : "yours "} ${note.name}`);
|
|
191
|
-
writeLine(door.io.stdout, ` ${counts}`);
|
|
192
|
-
}
|
|
193
|
-
return 0;
|
|
194
|
-
}
|
|
195
|
-
async function uploadNotes(command, door) {
|
|
196
|
-
const paths = command.paths ?? [];
|
|
197
|
-
const results = [];
|
|
198
|
-
let worstExit = 0;
|
|
199
|
-
for (const filePath of paths) {
|
|
200
|
-
// Read and screen locally BEFORE any network call — a refusal here never
|
|
201
|
-
// reaches the dashboard, same discipline as `cockpit jarvis --image`.
|
|
202
|
-
const read = await readNoteFile(filePath);
|
|
203
|
-
if (!read.ok) {
|
|
204
|
-
const sentence = noteFileRefusalSentence(read.refusal, filePath);
|
|
205
|
-
writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: read.refusal, detail: read.detail })}`);
|
|
206
|
-
if (!door.json)
|
|
207
|
-
writeLine(door.io.stderr, sentence);
|
|
208
|
-
results.push({ path: filePath, ok: false, reason: read.refusal });
|
|
209
|
-
worstExit = 1;
|
|
210
|
-
continue;
|
|
211
|
-
}
|
|
212
|
-
if (read.bytes.byteLength >= NOTE_SLOW_UPLOAD_BYTES) {
|
|
213
|
-
// The route reads the whole note with one model call and may take
|
|
214
|
-
// minutes. A still cursor reads as a hang, so say what is happening.
|
|
215
|
-
writeLine(door.io.stderr, `Reading ${read.fileName} (${Math.round(read.bytes.byteLength / 1024)} KB). This can take a couple of minutes.`);
|
|
216
|
-
}
|
|
217
|
-
const form = new FormData();
|
|
218
|
-
form.set("file", new File([new Uint8Array(read.bytes)], read.fileName));
|
|
219
|
-
if (command.exclude)
|
|
220
|
-
form.set("exclusions", command.exclude);
|
|
221
|
-
const answer = await ask(door, {
|
|
222
|
-
path: "/api/notes/upload",
|
|
223
|
-
method: "POST",
|
|
224
|
-
label: "notes upload",
|
|
225
|
-
timeoutMs: UPLOAD_DEADLINE_MS,
|
|
226
|
-
body: form,
|
|
227
|
-
});
|
|
228
|
-
if (!answer.ok) {
|
|
229
|
-
writeLine(door.io.stderr, `${TAG} upload failed ${JSON.stringify({ reason: answer.reason })}`);
|
|
230
|
-
if (!door.json)
|
|
231
|
-
writeLine(door.io.stderr, `${read.fileName}: ${answer.detail}`);
|
|
232
|
-
results.push({ path: filePath, ok: false, reason: answer.reason });
|
|
233
|
-
worstExit = 1;
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
const body = answer.body;
|
|
237
|
-
results.push({ path: filePath, ok: body.stored === true, body });
|
|
238
|
-
if (body.stored !== true)
|
|
239
|
-
worstExit = 1;
|
|
240
|
-
if (!door.json)
|
|
241
|
-
sayUpload(door, body);
|
|
242
|
-
writeLine(door.io.stderr, `${TAG} upload answered ${JSON.stringify({
|
|
243
|
-
stored: body.stored === true,
|
|
244
|
-
note_id: body.noteId ?? null,
|
|
245
|
-
scope: body.scope ?? null,
|
|
246
|
-
byte_size: read.bytes.byteLength,
|
|
247
|
-
extension: read.extension,
|
|
248
|
-
})}`);
|
|
249
|
-
}
|
|
250
|
-
if (door.json) {
|
|
251
|
-
emit(door, { ok: worstExit === 0, uploaded: results.length, results });
|
|
252
|
-
}
|
|
253
|
-
return worstExit;
|
|
254
|
-
}
|
|
255
|
-
async function pasteNote(command, door) {
|
|
256
|
-
let text;
|
|
257
|
-
if (command.filePath) {
|
|
258
|
-
const read = await readNoteFile(command.filePath);
|
|
259
|
-
if (!read.ok) {
|
|
260
|
-
const sentence = noteFileRefusalSentence(read.refusal, command.filePath);
|
|
261
|
-
writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: read.refusal, detail: read.detail })}`);
|
|
262
|
-
return fail(door, read.refusal, sentence);
|
|
263
|
-
}
|
|
264
|
-
const decoded = decodeTextBytes(read.bytes);
|
|
265
|
-
if (!decoded.ok) {
|
|
266
|
-
writeLine(door.io.stderr, `${TAG} file refused ${JSON.stringify({ reason: decoded.reason })}`);
|
|
267
|
-
return fail(door, decoded.reason, "That file is not text this command can decode (it may be a binary or an unusual encoding). Nothing was sent.");
|
|
268
|
-
}
|
|
269
|
-
text = decoded.text;
|
|
270
|
-
}
|
|
271
|
-
else {
|
|
272
|
-
if (isInteractiveStdin(door.io)) {
|
|
273
|
-
return fail(door, "nothing_piped", "Pass --file <path> (safest on Windows), or pipe the note in "
|
|
274
|
-
+ "(`pbpaste | cockpit notes paste` on macOS; on Windows use PowerShell 7 — "
|
|
275
|
-
+ "Windows PowerShell 5.1 turns non-ASCII into `?` on pipes).");
|
|
276
|
-
}
|
|
277
|
-
try {
|
|
278
|
-
text = await readPipedText(door.io.stdin, {
|
|
279
|
-
maxChars: PASTE_MAX_CHARS,
|
|
280
|
-
overflowMessage: `A pasted note is limited to ${PASTE_MAX_CHARS} characters. Save it to a file and use --file instead.`,
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
catch (error) {
|
|
284
|
-
return fail(door, "paste_too_long", errorText(error));
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
if (text.trim() === "") {
|
|
288
|
-
return fail(door, "empty_paste", "There was nothing to paste. Nothing was sent.");
|
|
289
|
-
}
|
|
290
|
-
const form = new FormData();
|
|
291
|
-
form.set("text", text);
|
|
292
|
-
if (command.name)
|
|
293
|
-
form.set("name", command.name);
|
|
294
|
-
if (command.exclude)
|
|
295
|
-
form.set("exclusions", command.exclude);
|
|
296
|
-
// Bytes, not `.length` (BLI-3482). `NOTE_SLOW_UPLOAD_BYTES` is a BYTE
|
|
297
|
-
// threshold, and the file path above already compares `bytes.byteLength`
|
|
298
|
-
// against it; `text.length` counts UTF-16 units, so a note in any non-Latin
|
|
299
|
-
// script was measured at a third to a half of the size actually being sent
|
|
300
|
-
// and the "this will take a while" line stayed silent through the wait.
|
|
301
|
-
const pastedBytes = Buffer.byteLength(text, "utf8");
|
|
302
|
-
if (pastedBytes >= NOTE_SLOW_UPLOAD_BYTES) {
|
|
303
|
-
writeLine(door.io.stderr, `Reading ${Math.round(pastedBytes / 1024)} KB of pasted text. This can take a couple of minutes.`);
|
|
304
|
-
}
|
|
305
|
-
const answer = await ask(door, {
|
|
306
|
-
path: "/api/notes/upload",
|
|
307
|
-
method: "POST",
|
|
308
|
-
label: "notes paste",
|
|
309
|
-
timeoutMs: UPLOAD_DEADLINE_MS,
|
|
310
|
-
body: form,
|
|
311
|
-
});
|
|
312
|
-
if (!answer.ok)
|
|
313
|
-
return fail(door, answer.reason, answer.detail);
|
|
314
|
-
const body = answer.body;
|
|
315
|
-
writeLine(door.io.stderr, `${TAG} paste answered ${JSON.stringify({
|
|
316
|
-
stored: body.stored === true,
|
|
317
|
-
note_id: body.noteId ?? null,
|
|
318
|
-
scope: body.scope ?? null,
|
|
319
|
-
chars: text.length,
|
|
320
|
-
byte_size: pastedBytes,
|
|
321
|
-
named_by_caller: Boolean(command.name),
|
|
322
|
-
})}`);
|
|
323
|
-
if (door.json)
|
|
324
|
-
return emit(door, body, body.stored === true ? 0 : 1);
|
|
325
|
-
sayUpload(door, body);
|
|
326
|
-
return body.stored === true ? 0 : 1;
|
|
327
|
-
}
|
|
328
|
-
async function shareNote(command, door) {
|
|
329
|
-
const wantsToShare = command.action === "share";
|
|
330
|
-
// Only sharing asks. Taking a note back narrows who can read it, and nobody
|
|
331
|
-
// needs to be talked out of that.
|
|
332
|
-
if (wantsToShare && !command.yes) {
|
|
333
|
-
if (!isInteractiveStdin(door.io)) {
|
|
334
|
-
return fail(door, "confirmation_required", "Sharing a note lets everyone signed in read it. Pass --yes to do it without being asked.");
|
|
335
|
-
}
|
|
336
|
-
const answer = await readLine(door.io, "Share this note with everyone signed in? [Y/n] ");
|
|
337
|
-
if (!yesByDefault(answer)) {
|
|
338
|
-
writeLine(door.io.stderr, `${TAG} share declined ${JSON.stringify({ reason: "operator_said_no" })}`);
|
|
339
|
-
writeLine(door.io.stdout, "Left alone. Nothing changed.");
|
|
340
|
-
return 0;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
const answer = await ask(door, {
|
|
344
|
-
path: "/api/notes/share",
|
|
345
|
-
method: "POST",
|
|
346
|
-
label: wantsToShare ? "notes share" : "notes unshare",
|
|
347
|
-
timeoutMs: READ_DEADLINE_MS,
|
|
348
|
-
body: { note_id: command.noteId, share: wantsToShare },
|
|
349
|
-
});
|
|
350
|
-
if (!answer.ok)
|
|
351
|
-
return fail(door, answer.reason, answer.detail);
|
|
352
|
-
const body = answer.body;
|
|
353
|
-
writeLine(door.io.stderr, `${TAG} ${wantsToShare ? "shared" : "taken back"} ${JSON.stringify({
|
|
354
|
-
note_id: command.noteId ?? null,
|
|
355
|
-
stored: body.stored === true,
|
|
356
|
-
})}`);
|
|
357
|
-
if (door.json)
|
|
358
|
-
return emit(door, body);
|
|
359
|
-
sayUpload(door, body);
|
|
360
|
-
return 0;
|
|
361
|
-
}
|
|
362
|
-
async function moveNote(command, door) {
|
|
363
|
-
const answer = await ask(door, {
|
|
364
|
-
path: "/api/notes/move",
|
|
365
|
-
method: "POST",
|
|
366
|
-
label: "notes move",
|
|
367
|
-
timeoutMs: READ_DEADLINE_MS,
|
|
368
|
-
// `--clear-shelf` sends the empty string, which is what the browser's own
|
|
369
|
-
// move box sends when a person empties it.
|
|
370
|
-
body: { note_id: command.noteId, category: command.clearShelf ? "" : command.to },
|
|
371
|
-
});
|
|
372
|
-
if (!answer.ok)
|
|
373
|
-
return fail(door, answer.reason, answer.detail);
|
|
374
|
-
const body = answer.body;
|
|
375
|
-
writeLine(door.io.stderr, `${TAG} move answered ${JSON.stringify({
|
|
376
|
-
note_id: command.noteId ?? null,
|
|
377
|
-
ok: body.ok === true,
|
|
378
|
-
reason: body.reason ?? null,
|
|
379
|
-
cleared: Boolean(command.clearShelf),
|
|
380
|
-
})}`);
|
|
381
|
-
if (door.json)
|
|
382
|
-
return emit(door, body, body.ok === true ? 0 : 1);
|
|
383
|
-
writeLine(door.io.stdout, body.headline ?? "Tower answered without a sentence.");
|
|
384
|
-
for (const line of body.lines ?? [])
|
|
385
|
-
writeLine(door.io.stdout, line);
|
|
386
|
-
return body.ok === true ? 0 : 1;
|
|
387
|
-
}
|
|
388
|
-
/**
|
|
389
|
-
* One request, and a refusal that keeps the ROUTE'S own words.
|
|
390
|
-
*
|
|
391
|
-
* `towerJsonRequest` maps a non-2xx through `responseErrorMessage`, which looks
|
|
392
|
-
* for `message` or `error` — the shape the ingest routes answer with. Every
|
|
393
|
-
* notes door answers in the browser's shape instead (`headline` plus `lines`,
|
|
394
|
-
* with a `reason` label beside them), because the same body is what the page
|
|
395
|
-
* renders. Reading it here rather than widening `responseErrorMessage` keeps one
|
|
396
|
-
* meaning per field: the sentence is written where the outcome is known, and the
|
|
397
|
-
* terminal relays it rather than inventing a second wording for the same thing.
|
|
398
|
-
*/
|
|
399
|
-
async function ask(door, options) {
|
|
400
|
-
const result = await towerRequest({
|
|
401
|
-
dashboardUrl: door.dashboardUrl,
|
|
402
|
-
path: options.path,
|
|
403
|
-
deviceToken: door.deviceToken,
|
|
404
|
-
fetch: door.io.fetch,
|
|
405
|
-
method: options.method,
|
|
406
|
-
label: options.label,
|
|
407
|
-
timeoutMs: options.timeoutMs,
|
|
408
|
-
...(options.body === undefined ? {} : { body: options.body }),
|
|
409
|
-
log: (line) => writeLine(door.io.stderr, line),
|
|
410
|
-
});
|
|
411
|
-
if (!result.ok) {
|
|
412
|
-
const failure = result;
|
|
413
|
-
return {
|
|
414
|
-
ok: false,
|
|
415
|
-
reason: failure.reason,
|
|
416
|
-
detail: towerFailureDetail(failure.reason, failure.detail),
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
const body = await readResponseJson(result.response);
|
|
420
|
-
if (!result.response.ok) {
|
|
421
|
-
const status = result.response.status;
|
|
422
|
-
return {
|
|
423
|
-
ok: false,
|
|
424
|
-
reason: refusalReason(body) ?? `http_${status}`,
|
|
425
|
-
detail: refusalSentence(body) ?? `Tower answered ${status} and said nothing about why.`,
|
|
426
|
-
};
|
|
427
|
-
}
|
|
428
|
-
return { ok: true, body };
|
|
429
|
-
}
|
|
430
|
-
function refusalReason(body) {
|
|
431
|
-
if (!body || typeof body !== "object")
|
|
432
|
-
return null;
|
|
433
|
-
const record = body;
|
|
434
|
-
for (const key of ["reason", "error", "code"]) {
|
|
435
|
-
const value = record[key];
|
|
436
|
-
if (typeof value === "string" && value.trim() !== "")
|
|
437
|
-
return value;
|
|
438
|
-
}
|
|
439
|
-
return null;
|
|
440
|
-
}
|
|
441
|
-
function refusalSentence(body) {
|
|
442
|
-
if (!body || typeof body !== "object")
|
|
443
|
-
return null;
|
|
444
|
-
const record = body;
|
|
445
|
-
const headline = typeof record["headline"] === "string" ? record["headline"] : null;
|
|
446
|
-
const message = typeof record["message"] === "string" ? record["message"] : null;
|
|
447
|
-
const lines = Array.isArray(record["lines"])
|
|
448
|
-
? record["lines"].filter((line) => typeof line === "string")
|
|
449
|
-
: [];
|
|
450
|
-
const said = [headline ?? message, ...lines].filter(Boolean);
|
|
451
|
-
return said.length > 0 ? said.join(" ") : null;
|
|
452
|
-
}
|
|
453
|
-
/** One machine-readable object on stdout, and nothing else on it. */
|
|
454
|
-
function emit(door, body, exitCode = 0) {
|
|
455
|
-
writeLine(door.io.stdout, JSON.stringify(body));
|
|
456
|
-
return exitCode;
|
|
457
|
-
}
|
|
458
|
-
function fail(door, reason, detail) {
|
|
459
|
-
writeLine(door.io.stderr, `${TAG} refused ${JSON.stringify({ reason })}`);
|
|
460
|
-
if (door.json) {
|
|
461
|
-
writeLine(door.io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
|
|
462
|
-
}
|
|
463
|
-
else {
|
|
464
|
-
writeLine(door.io.stderr, detail);
|
|
465
|
-
}
|
|
466
|
-
return 1;
|
|
467
|
-
}
|
|
468
|
-
/**
|
|
469
|
-
* The route's own words for what happened. Never rephrased here — the sentences
|
|
470
|
-
* are written where the outcome is known, and a second wording in the terminal
|
|
471
|
-
* would be a second thing to keep in step.
|
|
472
|
-
*/
|
|
473
|
-
function sayUpload(door, body) {
|
|
474
|
-
writeLine(door.io.stdout, body.headline ?? "Tower answered without a sentence.");
|
|
475
|
-
for (const line of body.lines ?? [])
|
|
476
|
-
writeLine(door.io.stdout, line);
|
|
477
|
-
if (body.noteId)
|
|
478
|
-
writeLine(door.io.stdout, `Note id: ${body.noteId}`);
|
|
479
|
-
}
|
|
480
|
-
/**
|
|
481
|
-
* Says out loud when an answer is narrower than the one a browser would give.
|
|
482
|
-
*
|
|
483
|
-
* A degraded read is still a real answer and is never withheld — but a person
|
|
484
|
-
* who cannot see their own unshared note has to be told that is why, not left
|
|
485
|
-
* to conclude it was never stored.
|
|
486
|
-
*/
|
|
487
|
-
function sayScope(door, body) {
|
|
488
|
-
if (!body.degradedBecause)
|
|
489
|
-
return;
|
|
490
|
-
writeLine(door.io.stderr, `${TAG} narrowed ${JSON.stringify({ scope: body.scope ?? null, reason: body.degradedBecause })}`);
|
|
491
|
-
writeLine(door.io.stderr, body.degradedNote ?? "This answer is narrower than the browser's.");
|
|
492
|
-
}
|
|
493
|
-
function errorText(error) {
|
|
494
|
-
return error instanceof Error ? error.message : String(error);
|
|
495
69
|
}
|
|
@@ -19,6 +19,11 @@ export function verdictWord(verdict) {
|
|
|
19
19
|
return "ok";
|
|
20
20
|
case "stale":
|
|
21
21
|
return "STALE";
|
|
22
|
+
// BLI-3723. Its own word, not a shade of STALE: a `failing` reader's input
|
|
23
|
+
// is CURRENT and its answer is bad, which sends a person to a laptop
|
|
24
|
+
// rather than to a cron config.
|
|
25
|
+
case "failing":
|
|
26
|
+
return "BAD";
|
|
22
27
|
case "never_produced":
|
|
23
28
|
return "EMPTY";
|
|
24
29
|
case "unreadable":
|
|
@@ -69,7 +74,9 @@ export function renderOpsStatus(payload, dim) {
|
|
|
69
74
|
for (const row of rows) {
|
|
70
75
|
const verdict = verdictWord(row.verdict).padEnd(6);
|
|
71
76
|
const id = (row.id ?? "?").padEnd(idWidth);
|
|
72
|
-
|
|
77
|
+
// BLI-3723: a row that has no age BY DESIGN says `n/a`, never `—`. The two
|
|
78
|
+
// look alike and mean opposite things — `—` is "the table is empty".
|
|
79
|
+
const age = (row.ageAbsence ? "n/a" : ageWord(row.ageHours)).padStart(7);
|
|
73
80
|
// BLI-3722: name the device this age belongs to right in the row — a bare
|
|
74
81
|
// `STALE collector-fleet 11h` was already misread once (BLI-3699) as "the
|
|
75
82
|
// fleet", not "one machine in it", and the name is otherwise buried in
|
|
@@ -77,6 +84,10 @@ export function renderOpsStatus(payload, dim) {
|
|
|
77
84
|
const staleName = row.verdict === "stale" && row.staleDeviceName ? ` ${dim(`(${row.staleDeviceName})`)}` : "";
|
|
78
85
|
lines.push(`${verdict} ${id} ${age}${staleName} ${dim(intervalWord(row))}`);
|
|
79
86
|
if (row.verdict !== "healthy") {
|
|
87
|
+
// The absence of an age is explained BEFORE the detail, because it is
|
|
88
|
+
// the first thing a reader's eye stopped on.
|
|
89
|
+
if (row.ageAbsence)
|
|
90
|
+
lines.push(dim(` no age: ${row.ageAbsence}`));
|
|
80
91
|
if (row.detail)
|
|
81
92
|
lines.push(dim(` ${row.detail}`));
|
|
82
93
|
if (row.caveat)
|
package/dist/commands/ops.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* **Exit codes are the whole point of running this in a script.** Zero when
|
|
14
14
|
* every job that CAN be answered for is healthy; 1 when anything is stale,
|
|
15
|
-
* empty or unreadable. `no_artifact` — a job that writes no row — does not fail
|
|
15
|
+
* failing, empty or unreadable. `no_artifact` — a job that writes no row — does not fail
|
|
16
16
|
* the command, and it does not pass silently either: it prints as `n/a` with
|
|
17
17
|
* the reason there is nothing to read.
|
|
18
18
|
*
|
|
@@ -60,7 +60,14 @@ async function runOpsStatus(command, io, tower) {
|
|
|
60
60
|
}
|
|
61
61
|
const payload = asRecord(result.body);
|
|
62
62
|
const rows = payload.pipelines ?? [];
|
|
63
|
-
|
|
63
|
+
// BLI-3723: `failing` joins the list. An older CLI that has never heard of it
|
|
64
|
+
// simply does not count it — which is why the server keeps the sentence in
|
|
65
|
+
// `detail`, so an un-upgraded terminal still PRINTS the outage even when it
|
|
66
|
+
// exits 0 over it.
|
|
67
|
+
const unhealthy = rows.filter((row) => row.verdict === "stale" ||
|
|
68
|
+
row.verdict === "never_produced" ||
|
|
69
|
+
row.verdict === "unreadable" ||
|
|
70
|
+
row.verdict === "failing");
|
|
64
71
|
if (command.json) {
|
|
65
72
|
writeLine(io.stdout, JSON.stringify(payload));
|
|
66
73
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit project list` — the projects issues are filed under (BLI-3716).
|
|
3
|
+
*
|
|
4
|
+
* One verb, because `GET /api/work/projects` is the whole of the Work
|
|
5
|
+
* surface's project door today (BLI-3703 scoped projects to list-only; there
|
|
6
|
+
* is no create/update/delete route to put a terminal in front of). Its own
|
|
7
|
+
* command rather than `cockpit issue projects` because a person says
|
|
8
|
+
* "projects", and `cockpit issue list --project <name>` resolves a name
|
|
9
|
+
* against exactly this list.
|
|
10
|
+
*/
|
|
11
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
|
|
12
|
+
import { writeLine } from "./cli-io.js";
|
|
13
|
+
const TAG = "[project cli]";
|
|
14
|
+
const READ_DEADLINE_MS = 30_000;
|
|
15
|
+
export async function runProject(command, io) {
|
|
16
|
+
const door = await openAgentDoor("project", command, io);
|
|
17
|
+
const answer = await askAgentDoor(door, {
|
|
18
|
+
path: `/api/work/projects${command.includeArchived ? "?include_archived=true" : ""}`,
|
|
19
|
+
method: "GET",
|
|
20
|
+
label: "project list",
|
|
21
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
22
|
+
});
|
|
23
|
+
if (!answer.ok)
|
|
24
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
25
|
+
const projects = answer.body.projects ?? [];
|
|
26
|
+
if (door.json)
|
|
27
|
+
return emitAgentDoor(door, { ok: true, projects });
|
|
28
|
+
if (projects.length === 0) {
|
|
29
|
+
writeLine(door.io.stdout, "No projects.");
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
for (const project of projects) {
|
|
33
|
+
writeLine(door.io.stdout, `${project.id} ${project.archived_at ? "archived" : "active "} ${project.name}`);
|
|
34
|
+
}
|
|
35
|
+
writeLine(door.io.stdout, "");
|
|
36
|
+
writeLine(door.io.stdout, `${projects.length} project(s).`);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
@@ -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.58");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a filesystem path or a git origin URL into the stable ids this
|
|
3
|
+
* package hands out (`repo_fingerprint`, `worktree_fingerprint`, the label),
|
|
4
|
+
* plus the collection-root canonicalization built on the same realpath call.
|
|
5
|
+
* Sibling of `repo-identity.ts`, named in its header.
|
|
6
|
+
*/
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
export async function stableWorktreeRoot(repoRoot) {
|
|
11
|
+
const resolvedRoot = path.resolve(repoRoot);
|
|
12
|
+
return fs.realpath(resolvedRoot).catch(() => resolvedRoot);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Canonicalizes existing collection roots through the filesystem so transcript
|
|
16
|
+
* paths and consent roots use the same spelling (for example `/private/var`
|
|
17
|
+
* versus the `/var` symlink on macOS). Missing roots remain resolved as typed.
|
|
18
|
+
*/
|
|
19
|
+
export async function canonicalizeCollectionRootPaths(roots) {
|
|
20
|
+
const canonical = await Promise.all(roots.map(stableWorktreeRoot));
|
|
21
|
+
return [...new Set(canonical)];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Returns the operator-entered resolved paths plus filesystem-canonical aliases.
|
|
25
|
+
* Both are needed for absent child repos because the child itself cannot be
|
|
26
|
+
* realpathed after deletion, while its transcript may retain either spelling.
|
|
27
|
+
*/
|
|
28
|
+
export async function collectionRootPathAliases(roots) {
|
|
29
|
+
const resolved = roots.map((root) => path.resolve(root));
|
|
30
|
+
const canonical = await canonicalizeCollectionRootPaths(resolved);
|
|
31
|
+
return [...new Set([...resolved, ...canonical])];
|
|
32
|
+
}
|
|
33
|
+
function normalizeFingerprintPath(repoRoot, pathApi = path) {
|
|
34
|
+
const resolved = pathApi.resolve(repoRoot);
|
|
35
|
+
return pathApi.sep === "\\" ? resolved.toLowerCase() : resolved;
|
|
36
|
+
}
|
|
37
|
+
export function stableWorktreeFingerprint(repoRoot, pathApi = path) {
|
|
38
|
+
return `wt-${sha256(`worktree:${normalizeFingerprintPath(repoRoot, pathApi)}`).slice(0, 24)}`;
|
|
39
|
+
}
|
|
40
|
+
export function repoFingerprintFromLocalRoot(root, pathApi = path) {
|
|
41
|
+
return `repo-${sha256(`local:${normalizeFingerprintPath(root, pathApi)}`).slice(0, 24)}`;
|
|
42
|
+
}
|
|
43
|
+
export function repoFingerprintFromOrigin(origin) {
|
|
44
|
+
const normalizedOrigin = normalizeGitOrigin(origin);
|
|
45
|
+
return `repo-${sha256(`origin:${normalizedOrigin}`).slice(0, 24)}`;
|
|
46
|
+
}
|
|
47
|
+
export function normalizeGitOrigin(rawOrigin) {
|
|
48
|
+
const trimmed = rawOrigin.trim();
|
|
49
|
+
if (!trimmed)
|
|
50
|
+
return "";
|
|
51
|
+
const scpLike = trimmed.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
|
|
52
|
+
if (scpLike && !trimmed.includes("://")) {
|
|
53
|
+
return normalizeOriginParts(scpLike[1] ?? "", scpLike[2] ?? "");
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const url = new URL(trimmed);
|
|
57
|
+
return normalizeOriginParts(url.hostname, url.pathname);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Deliberately silent (BLI-3238). `new URL` is being used as the test for
|
|
61
|
+
// "is this origin URL-shaped?", and a plain path or an unusual remote form
|
|
62
|
+
// failing to parse IS the answer — the fallback below is the intended
|
|
63
|
+
// normalization for exactly that case, not a degradation.
|
|
64
|
+
return trimmed
|
|
65
|
+
.replace(/\.git$/i, "")
|
|
66
|
+
.replace(/^\/+|\/+$/g, "")
|
|
67
|
+
.toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function repoLabelFromOrigin(origin) {
|
|
71
|
+
const segments = origin.split("/").filter(Boolean);
|
|
72
|
+
return segments.at(-1) ?? origin;
|
|
73
|
+
}
|
|
74
|
+
function normalizeOriginParts(host, repoPath) {
|
|
75
|
+
return [
|
|
76
|
+
host.trim().toLowerCase(),
|
|
77
|
+
repoPath
|
|
78
|
+
.trim()
|
|
79
|
+
.replace(/\.git$/i, "")
|
|
80
|
+
.replace(/^\/+|\/+$/g, "")
|
|
81
|
+
.toLowerCase(),
|
|
82
|
+
]
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.join("/");
|
|
85
|
+
}
|
|
86
|
+
export function sha256(value) {
|
|
87
|
+
return crypto.createHash("sha256").update(value, "utf8").digest("hex");
|
|
88
|
+
}
|