@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.
@@ -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
+ }
@@ -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, `${DIM}${whose}${pinned}${RESET}`);
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, `${DIM}Claim ids — pass one to \`cockpit correct --claim\`:${RESET}`);
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, `${DIM} [${claim.claimId}]${RESET} ${claim.text ?? ""}`);
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, `${DIM}Earlier versions could not be listed (${body.versionsReason ?? "no reason given"}).${RESET}`);
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, `${DIM}Versions — pass one to \`cockpit brief --version\`:${RESET}`);
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, `${DIM} ${version.version}/${version.of} ${version.pageId} ${version.asOf}${headline}${RESET}`);
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, `${DIM}You can also open: ${others.join(", ")} (\`--for <name>\`)${RESET}`);
122
+ writeLine(io.stdout, dim(`You can also open: ${others.join(", ")} (\`--for <name>\`)`, styled));
122
123
  }
123
124
  }
124
125
  }
@@ -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);
@@ -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, `${DIM}The record says: ${filed.finding}${RESET}`);
105
+ writeLine(io.stdout, dim(`The record says: ${filed.finding}`, styled));
107
106
  if (filed.link)
108
- writeLine(io.stdout, `${DIM}${filed.link}${RESET}`);
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
+ }
@@ -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} ${TRACE_DIM}${turns} · ${thread.lastAt ?? "unknown"}${TRACE_RESET}`);
112
+ writeLine(io.stdout, `${thread.name} ${dim(`${turns} · ${thread.lastAt ?? "unknown"}`, styled)}`);
114
113
  if (thread.preview)
115
- writeLine(io.stdout, `${TRACE_DIM} ${thread.preview}${TRACE_RESET}`);
114
+ writeLine(io.stdout, dim(` ${thread.preview}`, styled));
116
115
  }
117
116
  writeLine(io.stdout, "");
118
- writeLine(io.stdout, `${TRACE_DIM}Replay one with \`cockpit jarvis --thread <name> --history\`.${TRACE_RESET}`);
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, `${TRACE_DIM}Showing the most recent ${messages.length}; there is more before this (\`--limit\`).${TRACE_RESET}`);
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, `${TRACE_DIM} ⏺ ${step.label}${elapsed}${failure}${TRACE_RESET}`);
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.