@bli-cockpit/cli 0.2.39 → 0.2.41
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.js +11 -10
- package/dist/commands/cli-io.js +62 -1
- package/dist/commands/correct.js +5 -17
- package/dist/commands/jarvis.js +8 -20
- package/dist/commands/local-discovery.js +20 -0
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/local.js +34 -1
- package/dist/commands/notes-file.js +27 -0
- package/dist/commands/notes.js +24 -19
- 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 +9 -2
- package/dist/commands/text-width.js +108 -0
- package/dist/commands/tower-command.js +7 -8
- 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
|
}
|
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);
|
|
@@ -20,7 +49,39 @@ export function writeExecOutput(io, result, options) {
|
|
|
20
49
|
writeRaw(io.stderr, result.stderr);
|
|
21
50
|
}
|
|
22
51
|
export function isInteractiveStdin(io) {
|
|
23
|
-
|
|
52
|
+
const isTTY = io.stdin.isTTY;
|
|
53
|
+
// Git Bash (mintty/MSYS) gives Node a pipe for an interactive terminal, so
|
|
54
|
+
// `isTTY` is undefined and the piped branch would wait forever on an EOF
|
|
55
|
+
// that never comes (BLI-3480). MSYSTEM is set only inside MSYS shells, so
|
|
56
|
+
// launchd / Task Scheduler / spawned runs keep the non-interactive branch.
|
|
57
|
+
if (isTTY === undefined && process.env["MSYSTEM"])
|
|
58
|
+
return true;
|
|
59
|
+
return Boolean(isTTY);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Drops one leading U+FEFF. PowerShell 5.1 redirection and Notepad both write
|
|
63
|
+
* byte-order marks; stored verbatim, a BOM makes the first line of an env blob
|
|
64
|
+
* unreachable and pollutes pasted notes (BLI-3480). One strip, never more —
|
|
65
|
+
* a BOM anywhere else is content.
|
|
66
|
+
*/
|
|
67
|
+
export function stripLeadingBom(text) {
|
|
68
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Reads piped stdin whole as UTF-8, BOM-stripped. The one implementation
|
|
72
|
+
* behind every stdin-fed command (jarvis, correct, notes paste, settings env
|
|
73
|
+
* set) — each caller keeps its own size cap via `maxChars`.
|
|
74
|
+
*/
|
|
75
|
+
export async function readPipedText(stream, options) {
|
|
76
|
+
stream.setEncoding("utf8");
|
|
77
|
+
let text = "";
|
|
78
|
+
for await (const chunk of stream) {
|
|
79
|
+
text += chunk;
|
|
80
|
+
if (options?.maxChars !== undefined && text.length > options.maxChars) {
|
|
81
|
+
throw new Error(options.overflowMessage ?? `Piped input is limited to ${options.maxChars} characters.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return stripLeadingBom(text);
|
|
24
85
|
}
|
|
25
86
|
/**
|
|
26
87
|
* Reads one line from stdin after writing a prompt. Shared by the onboard email
|
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, 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,
|
|
@@ -134,21 +133,10 @@ async function resolveText(command, io) {
|
|
|
134
133
|
return command.text;
|
|
135
134
|
if (isInteractiveStdin(io))
|
|
136
135
|
return null;
|
|
137
|
-
const piped = await
|
|
136
|
+
const piped = await readPipedText(io.stdin, { maxChars: MAX_CORRECTION_LENGTH, overflowMessage: `A correction is limited to ${MAX_CORRECTION_LENGTH} characters.` });
|
|
138
137
|
const trimmed = piped.trim();
|
|
139
138
|
return trimmed.length > 0 ? trimmed : null;
|
|
140
139
|
}
|
|
141
|
-
async function readAll(stream) {
|
|
142
|
-
stream.setEncoding("utf8");
|
|
143
|
-
let text = "";
|
|
144
|
-
for await (const chunk of stream) {
|
|
145
|
-
text += chunk;
|
|
146
|
-
if (text.length > MAX_CORRECTION_LENGTH) {
|
|
147
|
-
throw new Error(`A correction is limited to ${MAX_CORRECTION_LENGTH} characters.`);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
return text;
|
|
151
|
-
}
|
|
152
140
|
function writeFailure(command, io, reason, detail) {
|
|
153
141
|
if (command.json) {
|
|
154
142
|
writeLine(io.stdout, JSON.stringify({ ok: false, error: reason, detail }));
|
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, 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";
|
|
@@ -136,7 +135,7 @@ async function resolveOneShotPrompt(command, io) {
|
|
|
136
135
|
return validatePrompt(command.prompt);
|
|
137
136
|
if (isInteractiveStdin(io))
|
|
138
137
|
return null;
|
|
139
|
-
const piped = await
|
|
138
|
+
const piped = await readPipedText(io.stdin, { maxChars: 4000, overflowMessage: "JARVIS questions are limited to 4000 characters." });
|
|
140
139
|
return validatePrompt(piped);
|
|
141
140
|
}
|
|
142
141
|
async function sendOneTurn(context, prompt, io) {
|
|
@@ -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.
|
|
@@ -368,15 +367,4 @@ function validatePrompt(raw) {
|
|
|
368
367
|
throw new Error("JARVIS questions are limited to 4000 characters.");
|
|
369
368
|
}
|
|
370
369
|
return prompt;
|
|
371
|
-
}
|
|
372
|
-
async function readAll(stream) {
|
|
373
|
-
stream.setEncoding("utf8");
|
|
374
|
-
let text = "";
|
|
375
|
-
for await (const chunk of stream) {
|
|
376
|
-
text += chunk;
|
|
377
|
-
if (text.length > 4000) {
|
|
378
|
-
throw new Error("JARVIS questions are limited to 4000 characters.");
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
return text;
|
|
382
370
|
}
|
|
@@ -38,6 +38,26 @@ export async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
|
|
|
38
38
|
throw new Error(message);
|
|
39
39
|
}
|
|
40
40
|
if (worktrees.length === 0 && !discovery.allowEmpty) {
|
|
41
|
+
// "No git repos found" is only true when the scan could actually SEE
|
|
42
|
+
// everywhere it looked. With unreadable folders in hand, the sentence sent
|
|
43
|
+
// the operator to the wrong repair — "run from a git repo", when the repos
|
|
44
|
+
// may be sitting right there behind a permission wall (BLI-3483). The
|
|
45
|
+
// warning above prints the folders; this refusal has to carry the fact,
|
|
46
|
+
// because it is the line that ends the command.
|
|
47
|
+
if (result.unreadable_dirs.length > 0) {
|
|
48
|
+
console.error("[local-discovery] scan found no repos while folders were unopenable", JSON.stringify({
|
|
49
|
+
reason: "empty_scan_with_unreadable_dirs",
|
|
50
|
+
unreadable_dir_count: result.unreadable_dirs.length,
|
|
51
|
+
codes: [...new Set(result.unreadable_dirs.map((dir) => dir.code))],
|
|
52
|
+
}));
|
|
53
|
+
throw new Error([
|
|
54
|
+
`No git repos found, and ${result.unreadable_dirs.length} folder(s) could not be opened — so this is not proof there are none.`,
|
|
55
|
+
...(io
|
|
56
|
+
? ["The folders are listed in the warning above."]
|
|
57
|
+
: ["Run `cockpit doctor` to see which folders."]),
|
|
58
|
+
"Fix the permissions on those folders and run the command again, or point --workspace at the project folders you can read.",
|
|
59
|
+
].join("\n"));
|
|
60
|
+
}
|
|
41
61
|
throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
|
|
42
62
|
}
|
|
43
63
|
return worktrees;
|
|
@@ -348,7 +348,7 @@ function localSubcommandHelp(command) {
|
|
|
348
348
|
"shelf [--limit <n>] — the notes YOU have put in, and what became of each.",
|
|
349
349
|
"shelves — the shelves in use, with how many notes are on each.",
|
|
350
350
|
"upload <paths...> [--exclude \"<sentence>\"] — put one or more local files in. Explicit paths only; your shell does any globbing, and a file that does not exist is named rather than skipped.",
|
|
351
|
-
"paste [--file <path>] [--name <n>] [--exclude \"<sentence>\"] — put text in
|
|
351
|
+
"paste [--file <path>] [--name <n>] [--exclude \"<sentence>\"] — put text in. --file <path> is safest (any editor encoding is decoded). Stdin works too: `pbpaste | cockpit notes paste` on macOS; on Windows use PowerShell 7 (`Get-Clipboard | cockpit notes paste`) — PowerShell 5.1 turns non-ASCII into `?` on pipes.",
|
|
352
352
|
"--exclude carries your own sentence about what to leave out, exactly as the browser's box does.",
|
|
353
353
|
"share <id> [--yes] — let everyone signed in read the statements that are safe to share. Asks first in a terminal; --yes is required without one, and --json implies --yes.",
|
|
354
354
|
"unshare <id> — take it back. Never asks: it only ever narrows who can read.",
|
package/dist/commands/local.js
CHANGED
|
@@ -155,6 +155,17 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
catch (error) {
|
|
158
|
+
// The outermost net under every subcommand. It printed `error.message` and
|
|
159
|
+
// dropped the name, the code, the syscall and the cause — and never said
|
|
160
|
+
// WHICH subcommand died, so a scheduled `cockpit sync` and a hand-typed
|
|
161
|
+
// `cockpit doctor` failing the same way were indistinguishable in
|
|
162
|
+
// sync.err.log (BLI-3483). The human line is unchanged; the structured one
|
|
163
|
+
// beside it carries names and codes only, scrubbed by `describeError`.
|
|
164
|
+
console.error("[cockpit-cli] command failed", JSON.stringify({
|
|
165
|
+
reason: "command_failed",
|
|
166
|
+
command: command.kind,
|
|
167
|
+
...describeError(error),
|
|
168
|
+
}));
|
|
158
169
|
writeLine(io.stderr, errorMessage(error));
|
|
159
170
|
return 1;
|
|
160
171
|
}
|
|
@@ -1115,8 +1126,26 @@ async function runAutostartSelfHealAfterSync(command, io, dashboardUrl) {
|
|
|
1115
1126
|
let result;
|
|
1116
1127
|
try {
|
|
1117
1128
|
const rawExec = io.exec;
|
|
1118
|
-
if (!rawExec)
|
|
1129
|
+
if (!rawExec) {
|
|
1130
|
+
// BLI-3483: this was a bare `return`. On Windows the self-heal is the
|
|
1131
|
+
// only thing that puts a broken scheduler back, so abandoning it here
|
|
1132
|
+
// meant a machine could stop collecting forever and leave no receipt
|
|
1133
|
+
// anywhere — the exact shape the fleet contract forbids. The packed CLI
|
|
1134
|
+
// always supplies a runner (`commands/cli-io.ts`), so this fires only for
|
|
1135
|
+
// an embedder that built its own `io`; it costs one line either way.
|
|
1136
|
+
console.error("[autostart-self-heal] no process runner on this io; the repair could not be attempted", JSON.stringify({
|
|
1137
|
+
reason: "runner_unavailable",
|
|
1138
|
+
platform: process.platform,
|
|
1139
|
+
next_action: "reinstall the CLI (npm i -g @bli-cockpit/cli) and run `cockpit autostart install`",
|
|
1140
|
+
}));
|
|
1141
|
+
result = {
|
|
1142
|
+
status: "skipped",
|
|
1143
|
+
reason: "runner_unavailable",
|
|
1144
|
+
detail: "No process runner available to this CLI invocation; run `cockpit autostart install` by hand.",
|
|
1145
|
+
};
|
|
1146
|
+
await reportAutostartSelfHealOutcome(command, io, dashboardUrl, result);
|
|
1119
1147
|
return;
|
|
1148
|
+
}
|
|
1120
1149
|
const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
|
|
1121
1150
|
const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
|
|
1122
1151
|
result = await runAutostartSelfHeal(getCollectorRuntimePaths(command.homeDir), {
|
|
@@ -1137,6 +1166,10 @@ async function runAutostartSelfHealAfterSync(command, io, dashboardUrl) {
|
|
|
1137
1166
|
// throttle are silent; an actual repair attempt reports either way.
|
|
1138
1167
|
if (!result || result.reason === "repair_throttled_recent_attempt")
|
|
1139
1168
|
return;
|
|
1169
|
+
await reportAutostartSelfHealOutcome(command, io, dashboardUrl, result);
|
|
1170
|
+
}
|
|
1171
|
+
/** One receipt for the repair, whichever branch above produced the outcome. */
|
|
1172
|
+
async function reportAutostartSelfHealOutcome(command, io, dashboardUrl, result) {
|
|
1140
1173
|
await reportInstallEventsBestEffort({
|
|
1141
1174
|
homeDir: command.homeDir,
|
|
1142
1175
|
dashboardUrl,
|
|
@@ -99,4 +99,31 @@ export async function readNoteFile(filePath) {
|
|
|
99
99
|
return { ok: false, refusal: "file_unreadable", detail: errorMessage(error) };
|
|
100
100
|
}
|
|
101
101
|
return { ok: true, bytes, fileName, extension: extension || "none" };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Decodes note/env bytes to text the way an editor would, not the way UTF-8
|
|
105
|
+
* hopes (BLI-3480). PowerShell 5.1 redirection writes UTF-16LE with a BOM and
|
|
106
|
+
* Notepad's "Unicode" save does the same; decoded blindly as UTF-8 that text
|
|
107
|
+
* becomes NUL-interleaved mojibake that passes an emptiness check and stores
|
|
108
|
+
* silently. The BOM decides the codec; a BOM-less file that still decodes to
|
|
109
|
+
* NULs is refused by name rather than stored as garbage.
|
|
110
|
+
*/
|
|
111
|
+
export function decodeTextBytes(bytes) {
|
|
112
|
+
let text;
|
|
113
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {
|
|
114
|
+
text = bytes.subarray(2).toString("utf16le");
|
|
115
|
+
}
|
|
116
|
+
else if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {
|
|
117
|
+
text = new TextDecoder("utf-16be").decode(bytes.subarray(2));
|
|
118
|
+
}
|
|
119
|
+
else if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
|
|
120
|
+
text = bytes.subarray(3).toString("utf8");
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
text = bytes.toString("utf8");
|
|
124
|
+
}
|
|
125
|
+
if (text.includes("\u0000")) {
|
|
126
|
+
return { ok: false, reason: "undecodable_text_encoding" };
|
|
127
|
+
}
|
|
128
|
+
return { ok: true, text };
|
|
102
129
|
}
|