@bli-cockpit/cli 0.2.19 → 0.2.21
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.js +17 -2
- package/dist/commands/local.js +105 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/scheduled-self-update.js +136 -0
- package/package.json +1 -1
package/dist/autostart.js
CHANGED
|
@@ -10,6 +10,16 @@ const WINDOWS_AUTOSTART_SCRIPT_NAME = "autostart-sync.ps1";
|
|
|
10
10
|
const WINDOWS_AUTOSTART_REGISTRATION_SCRIPT_NAME = "autostart-register.ps1";
|
|
11
11
|
const UTF8_BOM = "\uFEFF";
|
|
12
12
|
export const DEFAULT_AUTOSTART_INTERVAL_SECONDS = 15 * 60;
|
|
13
|
+
/**
|
|
14
|
+
* macOS rewrites this file on every network transition (join, leave, DNS
|
|
15
|
+
* change), so watching it retries spooled uploads the moment connectivity
|
|
16
|
+
* returns instead of waiting out the StartInterval \u2014 the lid-closed-mid-upload
|
|
17
|
+
* machine hopping caf\u00E9s is where most historical upload failures came from
|
|
18
|
+
* (BLI-2604). The real file, not the /etc/resolv.conf symlink: launchd watches
|
|
19
|
+
* the path it is given, and the symlink itself never changes. Burst fires are
|
|
20
|
+
* cheap \u2014 the collection lock turns overlap into a named no-op.
|
|
21
|
+
*/
|
|
22
|
+
export const DARWIN_NETWORK_CHANGE_SIGNAL = "/private/var/run/resolv.conf";
|
|
13
23
|
const UNSUPPORTED_MESSAGE = "autostart is supported on macOS and Windows only";
|
|
14
24
|
function plistPathFor(homeDir) {
|
|
15
25
|
return path.join(homeDir, "Library", "LaunchAgents", `${AUTOSTART_LABEL}.plist`);
|
|
@@ -49,8 +59,13 @@ export async function installAutostartAgent(options) {
|
|
|
49
59
|
const stderrPath = path.join(paths.state_dir, "sync.err.log");
|
|
50
60
|
// launchd will not reliably watch a path that does not exist at load time, so
|
|
51
61
|
// only feed it the transcript dirs that are present right now. A missing dir
|
|
52
|
-
// is fine — the StartInterval floor still covers it.
|
|
53
|
-
|
|
62
|
+
// is fine — the StartInterval floor still covers it. The network signal is
|
|
63
|
+
// appended unconditionally: it always exists on the Macs this plist targets,
|
|
64
|
+
// and an existence filter would drop it when rendering on another host.
|
|
65
|
+
const watchPaths = [
|
|
66
|
+
...(await existingWatchPaths(homeDir)),
|
|
67
|
+
DARWIN_NETWORK_CHANGE_SIGNAL,
|
|
68
|
+
];
|
|
54
69
|
await mkdir(path.dirname(plistPath), { recursive: true });
|
|
55
70
|
await mkdir(paths.state_dir, { recursive: true });
|
|
56
71
|
await writeFile(plistPath, renderPlist({
|
package/dist/commands/local.js
CHANGED
|
@@ -21,6 +21,7 @@ import { resolveDiscoveryLimits, saveDiscoveryLimits, } from "../discovery-limit
|
|
|
21
21
|
import { runAttributedWorktreeSync, matchesLiveSyncWorktree, } from "./session-sync.js";
|
|
22
22
|
import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, resolveOnboardingRoots, rootRejectionExplanation, } from "../onboarding-roots.js";
|
|
23
23
|
import { rawEvidenceGcSummary, runRawEvidenceLocalGc, } from "../raw-evidence-gc.js";
|
|
24
|
+
import { envWithNodeRuntimeOnPath, runScheduledSelfUpdate, } from "../scheduled-self-update.js";
|
|
24
25
|
import { enqueueInstallEventEntry, readPendingInstallEventEntries, recordInstallEventAttemptFailure, removeInstallEventEntry, } from "../spool/install-event-outbox.js";
|
|
25
26
|
import { createCapturedExecRunner, createInteractiveExecRunner, } from "../process-runner.js";
|
|
26
27
|
import { normalizeCollectionRoots } from "../root-normalization.js";
|
|
@@ -250,6 +251,9 @@ function localSubcommandHelp(command) {
|
|
|
250
251
|
"Newly discovered repos get a general ambient work context automatically.",
|
|
251
252
|
"Discovery scans 3 folder levels and up to 50 repos by default; tune with",
|
|
252
253
|
"--max-depth and --max-repos.",
|
|
254
|
+
"Also self-updates the CLI from npm latest once per day, strictly after",
|
|
255
|
+
"collection finishes; set COCKPIT_DISABLE_AUTO_UPDATE=1 to freeze the",
|
|
256
|
+
"installed version during incident triage.",
|
|
253
257
|
],
|
|
254
258
|
],
|
|
255
259
|
[
|
|
@@ -2149,6 +2153,9 @@ async function runSync(command, io) {
|
|
|
2149
2153
|
json: command.json,
|
|
2150
2154
|
io,
|
|
2151
2155
|
});
|
|
2156
|
+
// BLI-2601: self-update runs only after collection's own outcome above is
|
|
2157
|
+
// already decided and reported, win or lose. See the function doc.
|
|
2158
|
+
await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl);
|
|
2152
2159
|
return result.exitCode;
|
|
2153
2160
|
}
|
|
2154
2161
|
catch (error) {
|
|
@@ -2167,9 +2174,107 @@ async function runSync(command, io) {
|
|
|
2167
2174
|
json: command.json,
|
|
2168
2175
|
io,
|
|
2169
2176
|
});
|
|
2177
|
+
await runScheduledSelfUpdateAfterSync(command, io, dashboardUrl);
|
|
2170
2178
|
throw error;
|
|
2171
2179
|
}
|
|
2172
2180
|
}
|
|
2181
|
+
/**
|
|
2182
|
+
* BLI-2601: the fleet keeps itself current on npm `latest` without anyone
|
|
2183
|
+
* re-running `npm i -g @bli-cockpit/cli` by hand after day 0. This always
|
|
2184
|
+
* runs AFTER `runSync` has already decided and reported collection's own
|
|
2185
|
+
* outcome above — a stuck or failing self-update can never block or delay
|
|
2186
|
+
* collection, and a collection failure never blocks the chance to
|
|
2187
|
+
* self-update. Every error path here is swallowed on purpose: a failure is
|
|
2188
|
+
* reported as its own named `update` receipt, never surfaced as a `sync`
|
|
2189
|
+
* failure or thrown from this function.
|
|
2190
|
+
*/
|
|
2191
|
+
async function runScheduledSelfUpdateAfterSync(command, io, dashboardUrl) {
|
|
2192
|
+
let event;
|
|
2193
|
+
try {
|
|
2194
|
+
event = await runScheduledSelfUpdateForSync(command, io);
|
|
2195
|
+
}
|
|
2196
|
+
catch (error) {
|
|
2197
|
+
// The throttle/probe/install machinery below is defensive already; this
|
|
2198
|
+
// is the last-resort net so an update crash truly cannot touch the sync
|
|
2199
|
+
// result above.
|
|
2200
|
+
event = {
|
|
2201
|
+
step: "update",
|
|
2202
|
+
status: "fail",
|
|
2203
|
+
error_code: "self_update_threw",
|
|
2204
|
+
error_detail: redactedSyncErrorDetail(error),
|
|
2205
|
+
};
|
|
2206
|
+
}
|
|
2207
|
+
if (!event)
|
|
2208
|
+
return;
|
|
2209
|
+
await reportInstallEventsBestEffort({
|
|
2210
|
+
homeDir: command.homeDir,
|
|
2211
|
+
dashboardUrl,
|
|
2212
|
+
command: "update",
|
|
2213
|
+
events: [event],
|
|
2214
|
+
json: command.json,
|
|
2215
|
+
io,
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
async function runScheduledSelfUpdateForSync(command, io) {
|
|
2219
|
+
const rawExec = io.exec;
|
|
2220
|
+
if (!rawExec) {
|
|
2221
|
+
// Only the real production `defaultIo()` supplies a process runner. A
|
|
2222
|
+
// caller that omitted one gets a silent no-op rather than this reaching
|
|
2223
|
+
// for a real npm binary it was never given — never observed in
|
|
2224
|
+
// production, where `defaultIo()` always sets `exec`.
|
|
2225
|
+
return null;
|
|
2226
|
+
}
|
|
2227
|
+
// Every spawn in the scheduled path carries the running node's bin dir on
|
|
2228
|
+
// PATH — see envWithNodeRuntimeOnPath. Interactive doctor never needed
|
|
2229
|
+
// this; the scheduler's stripped environment does.
|
|
2230
|
+
const spawnEnv = envWithNodeRuntimeOnPath(io.env ?? process.env);
|
|
2231
|
+
const exec = (cmd, args, options) => rawExec(cmd, args, { ...options, env: options?.env ?? spawnEnv });
|
|
2232
|
+
const scheduledIo = { ...io, exec };
|
|
2233
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
2234
|
+
const result = await runScheduledSelfUpdate(paths, {
|
|
2235
|
+
exec,
|
|
2236
|
+
currentVersion: LOCAL_COLLECTOR_VERSION,
|
|
2237
|
+
install: (tag) => attemptScheduledSelfUpdateInstall(scheduledIo, tag),
|
|
2238
|
+
}, { env: io.env });
|
|
2239
|
+
return scheduledSelfUpdateInstallEvent(result);
|
|
2240
|
+
}
|
|
2241
|
+
async function attemptScheduledSelfUpdateInstall(io, tag) {
|
|
2242
|
+
try {
|
|
2243
|
+
// Reuses the exact npm-install machinery `cockpit doctor`'s
|
|
2244
|
+
// `fixCliLatest` uses (see doctor.ts:243-280) so there is one place that
|
|
2245
|
+
// knows how to invoke `npm i -g` and classify EACCES. Unlike doctor,
|
|
2246
|
+
// this call never re-execs — see runScheduledSelfUpdate's doc comment.
|
|
2247
|
+
await runSelfUpdate(io, { json: true, tag });
|
|
2248
|
+
return { ok: true };
|
|
2249
|
+
}
|
|
2250
|
+
catch (error) {
|
|
2251
|
+
if (!(error instanceof SelfUpdateError))
|
|
2252
|
+
throw error;
|
|
2253
|
+
return { ok: false, eacces: error.eacces };
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
function scheduledSelfUpdateInstallEvent(result) {
|
|
2257
|
+
// The steady-state "already checked today" case is a pure no-op; reporting
|
|
2258
|
+
// it would post a receipt on ~95 of every 96 sync ticks for no new
|
|
2259
|
+
// information. Only a real attempt (ok, fail, or an explicit disable)
|
|
2260
|
+
// produces a receipt.
|
|
2261
|
+
if (result.reason === "throttled_recent_attempt")
|
|
2262
|
+
return null;
|
|
2263
|
+
if (result.status === "ok")
|
|
2264
|
+
return { step: "update", status: "ok" };
|
|
2265
|
+
const detail = [
|
|
2266
|
+
result.target_version ? `target ${result.target_version}` : null,
|
|
2267
|
+
result.installed_version ? `installed ${result.installed_version}` : null,
|
|
2268
|
+
]
|
|
2269
|
+
.filter((part) => Boolean(part))
|
|
2270
|
+
.join("; ");
|
|
2271
|
+
return {
|
|
2272
|
+
step: "update",
|
|
2273
|
+
status: result.status,
|
|
2274
|
+
error_code: result.reason,
|
|
2275
|
+
...(detail ? { error_detail: detail } : {}),
|
|
2276
|
+
};
|
|
2277
|
+
}
|
|
2173
2278
|
async function runSyncWithHealthReceipt(command, io) {
|
|
2174
2279
|
const backfillLock = await inspectBackfillLock(getCollectorRuntimePaths(command.homeDir));
|
|
2175
2280
|
if (backfillLock.held) {
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.21");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const SELF_UPDATE_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
export const SELF_UPDATE_THROTTLE_MARKER = ".last-self-update-check";
|
|
5
|
+
/**
|
|
6
|
+
* launchd starts the scheduled tick with a bare PATH (`/usr/bin:/bin:...`) —
|
|
7
|
+
* the plist hardcodes absolute node/CLI paths for exactly that reason — so a
|
|
8
|
+
* bare `npm` spawn that works in every interactive shell resolves to nothing
|
|
9
|
+
* inside the tick, and every scheduled update would fail `npm_install_failed`
|
|
10
|
+
* on the whole Mac fleet while interactive `cockpit doctor` kept working. The
|
|
11
|
+
* npm shim ships beside the node binary in the standard layouts on both host
|
|
12
|
+
* families, so the running node's own directory is the one PATH entry that is
|
|
13
|
+
* always right.
|
|
14
|
+
*/
|
|
15
|
+
export function envWithNodeRuntimeOnPath(env, nodeExecutable = process.execPath) {
|
|
16
|
+
const nodeBinDir = path.dirname(nodeExecutable);
|
|
17
|
+
const currentPath = env["PATH"] ?? "";
|
|
18
|
+
if (currentPath.split(path.delimiter).includes(nodeBinDir))
|
|
19
|
+
return env;
|
|
20
|
+
return {
|
|
21
|
+
...env,
|
|
22
|
+
PATH: [nodeBinDir, currentPath].filter(Boolean).join(path.delimiter),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* BLI-2601. Runs at most once per day from inside `cockpit sync`'s
|
|
27
|
+
* post-collection tail, so the fleet converges on npm `latest` without
|
|
28
|
+
* anyone re-running `npm i -g @bli-cockpit/cli` by hand after day 0.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately does not re-exec: this process already has the OLD code
|
|
31
|
+
* loaded in memory, so nothing in-process could prove a re-exec actually
|
|
32
|
+
* picked up the new build. The next scheduled tick launches a fresh
|
|
33
|
+
* `cockpit` process from disk and picks up whatever npm actually installed —
|
|
34
|
+
* that is the verification, not a re-exec here.
|
|
35
|
+
*/
|
|
36
|
+
export async function runScheduledSelfUpdate(paths, deps, options = {}) {
|
|
37
|
+
const env = options.env ?? process.env;
|
|
38
|
+
const now = options.now ?? new Date();
|
|
39
|
+
const tag = options.tag ?? "latest";
|
|
40
|
+
const marker = path.join(paths.state_dir, SELF_UPDATE_THROTTLE_MARKER);
|
|
41
|
+
const lastCheck = await fs.stat(marker).catch(() => null);
|
|
42
|
+
if (lastCheck && now.getTime() - lastCheck.mtimeMs < SELF_UPDATE_MIN_INTERVAL_MS) {
|
|
43
|
+
// The steady-state case: already checked today, nothing to do. Not
|
|
44
|
+
// reported as an "attempt" by the caller — this fires on ~95 of every 96
|
|
45
|
+
// ticks and would otherwise be pure noise.
|
|
46
|
+
return { status: "skipped", reason: "throttled_recent_attempt" };
|
|
47
|
+
}
|
|
48
|
+
// The marker is written for the attempt, not the outcome — same idiom as
|
|
49
|
+
// raw-evidence GC (raw-evidence-gc.ts). A machine stuck on a permissions
|
|
50
|
+
// error must not spend every 15-min tick re-hitting the npm registry.
|
|
51
|
+
await fs.mkdir(paths.state_dir, { recursive: true }).catch(() => undefined);
|
|
52
|
+
await fs.writeFile(marker, now.toISOString()).catch(() => undefined);
|
|
53
|
+
// Incident triage freeze: an operator can pin a machine's installed
|
|
54
|
+
// version while debugging without touching the scheduler itself. Mirrors
|
|
55
|
+
// COCKPIT_DISABLE_GC. Checked after the throttle write on purpose, so a
|
|
56
|
+
// disabled machine reports itself at most once per day too, instead of on
|
|
57
|
+
// every tick.
|
|
58
|
+
if (env["COCKPIT_DISABLE_AUTO_UPDATE"] === "1") {
|
|
59
|
+
return { status: "skipped", reason: "disabled_by_env" };
|
|
60
|
+
}
|
|
61
|
+
const targetVersion = await latestCliVersionFromNpm(deps.exec, tag);
|
|
62
|
+
if (targetVersion && targetVersion === deps.currentVersion) {
|
|
63
|
+
return { status: "ok", reason: "already_latest", target_version: targetVersion };
|
|
64
|
+
}
|
|
65
|
+
const installed = await deps.install(tag);
|
|
66
|
+
if (!installed.ok) {
|
|
67
|
+
return {
|
|
68
|
+
status: "fail",
|
|
69
|
+
reason: installed.eacces ? "eacces_needs_chown" : "npm_install_failed",
|
|
70
|
+
...(targetVersion ? { target_version: targetVersion } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// Verify against what the platform returns, not the exit code npm handed
|
|
74
|
+
// back (BLI-2541 lesson: a green exit code is not proof). Read the
|
|
75
|
+
// globally installed package's version back off disk via `npm ls -g
|
|
76
|
+
// --json` rather than trusting that install succeeding means the version
|
|
77
|
+
// this process expected is what is actually there.
|
|
78
|
+
const installedVersion = await probeInstalledCliVersion(deps.exec);
|
|
79
|
+
if (!installedVersion || (targetVersion && installedVersion !== targetVersion)) {
|
|
80
|
+
return {
|
|
81
|
+
status: "fail",
|
|
82
|
+
reason: "stale_after_self_update",
|
|
83
|
+
...(targetVersion ? { target_version: targetVersion } : {}),
|
|
84
|
+
...(installedVersion ? { installed_version: installedVersion } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
status: "ok",
|
|
89
|
+
reason: "updated",
|
|
90
|
+
target_version: targetVersion ?? installedVersion,
|
|
91
|
+
installed_version: installedVersion,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
async function latestCliVersionFromNpm(exec, tag) {
|
|
95
|
+
const result = await exec("npm", [
|
|
96
|
+
"view",
|
|
97
|
+
`@bli-cockpit/cli@${tag}`,
|
|
98
|
+
"version",
|
|
99
|
+
"--json",
|
|
100
|
+
]);
|
|
101
|
+
if (result.code !== 0)
|
|
102
|
+
return null;
|
|
103
|
+
return parseNpmVersionField(result.stdout);
|
|
104
|
+
}
|
|
105
|
+
async function probeInstalledCliVersion(exec) {
|
|
106
|
+
// `npm ls` can exit non-zero on unrelated extraneous/peer-dependency
|
|
107
|
+
// warnings even when the requested package resolved cleanly, so the JSON
|
|
108
|
+
// body is parsed regardless of exit code — the printed tree is the ground
|
|
109
|
+
// truth here, not the exit code.
|
|
110
|
+
const result = await exec("npm", ["ls", "-g", "@bli-cockpit/cli", "--json"]);
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(result.stdout);
|
|
113
|
+
const version = parsed.dependencies?.["@bli-cockpit/cli"]?.version;
|
|
114
|
+
return typeof version === "string" && version.trim() ? version.trim() : null;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Small, intentional duplication of doctor.ts's `parseNpmVersion`: importing
|
|
121
|
+
// it here would create a cycle (doctor.ts already imports types from
|
|
122
|
+
// commands/local.ts, which would need to import this file to wire the sync
|
|
123
|
+
// integration). The parsing itself is a few lines and has no behavior this
|
|
124
|
+
// module doesn't already own.
|
|
125
|
+
function parseNpmVersionField(stdout) {
|
|
126
|
+
const trimmed = stdout.trim();
|
|
127
|
+
if (!trimmed)
|
|
128
|
+
return null;
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(trimmed);
|
|
131
|
+
return typeof parsed === "string" && parsed.trim() ? parsed.trim() : null;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return trimmed.replace(/^"|"$/gu, "") || null;
|
|
135
|
+
}
|
|
136
|
+
}
|