@bli-cockpit/cli 0.2.40 → 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/adapters/local-sources.js +51 -5
- package/dist/autostart.js +105 -5
- package/dist/commands/brief-edit.js +289 -0
- package/dist/commands/brief-rewrite.js +260 -0
- package/dist/commands/brief.js +11 -10
- package/dist/commands/cli-io.js +29 -0
- package/dist/commands/correct.js +4 -5
- package/dist/commands/editor.js +204 -0
- package/dist/commands/jarvis.js +7 -8
- package/dist/commands/local-args.js +36 -2
- package/dist/commands/local-discovery.js +20 -0
- package/dist/commands/local-help.js +13 -2
- package/dist/commands/local.js +47 -1
- package/dist/commands/notes.js +9 -2
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/scout-render.js +20 -18
- package/dist/commands/scout.js +4 -4
- package/dist/commands/sessions.js +45 -1
- package/dist/commands/settings-render.js +9 -4
- package/dist/commands/settings.js +6 -0
- package/dist/commands/text-width.js +108 -0
- package/dist/commands/workbook-render.js +34 -17
- package/dist/commands/workbook.js +6 -4
- package/dist/evidence-upload-client.js +42 -3
- package/dist/local-state.js +27 -2
- package/dist/upload-agent-artifacts.js +27 -2
- package/dist/upload-failure-reason.js +121 -0
- package/dist/upload-http.js +28 -0
- package/dist/upload.js +122 -21
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/commands/brief.js
CHANGED
|
@@ -12,10 +12,8 @@
|
|
|
12
12
|
* Identity is the existing paired device session. `--for` changes WHOSE page is
|
|
13
13
|
* asked for, never who is asking — the same rule `cockpit jarvis --as` follows.
|
|
14
14
|
*/
|
|
15
|
-
import { writeLine } from "./cli-io.js";
|
|
15
|
+
import { colorEnabled, dim, writeLine } from "./cli-io.js";
|
|
16
16
|
import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
|
|
17
|
-
const DIM = "\x1b[2m";
|
|
18
|
-
const RESET = "\x1b[0m";
|
|
19
17
|
const REQUEST_DEADLINE_MS = 30_000;
|
|
20
18
|
export async function runBrief(command, io) {
|
|
21
19
|
const session = await loadPairedSession("brief", command.homeDir);
|
|
@@ -81,19 +79,22 @@ function queryFor(command) {
|
|
|
81
79
|
return query ? `?${query}` : "";
|
|
82
80
|
}
|
|
83
81
|
function writeHuman(io, command, body) {
|
|
82
|
+
// Decided once per page: colour only for a real terminal that has not said
|
|
83
|
+
// NO_COLOR, so `cockpit brief > page.txt` is text and nothing else (BLI-3482).
|
|
84
|
+
const styled = colorEnabled(io);
|
|
84
85
|
const page = body.page ?? {};
|
|
85
86
|
// Whose page, and — when it is not the newest — that it is a record of a
|
|
86
87
|
// moment rather than something that failed to update.
|
|
87
88
|
const whose = page.displayName ? `${page.displayName}'s page` : "This page";
|
|
88
89
|
const pinned = page.olderVersionLabel ? ` · version from ${page.olderVersionLabel}` : "";
|
|
89
|
-
writeLine(io.stdout, `${
|
|
90
|
+
writeLine(io.stdout, dim(`${whose}${pinned}`, styled));
|
|
90
91
|
writeLine(io.stdout, "");
|
|
91
92
|
writeLine(io.stdout, body.text ?? "");
|
|
92
93
|
if (body.claims && body.claims.length > 0) {
|
|
93
94
|
writeLine(io.stdout, "");
|
|
94
|
-
writeLine(io.stdout,
|
|
95
|
+
writeLine(io.stdout, dim("Claim ids — pass one to `cockpit correct --claim`:", styled));
|
|
95
96
|
for (const claim of body.claims) {
|
|
96
|
-
writeLine(io.stdout, `${
|
|
97
|
+
writeLine(io.stdout, `${dim(` [${claim.claimId}]`, styled)} ${claim.text ?? ""}`);
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
if (body.versions) {
|
|
@@ -101,13 +102,13 @@ function writeHuman(io, command, body) {
|
|
|
101
102
|
if (body.versions.length === 0) {
|
|
102
103
|
// Never "there is one version". An empty list with a reason is an
|
|
103
104
|
// answer; an empty list without one is a wrong answer.
|
|
104
|
-
writeLine(io.stdout,
|
|
105
|
+
writeLine(io.stdout, dim(`Earlier versions could not be listed (${body.versionsReason ?? "no reason given"}).`, styled));
|
|
105
106
|
}
|
|
106
107
|
else {
|
|
107
|
-
writeLine(io.stdout,
|
|
108
|
+
writeLine(io.stdout, dim("Versions — pass one to `cockpit brief --version`:", styled));
|
|
108
109
|
for (const version of body.versions) {
|
|
109
110
|
const headline = version.headline ? ` — ${version.headline}` : "";
|
|
110
|
-
writeLine(io.stdout,
|
|
111
|
+
writeLine(io.stdout, dim(` ${version.version}/${version.of} ${version.pageId} ${version.asOf}${headline}`, styled));
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
114
|
}
|
|
@@ -118,7 +119,7 @@ function writeHuman(io, command, body) {
|
|
|
118
119
|
.filter(Boolean);
|
|
119
120
|
if (others.length > 0) {
|
|
120
121
|
writeLine(io.stdout, "");
|
|
121
|
-
writeLine(io.stdout,
|
|
122
|
+
writeLine(io.stdout, dim(`You can also open: ${others.join(", ")} (\`--for <name>\`)`, styled));
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
}
|
package/dist/commands/cli-io.js
CHANGED
|
@@ -13,6 +13,35 @@ export function writeRaw(stream, text) {
|
|
|
13
13
|
export function errorMessage(error) {
|
|
14
14
|
return error instanceof Error ? error.message : String(error);
|
|
15
15
|
}
|
|
16
|
+
// ------------------------------------------------------------------- styling
|
|
17
|
+
const SGR_DIM = "\x1b[2m";
|
|
18
|
+
const SGR_RESET = "\x1b[0m";
|
|
19
|
+
/**
|
|
20
|
+
* Whether this run may put colour escapes on stdout (BLI-3482). Five commands
|
|
21
|
+
* had their own copy of the same two escape constants and none of them asked
|
|
22
|
+
* this question, so `cockpit brief > page.txt` wrote `ESC[2m` into the file and
|
|
23
|
+
* `cockpit scout | grep` matched against bytes nobody typed.
|
|
24
|
+
*
|
|
25
|
+
* Two conditions, both from `io` rather than the process so a test can state
|
|
26
|
+
* them: stdout is a real terminal, and NO_COLOR is absent or empty
|
|
27
|
+
* (no-color.org — any non-empty value, whatever it says, means no colour).
|
|
28
|
+
* `defaultIo()` passes `process.env`, so in production this IS `process.env`.
|
|
29
|
+
*/
|
|
30
|
+
export function colorEnabled(io) {
|
|
31
|
+
if ((io.env["NO_COLOR"] ?? "") !== "")
|
|
32
|
+
return false;
|
|
33
|
+
return Boolean(io.stdout.isTTY);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The one dim-text helper. `enabled` is an explicit argument, never read from
|
|
37
|
+
* the process here, so the pure render modules stay deterministic and a caller
|
|
38
|
+
* cannot forget the gate without the compiler saying so.
|
|
39
|
+
*/
|
|
40
|
+
export function dim(text, enabled) {
|
|
41
|
+
if (!enabled || text === "")
|
|
42
|
+
return text;
|
|
43
|
+
return `${SGR_DIM}${text}${SGR_RESET}`;
|
|
44
|
+
}
|
|
16
45
|
export function writeExecOutput(io, result, options) {
|
|
17
46
|
if (options.stdout)
|
|
18
47
|
writeRaw(io.stdout, result.stdout);
|
package/dist/commands/correct.js
CHANGED
|
@@ -23,10 +23,8 @@
|
|
|
23
23
|
* is printed on stdout with an exit code of 0 — the correction WAS filed, with
|
|
24
24
|
* its outcome recorded. Only a failure to file at all is a non-zero exit.
|
|
25
25
|
*/
|
|
26
|
-
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
26
|
+
import { colorEnabled, dim, isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
27
27
|
import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
|
|
28
|
-
const DIM = "\x1b[2m";
|
|
29
|
-
const RESET = "\x1b[0m";
|
|
30
28
|
const REQUEST_DEADLINE_MS = 60_000;
|
|
31
29
|
const MAX_CORRECTION_LENGTH = 4000;
|
|
32
30
|
export async function runCorrect(command, io) {
|
|
@@ -101,11 +99,12 @@ export async function runCorrect(command, io) {
|
|
|
101
99
|
writeLine(io.stdout, JSON.stringify({ ok: true, ...filed }));
|
|
102
100
|
}
|
|
103
101
|
else {
|
|
102
|
+
const styled = colorEnabled(io);
|
|
104
103
|
writeLine(io.stdout, filed.reply ?? "Filed.");
|
|
105
104
|
if (filed.finding)
|
|
106
|
-
writeLine(io.stdout,
|
|
105
|
+
writeLine(io.stdout, dim(`The record says: ${filed.finding}`, styled));
|
|
107
106
|
if (filed.link)
|
|
108
|
-
writeLine(io.stdout,
|
|
107
|
+
writeLine(io.stdout, dim(filed.link, styled));
|
|
109
108
|
}
|
|
110
109
|
writeLine(io.stderr, `[correct cli] filed ${JSON.stringify({
|
|
111
110
|
tier: filed.tier ?? null,
|
|
@@ -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
|
+
}
|
package/dist/commands/jarvis.js
CHANGED
|
@@ -5,12 +5,10 @@
|
|
|
5
5
|
* existing paired device session, and every turn is executed by the dashboard
|
|
6
6
|
* through the same JARVIS runtime used by web chat and Slack.
|
|
7
7
|
*/
|
|
8
|
-
import { isInteractiveStdin, readLine, readPipedText, writeLine } from "./cli-io.js";
|
|
8
|
+
import { colorEnabled, dim, isInteractiveStdin, readLine, readPipedText, writeLine, } from "./cli-io.js";
|
|
9
9
|
import { attachedFileRefusalSentence, readAttachedImage, } from "./jarvis-attachment.js";
|
|
10
10
|
import { loadPairedSession, towerFailureDetail, towerJsonRequest, towerRequest, } from "../tower-client.js";
|
|
11
11
|
import { readTowerTurn, streamFailureDetail, } from "../tower-stream.js";
|
|
12
|
-
const TRACE_DIM = "\x1b[2m";
|
|
13
|
-
const TRACE_RESET = "\x1b[0m";
|
|
14
12
|
/**
|
|
15
13
|
* The dashboard route caps a turn at 120s (`maxDuration = 120`). The client
|
|
16
14
|
* waits slightly longer so the server's own named failure wins the race
|
|
@@ -108,14 +106,15 @@ function writeThreadList(io, threads) {
|
|
|
108
106
|
writeLine(io.stdout, "No terminal conversations yet. Ask something with `cockpit jarvis`.");
|
|
109
107
|
return;
|
|
110
108
|
}
|
|
109
|
+
const styled = colorEnabled(io);
|
|
111
110
|
for (const thread of threads) {
|
|
112
111
|
const turns = thread.turnCount === 1 ? "1 turn" : `${thread.turnCount ?? 0} turns`;
|
|
113
|
-
writeLine(io.stdout, `${thread.name} ${
|
|
112
|
+
writeLine(io.stdout, `${thread.name} ${dim(`${turns} · ${thread.lastAt ?? "unknown"}`, styled)}`);
|
|
114
113
|
if (thread.preview)
|
|
115
|
-
writeLine(io.stdout,
|
|
114
|
+
writeLine(io.stdout, dim(` ${thread.preview}`, styled));
|
|
116
115
|
}
|
|
117
116
|
writeLine(io.stdout, "");
|
|
118
|
-
writeLine(io.stdout,
|
|
117
|
+
writeLine(io.stdout, dim("Replay one with `cockpit jarvis --thread <name> --history`.", styled));
|
|
119
118
|
}
|
|
120
119
|
function writeThreadHistory(io, body) {
|
|
121
120
|
const messages = body.messages ?? [];
|
|
@@ -124,7 +123,7 @@ function writeThreadHistory(io, body) {
|
|
|
124
123
|
return;
|
|
125
124
|
}
|
|
126
125
|
if (body.truncated) {
|
|
127
|
-
writeLine(io.stdout,
|
|
126
|
+
writeLine(io.stdout, dim(`Showing the most recent ${messages.length}; there is more before this (\`--limit\`).`, colorEnabled(io)));
|
|
128
127
|
}
|
|
129
128
|
for (const message of messages) {
|
|
130
129
|
const speaker = message.role === "you" ? "you" : "jarvis";
|
|
@@ -294,7 +293,7 @@ function writeTraceBlock(io, trace) {
|
|
|
294
293
|
function writeTraceLine(io, step) {
|
|
295
294
|
const elapsed = typeof step.elapsedMs === "number" ? ` (${step.elapsedMs}ms)` : "";
|
|
296
295
|
const failure = step.status === "failed" ? ` — failed: ${step.detail ?? "no reason given"}` : "";
|
|
297
|
-
writeLine(io.stdout,
|
|
296
|
+
writeLine(io.stdout, dim(` ⏺ ${step.label}${elapsed}${failure}`, colorEnabled(io)));
|
|
298
297
|
}
|
|
299
298
|
/**
|
|
300
299
|
* Draws one streamed tool call, and says whether it drew anything.
|