@bli-cockpit/cli 0.2.57 → 0.2.59
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/browser-open.js +88 -0
- package/dist/commands/docs.js +27 -6
- package/dist/commands/doctor-report.js +17 -1
- package/dist/commands/doctor.js +12 -2
- package/dist/commands/heartbeat.js +65 -1
- 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/jarvis-answer-envelope.js +80 -0
- package/dist/commands/jarvis-turn.js +16 -1
- package/dist/commands/jarvis.js +3 -0
- package/dist/commands/local-args-collector-setup.js +17 -0
- package/dist/commands/local-args-tower-admin.js +12 -2
- package/dist/commands/local-args-tower-docs-msg.js +95 -6
- package/dist/commands/local-args-tower-search.js +50 -0
- package/dist/commands/local-args-tower-work.js +178 -0
- package/dist/commands/local-args-tower.js +7 -1
- package/dist/commands/local-args.js +29 -14
- package/dist/commands/local-command-shapes.js +12 -0
- package/dist/commands/local-help-commands.js +643 -0
- package/dist/commands/local-help.js +12 -551
- package/dist/commands/local.js +9 -0
- package/dist/commands/login.js +91 -8
- package/dist/commands/memory-install-claude.js +35 -15
- package/dist/commands/memory-install-codex-hooks.js +200 -0
- package/dist/commands/memory-install-codex.js +12 -2
- package/dist/commands/memory-install-config.js +140 -0
- package/dist/commands/memory-install-receipt.js +222 -0
- package/dist/commands/memory-install-report.js +113 -0
- package/dist/commands/memory-install.js +99 -276
- package/dist/commands/msg.js +85 -2
- 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/onboard-completion.js +47 -0
- package/dist/commands/onboard-setup.js +82 -2
- package/dist/commands/ops-render-memory.js +76 -0
- package/dist/commands/ops-render.js +13 -1
- package/dist/commands/ops.js +65 -3
- package/dist/commands/project.js +38 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/search.js +122 -0
- package/dist/commands/setup-receipt-lines.js +71 -0
- package/dist/commands/setup-receipt.js +241 -0
- package/dist/commands/status.js +20 -1
- package/dist/local-state-pairing-code.js +200 -0
- package/dist/local-state.js +6 -0
- 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 +7 -7
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening a URL in the person's own browser (BLI-3731).
|
|
3
|
+
*
|
|
4
|
+
* One sign-in means the terminal hands the browser a link. That is the whole
|
|
5
|
+
* job here, and it follows the same discipline `commands/editor.ts` wrote down
|
|
6
|
+
* for spawning a program on somebody's behalf:
|
|
7
|
+
*
|
|
8
|
+
* 1. **`spawnSync` with an ARRAY of arguments and `shell: false`.** Nothing is
|
|
9
|
+
* interpolated into a command line, ever.
|
|
10
|
+
* 2. **Windows does NOT go through `cmd /c start`.** `start` is a cmd builtin,
|
|
11
|
+
* so reaching it means handing cmd.exe a string it re-parses — and `&` in a
|
|
12
|
+
* query string is a command separator there. `rundll32 url.dll,
|
|
13
|
+
* FileProtocolHandler <url>` takes the URL as one argv and never sees a
|
|
14
|
+
* shell. (Our own URL has no `&` today; the point is that it cannot ever
|
|
15
|
+
* matter.)
|
|
16
|
+
* 3. **The URL is validated before it is spawned.** Only `https:` and a
|
|
17
|
+
* loopback `http:` are opened, so a bad dashboard URL cannot turn into a
|
|
18
|
+
* `file:` or a custom protocol handler.
|
|
19
|
+
*
|
|
20
|
+
* Opening is BEST EFFORT and never fatal: the caller has already printed the
|
|
21
|
+
* link, so a machine with no browser — an SSH session, a locked-down Windows
|
|
22
|
+
* host — loses nothing but a convenience. Every branch returns a reason label,
|
|
23
|
+
* success included, and the caller logs it.
|
|
24
|
+
*/
|
|
25
|
+
import { spawnSync } from "node:child_process";
|
|
26
|
+
/** `https:` anywhere, `http:` only on loopback — a dev dashboard is the reason. */
|
|
27
|
+
export function isOpenableUrl(url) {
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = new URL(url);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
if (parsed.protocol === "https:")
|
|
36
|
+
return true;
|
|
37
|
+
return (parsed.protocol === "http:" &&
|
|
38
|
+
(parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1"));
|
|
39
|
+
}
|
|
40
|
+
export function openInBrowser(url, options = {}) {
|
|
41
|
+
if (!isOpenableUrl(url)) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
reason: "url_not_openable",
|
|
45
|
+
detail: "Only https:// (or http:// on localhost) links are opened.",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const platform = options.platform ?? process.platform;
|
|
49
|
+
const spawnImpl = options.spawn ?? spawnSync;
|
|
50
|
+
const opener = openerFor(platform, url);
|
|
51
|
+
if (!opener) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
reason: "no_opener_for_platform",
|
|
55
|
+
detail: `No known browser opener for ${platform}; open the link by hand.`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const result = spawnImpl(opener.program, opener.args, {
|
|
59
|
+
stdio: "ignore",
|
|
60
|
+
shell: false,
|
|
61
|
+
});
|
|
62
|
+
if (result.error) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
reason: "opener_not_started",
|
|
66
|
+
detail: `${opener.program} could not be started; open the link by hand.`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (typeof result.status === "number" && result.status !== 0) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
reason: "opener_exited_nonzero",
|
|
73
|
+
detail: `${opener.program} exited ${result.status}; open the link by hand.`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, reason: "launched", program: opener.program };
|
|
77
|
+
}
|
|
78
|
+
function openerFor(platform, url) {
|
|
79
|
+
if (platform === "darwin")
|
|
80
|
+
return { program: "open", args: [url] };
|
|
81
|
+
if (platform === "win32") {
|
|
82
|
+
// See rule 2 in the header: never `cmd /c start`.
|
|
83
|
+
return { program: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
|
|
84
|
+
}
|
|
85
|
+
if (platform === "linux")
|
|
86
|
+
return { program: "xdg-open", args: [url] };
|
|
87
|
+
return null;
|
|
88
|
+
}
|
package/dist/commands/docs.js
CHANGED
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
* /api/docs/documents` already returns — the same read `docs list` prints,
|
|
15
15
|
* never a second query the server does not already answer.
|
|
16
16
|
*
|
|
17
|
+
* THE LIST IS METADATA (BLI-3737). It carries id, slug, title, visibility,
|
|
18
|
+
* parent, author, updated_at and `body_chars` — never a body. On 2026-09-05
|
|
19
|
+
* `cockpit docs list --json` answered 603,779 bytes for 155 documents,
|
|
20
|
+
* because the door's select still carried `body_markdown`; an agent had to
|
|
21
|
+
* spend the whole library to learn 155 titles. `--parent`, `--query` and
|
|
22
|
+
* `--limit` ride to the door as query parameters, so the narrowing happens in
|
|
23
|
+
* the database rather than here.
|
|
24
|
+
*
|
|
17
25
|
* A refusal keeps the door's own `reason` label verbatim (`agent-door.ts`):
|
|
18
26
|
* `needs_rls_client`, `document_not_found_or_unreadable`,
|
|
19
27
|
* `document_not_writable`, `title_too_long`, `content_too_long`,
|
|
@@ -30,7 +38,7 @@ export async function runDocs(command, io) {
|
|
|
30
38
|
const door = await openAgentDoor("docs", command, io);
|
|
31
39
|
switch (command.action) {
|
|
32
40
|
case "list":
|
|
33
|
-
return listDocs(door);
|
|
41
|
+
return listDocs(command, door);
|
|
34
42
|
case "tree":
|
|
35
43
|
return treeDocs(door);
|
|
36
44
|
case "read":
|
|
@@ -41,9 +49,21 @@ export async function runDocs(command, io) {
|
|
|
41
49
|
return updateDoc(command, door);
|
|
42
50
|
}
|
|
43
51
|
}
|
|
44
|
-
|
|
52
|
+
/** `--parent`, `--query`, `--limit` as the door's own query parameters (BLI-3737). */
|
|
53
|
+
export function docsListPath(command) {
|
|
54
|
+
const params = new URLSearchParams();
|
|
55
|
+
if (command.parentId)
|
|
56
|
+
params.set("parent_id", command.parentId);
|
|
57
|
+
if (command.query)
|
|
58
|
+
params.set("query", command.query);
|
|
59
|
+
if (command.limit !== undefined)
|
|
60
|
+
params.set("limit", String(command.limit));
|
|
61
|
+
const search = params.toString();
|
|
62
|
+
return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
|
|
63
|
+
}
|
|
64
|
+
async function listDocs(command, door) {
|
|
45
65
|
const answer = await askAgentDoor(door, {
|
|
46
|
-
path:
|
|
66
|
+
path: docsListPath(command),
|
|
47
67
|
method: "GET",
|
|
48
68
|
label: "docs list",
|
|
49
69
|
timeoutMs: READ_DEADLINE_MS,
|
|
@@ -54,14 +74,15 @@ async function listDocs(door) {
|
|
|
54
74
|
if (door.json)
|
|
55
75
|
return emitAgentDoor(door, { ok: true, documents });
|
|
56
76
|
if (documents.length === 0) {
|
|
57
|
-
writeLine(door.io.stdout, "No documents.");
|
|
77
|
+
writeLine(door.io.stdout, command.query ? `No document matches "${command.query}".` : "No documents.");
|
|
58
78
|
return 0;
|
|
59
79
|
}
|
|
60
80
|
for (const doc of documents) {
|
|
61
|
-
|
|
81
|
+
const size = typeof doc.body_chars === "number" ? `${doc.body_chars}c`.padStart(8) : " ?";
|
|
82
|
+
writeLine(door.io.stdout, `${doc.id} ${doc.visibility.padEnd(7)} ${size} ${doc.slug ?? "(no slug)"} ${doc.title}`);
|
|
62
83
|
}
|
|
63
84
|
writeLine(door.io.stdout, "");
|
|
64
|
-
writeLine(door.io.stdout, `${documents.length} document(s)
|
|
85
|
+
writeLine(door.io.stdout, `${documents.length} document(s). Bodies come from \`cockpit docs read <id|slug>\`.`);
|
|
65
86
|
return 0;
|
|
66
87
|
}
|
|
67
88
|
function renderTree(nodes, io, depth) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { redactedHealthDetail } from "../health-detail.js";
|
|
2
|
+
import { setupReceiptBlock } from "./setup-receipt-lines.js";
|
|
2
3
|
export function ok(id, code, message) {
|
|
3
4
|
return { id, status: "ok", code, message };
|
|
4
5
|
}
|
|
@@ -37,7 +38,9 @@ export async function maybeReportDoctorEvents(context, rows) {
|
|
|
37
38
|
io: context.io,
|
|
38
39
|
});
|
|
39
40
|
}
|
|
40
|
-
export function writeDoctorOutput(command, io, rows
|
|
41
|
+
export function writeDoctorOutput(command, io, rows,
|
|
42
|
+
/** BLI-3731. Absent means it could not be read; doctor says so rather than nothing. */
|
|
43
|
+
setupReceipt) {
|
|
41
44
|
if (command.json) {
|
|
42
45
|
writeLine(io.stdout, JSON.stringify({
|
|
43
46
|
status: rows.some((row) => row.status === "fail" || row.hardStop)
|
|
@@ -45,6 +48,8 @@ export function writeDoctorOutput(command, io, rows) {
|
|
|
45
48
|
: "pass",
|
|
46
49
|
dry_run: command.dryRun,
|
|
47
50
|
steps: rows,
|
|
51
|
+
setup_receipt: setupReceipt?.receipt ?? null,
|
|
52
|
+
memory_install: setupReceipt?.memory ?? null,
|
|
48
53
|
}, null, 2));
|
|
49
54
|
return;
|
|
50
55
|
}
|
|
@@ -61,6 +66,17 @@ export function writeDoctorOutput(command, io, rows) {
|
|
|
61
66
|
writeLine(io.stderr, `${row.id}:`);
|
|
62
67
|
writeLine(io.stderr, row.message);
|
|
63
68
|
}
|
|
69
|
+
// BLI-3731. The invariant table says whether collection is healthy; this
|
|
70
|
+
// says whether the machine is CONNECTED — the browser sign-in, the device
|
|
71
|
+
// session, both agent hosts and the scheduler, every word read back off this
|
|
72
|
+
// host. Printed last because it is what a person came to check on day one.
|
|
73
|
+
writeLine(io.stdout, "");
|
|
74
|
+
writeLine(io.stdout, "Connected:");
|
|
75
|
+
for (const line of setupReceipt
|
|
76
|
+
? setupReceiptBlock(setupReceipt, { indent: " " })
|
|
77
|
+
: [" unknown — this machine could not be read this run."]) {
|
|
78
|
+
writeLine(io.stdout, line);
|
|
79
|
+
}
|
|
64
80
|
}
|
|
65
81
|
function doctorEvent(row) {
|
|
66
82
|
const status = row.status === "fail" || row.hardStop
|
package/dist/commands/doctor.js
CHANGED
|
@@ -2,6 +2,7 @@ import { checkSingleInstallState, fixAuthState, fixRootState, readAuthState, rea
|
|
|
2
2
|
import { backfillCompletionStepState, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, } from "./doctor-pipeline.js";
|
|
3
3
|
import { checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
|
|
4
4
|
import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, } from "./doctor-report.js";
|
|
5
|
+
import { refreshSetupReceipt } from "./setup-receipt.js";
|
|
5
6
|
import { checkCliLatest, fixCliLatest, latestCliVersionFromNpm, reexecDoctor, } from "./doctor-update.js";
|
|
6
7
|
export async function runDoctor(command, io, hooks, overrides = {}) {
|
|
7
8
|
const deps = { ...defaultDoctorDeps(hooks), ...overrides };
|
|
@@ -40,12 +41,15 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
40
41
|
break;
|
|
41
42
|
if (fixed.reexecExitCode !== undefined) {
|
|
42
43
|
await maybeReportDoctorEvents(context, rows);
|
|
43
|
-
writeDoctorOutput(command, io, rows);
|
|
44
|
+
writeDoctorOutput(command, io, rows, await deps.readSetupReceipt(context));
|
|
44
45
|
return fixed.reexecExitCode;
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
await maybeReportDoctorEvents(context, rows);
|
|
48
|
-
|
|
49
|
+
// Doctor is the surface a person opens when something is wrong, so it
|
|
50
|
+
// re-READS the machine rather than quoting a cache — and caching what it
|
|
51
|
+
// read is what keeps the 15-minute heartbeat from paying for the probe.
|
|
52
|
+
writeDoctorOutput(command, io, rows, await deps.readSetupReceipt(context));
|
|
49
53
|
return rows.some((row) => row.status === "fail" || row.hardStop) ? 1 : 0;
|
|
50
54
|
}
|
|
51
55
|
function doctorInvariants() {
|
|
@@ -129,6 +133,12 @@ function defaultDoctorDeps(hooks) {
|
|
|
129
133
|
fixDisk: fixDiskState,
|
|
130
134
|
checkSync: checkSyncState,
|
|
131
135
|
fixSync: fixSyncState,
|
|
136
|
+
readSetupReceipt: (context) => refreshSetupReceipt(context.io, {
|
|
137
|
+
...(context.command.homeDir ? { homeDir: context.command.homeDir } : {}),
|
|
138
|
+
...(context.command.dashboardUrl
|
|
139
|
+
? { dashboardUrl: context.command.dashboardUrl }
|
|
140
|
+
: {}),
|
|
141
|
+
}),
|
|
132
142
|
};
|
|
133
143
|
}
|
|
134
144
|
// Re-exported so every consumer keeps importing from `./doctor.js` regardless
|
|
@@ -23,10 +23,12 @@ import crypto from "node:crypto";
|
|
|
23
23
|
import fs from "node:fs";
|
|
24
24
|
import os from "node:os";
|
|
25
25
|
import path from "node:path";
|
|
26
|
-
import { COLLECTOR_HEARTBEAT_SCHEMA_VERSION, } from "@bli-cockpit/telemetry-core";
|
|
26
|
+
import { COLLECTOR_HEARTBEAT_SCHEMA_VERSION, MemoryInstallReceiptSchema, memoryInstallGaps, setupReceiptGaps, } from "@bli-cockpit/telemetry-core";
|
|
27
|
+
import { readCachedSetupReceipt } from "./setup-receipt.js";
|
|
27
28
|
import { describeError } from "../health-detail.js";
|
|
28
29
|
import { shouldSuppressFleetReceipts, } from "../dev-build.js";
|
|
29
30
|
import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
|
|
31
|
+
import { readMemoryReceiptFile } from "./memory-install-receipt.js";
|
|
30
32
|
const HEARTBEAT_TIMEOUT_MS = 5_000;
|
|
31
33
|
/**
|
|
32
34
|
* The label form of one approved root.
|
|
@@ -108,8 +110,40 @@ export function buildCollectorHeartbeat(options) {
|
|
|
108
110
|
...(typeof options.facts.sessionsPendingUpload === "number"
|
|
109
111
|
? { sessions_pending_upload: options.facts.sessionsPendingUpload }
|
|
110
112
|
: {}),
|
|
113
|
+
...(options.memoryInstall ? { memory_install: options.memoryInstall } : {}),
|
|
114
|
+
...(options.setupReceipt ? { setup_receipt: options.setupReceipt } : {}),
|
|
111
115
|
};
|
|
112
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* The cached memory receipt for this tick, or null with a named reason
|
|
119
|
+
* (BLI-3729).
|
|
120
|
+
*
|
|
121
|
+
* Reads the file `cockpit memory install` / `cockpit memory status` left
|
|
122
|
+
* behind; it does NOT re-inspect the host configs, which would cost five file
|
|
123
|
+
* reads and a `--print-config` spawn every fifteen minutes to re-prove a state
|
|
124
|
+
* that changes about once a month. The reason is returned rather than logged
|
|
125
|
+
* here so the ONE heartbeat log line carries it.
|
|
126
|
+
*/
|
|
127
|
+
export async function readHeartbeatMemoryReceipt(options) {
|
|
128
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
129
|
+
const result = await readMemoryReceiptFile({
|
|
130
|
+
stateDir: paths.state_dir,
|
|
131
|
+
readText: async (file) => {
|
|
132
|
+
try {
|
|
133
|
+
return await fs.promises.readFile(file, "utf8");
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
...(options.now ? { now: options.now } : {}),
|
|
140
|
+
parse: (value) => {
|
|
141
|
+
const parsed = MemoryInstallReceiptSchema.safeParse(value);
|
|
142
|
+
return parsed.success ? parsed.data : null;
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
return { receipt: result.receipt, reason: result.reason };
|
|
146
|
+
}
|
|
113
147
|
/**
|
|
114
148
|
* Sends the heartbeat. Returns whether it landed; never throws.
|
|
115
149
|
*
|
|
@@ -148,10 +182,34 @@ export async function sendCollectorHeartbeatBestEffort(options) {
|
|
|
148
182
|
}));
|
|
149
183
|
return false;
|
|
150
184
|
}
|
|
185
|
+
const memory = await readHeartbeatMemoryReceipt({
|
|
186
|
+
...(options.homeDir ? { homeDir: options.homeDir } : {}),
|
|
187
|
+
...(options.now ? { now: options.now } : {}),
|
|
188
|
+
}).catch(() => ({ receipt: null, reason: "receipt_read_threw" }));
|
|
189
|
+
// BLI-3731. Read the CACHE, never re-probe: computing the receipt costs five
|
|
190
|
+
// file reads and a `--print-config` spawn, and these facts change about
|
|
191
|
+
// monthly. `cockpit memory install`, `onboard` and `doctor` refresh it; a
|
|
192
|
+
// tick only carries it. Both branches say something, because "the ops board
|
|
193
|
+
// shows no receipt for this machine" has two very different causes.
|
|
194
|
+
const setupReceipt = await readCachedSetupReceipt(options.homeDir, options.now ?? new Date());
|
|
195
|
+
console.error(setupReceipt
|
|
196
|
+
? "[heartbeat] carrying this machine's setup receipt"
|
|
197
|
+
: "[heartbeat] no usable setup receipt to carry; the ops board keeps the last one it has", JSON.stringify(setupReceipt
|
|
198
|
+
? {
|
|
199
|
+
reason: "setup_receipt_cached",
|
|
200
|
+
gap_count: setupReceiptGaps(setupReceipt).length,
|
|
201
|
+
gaps: setupReceiptGaps(setupReceipt),
|
|
202
|
+
}
|
|
203
|
+
: {
|
|
204
|
+
reason: "setup_receipt_absent_or_stale",
|
|
205
|
+
next_action: "run `cockpit doctor` to refresh it",
|
|
206
|
+
}));
|
|
151
207
|
const heartbeat = buildCollectorHeartbeat({
|
|
152
208
|
roots: options.roots,
|
|
153
209
|
facts: options.facts,
|
|
210
|
+
setupReceipt,
|
|
154
211
|
...(options.now ? { now: options.now } : {}),
|
|
212
|
+
memoryInstall: memory.receipt,
|
|
155
213
|
});
|
|
156
214
|
const controller = new AbortController();
|
|
157
215
|
const timeout = setTimeout(() => controller.abort(), HEARTBEAT_TIMEOUT_MS);
|
|
@@ -184,6 +242,12 @@ export async function sendCollectorHeartbeatBestEffort(options) {
|
|
|
184
242
|
sessions_outside_root: heartbeat.sessions_outside_root ?? null,
|
|
185
243
|
sessions_new_this_tick: heartbeat.sessions_new_this_tick ?? null,
|
|
186
244
|
sessions_pending_upload: heartbeat.sessions_pending_upload ?? null,
|
|
245
|
+
// BLI-3729. Both branches: a tick that reports no receipt says WHY,
|
|
246
|
+
// and a tick that reports one says whether memory is fully on. "Is BLI
|
|
247
|
+
// Memory switched on for this machine?" is now answerable from
|
|
248
|
+
// sync.err.log alone.
|
|
249
|
+
memory_receipt: memory.reason,
|
|
250
|
+
memory_gaps: memory.receipt ? memoryInstallGaps(memory.receipt) : null,
|
|
187
251
|
}));
|
|
188
252
|
return true;
|
|
189
253
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `cockpit issue` hands between its own halves (BLI-3716): the row
|
|
3
|
+
* shapes the `/api/work/**` doors answer with, the two deadlines, and the
|
|
4
|
+
* three "which one did you mean" resolvers both the read half (`issue.ts`)
|
|
5
|
+
* and the write half (`issue-write.ts`) need.
|
|
6
|
+
*
|
|
7
|
+
* Split out so neither half carries the other's weight — the same table-of-
|
|
8
|
+
* contents discipline `commands/session-sync*.ts` and `commands/backfill*.ts`
|
|
9
|
+
* follow, applied before the file got large rather than after.
|
|
10
|
+
*/
|
|
11
|
+
import { askAgentDoor } from "./agent-door.js";
|
|
12
|
+
import { isInteractiveStdin, readPipedText } from "./cli-io.js";
|
|
13
|
+
import { readNoteFile } from "./notes-file.js";
|
|
14
|
+
export const TAG = "[issue cli]";
|
|
15
|
+
export const READ_DEADLINE_MS = 30_000;
|
|
16
|
+
export const WRITE_DEADLINE_MS = 60_000;
|
|
17
|
+
export const BODY_MAX_CHARS = 200_000;
|
|
18
|
+
/** A project id from a project NAME or a uuid — exact after case-folding. */
|
|
19
|
+
export async function resolveProjectId(door, ref) {
|
|
20
|
+
const answer = await askAgentDoor(door, {
|
|
21
|
+
path: "/api/work/projects?include_archived=true",
|
|
22
|
+
method: "GET",
|
|
23
|
+
label: "issue project resolve",
|
|
24
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
25
|
+
});
|
|
26
|
+
if (!answer.ok)
|
|
27
|
+
return { status: "list_failed", reason: answer.reason, detail: answer.detail };
|
|
28
|
+
const projects = answer.body.projects ?? [];
|
|
29
|
+
const wanted = ref.toLowerCase();
|
|
30
|
+
const match = projects.find((project) => project.id === ref || project.name.toLowerCase() === wanted);
|
|
31
|
+
return match ? { status: "ok", id: match.id } : { status: "not_found" };
|
|
32
|
+
}
|
|
33
|
+
/** A parent issue's uuid from any issue reference, via the door's own resolver. */
|
|
34
|
+
export async function resolveIssueUuid(door, ref) {
|
|
35
|
+
const answer = await askAgentDoor(door, {
|
|
36
|
+
path: `/api/work/issues/${encodeURIComponent(ref)}`,
|
|
37
|
+
method: "GET",
|
|
38
|
+
label: "issue parent resolve",
|
|
39
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
40
|
+
});
|
|
41
|
+
if (!answer.ok)
|
|
42
|
+
return { status: "failed", reason: answer.reason, detail: answer.detail };
|
|
43
|
+
const issue = answer.body.issue;
|
|
44
|
+
if (!issue?.id) {
|
|
45
|
+
return {
|
|
46
|
+
status: "failed",
|
|
47
|
+
reason: "issue_not_found_or_unreadable",
|
|
48
|
+
detail: `That issue does not exist, or you cannot read it: ${ref}`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return { status: "ok", id: issue.id };
|
|
52
|
+
}
|
|
53
|
+
/** Body on stdin, never argv — `--file` is the safest way on Windows. */
|
|
54
|
+
export async function readBody(command, io) {
|
|
55
|
+
if (command.filePath) {
|
|
56
|
+
const read = await readNoteFile(command.filePath);
|
|
57
|
+
if (!read.ok) {
|
|
58
|
+
return { ok: false, reason: read.refusal, detail: `Could not read ${command.filePath}: ${read.detail}` };
|
|
59
|
+
}
|
|
60
|
+
return { ok: true, text: read.bytes.toString("utf8") };
|
|
61
|
+
}
|
|
62
|
+
if (isInteractiveStdin(io))
|
|
63
|
+
return { ok: true, text: "" };
|
|
64
|
+
try {
|
|
65
|
+
const text = await readPipedText(io.stdin, {
|
|
66
|
+
maxChars: BODY_MAX_CHARS,
|
|
67
|
+
overflowMessage: `An issue description is limited to ${BODY_MAX_CHARS} characters.`,
|
|
68
|
+
});
|
|
69
|
+
return { ok: true, text };
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return { ok: false, reason: "description_too_long", detail: error instanceof Error ? error.message : String(error) };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** The project/parent halves both `create` and `update` need, resolved once. */
|
|
76
|
+
export async function resolveRefs(command, door) {
|
|
77
|
+
let projectId;
|
|
78
|
+
let parentId;
|
|
79
|
+
if (command.project) {
|
|
80
|
+
const resolved = await resolveProjectId(door, command.project);
|
|
81
|
+
if (resolved.status === "list_failed")
|
|
82
|
+
return { ok: false, reason: resolved.reason, detail: resolved.detail };
|
|
83
|
+
if (resolved.status === "not_found") {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
reason: "project_not_found_or_unreadable",
|
|
87
|
+
detail: `No project here is called "${command.project}" — run \`cockpit project list\` for the names.`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
projectId = resolved.id;
|
|
91
|
+
}
|
|
92
|
+
if (command.parentRef) {
|
|
93
|
+
const resolved = await resolveIssueUuid(door, command.parentRef);
|
|
94
|
+
if (resolved.status === "failed")
|
|
95
|
+
return { ok: false, reason: resolved.reason, detail: resolved.detail };
|
|
96
|
+
parentId = resolved.id;
|
|
97
|
+
}
|
|
98
|
+
return { ok: true, ...(projectId ? { projectId } : {}), ...(parentId ? { parentId } : {}) };
|
|
99
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The verbs that CHANGE something: `cockpit issue create|update|move|comment`
|
|
3
|
+
* (BLI-3716). The reads and the router live in `issue.ts`, the shared shapes
|
|
4
|
+
* and resolvers in `issue-contracts.ts`.
|
|
5
|
+
*
|
|
6
|
+
* Every write prints a receipt line on stderr naming what it did and to
|
|
7
|
+
* which row, success included — `--json` owns stdout, so a receipt that went
|
|
8
|
+
* there would corrupt the one machine-readable object a caller parses.
|
|
9
|
+
*/
|
|
10
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, } from "./agent-door.js";
|
|
11
|
+
import { writeLine } from "./cli-io.js";
|
|
12
|
+
import { TAG, WRITE_DEADLINE_MS, readBody, resolveRefs, } from "./issue-contracts.js";
|
|
13
|
+
export async function createIssue(command, door) {
|
|
14
|
+
if (!command.title)
|
|
15
|
+
return failAgentDoor(door, TAG, "invalid_body", "issue create needs --title.");
|
|
16
|
+
const refs = await resolveRefs(command, door);
|
|
17
|
+
if (!refs.ok)
|
|
18
|
+
return failAgentDoor(door, TAG, refs.reason, refs.detail);
|
|
19
|
+
const body = await readBody(command, door.io);
|
|
20
|
+
if (!body.ok)
|
|
21
|
+
return failAgentDoor(door, TAG, body.reason, body.detail);
|
|
22
|
+
const answer = await askAgentDoor(door, {
|
|
23
|
+
path: "/api/work/issues",
|
|
24
|
+
method: "POST",
|
|
25
|
+
label: "issue create",
|
|
26
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
27
|
+
body: {
|
|
28
|
+
title: command.title,
|
|
29
|
+
...(body.text ? { description: body.text } : {}),
|
|
30
|
+
...(refs.projectId ? { project_id: refs.projectId } : {}),
|
|
31
|
+
...(refs.parentId ? { parent_id: refs.parentId } : {}),
|
|
32
|
+
...(command.priority === undefined ? {} : { priority: command.priority }),
|
|
33
|
+
...(command.assignee ? { assignee_id: command.assignee } : {}),
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
if (!answer.ok)
|
|
37
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
38
|
+
const issue = answer.body.issue;
|
|
39
|
+
writeLine(door.io.stderr, `${TAG} created ${JSON.stringify({ issue_id: issue?.id ?? null, identifier: issue?.identifier ?? null, description_bytes: Buffer.byteLength(body.text, "utf8") })}`);
|
|
40
|
+
if (door.json)
|
|
41
|
+
return emitAgentDoor(door, { ok: true, issue });
|
|
42
|
+
writeLine(door.io.stdout, `Created ${issue?.identifier ?? ""} — ${issue?.title ?? ""} (${issue?.id ?? ""}).`);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
export async function updateIssue(command, door) {
|
|
46
|
+
const ref = command.issueRef ?? "";
|
|
47
|
+
const refs = await resolveRefs(command, door);
|
|
48
|
+
if (!refs.ok)
|
|
49
|
+
return failAgentDoor(door, TAG, refs.reason, refs.detail);
|
|
50
|
+
let description;
|
|
51
|
+
if (command.bodyStdin || command.filePath) {
|
|
52
|
+
const body = await readBody(command, door.io);
|
|
53
|
+
if (!body.ok)
|
|
54
|
+
return failAgentDoor(door, TAG, body.reason, body.detail);
|
|
55
|
+
description = body.text;
|
|
56
|
+
}
|
|
57
|
+
if (description === undefined
|
|
58
|
+
&& command.title === undefined
|
|
59
|
+
&& command.priority === undefined
|
|
60
|
+
&& command.assignee === undefined
|
|
61
|
+
&& refs.projectId === undefined
|
|
62
|
+
&& refs.parentId === undefined) {
|
|
63
|
+
return failAgentDoor(door, TAG, "invalid_body", "issue update needs at least one of --title, --priority, --assignee, --project, --parent, or a description on --body-stdin/--file.");
|
|
64
|
+
}
|
|
65
|
+
const answer = await askAgentDoor(door, {
|
|
66
|
+
path: `/api/work/issues/${encodeURIComponent(ref)}`,
|
|
67
|
+
method: "PATCH",
|
|
68
|
+
label: "issue update",
|
|
69
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
70
|
+
body: {
|
|
71
|
+
...(command.title !== undefined ? { title: command.title } : {}),
|
|
72
|
+
...(description !== undefined ? { description } : {}),
|
|
73
|
+
...(command.priority === undefined ? {} : { priority: command.priority }),
|
|
74
|
+
...(command.assignee ? { assignee_id: command.assignee } : {}),
|
|
75
|
+
...(refs.projectId ? { project_id: refs.projectId } : {}),
|
|
76
|
+
...(refs.parentId ? { parent_id: refs.parentId } : {}),
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
if (!answer.ok)
|
|
80
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
81
|
+
const issue = answer.body.issue;
|
|
82
|
+
writeLine(door.io.stderr, `${TAG} updated ${JSON.stringify({ issue_id: issue?.id ?? null, identifier: issue?.identifier ?? null })}`);
|
|
83
|
+
if (door.json)
|
|
84
|
+
return emitAgentDoor(door, { ok: true, issue });
|
|
85
|
+
writeLine(door.io.stdout, `Updated ${issue?.identifier ?? ref} — ${issue?.title ?? ""}.`);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
export async function moveIssue(command, door) {
|
|
89
|
+
const ref = command.issueRef ?? "";
|
|
90
|
+
const answer = await askAgentDoor(door, {
|
|
91
|
+
path: `/api/work/issues/${encodeURIComponent(ref)}/state`,
|
|
92
|
+
method: "POST",
|
|
93
|
+
label: "issue move",
|
|
94
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
95
|
+
body: { state: command.moveState },
|
|
96
|
+
});
|
|
97
|
+
if (!answer.ok)
|
|
98
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
99
|
+
const issue = answer.body.issue;
|
|
100
|
+
writeLine(door.io.stderr, `${TAG} moved ${JSON.stringify({ issue_id: issue?.id ?? null, identifier: issue?.identifier ?? null, to_state: command.moveState })}`);
|
|
101
|
+
if (door.json)
|
|
102
|
+
return emitAgentDoor(door, { ok: true, issue });
|
|
103
|
+
writeLine(door.io.stdout, `${issue?.identifier ?? ref} is now ${issue?.state ?? command.moveState}.`);
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
export async function commentIssue(command, door) {
|
|
107
|
+
const ref = command.issueRef ?? "";
|
|
108
|
+
const body = await readBody(command, door.io);
|
|
109
|
+
if (!body.ok)
|
|
110
|
+
return failAgentDoor(door, TAG, body.reason, body.detail);
|
|
111
|
+
if (body.text.trim() === "") {
|
|
112
|
+
return failAgentDoor(door, TAG, "invalid_body", 'A comment is never taken on the command line — pipe it in: `echo "..." | cockpit issue comment BLI-3654`.');
|
|
113
|
+
}
|
|
114
|
+
const answer = await askAgentDoor(door, {
|
|
115
|
+
path: `/api/work/issues/${encodeURIComponent(ref)}/comments`,
|
|
116
|
+
method: "POST",
|
|
117
|
+
label: "issue comment",
|
|
118
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
119
|
+
body: { body_markdown: body.text },
|
|
120
|
+
});
|
|
121
|
+
if (!answer.ok)
|
|
122
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
123
|
+
const comment = answer.body.comment;
|
|
124
|
+
writeLine(door.io.stderr, `${TAG} commented ${JSON.stringify({ comment_id: comment?.id ?? null, byte_size: Buffer.byteLength(body.text, "utf8") })}`);
|
|
125
|
+
if (door.json)
|
|
126
|
+
return emitAgentDoor(door, { ok: true, comment });
|
|
127
|
+
writeLine(door.io.stdout, `Commented on ${ref} (${comment?.id ?? ""}).`);
|
|
128
|
+
return 0;
|
|
129
|
+
}
|