@bli-cockpit/cli 0.2.167 → 0.2.170
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/autostart-opt-out.js +43 -0
- package/dist/autostart-self-heal.js +23 -10
- package/dist/commands/autostart-command.js +10 -0
- package/dist/commands/autostart-heal.js +6 -4
- package/dist/commands/doctor-access.js +17 -21
- package/dist/commands/doctor-mcp.js +27 -0
- package/dist/commands/doctor-registration.js +9 -3
- package/dist/commands/doctor-report.js +28 -0
- package/dist/commands/doctor.js +20 -5
- package/dist/commands/heartbeat-token.js +14 -0
- package/dist/commands/heartbeat.js +2 -1
- package/dist/commands/local-args-tower-mail.js +30 -3
- package/dist/commands/local-help-commands-tower.js +3 -2
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/mail-attachments.js +207 -0
- package/dist/commands/mail-compose.js +88 -4
- package/dist/commands/mail.js +13 -3
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/sync-heal.js +144 -0
- package/dist/commands/sync.js +9 -1
- package/dist/sign-in-action.js +7 -0
- package/dist/sync-tick-stamp.js +22 -0
- package/package.json +4 -4
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { AUTOSTART_OPT_OUT_MARKER_FILE_NAME } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* BLI-12828. The tick now CREATES a missing scheduler registration (an absent
|
|
6
|
+
* LaunchAgent / Scheduled Task is how a Mac goes quiet after a reboot), so
|
|
7
|
+
* "absent" can no longer double as "the operator removed it on purpose". This
|
|
8
|
+
* marker is that deliberate act: `cockpit autostart uninstall` writes it,
|
|
9
|
+
* `cockpit autostart install` and `cockpit doctor` clear it, and the tick's
|
|
10
|
+
* create-when-absent repair refuses while it stands.
|
|
11
|
+
*
|
|
12
|
+
* One small file under the state dir; it holds a timestamp, never a path.
|
|
13
|
+
*/
|
|
14
|
+
export const AUTOSTART_OPT_OUT_MARKER = AUTOSTART_OPT_OUT_MARKER_FILE_NAME;
|
|
15
|
+
function markerPath(paths) {
|
|
16
|
+
return path.join(paths.state_dir, AUTOSTART_OPT_OUT_MARKER);
|
|
17
|
+
}
|
|
18
|
+
export async function isAutostartOptedOut(paths) {
|
|
19
|
+
return fs
|
|
20
|
+
.stat(markerPath(paths))
|
|
21
|
+
.then(() => true)
|
|
22
|
+
.catch(() => false);
|
|
23
|
+
}
|
|
24
|
+
export async function markAutostartOptedOut(paths, now = new Date()) {
|
|
25
|
+
try {
|
|
26
|
+
await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
|
|
27
|
+
await fs.writeFile(markerPath(paths), now.toISOString());
|
|
28
|
+
console.error("[autostart] opt-out recorded; the sync tick will not re-create the scheduler until `cockpit autostart install`", JSON.stringify({ reason: "operator_uninstalled" }));
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
console.error("[autostart] opt-out could not be recorded; the tick may re-create the scheduler", JSON.stringify({
|
|
32
|
+
reason: "opt_out_write_failed",
|
|
33
|
+
error_name: error instanceof Error ? error.name : typeof error,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function clearAutostartOptOut(paths) {
|
|
38
|
+
const had = await isAutostartOptedOut(paths);
|
|
39
|
+
await fs.rm(markerPath(paths), { force: true }).catch(() => undefined);
|
|
40
|
+
if (had) {
|
|
41
|
+
console.error("[autostart] opt-out cleared; background sync is wanted again", JSON.stringify({ reason: "operator_installed" }));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -3,8 +3,12 @@ import path from "node:path";
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { autostartStatus, installAutostartAgent, registeredRuntimePathProblems, } from "./autostart.js";
|
|
5
5
|
import { detectSecondCockpitInstall, secondInstallReason, } from "./second-install.js";
|
|
6
|
+
import { isAutostartOptedOut } from "./autostart-opt-out.js";
|
|
6
7
|
export const AUTOSTART_REPAIR_THROTTLE_MARKER = ".last-autostart-repair";
|
|
7
|
-
|
|
8
|
+
// BLI-12828: the FIRST repair of a break never waits. The marker only spaces
|
|
9
|
+
// out RETRIES of a repair that keeps failing, and a healthy reading clears it,
|
|
10
|
+
// so the next break is repaired on the next tick, not a day later.
|
|
11
|
+
const AUTOSTART_REPAIR_RETRY_AFTER_FAILURE_MS = 60 * 60 * 1000;
|
|
8
12
|
const DETAIL_MAX_CHARS = 300;
|
|
9
13
|
export async function runAutostartSelfHeal(paths, options) {
|
|
10
14
|
const platform = options.platform ?? process.platform;
|
|
@@ -20,9 +24,12 @@ export async function runAutostartSelfHeal(paths, options) {
|
|
|
20
24
|
exec: options.exec,
|
|
21
25
|
platform,
|
|
22
26
|
});
|
|
23
|
-
|
|
24
|
-
if (
|
|
27
|
+
const absent = status.status === "absent";
|
|
28
|
+
if (absent && (await isAutostartOptedOut(paths))) {
|
|
29
|
+
// Said on the tick's own log so "why is this Mac not ticking" is answerable.
|
|
30
|
+
console.error("[autostart-self-heal] scheduler absent; not re-creating it, the operator uninstalled it on purpose", JSON.stringify({ reason: "autostart_uninstalled_by_operator", platform }));
|
|
25
31
|
return null;
|
|
32
|
+
}
|
|
26
33
|
// A registration can read as "loaded" and still name a binary that no longer
|
|
27
34
|
// exists — that is precisely what `brew upgrade node` leaves behind. Ask the
|
|
28
35
|
// filesystem about the paths the SCHEDULER holds, not the ones this process
|
|
@@ -34,11 +41,16 @@ export async function runAutostartSelfHeal(paths, options) {
|
|
|
34
41
|
platform,
|
|
35
42
|
})
|
|
36
43
|
: [];
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
const marker = path.join(paths.state_dir, AUTOSTART_REPAIR_THROTTLE_MARKER);
|
|
45
|
+
if (!absent && status.status !== "not_loaded" && runtimeProblems.length === 0) {
|
|
46
|
+
// Healthy is the steady state and stays silent. A marker left by an
|
|
47
|
+
// earlier attempt is spent: that repair worked, so the next break starts
|
|
48
|
+
// fresh instead of waiting out a retry window.
|
|
49
|
+
await fs.rm(marker, { force: true }).catch(() => undefined);
|
|
39
50
|
return null;
|
|
40
51
|
}
|
|
41
52
|
const problem = [
|
|
53
|
+
...(absent ? ["registration absent"] : []),
|
|
42
54
|
...(status.message ? [status.message] : []),
|
|
43
55
|
...runtimeProblems,
|
|
44
56
|
]
|
|
@@ -59,15 +71,16 @@ export async function runAutostartSelfHeal(paths, options) {
|
|
|
59
71
|
step: platform === "darwin" ? "autostart_heal" : "autostart_repair",
|
|
60
72
|
};
|
|
61
73
|
}
|
|
62
|
-
//
|
|
63
|
-
// outcome
|
|
74
|
+
// Only a RETRY waits (marker mtime, written for the attempt not the
|
|
75
|
+
// outcome, cleared by the next healthy reading): a repair that keeps failing
|
|
76
|
+
// is retried hourly instead of churning the scheduler every 15 minutes. The
|
|
64
77
|
// status probe above still runs every tick — it is one launchctl/schtasks
|
|
65
78
|
// query.
|
|
66
79
|
const now = options.now ?? new Date();
|
|
67
|
-
const marker = path.join(paths.state_dir, AUTOSTART_REPAIR_THROTTLE_MARKER);
|
|
68
80
|
const lastAttempt = await fs.stat(marker).catch(() => null);
|
|
69
81
|
if (lastAttempt &&
|
|
70
|
-
now.getTime() - lastAttempt.mtimeMs <
|
|
82
|
+
now.getTime() - lastAttempt.mtimeMs < AUTOSTART_REPAIR_RETRY_AFTER_FAILURE_MS) {
|
|
83
|
+
console.error("[autostart-self-heal] scheduler still broken; the last repair attempt was under an hour ago, retrying after that", JSON.stringify({ reason: "repair_retry_waiting", platform }));
|
|
71
84
|
return { status: "skipped", reason: "repair_throttled_recent_attempt" };
|
|
72
85
|
}
|
|
73
86
|
await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
|
|
@@ -85,7 +98,7 @@ export async function runAutostartSelfHeal(paths, options) {
|
|
|
85
98
|
if (repaired.loaded) {
|
|
86
99
|
return {
|
|
87
100
|
status: "ok",
|
|
88
|
-
reason: "autostart_repaired",
|
|
101
|
+
reason: absent ? "autostart_created" : "autostart_repaired",
|
|
89
102
|
detail: problem,
|
|
90
103
|
step: "autostart_repair",
|
|
91
104
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defaultExec, writeLine } from "./cli-io.js";
|
|
2
2
|
import { runAutostartHealDetached } from "./autostart-heal.js";
|
|
3
3
|
import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
|
|
4
|
+
import { clearAutostartOptOut, markAutostartOptedOut, } from "../autostart-opt-out.js";
|
|
4
5
|
import { getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
|
|
5
6
|
export async function runAutostart(command, io) {
|
|
6
7
|
if (command.action === "heal-detached") {
|
|
@@ -37,6 +38,15 @@ export async function runAutostart(command, io) {
|
|
|
37
38
|
intervalSeconds: command.intervalSeconds,
|
|
38
39
|
exec,
|
|
39
40
|
});
|
|
41
|
+
// BLI-12828: the tick re-creates an absent scheduler, so a deliberate
|
|
42
|
+
// uninstall has to leave a mark the tick can read.
|
|
43
|
+
const optOutPaths = getCollectorRuntimePaths(command.homeDir);
|
|
44
|
+
if (command.action === "uninstall" && result.status !== "unsupported") {
|
|
45
|
+
await markAutostartOptedOut(optOutPaths);
|
|
46
|
+
}
|
|
47
|
+
else if (command.action === "install" && result.loaded !== false) {
|
|
48
|
+
await clearAutostartOptOut(optOutPaths);
|
|
49
|
+
}
|
|
40
50
|
if (command.json) {
|
|
41
51
|
writeLine(io.stdout, JSON.stringify(result, null, 2));
|
|
42
52
|
return autostartExitCode(command.action, result);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { appendFile } from "node:fs/promises";
|
|
3
3
|
import { AUTOSTART_LABEL, autostartStatus, reregisterDarwinAgent, writeDarwinAutostartPlist, } from "../autostart.js";
|
|
4
|
+
import { isAutostartOptedOut } from "../autostart-opt-out.js";
|
|
4
5
|
import { redactedHealthDetail } from "../health-detail.js";
|
|
5
6
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
|
|
6
7
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
@@ -72,9 +73,10 @@ async function healDetached(command, io, deps) {
|
|
|
72
73
|
exec,
|
|
73
74
|
platform,
|
|
74
75
|
});
|
|
75
|
-
if (before.status === "absent"
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
if (before.status === "absent" &&
|
|
77
|
+
(await isAutostartOptedOut(getCollectorRuntimePaths(command.homeDir)))) {
|
|
78
|
+
// BLI-12828: absent is created now; only a deliberate uninstall stops it.
|
|
79
|
+
return { status: "skipped", reason: "autostart_uninstalled_by_operator" };
|
|
78
80
|
}
|
|
79
81
|
let written;
|
|
80
82
|
try {
|
|
@@ -125,7 +127,7 @@ async function healDetached(command, io, deps) {
|
|
|
125
127
|
}
|
|
126
128
|
return {
|
|
127
129
|
status: "ok",
|
|
128
|
-
reason: "autostart_healed",
|
|
130
|
+
reason: before.status === "absent" ? "autostart_created" : "autostart_healed",
|
|
129
131
|
detail: `node_path_reason=${written.node_path_reason}`,
|
|
130
132
|
};
|
|
131
133
|
}
|
|
@@ -8,6 +8,7 @@ import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConf
|
|
|
8
8
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
9
9
|
import { detectSecondCockpitInstall } from "../second-install.js";
|
|
10
10
|
import { hardStop, needsFix, ok, skipped } from "./doctor-report.js";
|
|
11
|
+
import { SIGN_IN_ACTION } from "../sign-in-action.js";
|
|
11
12
|
/**
|
|
12
13
|
* The `authed`, `roots-ok`, and `single-install` check family: who is this
|
|
13
14
|
* machine and where does it collect. Auth and roots are hard stops with no
|
|
@@ -31,7 +32,7 @@ export async function fixAuthState(context, state) {
|
|
|
31
32
|
return checked;
|
|
32
33
|
}
|
|
33
34
|
if (!isInteractiveDoctorFix(context))
|
|
34
|
-
return { ...state, nextAction:
|
|
35
|
+
return { ...state, nextAction: SIGN_IN_ACTION };
|
|
35
36
|
const code = await context.deps.runLogin(context).catch((error) => {
|
|
36
37
|
// Exit code 1 with no reason at all is what an operator saw when doctor
|
|
37
38
|
// tried and failed to repair their auth — the same output as a login that
|
|
@@ -73,13 +74,10 @@ export async function readAuthState(context) {
|
|
|
73
74
|
}
|
|
74
75
|
return ok("authed", "device_token_present", "this machine is signed in");
|
|
75
76
|
}
|
|
76
|
-
return
|
|
77
|
-
"
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
` 2) If this is a reused machine, run \`${onboardOneLiner(context.command)}\` to refresh onboarding.`,
|
|
81
|
-
" 3) Send this output to Edward if approval is blocked.",
|
|
82
|
-
].join("\n"));
|
|
77
|
+
return {
|
|
78
|
+
...hardStop("authed", "pairing_required", `this machine is signed out. ${SIGN_IN_ACTION}`),
|
|
79
|
+
nextAction: SIGN_IN_ACTION,
|
|
80
|
+
};
|
|
83
81
|
}
|
|
84
82
|
export async function readRootState(context) {
|
|
85
83
|
const paths = getCollectorRuntimePaths(context.command.homeDir);
|
|
@@ -92,13 +90,11 @@ export async function readRootState(context) {
|
|
|
92
90
|
roots,
|
|
93
91
|
};
|
|
94
92
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
"
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
" 3) Run `cockpit doctor` interactively to choose approved roots.",
|
|
101
|
-
].join("\n"));
|
|
93
|
+
const action = `Run \`${onboardOneLiner(context.command)}\` in a terminal from the folder where you work.`;
|
|
94
|
+
return {
|
|
95
|
+
...hardStop("roots-ok", "no_roots", `no collection folders are saved yet. ${action}`),
|
|
96
|
+
nextAction: action,
|
|
97
|
+
};
|
|
102
98
|
}
|
|
103
99
|
/**
|
|
104
100
|
* BLI-3218/BLI-3553. Two Tower installs on one machine make each one's tick
|
|
@@ -164,10 +160,10 @@ function shellQuote(value) {
|
|
|
164
160
|
* a gate on it, so a session sitting outside one is not a defect doctor found
|
|
165
161
|
* and cannot repair. This row is therefore always informational (`ok`): it
|
|
166
162
|
* reports how many of this scan's sessions were inside vs. outside, never
|
|
167
|
-
* asks for approval, and attempts no fix. Widening the
|
|
168
|
-
* the
|
|
169
|
-
*
|
|
170
|
-
*
|
|
163
|
+
* asks for approval, and attempts no fix. Widening a root only adds the redacted transcript BYTES for those
|
|
164
|
+
* directories, so the message names `cockpit roots add` as an OPTIONAL step an
|
|
165
|
+
* operator can choose, never something doctor, or any person, is waiting on to
|
|
166
|
+
* approve. The
|
|
171
167
|
* row only turns red the ordinary way every other doctor check does: the scan
|
|
172
168
|
* itself throwing (`work_directory_scan_incomplete` from unreadable session
|
|
173
169
|
* folders), which `runDoctorWithDeps`'s existing `check_threw` handling
|
|
@@ -182,9 +178,9 @@ export async function readRootsCoverWork(context) {
|
|
|
182
178
|
const outsideCount = outside.reduce((sum, entry) => sum + entry.count, 0);
|
|
183
179
|
const insideCount = scan.observed - outsideCount;
|
|
184
180
|
if (!outsideCount)
|
|
185
|
-
return ok("roots-cover-work", "work_covered", `${scan.observed} sessions seen; no
|
|
181
|
+
return ok("roots-cover-work", "work_covered", `${scan.observed} sessions seen; no working folders outside your saved roots. ${sessionCoverageLine(scan, outsideCount)}`);
|
|
186
182
|
return {
|
|
187
|
-
...ok("roots-cover-work", "work_outside_roots_labelled", `${insideCount} of ${scan.observed} sessions inside
|
|
183
|
+
...ok("roots-cover-work", "work_outside_roots_labelled", `${insideCount} of ${scan.observed} sessions inside your saved roots, ${outsideCount} outside — every one already reached the server, the outside ones carrying an outside label. Nothing needs approving. Optional: add a folder as a root to also upload its redacted transcript text (scan ${scan.generated_at}):\n${outside.map(entry => `${entry.count}: ${entry.directory}\n ${rootsAddCommand(entry.directory, process.platform, homeDir)}`).join("\n")}\n\n${sessionCoverageLine(scan, outsideCount)}`),
|
|
188
184
|
nextAction: rootsAddCommand(outside[0].directory, process.platform, homeDir),
|
|
189
185
|
};
|
|
190
186
|
}
|
|
@@ -30,6 +30,12 @@
|
|
|
30
30
|
* ledger with its reason label (`doctor-report.ts`'s `doctorEvent`
|
|
31
31
|
* maps every other status to `ok`).
|
|
32
32
|
*
|
|
33
|
+
* BLI-12828: a `fail` (nothing answered) now has a repair. The server ships
|
|
34
|
+
* inside the CLI and is wired in by `memory install`, so the fix re-registers,
|
|
35
|
+
* takes the newest CLI if that alone did not wake it, and asks again; the row's
|
|
36
|
+
* receipt shows the before and after. A `needs_fix` (Tower refused) has none:
|
|
37
|
+
* the refusal is the sign-in's, and the `authed` row owns that.
|
|
38
|
+
*
|
|
33
39
|
* `skipped` when the bin is not on this machine at all, for the same reason
|
|
34
40
|
* `memory-registered` skips: nothing to probe is not a broken machine.
|
|
35
41
|
*/
|
|
@@ -95,4 +101,25 @@ function probeEnv(context) {
|
|
|
95
101
|
if (context.command.dashboardUrl)
|
|
96
102
|
base[TOWER_DASHBOARD_URL_ENV] = context.command.dashboardUrl;
|
|
97
103
|
return base;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The `mcp-answers` repair (BLI-12828): re-register, then take the newest CLI,
|
|
107
|
+
* asking the server again after each. Heavy (it spawns the server and may run
|
|
108
|
+
* the updater), so doctor runs it only on a `fail` and the tick leaves it to
|
|
109
|
+
* the daily memory install and the CLI floor, which do the same two things.
|
|
110
|
+
*/
|
|
111
|
+
export async function fixMcpAnswersState(context, state) {
|
|
112
|
+
if (state.status !== "fail") {
|
|
113
|
+
console.error("[doctor] mcp-answers not repaired; Tower refused the call and the sign-in row owns that", JSON.stringify({ reason: state.code }));
|
|
114
|
+
return state;
|
|
115
|
+
}
|
|
116
|
+
await context.deps.fixMemory(context);
|
|
117
|
+
let checked = await context.deps.checkMcpAnswers(context);
|
|
118
|
+
console.error("[doctor] mcp-answers re-registered and asked again", JSON.stringify({ reason: checked.code, status: checked.status }));
|
|
119
|
+
if (checked.status === "fail") {
|
|
120
|
+
await context.deps.selfUpdate(context.io, { json: true, tag: context.command.updateTag });
|
|
121
|
+
checked = await context.deps.checkMcpAnswers(context);
|
|
122
|
+
console.error("[doctor] mcp-answers updated the CLI and asked again", JSON.stringify({ reason: checked.code, status: checked.status }));
|
|
123
|
+
}
|
|
124
|
+
return checked;
|
|
98
125
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { inspectAgentRules, installAgentRules } from "../agent-rules.js";
|
|
2
2
|
import { doctorRoots } from "./doctor-access.js";
|
|
3
3
|
import { installAutostartAgent } from "../autostart.js";
|
|
4
|
+
import { clearAutostartOptOut } from "../autostart-opt-out.js";
|
|
5
|
+
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
4
6
|
import { fail, needsFix, ok, skipped } from "./doctor-report.js";
|
|
5
7
|
import { autostartDoctorStatus, autostartRegistrationRoots, readAutostartRegistration, } from "./autostart-reading.js";
|
|
6
8
|
import { installMemoryIntegration, inspectMemoryIntegration, } from "./memory-install.js";
|
|
@@ -41,7 +43,7 @@ export async function fixAutostartState(context) {
|
|
|
41
43
|
// leave the row red forever (BLI-3793).
|
|
42
44
|
const roots = await autostartRegistrationRoots(context.command.homeDir, context.command.repoRoot);
|
|
43
45
|
if (roots.length === 0)
|
|
44
|
-
return needsFix("autostart-alive", "no_roots", "Run `cockpit doctor`
|
|
46
|
+
return needsFix("autostart-alive", "no_roots", "Run `cockpit doctor` in a terminal and pick the folders where you work.");
|
|
45
47
|
const result = await installAutostartAgent({
|
|
46
48
|
...(roots[0] ? { repoRoot: roots[0] } : {}),
|
|
47
49
|
repoRoots: roots,
|
|
@@ -52,6 +54,10 @@ export async function fixAutostartState(context) {
|
|
|
52
54
|
if (result.status === "unsupported") {
|
|
53
55
|
return skipped("autostart-alive", "unsupported", result.message ?? "unsupported");
|
|
54
56
|
}
|
|
57
|
+
if (result.loaded !== false) {
|
|
58
|
+
// BLI-12828: a person asked for background sync again; the tick may keep it.
|
|
59
|
+
await clearAutostartOptOut(getCollectorRuntimePaths(context.command.homeDir));
|
|
60
|
+
}
|
|
55
61
|
if (result.loaded === false) {
|
|
56
62
|
return fail("autostart-alive", "autostart_load_failed", result.message ?? "operating-system scheduler load failed");
|
|
57
63
|
}
|
|
@@ -111,12 +117,12 @@ function memoryStepState(outcome, phase) {
|
|
|
111
117
|
}
|
|
112
118
|
export async function checkDoctorAgentRules(context) {
|
|
113
119
|
const result = await inspectAgentRules({ homeDir: context.command.homeDir, scopePaths: await doctorRoots(context) });
|
|
114
|
-
return result.installed ? ok("agent-rules", "installed", "agent rules are installed for both hosts") : needsFix("agent-rules", "missing", "
|
|
120
|
+
return result.installed ? ok("agent-rules", "installed", "agent rules are installed for both hosts") : needsFix("agent-rules", "missing", "agent rules are missing; `cockpit agent-rules install` writes them");
|
|
115
121
|
}
|
|
116
122
|
export async function fixDoctorAgentRules(context) {
|
|
117
123
|
const roots = await doctorRoots(context);
|
|
118
124
|
if (roots.length === 0)
|
|
119
|
-
return needsFix("agent-rules", "no_roots", "Run `cockpit doctor`
|
|
125
|
+
return needsFix("agent-rules", "no_roots", "Run `cockpit doctor` in a terminal and pick the folders where you work.");
|
|
120
126
|
await installAgentRules({ homeDir: context.command.homeDir, scopePaths: roots });
|
|
121
127
|
return checkDoctorAgentRules(context);
|
|
122
128
|
}
|
|
@@ -70,6 +70,8 @@ setupReceipt, repairs = []) {
|
|
|
70
70
|
writeLine(io.stderr, `${row.id}:`);
|
|
71
71
|
writeLine(io.stderr, row.message.replaceAll("—", ";"));
|
|
72
72
|
}
|
|
73
|
+
for (const line of repairReceiptLines(repairs))
|
|
74
|
+
writeLine(io.stdout, line);
|
|
73
75
|
// BLI-3731. The invariant table says whether collection is healthy; this
|
|
74
76
|
// says whether the machine is CONNECTED — the browser sign-in, the device
|
|
75
77
|
// session, both agent hosts and the scheduler, every word read back off this
|
|
@@ -132,6 +134,32 @@ export function asRecord(value) {
|
|
|
132
134
|
export function writeLine(stream, text) {
|
|
133
135
|
stream.write(`${text}\n`);
|
|
134
136
|
}
|
|
137
|
+
/** Fills each repair's before -> after receipt from the diagnosis and the final rows. */
|
|
138
|
+
export function finalizeDoctorRepairs(repairs, diagnosed, rows, attempted) {
|
|
139
|
+
for (const repair of repairs) {
|
|
140
|
+
const found = diagnosed.get(repair.step);
|
|
141
|
+
const final = rows.find((row) => row.id === repair.step);
|
|
142
|
+
if (found) {
|
|
143
|
+
repair.before = `${found.status}:${found.code}`;
|
|
144
|
+
repair.before_message = oneLine(found.message);
|
|
145
|
+
}
|
|
146
|
+
if (final) {
|
|
147
|
+
repair.after = `${final.status}:${final.code}`;
|
|
148
|
+
repair.after_message = oneLine(final.message);
|
|
149
|
+
}
|
|
150
|
+
repair.fix_attempted = attempted.has(repair.step);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** The "Repairs:" block of the human table; empty when no fix ran. */
|
|
154
|
+
export function repairReceiptLines(repairs) {
|
|
155
|
+
const ran = repairs.filter((repair) => repair.fix_attempted);
|
|
156
|
+
if (ran.length === 0)
|
|
157
|
+
return [];
|
|
158
|
+
return [
|
|
159
|
+
"Repairs (before -> after):",
|
|
160
|
+
...ran.map((repair) => ` ${repair.after?.startsWith("ok:") ? "fixed" : "still needs you"} ${repair.step}: ${repair.before_message ?? ""} -> ${repair.after_message ?? ""}`.replaceAll("—", ";")),
|
|
161
|
+
];
|
|
162
|
+
}
|
|
135
163
|
export function doctorNeedsPerson(rows, receipt) {
|
|
136
164
|
const defaults = {
|
|
137
165
|
"cli-latest": "npm i -g @bli-cockpit/cli && cockpit doctor",
|
package/dist/commands/doctor.js
CHANGED
|
@@ -4,11 +4,11 @@ import { describeError } from "../health-detail.js";
|
|
|
4
4
|
import { withStage } from "../crash-guard.js";
|
|
5
5
|
import { checkSingleInstallState, readRootsCoverWork, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
|
|
6
6
|
import { backfillCompletionStepState, backfillFixVerdict, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline.js";
|
|
7
|
-
import { checkMcpAnswersState } from "./doctor-mcp.js";
|
|
7
|
+
import { checkMcpAnswersState, fixMcpAnswersState } from "./doctor-mcp.js";
|
|
8
8
|
import { budgetSpentRow, doctorRunDeadline, DOCTOR_JSON_BUDGET_SECONDS } from "./doctor-budget.js";
|
|
9
9
|
import { checkMemoryDaemonState } from "./doctor-memory-daemon.js";
|
|
10
10
|
import { checkDoctorAgentRules, fixDoctorAgentRules, checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
|
|
11
|
-
import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, doctorNeedsPerson, } from "./doctor-report.js";
|
|
11
|
+
import { dryRunPreview, isInteractiveDoctorFix, maybeReportDoctorEvents, writeDoctorOutput, doctorNeedsPerson, finalizeDoctorRepairs, } from "./doctor-report.js";
|
|
12
12
|
import { buildSetupReceipt, refreshSetupReceipt } from "./setup-receipt.js";
|
|
13
13
|
import { checkCliLatest, fixCliLatest, latestCliVersionFromNpm, reexecDoctor, } from "./doctor-update.js";
|
|
14
14
|
export async function runDoctor(command, io, hooks, overrides = {}) {
|
|
@@ -24,6 +24,7 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
24
24
|
const repairs = [];
|
|
25
25
|
const diagnoseOnly = command.dryRun || command.checkOnly;
|
|
26
26
|
const diagnosed = new Map();
|
|
27
|
+
const attempted = new Set();
|
|
27
28
|
// BLI-11101: every row says how long it took, so the next slow run names
|
|
28
29
|
// the row without a profiler. A killed run still leaves every finished row.
|
|
29
30
|
const checkMs = new Map();
|
|
@@ -54,6 +55,7 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
54
55
|
repairs.push({ step: invariant.id, outcome: "needs_person", reason: row.code });
|
|
55
56
|
}
|
|
56
57
|
else if (row.status !== "ok" && row.status !== "skipped" && canFix && invariant.fix) {
|
|
58
|
+
attempted.add(invariant.id);
|
|
57
59
|
row = await withStage(`doctor:fix:${invariant.id}`, () => invariant.fix(context, row));
|
|
58
60
|
repairs.push({ step: invariant.id, outcome: row.status === "ok" ? "repaired" : "needs_person", reason: row.code });
|
|
59
61
|
// The replacement process owns the one final receipt, including JSON.
|
|
@@ -106,6 +108,8 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
106
108
|
}
|
|
107
109
|
}
|
|
108
110
|
}
|
|
111
|
+
// BLI-12828: every repair's receipt says what it found and what it left.
|
|
112
|
+
finalizeDoctorRepairs(repairs, diagnosed, rows, attempted);
|
|
109
113
|
const floor = await maybeReportDoctorEvents(context, rows);
|
|
110
114
|
if (floor && isSemverBelow(LOCAL_COLLECTOR_VERSION, floor)) {
|
|
111
115
|
const index = rows.findIndex((row) => row.id === "cli-latest");
|
|
@@ -127,6 +131,15 @@ export async function runDoctorWithDeps(command, io, deps) {
|
|
|
127
131
|
writeDoctorOutput(command, io, rows, receipt, repairs);
|
|
128
132
|
return needsPerson === 0 ? 0 : 1;
|
|
129
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* The table's row ids in order, and whether each one carries a repair.
|
|
136
|
+
* `doctor-repair-classes.test.ts` pins this against the runbook's audit table
|
|
137
|
+
* (`docs/runbooks/cockpit-doctor-repairs.md`) so a row cannot be added without
|
|
138
|
+
* either a repair or a named reason it has none.
|
|
139
|
+
*/
|
|
140
|
+
export function doctorInvariantTable() {
|
|
141
|
+
return doctorInvariants().map((invariant) => ({ id: invariant.id, hasFix: Boolean(invariant.fix) }));
|
|
142
|
+
}
|
|
130
143
|
function doctorInvariants() {
|
|
131
144
|
return [
|
|
132
145
|
{ id: "cli-latest", check: checkCliLatest, fix: fixCliLatest },
|
|
@@ -166,12 +179,14 @@ function doctorInvariants() {
|
|
|
166
179
|
// BLI-3804. Right after the registration row, because it asks the second
|
|
167
180
|
// half of the same question: `memory-registered` proves the entry exists
|
|
168
181
|
// and names a bin, this one proves the server behind it starts, speaks the
|
|
169
|
-
// protocol and reaches Tower.
|
|
170
|
-
//
|
|
171
|
-
//
|
|
182
|
+
// protocol and reaches Tower. BLI-12828 gave it the repair the row always
|
|
183
|
+
// named in words: re-register, take the newest CLI, ask again. A Tower
|
|
184
|
+
// refusal is the sign-in row's, so that outcome is returned untouched
|
|
185
|
+
// (`doctor-mcp.ts`).
|
|
172
186
|
{
|
|
173
187
|
id: "mcp-answers",
|
|
174
188
|
check: (context) => context.deps.checkMcpAnswers(context),
|
|
189
|
+
fix: fixMcpAnswersState,
|
|
175
190
|
},
|
|
176
191
|
// BLI-3884. Beside the two rows above because it is the same family — what
|
|
177
192
|
// this machine's agents can reach — and, like `mcp-answers`, it has NO fix:
|
|
@@ -77,4 +77,18 @@ async function readTokenExpiry(response) {
|
|
|
77
77
|
catch {
|
|
78
78
|
return null;
|
|
79
79
|
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* BLI-12828. The one fact the tick's sign-in heal needs from the check-in it
|
|
83
|
+
* just made: did the DOOR refuse this machine's token (401/403), as opposed to
|
|
84
|
+
* the network being down or the build being a checkout? A lapsed local date
|
|
85
|
+
* alone proves nothing offline. Process-scoped on purpose: one tick is one
|
|
86
|
+
* process, and `null` means "no refusal observed", never "accepted".
|
|
87
|
+
*/
|
|
88
|
+
let lastRefusalStatus = null;
|
|
89
|
+
export function noteHeartbeatRefusal(status) {
|
|
90
|
+
lastRefusalStatus = status;
|
|
91
|
+
}
|
|
92
|
+
export function heartbeatRefusalStatus() {
|
|
93
|
+
return lastRefusalStatus;
|
|
80
94
|
}
|
|
@@ -29,7 +29,7 @@ import { readStagingInventory } from "../disk-usage.js";
|
|
|
29
29
|
import { describeError } from "../health-detail.js";
|
|
30
30
|
import { shouldSuppressFleetReceipts, } from "../dev-build.js";
|
|
31
31
|
import { getCollectorRuntimePaths, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
|
|
32
|
-
import { readHeartbeatSession, recordHeartbeatTokenExpiry, } from "./heartbeat-token.js";
|
|
32
|
+
import { noteHeartbeatRefusal, readHeartbeatSession, recordHeartbeatTokenExpiry, } from "./heartbeat-token.js";
|
|
33
33
|
import { classifySyncHealthError } from "../sync-health-class.js";
|
|
34
34
|
import { readMemoryReceiptFile } from "./memory-install-receipt.js";
|
|
35
35
|
import { readMemoryHookCounts } from "./memory-hook-counts.js";
|
|
@@ -284,6 +284,7 @@ export async function sendCollectorHeartbeatBestEffort(options) {
|
|
|
284
284
|
body: JSON.stringify(heartbeat),
|
|
285
285
|
signal: controller.signal,
|
|
286
286
|
});
|
|
287
|
+
noteHeartbeatRefusal(response.ok ? null : response.status);
|
|
287
288
|
if (!response.ok) {
|
|
288
289
|
// A heartbeat is not retried, so the reason has to be said once, here.
|
|
289
290
|
// BLI-4019: the CLASS comes from `sync-health-class.ts`, the one place
|
|
@@ -16,7 +16,12 @@
|
|
|
16
16
|
* stores.
|
|
17
17
|
* - **The message body.** `cockpit mail send` and `cockpit mail draft` take it
|
|
18
18
|
* from stdin or `--file`, the same discipline `cockpit issue comment` and
|
|
19
|
-
* `cockpit docs update` keep.
|
|
19
|
+
* `cockpit docs update` keep. `--file` is the BODY and nothing else.
|
|
20
|
+
*
|
|
21
|
+
* `--attach <path>` (BLI-12661, repeatable, `send` and `draft` only) names a
|
|
22
|
+
* file to ride along. It is a PATH on argv, never the file's bytes: the bytes
|
|
23
|
+
* are read in `mail-attachments.ts`, screened there, and posted in the JSON
|
|
24
|
+
* body like the text is.
|
|
20
25
|
*
|
|
21
26
|
* `draft` (BLI-12530) takes `send`'s flags exactly — `--account`, `--to`,
|
|
22
27
|
* `--cc`, `--subject`, `--reply-to`, `--file` — and is refused the same two
|
|
@@ -60,6 +65,7 @@ export function parseMailArgs(args) {
|
|
|
60
65
|
"--subject",
|
|
61
66
|
"--reply-to",
|
|
62
67
|
"--file",
|
|
68
|
+
"--attach",
|
|
63
69
|
"--body-stdin",
|
|
64
70
|
"--out",
|
|
65
71
|
"--json",
|
|
@@ -78,6 +84,7 @@ export function parseMailArgs(args) {
|
|
|
78
84
|
"--subject",
|
|
79
85
|
"--reply-to",
|
|
80
86
|
"--file",
|
|
87
|
+
"--attach",
|
|
81
88
|
"--out",
|
|
82
89
|
],
|
|
83
90
|
});
|
|
@@ -87,7 +94,8 @@ export function parseMailArgs(args) {
|
|
|
87
94
|
const { to, cc } = readMailRecipients(action, values);
|
|
88
95
|
const outPath = readAttachmentPath(action, values);
|
|
89
96
|
const limit = readMailLimit(action, values);
|
|
90
|
-
|
|
97
|
+
const attachPaths = readAttachPaths(action, values);
|
|
98
|
+
return buildMailCommand(values, action, subject, query, address, to, cc, outPath, limit, attachPaths);
|
|
91
99
|
}
|
|
92
100
|
function readMailAction(values) {
|
|
93
101
|
const first = values.positionals[0];
|
|
@@ -152,6 +160,24 @@ function readAttachmentPath(action, values) {
|
|
|
152
160
|
}
|
|
153
161
|
return outPath;
|
|
154
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* `--attach <path>`, repeatable (BLI-12661): files that ride along with a
|
|
165
|
+
* `send` or a `draft`. Collected here and read, screened and refused by name in
|
|
166
|
+
* `mail-attachments.ts`, so a missing file or a fifth too many answers as a
|
|
167
|
+
* door refusal with a reason label (and under --json as one object) rather than
|
|
168
|
+
* as a usage error. `--file` is untouched and still means the BODY.
|
|
169
|
+
*/
|
|
170
|
+
function readAttachPaths(action, values) {
|
|
171
|
+
const named = values.flagValues.get("--attach") ?? [];
|
|
172
|
+
if (named.length > 0 && !MAIL_ACTIONS_COMPOSING.has(action)) {
|
|
173
|
+
throw new Error("--attach belongs to `cockpit mail send` and `cockpit mail draft`: a file to attach to the message.");
|
|
174
|
+
}
|
|
175
|
+
// A blank path is a mistake to name, never a file to drop without a word.
|
|
176
|
+
const paths = named.map((value) => optionalNonEmpty(value));
|
|
177
|
+
if (paths.some((value) => value === undefined))
|
|
178
|
+
throw new Error("--attach needs a file path, not a blank value.");
|
|
179
|
+
return paths;
|
|
180
|
+
}
|
|
155
181
|
function readMailLimit(action, values) {
|
|
156
182
|
const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
|
|
157
183
|
if (limit !== undefined && action !== "inbox" && action !== "search") {
|
|
@@ -159,7 +185,7 @@ function readMailLimit(action, values) {
|
|
|
159
185
|
}
|
|
160
186
|
return limit;
|
|
161
187
|
}
|
|
162
|
-
function buildMailCommand(values, action, subject, query, address, to, cc, outPath, limit) {
|
|
188
|
+
function buildMailCommand(values, action, subject, query, address, to, cc, outPath, limit, attachPaths) {
|
|
163
189
|
return {
|
|
164
190
|
kind: "mail",
|
|
165
191
|
action,
|
|
@@ -179,6 +205,7 @@ function buildMailCommand(values, action, subject, query, address, to, cc, outPa
|
|
|
179
205
|
subjectLine: optionalNonEmpty(values.flags.get("--subject")),
|
|
180
206
|
replyTo: optionalNonEmpty(values.flags.get("--reply-to")),
|
|
181
207
|
filePath: optionalNonEmpty(values.flags.get("--file")),
|
|
208
|
+
...(attachPaths.length > 0 ? { attachPaths } : {}),
|
|
182
209
|
bodyStdin: values.booleans.has("--body-stdin"),
|
|
183
210
|
outPath,
|
|
184
211
|
json: values.booleans.has("--json"),
|
|
@@ -133,8 +133,9 @@ export const TOWER_COMMAND_HELP = [
|
|
|
133
133
|
"inbox [--account <id>] [--unread] [--label <l>] [--limit <n>] [--before <iso>] — everything across every mailbox, newest first. * is unread, @ has an attachment. Every row (text or --json) carries the thread id — `[thread <id>]` in text — so `mail read <thread>` never costs a second --json call to learn it (BLI-10450).",
|
|
134
134
|
"read <thread> — one conversation with its bodies. Thread ids come from `cockpit mail inbox` or `cockpit mail search`, in either --json or plain text.",
|
|
135
135
|
"search \"<words>\" [--account <id>] [--limit <n>] — full text over subject and body. Quotes and OR work the way they do in a search box. Every row (text or --json) carries the thread id.",
|
|
136
|
-
"send --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] — the BODY comes from --file or stdin. --account is required: which address this goes out from is yours to say.",
|
|
137
|
-
"draft --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] — saves the message as a DRAFT in that mailbox for a person to read, edit and send themselves. NOTHING IS SENT and nobody is notified, so there is no confirmation step. Same flags and same body rule as send (--file or stdin). Only the mailbox's owner can draft into it. --reply-to threads the draft into that conversation the way send does; Gmail files it there only when the subject matches the original's, and the answer says whether it did. Gmail mailboxes only: an IMAP mailbox is refused as imap_drafts_unsupported. --json answers { ok, draft: { account_id, address, provider_draft_id, provider_thread_id, threaded } } (BLI-12530).",
|
|
136
|
+
"send --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] [--attach <path>]... — the BODY comes from --file or stdin. --account is required: which address this goes out from is yours to say.",
|
|
137
|
+
"draft --account <id> --to <a[,b]> [--cc <c>] --subject \"<s>\" [--reply-to <message id>] [--file <path>] [--attach <path>]... — saves the message as a DRAFT in that mailbox for a person to read, edit and send themselves. NOTHING IS SENT and nobody is notified, so there is no confirmation step. Same flags and same body rule as send (--file or stdin). Only the mailbox's owner can draft into it. --reply-to threads the draft into that conversation the way send does; Gmail files it there only when the subject matches the original's, and the answer says whether it did. Gmail mailboxes only: an IMAP mailbox is refused as imap_drafts_unsupported. --json answers { ok, draft: { account_id, address, provider_draft_id, provider_thread_id, threaded } } (BLI-12530).",
|
|
138
|
+
"--attach <path> (send and draft; repeat it for each file) — a file that rides along with the message: up to 10, and 3,000,000 bytes together (about 3 MB: a request to Tower is capped at 4.5 MB and a file travels as base64, a third bigger than itself). The type comes from the extension (.docx .pdf .png .jpg .jpeg .txt .csv .md .zip and more; anything else goes as application/octet-stream), the name is the file's own. --file is still the BODY and only the body. Refused BEFORE anything is sent, by name and with the numbers: too_many_attachments, attachment_unreadable (a path that is missing, a folder or empty), attachment_too_large. A reply (--reply-to) keeps its thread with files in it. Works on Gmail mailboxes and on IMAP mailboxes for send; a draft is Gmail only. --json adds { attachments: { count, bytes, content_types } } to the answer. The terminal reports from Tower's own answer, not from what it posted: if Tower does not confirm the files (an older Tower), it exits 1 with attachments_not_confirmed and says whether the message went. Names, paths and contents are never logged; a refusal says \"Attachment 2\", not which file (BLI-12661).",
|
|
138
139
|
"attachment <id> --out <path> — downloads one attachment to a file. Attachments are pointers until you ask; nothing is stored in Tower.",
|
|
139
140
|
"sync <account> — reads that mailbox now instead of waiting for the cron. Prints what landed, or the reason it did not.",
|
|
140
141
|
"sync-status [<account>] — every mailbox's health, or one's: last successful sync, the last pass's own reason, how many messages it carried, failures in the last 24h, and the lag between a message's own clock and when Tower stored it (ticket 10730).",
|
|
@@ -124,7 +124,7 @@ export function localCommandHelp(command) {
|
|
|
124
124
|
" cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>|delete <id|slug>] [--parent <id>|root|--clear-parent] [--query <text> [--word]] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
|
|
125
125
|
" cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--dashboard-url <url>] [--json]",
|
|
126
126
|
" cockpit issue [list|show <BLI-id>|create --title <t>|update <BLI-id>|move <BLI-id> <state>|comment <BLI-id>|history <BLI-id>] [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] [--priority 0-4] [--size S|M|L] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
|
|
127
|
-
" cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|draft --account <id> --to <a> --subject <s> [--reply-to <message id>]|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--dashboard-url <url>] [--json]",
|
|
127
|
+
" cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|draft --account <id> --to <a> --subject <s> [--reply-to <message id>]|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--attach <path>]... [--dashboard-url <url>] [--json]",
|
|
128
128
|
" cockpit cal [today|week|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
|
|
129
129
|
" cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
|
|
130
130
|
" cockpit github <pr|merge|retitle|rerun> <owner/repo> <n> [--claim <state>] [--method squash|merge|rebase] [--title \"<text>\"] [--run <id>] [--confirm <code>] [--dashboard-url <url>] [--json]",
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--attach <path>` for `cockpit mail send` and `cockpit mail draft` (BLI-12661):
|
|
3
|
+
* read the files on THIS machine, screen them, and turn them into the
|
|
4
|
+
* `attachments` the two doors take. Nothing here talks to Tower, and nothing is
|
|
5
|
+
* read from a disk that is not the person's own: the server is never handed a
|
|
6
|
+
* path.
|
|
7
|
+
*
|
|
8
|
+
* **Everything that can be refused is refused before a byte leaves the
|
|
9
|
+
* machine**, each with a named reason and its numbers (the server holds the same
|
|
10
|
+
* caps and says the same words, `lib/mail/outbound-attachments.ts`):
|
|
11
|
+
*
|
|
12
|
+
* too_many_attachments more than 10 `--attach`
|
|
13
|
+
* attachment_unreadable a path that is missing, is not a regular file, cannot
|
|
14
|
+
* be read, or is empty
|
|
15
|
+
* attachment_too_large more than 3,000,000 bytes together, or a whole
|
|
16
|
+
* request that would not fit the platform's body cap
|
|
17
|
+
*
|
|
18
|
+
* **Why 3,000,000 bytes.** Vercel caps a function REQUEST body at 4.5 MB (413
|
|
19
|
+
* `FUNCTION_PAYLOAD_TOO_LARGE`, before Tower's code runs), and a file travels as
|
|
20
|
+
* base64, a third bigger than itself. 3,000,000 bytes is 4,000,000 characters of
|
|
21
|
+
* base64, which leaves about half a megabyte for the JSON around it. Gmail's own
|
|
22
|
+
* 25 MB is not the binding limit. `MAIL_REQUEST_MAX_BYTES` is the second check:
|
|
23
|
+
* a 3 MB file next to a very long body is still too big for one request, and a
|
|
24
|
+
* platform 413 says nothing about why.
|
|
25
|
+
*
|
|
26
|
+
* **Sizes are read off `stat` first.** Every file is stat'ed, the total is
|
|
27
|
+
* checked, and only then is a single byte read, so a 5 GB file named by mistake
|
|
28
|
+
* costs one system call and no memory.
|
|
29
|
+
*
|
|
30
|
+
* **The type comes from the extension and nothing else.** Unknown is
|
|
31
|
+
* `application/octet-stream`. The table is copied, on purpose, into
|
|
32
|
+
* `packages/bli-cockpit-mcp/src/mail-attachments.ts` (that package depends on
|
|
33
|
+
* neither this one nor `cockpit-cli`), and `mail-attachment-parity.test.ts` in
|
|
34
|
+
* the MCP package fails when the two drift.
|
|
35
|
+
*
|
|
36
|
+
* **Names are never logged, and nor are paths.** The one success line says how
|
|
37
|
+
* many files, how big and of what type (`describeMailAttachments`). A refusal
|
|
38
|
+
* names the file by POSITION ("Attachment 2") and the file system's own error
|
|
39
|
+
* CODE, never its path or its name (the message standard: no local path in an
|
|
40
|
+
* operator-facing string): the person who typed the command is looking at it.
|
|
41
|
+
*
|
|
42
|
+
* No secret-name refusal lives here (`notes upload` has one): a person who names
|
|
43
|
+
* a file here has decided to send it, and the outbound guard masks text, not
|
|
44
|
+
* files. See the doc.
|
|
45
|
+
*/
|
|
46
|
+
import { readFile, stat } from "node:fs/promises";
|
|
47
|
+
import path from "node:path";
|
|
48
|
+
import { isSecretLikePathSegment } from "@bli-cockpit/telemetry-core";
|
|
49
|
+
export const MAIL_ATTACHMENT_MAX_COUNT = 10;
|
|
50
|
+
export const MAIL_ATTACHMENTS_MAX_TOTAL_BYTES = 3_000_000;
|
|
51
|
+
/** The whole JSON request, under Vercel's 4.5 MB function-body cap with room for the headers. */
|
|
52
|
+
export const MAIL_REQUEST_MAX_BYTES = 4_400_000;
|
|
53
|
+
// BEGIN content types: identical in packages/bli-cockpit-mcp/src/mail-attachments.ts
|
|
54
|
+
export const MAIL_ATTACHMENT_CONTENT_TYPES = {
|
|
55
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
56
|
+
doc: "application/msword",
|
|
57
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
58
|
+
xls: "application/vnd.ms-excel",
|
|
59
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
60
|
+
ppt: "application/vnd.ms-powerpoint",
|
|
61
|
+
pdf: "application/pdf",
|
|
62
|
+
png: "image/png",
|
|
63
|
+
jpg: "image/jpeg",
|
|
64
|
+
jpeg: "image/jpeg",
|
|
65
|
+
gif: "image/gif",
|
|
66
|
+
webp: "image/webp",
|
|
67
|
+
txt: "text/plain",
|
|
68
|
+
csv: "text/csv",
|
|
69
|
+
md: "text/markdown",
|
|
70
|
+
json: "application/json",
|
|
71
|
+
html: "text/html",
|
|
72
|
+
rtf: "application/rtf",
|
|
73
|
+
zip: "application/zip",
|
|
74
|
+
};
|
|
75
|
+
// END content types
|
|
76
|
+
// BEGIN secret-like names: identical in the CLI and MCP mail-attachments.ts
|
|
77
|
+
/**
|
|
78
|
+
* A name a person almost never means to email: the repo's own secret-name rule
|
|
79
|
+
* (`isSecretLikePathSegment`, the one `notes upload` uses: `.env*`, anything with
|
|
80
|
+
* secret, credential, private_key or service_role in it), with `.env.example`
|
|
81
|
+
* let through (a template, no values), plus the private-key names that rule does
|
|
82
|
+
* not know: id_rsa and its siblings, `*.pem`, `*.key`.
|
|
83
|
+
*/
|
|
84
|
+
export function attachmentLooksSecret(fileName) {
|
|
85
|
+
if (/^\.env\.example$/i.test(fileName))
|
|
86
|
+
return false;
|
|
87
|
+
return isSecretLikePathSegment(fileName) || /^id_(rsa|dsa|ecdsa|ed25519)$/i.test(fileName) || /\.(pem|key)$/i.test(fileName);
|
|
88
|
+
}
|
|
89
|
+
// END secret-like names
|
|
90
|
+
const FALLBACK_CONTENT_TYPE = "application/octet-stream";
|
|
91
|
+
/** The type a file name's extension says it is. Case-insensitive; anything unknown is octet-stream. */
|
|
92
|
+
export function mailAttachmentContentType(fileName) {
|
|
93
|
+
const extension = path.extname(fileName).slice(1).toLowerCase();
|
|
94
|
+
return MAIL_ATTACHMENT_CONTENT_TYPES[extension] ?? FALLBACK_CONTENT_TYPE;
|
|
95
|
+
}
|
|
96
|
+
const NOTHING_SENT = "Nothing was sent.";
|
|
97
|
+
function refuse(reason, detail) {
|
|
98
|
+
return { ok: false, reason, detail };
|
|
99
|
+
}
|
|
100
|
+
function bytesLabel(count) {
|
|
101
|
+
return `${count.toLocaleString("en-US")} byte${count === 1 ? "" : "s"}`;
|
|
102
|
+
}
|
|
103
|
+
/** The verdict on a set of sizes, or null when it may go. Shared by the stat pass and the read pass. */
|
|
104
|
+
function totalTooLarge(total) {
|
|
105
|
+
if (total <= MAIL_ATTACHMENTS_MAX_TOTAL_BYTES)
|
|
106
|
+
return null;
|
|
107
|
+
return refuse("attachment_too_large", `The attachments come to ${bytesLabel(total)} and one message carries at most ${bytesLabel(MAIL_ATTACHMENTS_MAX_TOTAL_BYTES)} `
|
|
108
|
+
+ "(about 3 MB: the most one request to Tower holds once the files are encoded). "
|
|
109
|
+
+ `Share a link for anything bigger. ${NOTHING_SENT}`);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Reads every `--attach` path, or says which one cannot go and why. Never throws.
|
|
113
|
+
* Order: the count, then each file's `stat` (missing, not a file, empty), then the
|
|
114
|
+
* total, and only then the bytes.
|
|
115
|
+
*/
|
|
116
|
+
export async function readMailAttachments(paths) {
|
|
117
|
+
if (paths.length === 0)
|
|
118
|
+
return { ok: true, attachments: [], summary: { count: 0, bytes: 0, sizes: [], content_types: [] } };
|
|
119
|
+
if (paths.length > MAIL_ATTACHMENT_MAX_COUNT) {
|
|
120
|
+
return refuse("too_many_attachments", `${paths.length} files were named with --attach and one message carries at most ${MAIL_ATTACHMENT_MAX_COUNT}. ${NOTHING_SENT}`);
|
|
121
|
+
}
|
|
122
|
+
// Names first, before a single stat or read: a secret-looking file is never opened.
|
|
123
|
+
const secret = paths.findIndex((filePath) => attachmentLooksSecret(path.basename(filePath)));
|
|
124
|
+
if (secret !== -1) {
|
|
125
|
+
return refuse("attachment_secret_like", `Attachment ${secret + 1} has a name that looks like a key or credential file, so it was not opened. ${NOTHING_SENT}`);
|
|
126
|
+
}
|
|
127
|
+
const sizes = [];
|
|
128
|
+
for (const [index, filePath] of paths.entries()) {
|
|
129
|
+
const label = `Attachment ${index + 1}`;
|
|
130
|
+
let size;
|
|
131
|
+
try {
|
|
132
|
+
const info = await stat(filePath);
|
|
133
|
+
if (!info.isFile())
|
|
134
|
+
return refuse("attachment_unreadable", `${label} is not a regular file: a folder or a device cannot be attached. ${NOTHING_SENT}`);
|
|
135
|
+
size = info.size;
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
const code = error.code;
|
|
139
|
+
return refuse("attachment_unreadable", code === "ENOENT"
|
|
140
|
+
? `${label} does not exist. ${NOTHING_SENT}`
|
|
141
|
+
: `${label} could not be read (${code ?? "unknown error"}). ${NOTHING_SENT}`);
|
|
142
|
+
}
|
|
143
|
+
if (size === 0)
|
|
144
|
+
return refuse("attachment_unreadable", `${label} is empty, and an empty file is not worth sending. ${NOTHING_SENT}`);
|
|
145
|
+
sizes.push(size);
|
|
146
|
+
}
|
|
147
|
+
const tooLarge = totalTooLarge(sizes.reduce((sum, size) => sum + size, 0));
|
|
148
|
+
if (tooLarge)
|
|
149
|
+
return tooLarge;
|
|
150
|
+
const attachments = [];
|
|
151
|
+
const actual = [];
|
|
152
|
+
for (const [index, filePath] of paths.entries()) {
|
|
153
|
+
let bytes;
|
|
154
|
+
try {
|
|
155
|
+
bytes = await readFile(filePath);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
const code = error.code;
|
|
159
|
+
return refuse("attachment_unreadable", `Attachment ${index + 1} could not be read (${code ?? "unknown error"}). ${NOTHING_SENT}`);
|
|
160
|
+
}
|
|
161
|
+
actual.push(bytes.length);
|
|
162
|
+
const filename = path.basename(filePath);
|
|
163
|
+
attachments.push({
|
|
164
|
+
filename,
|
|
165
|
+
content_type: mailAttachmentContentType(filename),
|
|
166
|
+
content_base64: bytes.toString("base64"),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
// A file that grew between the stat and the read is the file as it is now.
|
|
170
|
+
const total = actual.reduce((sum, size) => sum + size, 0);
|
|
171
|
+
const grew = totalTooLarge(total);
|
|
172
|
+
if (grew)
|
|
173
|
+
return grew;
|
|
174
|
+
if (actual.includes(0)) {
|
|
175
|
+
return refuse("attachment_unreadable", `Attachment ${actual.indexOf(0) + 1} is empty, and an empty file is not worth sending. ${NOTHING_SENT}`);
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
attachments,
|
|
180
|
+
summary: {
|
|
181
|
+
count: attachments.length,
|
|
182
|
+
bytes: total,
|
|
183
|
+
sizes: actual,
|
|
184
|
+
content_types: attachments.map((attachment) => attachment.content_type),
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The second check: the whole request, not just the files. Called with the JSON
|
|
190
|
+
* body about to be posted, and only when it carries files.
|
|
191
|
+
*/
|
|
192
|
+
export function mailRequestFits(body) {
|
|
193
|
+
const bytes = Buffer.byteLength(JSON.stringify(body), "utf8");
|
|
194
|
+
if (bytes <= MAIL_REQUEST_MAX_BYTES)
|
|
195
|
+
return { ok: true, bytes };
|
|
196
|
+
return refuse("attachment_too_large", `The whole request would be ${bytesLabel(bytes)} (the message, the files encoded as base64, and the JSON around them) `
|
|
197
|
+
+ `and Tower takes at most ${bytesLabel(MAIL_REQUEST_MAX_BYTES)}: a request over 4.5 MB is refused by the platform before Tower sees it. `
|
|
198
|
+
+ `Shorten the message or send fewer or smaller files. ${NOTHING_SENT}`);
|
|
199
|
+
}
|
|
200
|
+
/** The single stderr line for a send or a draft that carries files: count, bytes, sizes, types. */
|
|
201
|
+
export function describeMailAttachments(summary) {
|
|
202
|
+
return `[mail cli] attaching ${JSON.stringify(summary)}`;
|
|
203
|
+
}
|
|
204
|
+
/** The sentence a person reads after a send or a draft that carried files. */
|
|
205
|
+
export function attachmentSentence(summary) {
|
|
206
|
+
return `${summary.count} attachment${summary.count === 1 ? "" : "s"} went with it (${bytesLabel(summary.bytes)}).`;
|
|
207
|
+
}
|
|
@@ -18,9 +18,16 @@
|
|
|
18
18
|
* `send`, `issue comment` and `docs update` already keep — argv is world-
|
|
19
19
|
* readable on a shared machine and is captured by this product's own session
|
|
20
20
|
* harvester.
|
|
21
|
+
*
|
|
22
|
+
* FILES RIDE ALONG WITH `--attach <path>` (BLI-12661), repeatable, on `send` and
|
|
23
|
+
* `draft` alike. `composeMessage` reads them through `mail-attachments.ts` and
|
|
24
|
+
* puts them in the one request body both verbs post, so a field added for one
|
|
25
|
+
* cannot be forgotten by the other. `--file` still means the body and only the
|
|
26
|
+
* body; a path on argv is a path, not the file's contents.
|
|
21
27
|
*/
|
|
22
28
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, } from "./agent-door.js";
|
|
23
29
|
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
30
|
+
import { attachmentSentence, describeMailAttachments, mailRequestFits, readMailAttachments, } from "./mail-attachments.js";
|
|
24
31
|
import { readNoteFile } from "./notes-file.js";
|
|
25
32
|
// One spelling per surface: the same tag `mail.ts` uses.
|
|
26
33
|
const TAG = "[mail cli]";
|
|
@@ -51,18 +58,86 @@ export async function readMailBody(command, door) {
|
|
|
51
58
|
/**
|
|
52
59
|
* The JSON that `send` and `draft` post. One shape on purpose: the two doors
|
|
53
60
|
* take the same fields, and a message that can be sent can be saved unsent.
|
|
61
|
+
* `attachments` is present only when there are files (BLI-12661), so a message
|
|
62
|
+
* without any is the same bytes it always was.
|
|
54
63
|
*/
|
|
55
|
-
export function composedMessageBody(command, text) {
|
|
64
|
+
export function composedMessageBody(command, text, attachments = []) {
|
|
56
65
|
return {
|
|
57
66
|
account_id: command.accountId,
|
|
58
67
|
to: command.to.map((address) => ({ address })),
|
|
59
68
|
...(command.cc.length > 0 ? { cc: command.cc.map((address) => ({ address })) } : {}),
|
|
60
69
|
subject: command.subjectLine ?? "",
|
|
61
70
|
text,
|
|
71
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
62
72
|
...(command.replyTo ? { in_reply_to_message_id: command.replyTo } : {}),
|
|
63
73
|
};
|
|
64
74
|
}
|
|
65
|
-
|
|
75
|
+
const NO_ATTACHMENTS = { json: {}, sentence: null, posted: null };
|
|
76
|
+
/**
|
|
77
|
+
* Did the door SAY it carried the files that were posted? Claim only what the
|
|
78
|
+
* receipt shows: a Tower that predates attachments answers an ordinary 201,
|
|
79
|
+
* because its schema strips the key it does not know, and the message goes out
|
|
80
|
+
* or is saved WITHOUT its files while a terminal that reported from what it
|
|
81
|
+
* posted would say they went. Only the door's own `attachments` receipt (count
|
|
82
|
+
* and bytes, both) says they did.
|
|
83
|
+
*
|
|
84
|
+
* Returns the sentence to print when the answer does not say so, else null.
|
|
85
|
+
*/
|
|
86
|
+
export function unconfirmedAttachments(receipt, carried, verb) {
|
|
87
|
+
if (!receipt.posted)
|
|
88
|
+
return null;
|
|
89
|
+
const said = carried !== null && typeof carried === "object" ? carried : {};
|
|
90
|
+
if (said.count === receipt.posted.count && said.bytes === receipt.posted.bytes)
|
|
91
|
+
return null;
|
|
92
|
+
const files = `${receipt.posted.count} attachment${receipt.posted.count === 1 ? "" : "s"}`;
|
|
93
|
+
return verb === "send"
|
|
94
|
+
? `Tower accepted the message but did not confirm that its ${files} ${receipt.posted.count === 1 ? "was" : "were"} in it. `
|
|
95
|
+
+ "That usually means this Tower deployment predates attachments, in which case the message went out WITHOUT them. "
|
|
96
|
+
+ "It was sent once: look in that mailbox's Sent folder before doing anything else."
|
|
97
|
+
: `Tower saved the draft but did not confirm that its ${files} ${receipt.posted.count === 1 ? "was" : "were"} in it. `
|
|
98
|
+
+ "That usually means this Tower deployment predates attachments, in which case the draft has NO file. "
|
|
99
|
+
+ "Open that mailbox's Drafts, delete it, and try again once Tower is updated.";
|
|
100
|
+
}
|
|
101
|
+
/** The failure for a send or a draft whose files the door did not confirm. The message DID go, so the answer rides along. */
|
|
102
|
+
export function failAttachmentsUnconfirmed(door, verb, detail, answer) {
|
|
103
|
+
writeLine(door.io.stderr, `${TAG} not confirmed ${JSON.stringify({ reason: "attachments_not_confirmed" })}`);
|
|
104
|
+
if (door.json) {
|
|
105
|
+
writeLine(door.io.stdout, JSON.stringify({ ok: false, error: "attachments_not_confirmed", detail, [verb === "send" ? "sent" : "draft"]: answer }));
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
writeLine(door.io.stderr, detail);
|
|
109
|
+
}
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The request `send` and `draft` post: the body, plus the files named with
|
|
114
|
+
* `--attach`, read and screened HERE, before any request is made (BLI-12661).
|
|
115
|
+
* A refusal is the named reason and its numbers (`mail-attachments.ts`).
|
|
116
|
+
*
|
|
117
|
+
* The one stderr line it writes says how many files, how big and of what type;
|
|
118
|
+
* never a name, never a byte. Under `--json` stdout still carries one object.
|
|
119
|
+
*/
|
|
120
|
+
export async function composeMessage(command, door, text) {
|
|
121
|
+
const read = await readMailAttachments(command.attachPaths ?? []);
|
|
122
|
+
if (!read.ok)
|
|
123
|
+
return read;
|
|
124
|
+
if (read.attachments.length === 0)
|
|
125
|
+
return { ok: true, body: composedMessageBody(command, text), receipt: NO_ATTACHMENTS };
|
|
126
|
+
const body = composedMessageBody(command, text, read.attachments);
|
|
127
|
+
const fits = mailRequestFits(body);
|
|
128
|
+
if (!fits.ok)
|
|
129
|
+
return fits;
|
|
130
|
+
writeLine(door.io.stderr, describeMailAttachments(read.summary));
|
|
131
|
+
return { ok: true, body, receipt: attachmentReceipt(read.summary) };
|
|
132
|
+
}
|
|
133
|
+
function attachmentReceipt(summary) {
|
|
134
|
+
return {
|
|
135
|
+
json: { attachments: { count: summary.count, bytes: summary.bytes, content_types: summary.content_types } },
|
|
136
|
+
sentence: attachmentSentence(summary),
|
|
137
|
+
posted: { count: summary.count, bytes: summary.bytes },
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** `draft --account <id> --to <a[,b]> [--cc <c>] --subject "<s>" [--reply-to <message id>] [--file <path>] [--attach <path>]...` */
|
|
66
141
|
export async function runMailDraft(command, door) {
|
|
67
142
|
const body = await readMailBody(command, door);
|
|
68
143
|
if (!body.ok)
|
|
@@ -70,20 +145,29 @@ export async function runMailDraft(command, door) {
|
|
|
70
145
|
if (body.text.trim() === "") {
|
|
71
146
|
return failAgentDoor(door, TAG, "body_required", 'A draft needs a body: `echo "Tuesday works for me." | cockpit mail draft --account <id> --to someone@example.com --subject "Re: lunch"`.');
|
|
72
147
|
}
|
|
148
|
+
const message = await composeMessage(command, door, body.text);
|
|
149
|
+
if (!message.ok)
|
|
150
|
+
return failAgentDoor(door, TAG, message.reason, message.detail);
|
|
73
151
|
const answer = await askAgentDoor(door, {
|
|
74
152
|
path: "/api/mail/drafts",
|
|
75
153
|
method: "POST",
|
|
76
154
|
label: "mail draft",
|
|
77
155
|
timeoutMs: WRITE_DEADLINE_MS,
|
|
78
|
-
body:
|
|
156
|
+
body: message.body,
|
|
79
157
|
});
|
|
80
158
|
if (!answer.ok)
|
|
81
159
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
82
160
|
const draft = answer.body.draft;
|
|
161
|
+
// Report only what the door's receipt shows (BLI-12661), never what was posted.
|
|
162
|
+
const unconfirmed = unconfirmedAttachments(message.receipt, draft?.attachments, "draft");
|
|
163
|
+
if (unconfirmed)
|
|
164
|
+
return failAttachmentsUnconfirmed(door, "draft", unconfirmed, draft);
|
|
83
165
|
if (door.json)
|
|
84
|
-
return emitAgentDoor(door, { ok: true, draft });
|
|
166
|
+
return emitAgentDoor(door, { ok: true, draft, ...message.receipt.json });
|
|
85
167
|
writeLine(door.io.stdout, `Saved a draft in ${draft?.address ?? "that mailbox"} for ${command.to.join(", ")}. `
|
|
86
168
|
+ "Nothing was sent: it is in Drafts for a person to read, edit and send.");
|
|
169
|
+
if (message.receipt.sentence)
|
|
170
|
+
writeLine(door.io.stdout, message.receipt.sentence);
|
|
87
171
|
if (command.replyTo) {
|
|
88
172
|
// `threaded` is what Gmail answered, not what was asked: a reply is only
|
|
89
173
|
// filed in its conversation when the subject still matches the original's.
|
package/dist/commands/mail.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
import { writeFile } from "node:fs/promises";
|
|
26
26
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
27
27
|
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
28
|
-
import {
|
|
28
|
+
import { composeMessage, failAttachmentsUnconfirmed, readMailBody, runMailDraft, unconfirmedAttachments, } from "./mail-compose.js";
|
|
29
29
|
import { cacheAgeLabel, readThroughCache } from "./read-cache.js";
|
|
30
30
|
import { towerRequest } from "../tower-client.js";
|
|
31
31
|
const TAG = "[mail cli]";
|
|
@@ -259,19 +259,29 @@ async function send(command, door) {
|
|
|
259
259
|
if (body.text.trim() === "") {
|
|
260
260
|
return failAgentDoor(door, TAG, "body_required", 'A message needs a body: `echo "on my way" | cockpit mail send --account <id> --to someone@example.com --subject "Re: lunch"`.');
|
|
261
261
|
}
|
|
262
|
+
// The files named with --attach are read and refused HERE, by name, before any request.
|
|
263
|
+
const message = await composeMessage(command, door, body.text);
|
|
264
|
+
if (!message.ok)
|
|
265
|
+
return failAgentDoor(door, TAG, message.reason, message.detail);
|
|
262
266
|
const answer = await askAgentDoor(door, {
|
|
263
267
|
path: "/api/mail/send",
|
|
264
268
|
method: "POST",
|
|
265
269
|
label: "mail send",
|
|
266
270
|
timeoutMs: WRITE_DEADLINE_MS,
|
|
267
|
-
body:
|
|
271
|
+
body: message.body,
|
|
268
272
|
});
|
|
269
273
|
if (!answer.ok)
|
|
270
274
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
271
275
|
const sent = answer.body.sent;
|
|
276
|
+
// Report only what the door's receipt shows (BLI-12661), never what was posted.
|
|
277
|
+
const unconfirmed = unconfirmedAttachments(message.receipt, sent?.attachments, "send");
|
|
278
|
+
if (unconfirmed)
|
|
279
|
+
return failAttachmentsUnconfirmed(door, "send", unconfirmed, sent);
|
|
272
280
|
if (door.json)
|
|
273
|
-
return emitAgentDoor(door, { ok: true, sent });
|
|
281
|
+
return emitAgentDoor(door, { ok: true, sent, ...message.receipt.json });
|
|
274
282
|
writeLine(door.io.stdout, `Sent from ${sent?.address ?? "that mailbox"} to ${command.to.join(", ")}${sent?.threaded ? " (as a reply)" : ""}.`);
|
|
283
|
+
if (message.receipt.sentence)
|
|
284
|
+
writeLine(door.io.stdout, message.receipt.sentence);
|
|
275
285
|
return 0;
|
|
276
286
|
}
|
|
277
287
|
async function attachment(command, door) {
|
|
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
24
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
24
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.170");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tick's machine-heal follow-ups that `cockpit doctor` also performs
|
|
3
|
+
* (BLI-12828). Doctor and the tick are meant to be the same repairs run by a
|
|
4
|
+
* person and run by the clock; `docs/runbooks/cockpit-doctor-repairs.md` is the
|
|
5
|
+
* table of which row each one answers.
|
|
6
|
+
*
|
|
7
|
+
* agent rules missing re-install them inspected every tick, a
|
|
8
|
+
* failed install retried hourly
|
|
9
|
+
* sign-in lapsed record the ONE human written when the heartbeat
|
|
10
|
+
* action, stop nagging could not renew, reported daily
|
|
11
|
+
*
|
|
12
|
+
* The rest of the table is already on the tick elsewhere: the CLI floor
|
|
13
|
+
* (`sync-followups-self-update.ts`), the scheduler incl. an ABSENT one
|
|
14
|
+
* (`sync-followups-autostart.ts` over `../autostart-self-heal.ts`), BLI Memory
|
|
15
|
+
* (`sync-followups-memory.ts`) and staging (`sync-followups-staging.ts`).
|
|
16
|
+
*
|
|
17
|
+
* Same rule as every follow-up: it runs after collection's outcome is decided,
|
|
18
|
+
* never throws, and its outcome is its own named receipt, never a sync failure.
|
|
19
|
+
* A new sibling must also join `scripts/build-public-cli.mjs` `runtimeFiles`.
|
|
20
|
+
*/
|
|
21
|
+
import fs from "node:fs/promises";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
|
|
24
|
+
import { heartbeatRefusalStatus } from "./heartbeat-token.js";
|
|
25
|
+
import { SIGN_IN_ACTION } from "../sign-in-action.js";
|
|
26
|
+
import { inspectAgentRules, installAgentRules } from "../agent-rules.js";
|
|
27
|
+
import { getCollectorRuntimePaths, readLocalCollectorConfig, readLocalCollectorSessionFile, toSessionReference, } from "../local-state.js";
|
|
28
|
+
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
29
|
+
export const AGENT_RULES_REPAIR_MARKER = ".last-agent-rules-repair";
|
|
30
|
+
export const SESSION_NEEDS_LOGIN_MARKER = "session-needs-login.json";
|
|
31
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
32
|
+
/** Only a RETRY of a failed install waits; the first install never does. */
|
|
33
|
+
const AGENT_RULES_RETRY_AFTER_FAILURE_MS = 60 * 60 * 1000;
|
|
34
|
+
export async function runMachineHealAfterSync(command, io, dashboardUrl, options = {}) {
|
|
35
|
+
const now = options.now ?? new Date();
|
|
36
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
37
|
+
const events = [];
|
|
38
|
+
for (const heal of [
|
|
39
|
+
() => healAgentRules(command, paths, now),
|
|
40
|
+
() => noteSessionState(paths, heartbeatRefusalStatus(), now),
|
|
41
|
+
]) {
|
|
42
|
+
try {
|
|
43
|
+
const event = await heal();
|
|
44
|
+
if (event)
|
|
45
|
+
events.push(event);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
console.error("[sync-heal] a machine-heal step threw and was dropped", JSON.stringify({ reason: "heal_step_threw", detail: redactedSyncErrorDetail(error) }));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (events.length === 0)
|
|
52
|
+
return;
|
|
53
|
+
await reportInstallEventsBestEffort({
|
|
54
|
+
homeDir: command.homeDir,
|
|
55
|
+
dashboardUrl,
|
|
56
|
+
command: "sync",
|
|
57
|
+
events,
|
|
58
|
+
json: command.json,
|
|
59
|
+
io,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Agent rules: cheap to read, so read every tick; write only when missing. */
|
|
63
|
+
async function healAgentRules(command, paths, now) {
|
|
64
|
+
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
65
|
+
const roots = normalizeCollectionRoots(config?.default_repo_paths ?? []);
|
|
66
|
+
if (roots.length === 0) {
|
|
67
|
+
console.error("[sync-heal] agent rules not checked; no saved roots to scope them to", JSON.stringify({ reason: "no_saved_roots" }));
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const scope = { ...(command.homeDir ? { homeDir: command.homeDir } : {}), scopePaths: roots };
|
|
71
|
+
const marker = path.join(paths.state_dir, AGENT_RULES_REPAIR_MARKER);
|
|
72
|
+
const before = await inspectAgentRules(scope);
|
|
73
|
+
if (before.installed) {
|
|
74
|
+
// A marker from an earlier attempt is spent; the next removal is
|
|
75
|
+
// reinstalled on the next tick, not after a retry window.
|
|
76
|
+
await fs.rm(marker, { force: true }).catch(() => undefined);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const last = await fs.stat(marker).catch(() => null);
|
|
80
|
+
if (last && now.getTime() - last.mtimeMs < AGENT_RULES_RETRY_AFTER_FAILURE_MS) {
|
|
81
|
+
console.error("[sync-heal] agent rules still missing; the last install attempt was under an hour ago, retrying after that", JSON.stringify({ reason: "agent_rules_repair_throttled" }));
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
|
|
85
|
+
await fs.writeFile(marker, now.toISOString()).catch(() => undefined);
|
|
86
|
+
await installAgentRules(scope);
|
|
87
|
+
const after = await inspectAgentRules(scope);
|
|
88
|
+
console.error(after.installed
|
|
89
|
+
? "[sync-heal] agent rules were missing and are installed"
|
|
90
|
+
: "[sync-heal] agent rules install ran but they still read missing", JSON.stringify({ reason: after.installed ? "agent_rules_installed" : "agent_rules_still_missing", before: "missing", after: after.installed ? "installed" : "missing" }));
|
|
91
|
+
return after.installed
|
|
92
|
+
? { step: "agent_rules_install", status: "ok", error_detail: "before=missing; after=installed" }
|
|
93
|
+
: { step: "agent_rules_install", status: "fail", error_code: "agent_rules_still_missing", error_detail: "before=missing; after=missing" };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Sign-in: the heartbeat (which ran just before this) renews a lapsed token
|
|
97
|
+
* inside the server's grace window without a browser. If this machine is STILL
|
|
98
|
+
* signed out or the door refused the token (401/403), only a browser can fix
|
|
99
|
+
* it, so record the one action and say it once a day instead of every fifteen
|
|
100
|
+
* minutes. A lapsed local date with no refusal (offline, a checkout build)
|
|
101
|
+
* proves nothing, so it says nothing.
|
|
102
|
+
*/
|
|
103
|
+
async function noteSessionState(paths, refusedStatus, now) {
|
|
104
|
+
const marker = path.join(paths.state_dir, SESSION_NEEDS_LOGIN_MARKER);
|
|
105
|
+
const session = await readLocalCollectorSessionFile(paths).catch(() => null);
|
|
106
|
+
const state = session ? toSessionReference(session).session_state : "missing";
|
|
107
|
+
const noted = await readMarker(marker);
|
|
108
|
+
const refused = refusedStatus === 401 || refusedStatus === 403;
|
|
109
|
+
if (state === "valid" && !refused) {
|
|
110
|
+
if (!noted)
|
|
111
|
+
return null;
|
|
112
|
+
await fs.rm(marker, { force: true }).catch(() => undefined);
|
|
113
|
+
console.error("[sync-heal] sign-in is valid again; the needs-login note is cleared", JSON.stringify({ reason: "session_renewed", before: "needs_login", after: "valid" }));
|
|
114
|
+
return { step: "session_renewal", status: "ok", error_detail: "before=needs_login; after=valid" };
|
|
115
|
+
}
|
|
116
|
+
if (state !== "missing" && !refused)
|
|
117
|
+
return null;
|
|
118
|
+
const nowIso = now.toISOString();
|
|
119
|
+
if (noted && now.getTime() - Date.parse(noted.last_reported_at) < DAY_MS) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const record = { since: noted?.since ?? nowIso, last_reported_at: nowIso, session_state: state, action: SIGN_IN_ACTION };
|
|
123
|
+
await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
|
|
124
|
+
await fs.writeFile(marker, JSON.stringify(record)).catch(() => undefined);
|
|
125
|
+
console.error("[sync-heal] this machine is signed out and a browser sign-in is the only fix; saying so once a day", JSON.stringify({ reason: "browser_sign_in_required", session_state: state, next_action: SIGN_IN_ACTION }));
|
|
126
|
+
return {
|
|
127
|
+
step: "session_renewal",
|
|
128
|
+
status: "fail",
|
|
129
|
+
error_code: "browser_sign_in_required",
|
|
130
|
+
error_detail: `${state}; ${SIGN_IN_ACTION}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
async function readMarker(file) {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(await fs.readFile(file, "utf8"));
|
|
136
|
+
if (typeof parsed["since"] === "string" && typeof parsed["last_reported_at"] === "string") {
|
|
137
|
+
return { since: parsed["since"], last_reported_at: parsed["last_reported_at"] };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// No marker, or an unreadable one, reads as "never said".
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* collect behind both locks sync-receipt.ts
|
|
12
12
|
* check in with the heartbeat door sync-heartbeat.ts
|
|
13
13
|
* say how the tick went reportTickOutcome / reportTickThrew
|
|
14
|
-
* converge the rest of the machine sync-followups.ts
|
|
14
|
+
* converge the rest of the machine sync-followups.ts, sync-heal.ts
|
|
15
15
|
*
|
|
16
16
|
* The supporting modules each own one thing those steps share:
|
|
17
17
|
*
|
|
@@ -39,13 +39,18 @@ import { createInteractiveExecRunner } from "../process-runner.js";
|
|
|
39
39
|
import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
|
|
40
40
|
import { runMemoryExperienceAfterSync } from "./memory-log.js";
|
|
41
41
|
import { classifySyncHealthError, redactedSyncErrorDetail, reportInstallEventsBestEffort, } from "./install-receipts.js";
|
|
42
|
+
import { runMachineHealAfterSync } from "./sync-heal.js";
|
|
42
43
|
import { sendSyncHeartbeat } from "./sync-heartbeat.js";
|
|
43
44
|
import { runSyncWithHealthReceipt } from "./sync-receipt.js";
|
|
44
45
|
import { runAutostartSelfHealAfterSync, runMemoryInstallAfterSync, runScheduledSelfUpdateAfterSync, runStagingPruneAfterSync, } from "./sync-followups.js";
|
|
45
46
|
import { rotateCollectorLogsBestEffort } from "../log-rotation.js";
|
|
47
|
+
import { stampSyncTick } from "../sync-tick-stamp.js";
|
|
46
48
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
|
|
47
49
|
export async function runSync(command, io) {
|
|
48
50
|
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
51
|
+
// Ticket 12831: the memory prompt hook reads this stamp to tell a scheduler
|
|
52
|
+
// that stopped firing from one that is merely quiet.
|
|
53
|
+
await stampSyncTick(paths);
|
|
49
54
|
// BLI-3553, first thing in the tick: cap the scheduler's own logs. Nothing
|
|
50
55
|
// rotated them before, and two months of a 15-minute tick left 211 MB of
|
|
51
56
|
// sync.log on the reference Mac. Best-effort by construction — a rotation
|
|
@@ -165,5 +170,8 @@ async function reportTickThrew(command, io, dashboardUrl, error, errorCode) {
|
|
|
165
170
|
async function convergeAfterTick(command, io, dashboardUrl) {
|
|
166
171
|
await runAutostartSelfHealAfterSync(command, io, dashboardUrl);
|
|
167
172
|
await runMemoryInstallAfterSync(command, io, dashboardUrl);
|
|
173
|
+
// BLI-12828: the repairs `cockpit doctor` also makes (agent rules, a lapsed
|
|
174
|
+
// sign-in), so nobody has to be told to run it.
|
|
175
|
+
await runMachineHealAfterSync(command, io, dashboardUrl);
|
|
168
176
|
await runMemoryExperienceAfterSync({ ...command, dashboardUrl }, io);
|
|
169
177
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE sentence a person gets when only a browser can fix their sign-in
|
|
3
|
+
* (BLI-12828). Doctor's `authed` row, doctor's exit-code receipt and the sync
|
|
4
|
+
* tick's once-a-day note all say these exact words, so nobody is told two
|
|
5
|
+
* different things for the same problem.
|
|
6
|
+
*/
|
|
7
|
+
export const SIGN_IN_ACTION = "Run `cockpit login` and approve the sign-in in your browser.";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { SYNC_TICK_STAMP_FILE_NAME } from "@bli-cockpit/telemetry-core";
|
|
4
|
+
/**
|
|
5
|
+
* Ticket 12831. Touched first thing in every `cockpit sync`, so "when did this
|
|
6
|
+
* machine last tick" is one file stat. The memory prompt hook reads it
|
|
7
|
+
* (`@bli-cockpit/memory-mcp` `hooks/sync-revive.ts`) and revives a scheduler
|
|
8
|
+
* that stopped firing. Best-effort: a stamp that cannot be written says so and
|
|
9
|
+
* never fails the tick (the worst case is one extra hourly revive attempt).
|
|
10
|
+
*/
|
|
11
|
+
export async function stampSyncTick(paths, now = new Date()) {
|
|
12
|
+
try {
|
|
13
|
+
await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
|
|
14
|
+
await fs.writeFile(path.join(paths.state_dir, SYNC_TICK_STAMP_FILE_NAME), now.toISOString());
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
console.error("[sync-tick-stamp] tick stamp not written; the prompt hook may attempt a revive", JSON.stringify({
|
|
18
|
+
reason: "tick_stamp_write_failed",
|
|
19
|
+
error_name: error instanceof Error ? error.name : typeof error,
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.170",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/memory-mcp": "0.1.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.46",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.87",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.58"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|