@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
|
@@ -2,6 +2,7 @@ import { collectCarState } from "./car-state.js";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import { makeSourceScan, makeUnavailableScan, } from "./common.js";
|
|
4
4
|
import { collectGitState } from "./git-state.js";
|
|
5
|
+
import { describeError } from "../health-detail.js";
|
|
5
6
|
import { collectRawEvidencePack, } from "./raw-evidence.js";
|
|
6
7
|
import { generateDumbRiskFlags } from "./risk-flags.js";
|
|
7
8
|
import { resolveTicketBinding, } from "./ticket-binding.js";
|
|
@@ -50,9 +51,11 @@ export async function runLocalSourceCollectors(options) {
|
|
|
50
51
|
makeUnavailableScan(context, "claude_hooks", "claude-hooks", "claude_hooks_not_present"),
|
|
51
52
|
];
|
|
52
53
|
// claude_jsonl availability (B.4): "ok" when ~/.claude/projects exists, else
|
|
53
|
-
// skipped with
|
|
54
|
-
//
|
|
55
|
-
//
|
|
54
|
+
// skipped with the reason it could not be read — absent, permission-denied,
|
|
55
|
+
// not a directory, or unreadable (BLI-3483) — so an operator sees whether
|
|
56
|
+
// the empty claude funnel is normal or a wall they can take down. Collection
|
|
57
|
+
// itself is gated separately by collect_claude_jsonl in the sync
|
|
58
|
+
// orchestrator.
|
|
56
59
|
const claudeJsonlScan = await makeClaudeJsonlScan(context, options.claudeProjectsDir);
|
|
57
60
|
const candidates = collectBindingCandidates([
|
|
58
61
|
git.scan,
|
|
@@ -93,9 +96,52 @@ async function makeClaudeJsonlScan(context, projectsDir) {
|
|
|
93
96
|
if (!projectsDir) {
|
|
94
97
|
return makeUnavailableScan(context, "claude_jsonl", "claude-jsonl", "claude_projects_dir_not_configured");
|
|
95
98
|
}
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
// Until BLI-3483 every failure of this stat became `claude_projects_dir_
|
|
100
|
+
// not_found`, so a machine that HAS the folder and cannot open it — macOS
|
|
101
|
+
// TCC withholding Full Disk Access, a Windows ACL, a corporate MDM profile —
|
|
102
|
+
// reported the same word as a machine that has never run Claude Code. One
|
|
103
|
+
// repair is "nothing to do"; the other is "grant this app access, then every
|
|
104
|
+
// Claude session on this laptop starts collecting". Opposite meanings, one
|
|
105
|
+
// label, and the empty funnel looked normal either way.
|
|
106
|
+
const inspected = await fs.stat(projectsDir).then((stat) => ({ ok: true, isDirectory: stat.isDirectory() }), (error) => ({ ok: false, error }));
|
|
107
|
+
if (inspected.ok && inspected.isDirectory) {
|
|
108
|
+
return makeSourceScan(context, "claude_jsonl", "claude-jsonl", "ok", "claude_projects_dir_present");
|
|
109
|
+
}
|
|
110
|
+
const reason = inspected.ok
|
|
111
|
+
? "claude_projects_dir_not_a_directory"
|
|
112
|
+
: claudeProjectsDirFailureReason(inspected.error);
|
|
113
|
+
// Never the path: it is under the operator's home directory.
|
|
114
|
+
console.error("[local-sources] claude projects directory unavailable", JSON.stringify({
|
|
115
|
+
reason,
|
|
116
|
+
fix_hint: CLAUDE_PROJECTS_FIX_HINTS[reason],
|
|
117
|
+
...(inspected.ok ? {} : describeError(inspected.error)),
|
|
118
|
+
}));
|
|
119
|
+
return makeSourceScan(context, "claude_jsonl", "claude-jsonl", "skipped", reason);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* `ENOENT`/`ENOTDIR` is the ordinary "this machine does not run Claude Code".
|
|
123
|
+
* `EACCES`/`EPERM` is a permission wall a person can take down. Anything else
|
|
124
|
+
* (`ELOOP`, `ENAMETOOLONG`, an I/O error) is named as unreadable rather than
|
|
125
|
+
* folded into either, because guessing between them is what created this bug.
|
|
126
|
+
*/
|
|
127
|
+
function claudeProjectsDirFailureReason(error) {
|
|
128
|
+
const code = error && typeof error === "object"
|
|
129
|
+
? error.code
|
|
130
|
+
: undefined;
|
|
131
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
132
|
+
return "claude_projects_dir_not_found";
|
|
133
|
+
}
|
|
134
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
135
|
+
return "claude_projects_dir_permission_denied";
|
|
136
|
+
}
|
|
137
|
+
return "claude_projects_dir_unreadable";
|
|
98
138
|
}
|
|
139
|
+
const CLAUDE_PROJECTS_FIX_HINTS = {
|
|
140
|
+
claude_projects_dir_not_found: "nothing to repair unless this machine runs Claude Code",
|
|
141
|
+
claude_projects_dir_permission_denied: "macOS: grant the terminal/CLI Full Disk Access in System Settings > Privacy & Security; Windows: check the folder ACL on the user profile",
|
|
142
|
+
claude_projects_dir_not_a_directory: "a file sits where the projects directory should be; move or remove it",
|
|
143
|
+
claude_projects_dir_unreadable: "read the error_code beside this line; the folder exists but the stat failed",
|
|
144
|
+
};
|
|
99
145
|
function isGitStateFacts(value) {
|
|
100
146
|
return Boolean(value && typeof value === "object" && "repo_root" in value);
|
|
101
147
|
}
|
package/dist/autostart.js
CHANGED
|
@@ -3,6 +3,7 @@ import os from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths } from "./local-state.js";
|
|
5
5
|
import { savedDiscoveryLimitArgs } from "./discovery-limits.js";
|
|
6
|
+
import { redactedHealthDetail } from "./health-detail.js";
|
|
6
7
|
/** launchd LaunchAgent label; matches docs/runbooks/cockpit-launchd-sync.md. */
|
|
7
8
|
export const AUTOSTART_LABEL = "com.bli.cockpit.sync";
|
|
8
9
|
export const WINDOWS_AUTOSTART_TASK_NAME = "BLI Cockpit Sync";
|
|
@@ -100,6 +101,28 @@ export async function installAutostartAgent(options) {
|
|
|
100
101
|
await options.exec("launchctl", ["unload", plistPath]).catch(() => undefined);
|
|
101
102
|
const load = await options.exec("launchctl", ["load", plistPath]);
|
|
102
103
|
const loaded = load.code === 0;
|
|
104
|
+
// Both branches log, for the same reason the Windows path does: a line that
|
|
105
|
+
// only fires on failure cannot answer "did background collection get
|
|
106
|
+
// installed at all today?". Until BLI-3483 the macOS failure existed only in
|
|
107
|
+
// the returned `message`, which nothing on the sync path reads — a Mac could
|
|
108
|
+
// finish `cockpit onboard` with no scheduler and nothing in sync.err.log.
|
|
109
|
+
// Metadata only: launchctl's stderr can carry the state directory, so the
|
|
110
|
+
// exit code and the redacted first line travel, never the raw text.
|
|
111
|
+
if (loaded) {
|
|
112
|
+
console.error("[autostart] launchd agent loaded", JSON.stringify({
|
|
113
|
+
label: AUTOSTART_LABEL,
|
|
114
|
+
interval_seconds: intervalSeconds,
|
|
115
|
+
watch_path_count: watchPaths.length,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
console.error("[autostart] launchctl load failed; this Mac has no background collection", JSON.stringify({
|
|
120
|
+
reason: "autostart_load_failed",
|
|
121
|
+
label: AUTOSTART_LABEL,
|
|
122
|
+
exit_code: load.code,
|
|
123
|
+
detail: launchctlDetail(load),
|
|
124
|
+
}));
|
|
125
|
+
}
|
|
103
126
|
return {
|
|
104
127
|
status: "installed",
|
|
105
128
|
label: AUTOSTART_LABEL,
|
|
@@ -112,10 +135,27 @@ export async function installAutostartAgent(options) {
|
|
|
112
135
|
...(loaded
|
|
113
136
|
? {}
|
|
114
137
|
: {
|
|
115
|
-
message: `launchctl load exited ${load.code}: ${load
|
|
138
|
+
message: `launchctl load exited ${load.code}: ${launchctlDetail(load)}`,
|
|
116
139
|
}),
|
|
117
140
|
};
|
|
118
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* What `launchctl` actually said, or an honest account of it saying nothing.
|
|
144
|
+
*
|
|
145
|
+
* "unknown error" was the old fallback, and it is the least useful string in
|
|
146
|
+
* the file: `launchctl load` exits nonzero with EMPTY stderr for the ordinary
|
|
147
|
+
* cases — the agent is already loaded, or the plist was rejected — so the
|
|
148
|
+
* message an operator saw named neither the exit code's meaning nor the
|
|
149
|
+
* silence itself (BLI-3483). Exit 1 with no output is a fact; say so, and say
|
|
150
|
+
* what to do next. stdout is consulted too because launchctl is inconsistent
|
|
151
|
+
* about which stream it uses across macOS versions.
|
|
152
|
+
*/
|
|
153
|
+
function launchctlDetail(result) {
|
|
154
|
+
const spoken = result.stderr.trim() || result.stdout.trim();
|
|
155
|
+
if (spoken)
|
|
156
|
+
return redactedHealthDetail(spoken);
|
|
157
|
+
return `no output on either stream (exit ${result.code}); the agent may already be loaded, or launchd rejected the plist — check \`launchctl print gui/$(id -u)/${AUTOSTART_LABEL}\``;
|
|
158
|
+
}
|
|
119
159
|
/**
|
|
120
160
|
* Removes the LaunchAgent. Reports `absent` when there was nothing to remove so
|
|
121
161
|
* the command is safe to run repeatedly.
|
|
@@ -379,8 +419,9 @@ async function windowsTaskStatus(options) {
|
|
|
379
419
|
syncLogPath: windowsSyncLogPath(options.homeDir),
|
|
380
420
|
})}`;
|
|
381
421
|
const currentScript = await readFile(scriptPath, "utf8").catch(() => null);
|
|
382
|
-
|
|
383
|
-
|
|
422
|
+
const scriptDifference = renderedFileDifference(currentScript, expectedScript);
|
|
423
|
+
if (scriptDifference) {
|
|
424
|
+
registrationProblems.push(`sync script does not match the current roots or Tower runtime (${describeDifference(scriptDifference)})`);
|
|
384
425
|
}
|
|
385
426
|
}
|
|
386
427
|
// Same shape as the sync-script checks: existence always, content only when
|
|
@@ -398,8 +439,9 @@ async function windowsTaskStatus(options) {
|
|
|
398
439
|
scriptPath,
|
|
399
440
|
});
|
|
400
441
|
const currentLauncher = await readFile(launcherPath, "utf8").catch(() => null);
|
|
401
|
-
|
|
402
|
-
|
|
442
|
+
const launcherDifference = renderedFileDifference(currentLauncher, expectedLauncher);
|
|
443
|
+
if (launcherDifference) {
|
|
444
|
+
registrationProblems.push(`sync launcher does not match the current Tower runtime (${describeDifference(launcherDifference)})`);
|
|
403
445
|
}
|
|
404
446
|
}
|
|
405
447
|
// Both branches log. A line that only fires on failure cannot answer "did
|
|
@@ -431,6 +473,64 @@ async function windowsTaskStatus(options) {
|
|
|
431
473
|
: {}),
|
|
432
474
|
};
|
|
433
475
|
}
|
|
476
|
+
function renderedFileDifference(current, expected) {
|
|
477
|
+
const expectedLines = normalizedScriptLines(expected);
|
|
478
|
+
if (current === null) {
|
|
479
|
+
return {
|
|
480
|
+
reason: "file_unreadable",
|
|
481
|
+
first_differing_line: null,
|
|
482
|
+
current_line_count: null,
|
|
483
|
+
expected_line_count: expectedLines.length,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
if (hasByteOrderMark(current) !== hasByteOrderMark(expected)) {
|
|
487
|
+
return {
|
|
488
|
+
reason: "byte_order_mark",
|
|
489
|
+
first_differing_line: 0,
|
|
490
|
+
current_line_count: normalizedScriptLines(current).length,
|
|
491
|
+
expected_line_count: expectedLines.length,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
const currentLines = normalizedScriptLines(current);
|
|
495
|
+
const shared = Math.min(currentLines.length, expectedLines.length);
|
|
496
|
+
for (let index = 0; index < shared; index += 1) {
|
|
497
|
+
if (currentLines[index] !== expectedLines[index]) {
|
|
498
|
+
return {
|
|
499
|
+
reason: "line_content",
|
|
500
|
+
first_differing_line: index,
|
|
501
|
+
current_line_count: currentLines.length,
|
|
502
|
+
expected_line_count: expectedLines.length,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (currentLines.length !== expectedLines.length) {
|
|
507
|
+
return {
|
|
508
|
+
reason: "line_count",
|
|
509
|
+
first_differing_line: shared,
|
|
510
|
+
current_line_count: currentLines.length,
|
|
511
|
+
expected_line_count: expectedLines.length,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
/** Safe to print: a reason label, a line index, two counts. No content. */
|
|
517
|
+
function describeDifference(difference) {
|
|
518
|
+
if (difference.reason === "file_unreadable")
|
|
519
|
+
return "file could not be read";
|
|
520
|
+
if (difference.reason === "byte_order_mark")
|
|
521
|
+
return "byte-order mark differs";
|
|
522
|
+
return `${difference.reason} at line ${difference.first_differing_line}, ${difference.current_line_count} lines on disk vs ${difference.expected_line_count} expected`;
|
|
523
|
+
}
|
|
524
|
+
function normalizedScriptLines(value) {
|
|
525
|
+
const withoutBom = hasByteOrderMark(value) ? value.slice(UTF8_BOM.length) : value;
|
|
526
|
+
return withoutBom
|
|
527
|
+
.replace(/\r\n/gu, "\n")
|
|
528
|
+
.replace(/\s+$/u, "")
|
|
529
|
+
.split("\n");
|
|
530
|
+
}
|
|
531
|
+
function hasByteOrderMark(value) {
|
|
532
|
+
return value.startsWith(UTF8_BOM);
|
|
533
|
+
}
|
|
434
534
|
function windowsAutostartScriptPath(homeDir) {
|
|
435
535
|
return path.join(getCollectorRuntimePaths(homeDir ?? os.homedir()).state_dir, WINDOWS_AUTOSTART_SCRIPT_NAME);
|
|
436
536
|
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit brief edit` — rewrite the page like a document (BLI-3458, S2.7).
|
|
3
|
+
*
|
|
4
|
+
* The browser has had this since BLI-3120: click into a line, fix the words,
|
|
5
|
+
* press save, and the edit IS the correction — the exact wrong words, the exact
|
|
6
|
+
* right words, and optionally why, which is strictly more than any complaint
|
|
7
|
+
* could carry. This is the same door from a terminal.
|
|
8
|
+
*
|
|
9
|
+
* Four steps, and the order matters:
|
|
10
|
+
*
|
|
11
|
+
* 1. `GET /api/jarvis/brief?editable=1` — the page as a file, one
|
|
12
|
+
* `[claimId] the prose` line per rewritable claim. The server builds it
|
|
13
|
+
* from the SAME `indexClaims` the edit door diffs against, so an untouched
|
|
14
|
+
* line comes back byte-identical instead of being filed as an edit.
|
|
15
|
+
* 2. Open `$VISUAL`/`$EDITOR` on a temp copy (`editor.ts` owns that, and it is
|
|
16
|
+
* the one place in this package that spawns a program a PERSON chose). No
|
|
17
|
+
* editor, or nothing to open one on — piped input, a script, CI — and the
|
|
18
|
+
* edited document is read from stdin instead.
|
|
19
|
+
* 3. Diff HERE, not on the server. Only lines that really changed are sent, so
|
|
20
|
+
* an accidental save writes nothing and publishes no version.
|
|
21
|
+
* 4. `POST /api/jarvis/edit` — the same door the browser's save posts to.
|
|
22
|
+
*
|
|
23
|
+
* Two refusals happen before anything is sent, both by name:
|
|
24
|
+
*
|
|
25
|
+
* - **A claim id that vanished.** The document is a set of lines, not a text
|
|
26
|
+
* file: deleting one is not "delete that sentence", it is "I have no opinion
|
|
27
|
+
* about it", and the two are indistinguishable to a diff. Sending it as an
|
|
28
|
+
* emptied line would publish a page with a hole in it.
|
|
29
|
+
* - **A claim id that was invented.** Filing a correction against a clause
|
|
30
|
+
* nobody can find is how a ledger fills with rows that mean nothing.
|
|
31
|
+
*/
|
|
32
|
+
import { colorEnabled, dim, isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
33
|
+
import { editInEditor, resolveEditorCommand } from "./editor.js";
|
|
34
|
+
import { loadPairedSession, towerJsonRequest } from "../tower-client.js";
|
|
35
|
+
const REQUEST_DEADLINE_MS = 120_000;
|
|
36
|
+
/** A page is long; a document that arrives bigger than this is not one of ours. */
|
|
37
|
+
const MAX_DOCUMENT_CHARS = 400_000;
|
|
38
|
+
export async function runBriefEdit(command, io) {
|
|
39
|
+
const session = await loadPairedSession("brief edit", command.homeDir);
|
|
40
|
+
const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
|
|
41
|
+
const log = (line) => writeLine(io.stderr, line);
|
|
42
|
+
const startedAt = Date.now();
|
|
43
|
+
// ── 1. The page, as a document ────────────────────────────────────────────
|
|
44
|
+
const read = await towerJsonRequest({
|
|
45
|
+
dashboardUrl,
|
|
46
|
+
path: `/api/jarvis/brief${briefQuery(command)}`,
|
|
47
|
+
deviceToken: session.device_token,
|
|
48
|
+
fetch: io.fetch,
|
|
49
|
+
method: "GET",
|
|
50
|
+
label: "brief:edit",
|
|
51
|
+
timeoutMs: REQUEST_DEADLINE_MS,
|
|
52
|
+
log,
|
|
53
|
+
});
|
|
54
|
+
if (!read.ok)
|
|
55
|
+
return fail(command, io, read.reason, read.detail);
|
|
56
|
+
const brief = read.body;
|
|
57
|
+
const personId = brief.page?.personId;
|
|
58
|
+
const original = brief.editable?.document;
|
|
59
|
+
const lines = (brief.editable?.lines ?? []).filter((line) => typeof line.claimId === "string" && typeof line.text === "string");
|
|
60
|
+
if (!brief.ok || !personId || typeof original !== "string" || lines.length === 0) {
|
|
61
|
+
return fail(command, io, brief.error ?? "no_page", brief.reply ?? "There is no page to edit.");
|
|
62
|
+
}
|
|
63
|
+
// ── 2. Their editor, or their stdin ───────────────────────────────────────
|
|
64
|
+
const edited = await collectEditedDocument(io, original, log);
|
|
65
|
+
if (!edited.ok)
|
|
66
|
+
return fail(command, io, edited.reason, edited.detail);
|
|
67
|
+
// ── 3. The diff, here ─────────────────────────────────────────────────────
|
|
68
|
+
const diff = diffDocument(lines, edited.text);
|
|
69
|
+
if (diff.invented.length > 0) {
|
|
70
|
+
return fail(command, io, "invented_claim", `The page has no line called “${diff.invented[0]}”. Change the words after an id, never the id ` +
|
|
71
|
+
"itself — nothing has been sent to Tower.");
|
|
72
|
+
}
|
|
73
|
+
if (diff.deleted.length > 0) {
|
|
74
|
+
return fail(command, io, "deleted_claim", `The line [${diff.deleted[0]}] is missing from what you saved. Put it back — a line you delete ` +
|
|
75
|
+
"reads as no opinion at all, not as a sentence to remove, and Tower cannot tell those apart. " +
|
|
76
|
+
"Nothing has been sent.");
|
|
77
|
+
}
|
|
78
|
+
if (diff.edits.length === 0) {
|
|
79
|
+
const styled = colorEnabled(io);
|
|
80
|
+
if (command.json) {
|
|
81
|
+
writeLine(io.stdout, JSON.stringify({ ok: true, saved: 0, reason: "nothing_changed" }));
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
writeLine(io.stdout, dim("Nothing changed, so nothing was written.", styled));
|
|
85
|
+
}
|
|
86
|
+
writeLine(io.stderr, `[brief edit] nothing changed ${JSON.stringify({
|
|
87
|
+
lines: lines.length,
|
|
88
|
+
elapsed_ms: Date.now() - startedAt,
|
|
89
|
+
})}`);
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
// ── 4. The same door the browser's save posts to ──────────────────────────
|
|
93
|
+
const saved = await towerJsonRequest({
|
|
94
|
+
dashboardUrl,
|
|
95
|
+
path: "/api/jarvis/edit",
|
|
96
|
+
deviceToken: session.device_token,
|
|
97
|
+
fetch: io.fetch,
|
|
98
|
+
label: "brief:edit:save",
|
|
99
|
+
timeoutMs: REQUEST_DEADLINE_MS,
|
|
100
|
+
log,
|
|
101
|
+
body: {
|
|
102
|
+
personId,
|
|
103
|
+
pageId: brief.page?.pageId ?? null,
|
|
104
|
+
edits: diff.edits,
|
|
105
|
+
...(command.reason ? { reason: command.reason } : {}),
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
if (!saved.ok)
|
|
109
|
+
return fail(command, io, saved.reason, saved.detail);
|
|
110
|
+
const answer = saved.body;
|
|
111
|
+
writeOutcome(command, io, answer, diff.edits.length);
|
|
112
|
+
writeLine(io.stderr, `[brief edit] saved ${JSON.stringify({
|
|
113
|
+
sent: diff.edits.length,
|
|
114
|
+
saved: answer.saved ?? null,
|
|
115
|
+
versions: answer.versions ?? null,
|
|
116
|
+
skipped: answer.skipped?.length ?? 0,
|
|
117
|
+
overruled: answer.overruled?.length ?? 0,
|
|
118
|
+
reason: answer.reason ?? null,
|
|
119
|
+
had_reason: command.reason != null,
|
|
120
|
+
subject: command.subject ? "selected" : "caller",
|
|
121
|
+
elapsed_ms: Date.now() - startedAt,
|
|
122
|
+
})}`);
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
function briefQuery(command) {
|
|
126
|
+
const params = new URLSearchParams({ editable: "1" });
|
|
127
|
+
if (command.subject)
|
|
128
|
+
params.set("p", command.subject);
|
|
129
|
+
if (command.version)
|
|
130
|
+
params.set("v", command.version);
|
|
131
|
+
return `?${params.toString()}`;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The edited document.
|
|
135
|
+
*
|
|
136
|
+
* Piped stdin wins, and deliberately: somebody who prepared a file has already
|
|
137
|
+
* done the editing, and opening an editor on top of it would be the command
|
|
138
|
+
* ignoring what it was handed. Otherwise the editor opens — and when there is
|
|
139
|
+
* neither, the refusal names both ways out rather than guessing `vi` at
|
|
140
|
+
* somebody who has never used it.
|
|
141
|
+
*/
|
|
142
|
+
async function collectEditedDocument(io, original, log) {
|
|
143
|
+
if (!isInteractiveStdin(io)) {
|
|
144
|
+
let piped;
|
|
145
|
+
try {
|
|
146
|
+
piped = await readPipedText(io.stdin, {
|
|
147
|
+
maxChars: MAX_DOCUMENT_CHARS,
|
|
148
|
+
overflowMessage: `An edited page is limited to ${MAX_DOCUMENT_CHARS} characters.`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
reason: "stdin_unreadable",
|
|
155
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (piped.trim() === "") {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
reason: "nothing_on_stdin",
|
|
162
|
+
detail: "Nothing arrived on stdin. Run `cockpit brief edit` in a terminal to open your editor, " +
|
|
163
|
+
"or pipe the edited document in: `cockpit brief --editable > page.md; … ; " +
|
|
164
|
+
"cockpit brief edit < page.md`.",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
log(`[brief edit] read from stdin ${JSON.stringify({ chars: piped.length })}`);
|
|
168
|
+
return { ok: true, text: piped, via: "stdin" };
|
|
169
|
+
}
|
|
170
|
+
if (!resolveEditorCommand(io.env)) {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
reason: "no_editor_configured",
|
|
174
|
+
detail: "No editor is configured. Set EDITOR (or VISUAL) — `export EDITOR=nano` — or pipe the " +
|
|
175
|
+
"edited document in on stdin instead.",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const session = await editInEditor({
|
|
179
|
+
contents: original,
|
|
180
|
+
suffix: ".md",
|
|
181
|
+
env: io.env,
|
|
182
|
+
log,
|
|
183
|
+
});
|
|
184
|
+
if (!session.ok)
|
|
185
|
+
return { ok: false, reason: session.reason, detail: session.detail };
|
|
186
|
+
return { ok: true, text: session.text, via: "editor" };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* What actually changed, decided here rather than on the server.
|
|
190
|
+
*
|
|
191
|
+
* The parse mirrors `apps/dashboard/src/lib/jarvis/serialize/editable-document.ts`,
|
|
192
|
+
* which produced the document: `#` is a comment, a `[id]` starts a line, a
|
|
193
|
+
* following un-prefixed line is that line wrapped rather than a new one. It only
|
|
194
|
+
* ever has to read what the server wrote, and the `unchanged` case proves it
|
|
195
|
+
* did — a parser that got this wrong would report every line as an edit, which
|
|
196
|
+
* is exactly the failure `editable-document.test.ts` holds the server side to.
|
|
197
|
+
*/
|
|
198
|
+
export function diffDocument(original, edited) {
|
|
199
|
+
const before = new Map(original.filter((line) => line.rewritable).map((line) => [line.claimId, normalize(line.text)]));
|
|
200
|
+
const seen = new Set();
|
|
201
|
+
const diff = { edits: [], invented: [], deleted: [] };
|
|
202
|
+
for (const parsed of parseDocument(edited)) {
|
|
203
|
+
if (seen.has(parsed.claimId))
|
|
204
|
+
continue;
|
|
205
|
+
seen.add(parsed.claimId);
|
|
206
|
+
const stored = before.get(parsed.claimId);
|
|
207
|
+
if (stored === undefined) {
|
|
208
|
+
diff.invented.push(parsed.claimId);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const after = normalize(parsed.text);
|
|
212
|
+
if (after !== stored)
|
|
213
|
+
diff.edits.push({ claimId: parsed.claimId, after });
|
|
214
|
+
}
|
|
215
|
+
for (const claimId of before.keys()) {
|
|
216
|
+
if (!seen.has(claimId))
|
|
217
|
+
diff.deleted.push(claimId);
|
|
218
|
+
}
|
|
219
|
+
return diff;
|
|
220
|
+
}
|
|
221
|
+
/** The same collapse `normalizeEditedText` does server-side, so the two agree. */
|
|
222
|
+
function normalize(text) {
|
|
223
|
+
return text.replace(/ /g, " ").replace(/\s+/g, " ").trim();
|
|
224
|
+
}
|
|
225
|
+
const CLAIM_LINE = /^\[([^\]\s]+)\]\s?(.*)$/;
|
|
226
|
+
export function parseDocument(text) {
|
|
227
|
+
const out = [];
|
|
228
|
+
let current = null;
|
|
229
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
230
|
+
const line = raw.trim();
|
|
231
|
+
if (line.startsWith("#")) {
|
|
232
|
+
current = null;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const match = CLAIM_LINE.exec(line);
|
|
236
|
+
if (match) {
|
|
237
|
+
current = { claimId: match[1], text: (match[2] ?? "").trim() };
|
|
238
|
+
out.push(current);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (line === "") {
|
|
242
|
+
current = null;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (current)
|
|
246
|
+
current.text = `${current.text} ${line}`.trim();
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
function writeOutcome(command, io, answer, sent) {
|
|
251
|
+
if (command.json) {
|
|
252
|
+
writeLine(io.stdout, JSON.stringify({ ok: true, sent, ...answer }));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const styled = colorEnabled(io);
|
|
256
|
+
const saved = answer.saved ?? 0;
|
|
257
|
+
const versions = answer.versions;
|
|
258
|
+
writeLine(io.stdout, saved === 1
|
|
259
|
+
? "One line rewritten, and Tower has it."
|
|
260
|
+
: `${saved} lines rewritten, and Tower has them.`);
|
|
261
|
+
if (typeof versions === "number") {
|
|
262
|
+
writeLine(io.stdout, dim(`Your page now has ${versions} versions.`, styled));
|
|
263
|
+
}
|
|
264
|
+
else if (answer.reason) {
|
|
265
|
+
// The edit is on the record and the page did not move. Never silent: the
|
|
266
|
+
// server named which of the four reasons it was and it goes straight out.
|
|
267
|
+
writeLine(io.stdout, dim(`The page itself was not republished (${answer.reason}); your words are on the record.`, styled));
|
|
268
|
+
}
|
|
269
|
+
for (const skip of answer.skipped ?? []) {
|
|
270
|
+
writeLine(io.stdout, dim(`Not applied: [${skip.claimId ?? "?"}] — ${skip.reason ?? "no reason given"}.`, styled));
|
|
271
|
+
}
|
|
272
|
+
// The push-back is the point, not an error path: the record disagreed with
|
|
273
|
+
// what they typed, their words were kept anyway, and the disagreement is said
|
|
274
|
+
// out loud rather than quietly stored.
|
|
275
|
+
for (const overruled of answer.overruled ?? []) {
|
|
276
|
+
if (overruled.pushBack)
|
|
277
|
+
writeLine(io.stdout, overruled.pushBack);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function fail(command, io, reason, detail) {
|
|
281
|
+
if (command.json) {
|
|
282
|
+
writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
writeLine(io.stderr, detail);
|
|
286
|
+
}
|
|
287
|
+
writeLine(io.stderr, `[brief edit] not saved ${JSON.stringify({ reason })}`);
|
|
288
|
+
return 1;
|
|
289
|
+
}
|