@bli-cockpit/cli 0.2.28 → 0.2.30

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.
Files changed (33) hide show
  1. package/README.md +22 -15
  2. package/dist/adapters/local-sources.js +1 -0
  3. package/dist/adapters/raw-evidence-completeness.js +226 -0
  4. package/dist/adapters/raw-evidence-git-diff.js +90 -0
  5. package/dist/adapters/raw-evidence-keys.js +92 -0
  6. package/dist/adapters/raw-evidence-manifest.js +132 -0
  7. package/dist/adapters/raw-evidence-pack-store.js +136 -0
  8. package/dist/adapters/raw-evidence-sanitize.js +190 -0
  9. package/dist/adapters/raw-evidence.js +656 -1257
  10. package/dist/commands/backfill.js +7 -0
  11. package/dist/commands/cli-io.js +92 -0
  12. package/dist/commands/collection-report.js +139 -0
  13. package/dist/commands/collection-roots.js +153 -0
  14. package/dist/commands/doctor.js +19 -17
  15. package/dist/commands/install-receipts.js +193 -0
  16. package/dist/commands/install-update.js +305 -0
  17. package/dist/commands/local-auth.js +268 -0
  18. package/dist/commands/local-discovery.js +100 -0
  19. package/dist/commands/local-help.js +281 -0
  20. package/dist/commands/local.js +182 -1872
  21. package/dist/commands/public-root.js +1 -1
  22. package/dist/commands/sessions.js +162 -0
  23. package/dist/commands/status.js +230 -0
  24. package/dist/evidence-upload-client.js +43 -2
  25. package/dist/raw-evidence-gc.js +1 -1
  26. package/dist/raw-evidence-staging.js +15 -2
  27. package/dist/upload-agent-artifacts.js +153 -0
  28. package/dist/upload-envelope.js +407 -0
  29. package/dist/upload-evidence-delivery.js +505 -0
  30. package/dist/upload-http.js +46 -0
  31. package/dist/upload-session-reports.js +404 -0
  32. package/dist/upload.js +132 -1264
  33. package/package.json +2 -2
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Receipts: what this machine tells the dashboard about how a command went.
3
+ *
4
+ * Every step of `install`, `onboard`, `update` and every `sync` tick records a
5
+ * named step result here, and the outbox delivers them best-effort. A failure
6
+ * that names itself is the whole point — "it failed" is not a result, and a
7
+ * receipt nobody can act on is why BLI-2526 and BLI-2542 exist.
8
+ *
9
+ * Split out of commands/local.ts (BLI-3104) — moved verbatim; the step names,
10
+ * error codes and redaction rules are a server-side contract.
11
+ */
12
+ import os from "node:os";
13
+ import { errorMessage, writeLine } from "./cli-io.js";
14
+ import { maskLocalIdentifiers, redactedHealthDetail, } from "../health-detail.js";
15
+ import { getCollectorRuntimePaths, readLocalCollectorSessionFile, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
16
+ import { redactSecretLikeContent } from "@bli-cockpit/telemetry-core";
17
+ import { COLLECTION_ROOT_REQUIRED } from "../onboarding-roots.js";
18
+ import { enqueueInstallEventEntry, readPendingInstallEventEntries, recordInstallEventAttemptFailure, removeInstallEventEntry, } from "../spool/install-event-outbox.js";
19
+ export function addInstallEvent(events, step, status, errorCode,
20
+ // BLI-2542: the bucket alone cannot be acted on. Callers that hold the reason
21
+ // pass it; it is redacted at this boundary, not at the call site.
22
+ errorMessage) {
23
+ const detail = errorMessage ? redactedHealthDetail(errorMessage) : "";
24
+ const code = errorCode ? sanitizeInstallErrorCode(errorCode) : undefined;
25
+ events.push({
26
+ step,
27
+ status,
28
+ ...(code ? { error_code: code } : {}),
29
+ ...(detail && detail !== code ? { error_detail: detail } : {}),
30
+ });
31
+ }
32
+ export function sanitizeInstallErrorCode(value) {
33
+ const normalized = value
34
+ .trim()
35
+ .toLowerCase()
36
+ .replace(/[^a-z0-9_]+/gu, "_")
37
+ .replace(/^_+|_+$/gu, "")
38
+ .slice(0, 120);
39
+ return normalized || "unknown";
40
+ }
41
+ /**
42
+ * Posts pending install events. Also the collector's only per-tick listening
43
+ * post: the response carries the server-published `min_cli_version` floor
44
+ * (BLI-2678), so the last one observed is returned for the scheduled
45
+ * self-update step to act on. Every early-out returns null — no receipt, no
46
+ * floor.
47
+ */
48
+ export async function reportInstallEventsBestEffort(options) {
49
+ if (options.events.length === 0)
50
+ return null;
51
+ const paths = getCollectorRuntimePaths(options.homeDir);
52
+ try {
53
+ await enqueueInstallEventEntry(paths, {
54
+ dashboardUrl: options.dashboardUrl,
55
+ cliVersion: LOCAL_COLLECTOR_VERSION,
56
+ command: options.command,
57
+ osPlatform: os.platform(),
58
+ events: options.events.map((event) => ({
59
+ step: event.step.trim().slice(0, 120),
60
+ status: event.status,
61
+ ...(event.error_code
62
+ ? { error_code: sanitizeInstallErrorCode(event.error_code) }
63
+ : {}),
64
+ // Already redacted and capped at the point it was produced; bounded
65
+ // again here because this mapping is what the server contract sees.
66
+ ...(event.error_detail
67
+ ? {
68
+ error_detail: event.error_detail
69
+ .trim()
70
+ .slice(0, SYNC_ERROR_DETAIL_MAX_CHARS),
71
+ }
72
+ : {}),
73
+ ...(event.at ? { at: event.at } : {}),
74
+ })),
75
+ });
76
+ }
77
+ catch {
78
+ if (options.json) {
79
+ writeLine(options.io.stderr, "Install event outbox unavailable: local_write_failed");
80
+ }
81
+ return null;
82
+ }
83
+ const session = await readLocalCollectorSessionFile(paths).catch(() => null);
84
+ if (!session ||
85
+ session.session_state !== "valid" ||
86
+ typeof session.device_token !== "string" ||
87
+ !session.device_token) {
88
+ return null;
89
+ }
90
+ const pending = (await readPendingInstallEventEntries(paths)).slice(0, 20);
91
+ const failures = [];
92
+ let observedMinCliVersion = null;
93
+ for (let offset = 0; offset < pending.length; offset += 5) {
94
+ await Promise.all(pending.slice(offset, offset + 5).map(async (entry) => {
95
+ const controller = new AbortController();
96
+ const timeout = setTimeout(() => controller.abort(), 5_000);
97
+ try {
98
+ const response = await options.io.fetch(`${entry.dashboard_url}/api/ambient/install-events`, {
99
+ method: "POST",
100
+ headers: {
101
+ "Content-Type": "application/json",
102
+ Authorization: `Bearer ${session.device_token}`,
103
+ },
104
+ body: JSON.stringify({
105
+ cli_version: entry.cli_version,
106
+ command: entry.command,
107
+ os_platform: entry.os_platform,
108
+ events: entry.events,
109
+ }),
110
+ signal: controller.signal,
111
+ });
112
+ if (!response.ok) {
113
+ throw new Error(`http_${response.status}`);
114
+ }
115
+ const receipt = (await response
116
+ .json()
117
+ .catch(() => null));
118
+ if (typeof receipt?.min_cli_version === "string" &&
119
+ receipt.min_cli_version.trim()) {
120
+ observedMinCliVersion = receipt.min_cli_version.trim();
121
+ }
122
+ await removeInstallEventEntry(paths, entry.outbox_id);
123
+ }
124
+ catch (error) {
125
+ const failureReason = classifyInstallTelemetryError(error);
126
+ failures.push(failureReason);
127
+ await recordInstallEventAttemptFailure(paths, entry, {
128
+ attemptedAt: new Date().toISOString(),
129
+ failureReason,
130
+ }).catch(() => undefined);
131
+ }
132
+ finally {
133
+ clearTimeout(timeout);
134
+ }
135
+ }));
136
+ }
137
+ if (options.json && failures.length > 0) {
138
+ writeLine(options.io.stderr, `Install event telemetry queued for retry: ${[...new Set(failures)].join(",")}`);
139
+ }
140
+ return observedMinCliVersion;
141
+ }
142
+ function classifyInstallTelemetryError(error) {
143
+ if (error instanceof Error && error.name === "AbortError") {
144
+ return "timeout";
145
+ }
146
+ const message = errorMessage(error);
147
+ const status = message.match(/http_(\d{3})/i)?.[1];
148
+ if (status)
149
+ return `http_${status}`;
150
+ if (/fetch|network|ENOTFOUND|ECONNREFUSED/i.test(message))
151
+ return "network";
152
+ return "failed";
153
+ }
154
+ export function classifySyncHealthError(error) {
155
+ const message = errorMessage(error);
156
+ if (/auth|token|session|unauthorized|forbidden|401|403/iu.test(message)) {
157
+ return "auth_failed";
158
+ }
159
+ if (/fetch|network|enotfound|econnrefused|timeout/iu.test(message)) {
160
+ return "network_failed";
161
+ }
162
+ // Anchored on the code the collector actually throws rather than on loose
163
+ // vocabulary. The old test matched /collection.root|workspace|repo|worktree/
164
+ // against the message, so any failure that merely mentioned a repo was filed
165
+ // as a collection-root failure and the real reason was lost (BLI-2492).
166
+ if (message.includes(COLLECTION_ROOT_REQUIRED) ||
167
+ /collection root/iu.test(message)) {
168
+ return "collection_root_failed";
169
+ }
170
+ return "sync_failed";
171
+ }
172
+ // The bucket above is for aggregation. This is the reason — the actual message,
173
+ // redacted on the machine that produced it, before it ever leaves.
174
+ //
175
+ // Error text can carry absolute paths and, on some auth failures, token-shaped
176
+ // fragments. It goes through the same deterministic redaction the collector
177
+ // already applies to evidence, and is capped so one pathological stack trace
178
+ // cannot dominate a health receipt.
179
+ export const SYNC_ERROR_DETAIL_MAX_CHARS = 600;
180
+ export function redactedSyncErrorDetail(error) {
181
+ const message = errorMessage(error).replace(/\s+/gu, " ").trim();
182
+ const { text } = redactSecretLikeContent(message, {
183
+ appliedBy: "local_collector",
184
+ });
185
+ // BLI-2542: the comment above always said this text can carry absolute paths,
186
+ // and until now nothing removed them — secret redaction matches token shapes,
187
+ // not filesystem paths. Same masking the doctor receipts use, so one boundary
188
+ // rule covers every health receipt.
189
+ const masked = maskLocalIdentifiers(text);
190
+ return masked.length > SYNC_ERROR_DETAIL_MAX_CHARS
191
+ ? `${masked.slice(0, SYNC_ERROR_DETAIL_MAX_CHARS - 1)}…`
192
+ : masked;
193
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Getting Cockpit onto this machine and keeping it current: `cockpit install`,
3
+ * `cockpit update`, the self-update the scheduled sync calls, and the
4
+ * maintainer-only `cockpit release`.
5
+ *
6
+ * `release` rides along because it is the same subject from the other end — the
7
+ * npm package everything here installs. Split out of commands/local.ts
8
+ * (BLI-3104); moved verbatim, including every install-event step name.
9
+ */
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { readFile } from "node:fs/promises";
13
+ import { defaultExec, defaultInteractiveExec, writeExecOutput, writeLine } from "./cli-io.js";
14
+ import { assertCollectionRootPersisted } from "./collection-roots.js";
15
+ import { addInstallEvent, reportInstallEventsBestEffort } from "./install-receipts.js";
16
+ import { installLocalCollector, LOCAL_COLLECTOR_VERSION, } from "../local-state.js";
17
+ import { COLLECTION_ROOT_REQUIRED, missingCollectionRootMessage, normalizeRootsDetailed, rootRejectionExplanation, } from "../onboarding-roots.js";
18
+ export class SelfUpdateError extends Error {
19
+ result;
20
+ eacces;
21
+ constructor(result) {
22
+ super("npm install failed; Cockpit CLI was not refreshed.");
23
+ this.name = "SelfUpdateError";
24
+ this.result = result;
25
+ this.eacces = isNpmEaccesFailure(result.stderr);
26
+ }
27
+ }
28
+ export async function runInstall(command, io) {
29
+ const installEvents = [];
30
+ const finish = async (code) => {
31
+ await reportInstallEventsBestEffort({
32
+ homeDir: command.homeDir,
33
+ dashboardUrl: command.dashboardUrl,
34
+ command: "install",
35
+ events: installEvents,
36
+ json: command.json,
37
+ io,
38
+ });
39
+ return code;
40
+ };
41
+ const resolved = resolveInstallCommandRoots(command);
42
+ if (resolved.homeRootOptIn) {
43
+ addInstallEvent(installEvents, "home_root_optin", "ok");
44
+ }
45
+ const result = await installLocalCollector(resolved.command);
46
+ // Same invariant as the onboarding path: never report a successful install
47
+ // over a config that saved no usable collection root.
48
+ await assertCollectionRootPersisted(resolved.command.homeDir);
49
+ addInstallEvent(installEvents, "install", "ok");
50
+ if (command.json) {
51
+ writeLine(io.stdout, JSON.stringify(result, null, 2));
52
+ return finish(0);
53
+ }
54
+ writeLine(io.stdout, "Cockpit local collector installed.");
55
+ writeLine(io.stdout, `Config: ${result.paths.config_file}`);
56
+ writeLine(io.stdout, `Session: ${result.paths.session_file}`);
57
+ writeLine(io.stdout, "Auth: missing; upload stays local-only until pairing/login.");
58
+ writeLine(io.stdout, "Next: run `cockpit login`, then `cockpit start` inside the repo; add `--ticket <id>` only when ticket work begins.");
59
+ return finish(0);
60
+ }
61
+ function resolveInstallCommandRoots(command) {
62
+ const detailed = normalizeRootsDetailed([command.repoRoot ?? process.cwd()], {
63
+ homeDir: command.homeDir,
64
+ allowHomeRoot: command.allowHomeRoot,
65
+ });
66
+ if (detailed.roots.length > 0) {
67
+ const root = detailed.roots[0];
68
+ return {
69
+ command: {
70
+ ...command,
71
+ repoRoot: root,
72
+ },
73
+ homeRootOptIn: Boolean(command.allowHomeRoot) &&
74
+ path.resolve(root) === path.resolve(command.homeDir ?? os.homedir()),
75
+ };
76
+ }
77
+ if (detailed.rejected.length > 0) {
78
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${rootRejectionExplanation(detailed.rejected[0], command)}`);
79
+ }
80
+ throw new Error(`${COLLECTION_ROOT_REQUIRED}: ${missingCollectionRootMessage(command)}`);
81
+ }
82
+ export async function runUpdate(command, io) {
83
+ const installEvents = [];
84
+ const finish = async (code) => {
85
+ await reportInstallEventsBestEffort({
86
+ homeDir: command.homeDir,
87
+ dashboardUrl: command.dashboardUrl,
88
+ command: "update",
89
+ events: installEvents,
90
+ json: command.json,
91
+ io,
92
+ });
93
+ return code;
94
+ };
95
+ const exec = io.exec ?? defaultExec();
96
+ try {
97
+ await runSelfUpdate(io, { json: command.json });
98
+ }
99
+ catch (error) {
100
+ if (!(error instanceof SelfUpdateError))
101
+ throw error;
102
+ addInstallEvent(installEvents, "npm_install", "fail", error.eacces
103
+ ? "npm_install_eacces"
104
+ : "npm_install_failed");
105
+ if (command.json) {
106
+ writeLine(io.stdout, JSON.stringify({
107
+ status: "blocked",
108
+ step: "npm_install",
109
+ command: `npm ${selfUpdateInstallArgs().join(" ")}`,
110
+ exit_code: error.result.code,
111
+ }, null, 2));
112
+ }
113
+ else {
114
+ writeLine(io.stderr, "BLOCKED: npm install failed; Cockpit CLI was not refreshed.");
115
+ if (error.eacces) {
116
+ writeLine(io.stderr, "Fix Homebrew npm ownership once: sudo chown -R $(whoami) /opt/homebrew/lib/node_modules/@bli-cockpit /opt/homebrew/bin/cockpit");
117
+ writeLine(io.stderr, "Do not use `sudo npm i -g`; it makes the ownership problem come back.");
118
+ }
119
+ }
120
+ return finish(error.result.code || 1);
121
+ }
122
+ addInstallEvent(installEvents, "npm_install", "ok");
123
+ if (!command.json) {
124
+ writeLine(io.stdout, "Cockpit CLI updated. Rechecking onboarding...");
125
+ }
126
+ const onboard = await exec("cockpit", [
127
+ "onboard",
128
+ ...updateOnboardArgs(command),
129
+ ]);
130
+ writeExecOutput(io, onboard, { stdout: true, stderr: true });
131
+ addInstallEvent(installEvents, "onboard_rerun", onboard.code === 0 ? "ok" : "fail", onboard.code === 0 ? undefined : updateOnboardFailureCode(onboard));
132
+ return finish(onboard.code);
133
+ }
134
+ export async function runSelfUpdate(io, options = {}) {
135
+ const exec = io.exec ?? defaultExec();
136
+ const tag = options.tag ?? "latest";
137
+ if (!options.json) {
138
+ writeLine(io.stdout, `Updating Cockpit CLI from npm (${tag})...`);
139
+ }
140
+ const install = await exec("npm", selfUpdateInstallArgs(tag));
141
+ writeExecOutput(io, install, { stdout: !options.json, stderr: true });
142
+ if (install.code !== 0) {
143
+ throw new SelfUpdateError(install);
144
+ }
145
+ return { updated: true, version: LOCAL_COLLECTOR_VERSION };
146
+ }
147
+ function selfUpdateInstallArgs(tag = "latest") {
148
+ return [
149
+ "install",
150
+ "-g",
151
+ `@bli-cockpit/cli@${tag}`,
152
+ "--prefer-online",
153
+ ];
154
+ }
155
+ /** Re-runs `cockpit onboard` with the same setup flags the update carried. */
156
+ function updateOnboardArgs(command) {
157
+ const args = [];
158
+ if (command.homeDir)
159
+ args.push("--home", command.homeDir);
160
+ for (const root of updateCollectionRoots(command)) {
161
+ args.push("--workspace", root);
162
+ }
163
+ if (command.dashboardUrlExplicit) {
164
+ args.push("--dashboard-url", command.dashboardUrl);
165
+ }
166
+ if (command.claimedOwnerEmail)
167
+ args.push("--email", command.claimedOwnerEmail);
168
+ if (command.noAuth)
169
+ args.push("--no-auth");
170
+ if (command.deviceName)
171
+ args.push("--device-name", command.deviceName);
172
+ if (command.activeTicketId)
173
+ args.push("--ticket", command.activeTicketId);
174
+ if (command.branch)
175
+ args.push("--branch", command.branch);
176
+ if (command.pollIntervalMs !== undefined) {
177
+ args.push("--poll-interval-ms", String(command.pollIntervalMs));
178
+ }
179
+ if (command.timeoutMs !== undefined) {
180
+ args.push("--timeout-ms", String(command.timeoutMs));
181
+ }
182
+ if (command.maxDepth !== undefined)
183
+ args.push("--max-depth", String(command.maxDepth));
184
+ if (command.maxRepos !== undefined)
185
+ args.push("--max-repos", String(command.maxRepos));
186
+ if (command.allowHomeRoot)
187
+ args.push("--allow-home-root");
188
+ if (command.json)
189
+ args.push("--json");
190
+ return args;
191
+ }
192
+ function isNpmEaccesFailure(stderr) {
193
+ return /EACCES|permission denied/i.test(stderr);
194
+ }
195
+ function updateOnboardFailureCode(result) {
196
+ const output = `${result.stdout}\n${result.stderr}`;
197
+ if (output.includes(COLLECTION_ROOT_REQUIRED))
198
+ return COLLECTION_ROOT_REQUIRED;
199
+ if (/pairing|approval|device_pairing/i.test(output))
200
+ return "pairing_timeout";
201
+ if (/sync_blocked|spooled|upload failed|network_or_ingest/i.test(output)) {
202
+ return "sync_blocked";
203
+ }
204
+ return "onboard_rerun_failed";
205
+ }
206
+ function updateCollectionRoots(command) {
207
+ const roots = command.collectionRoots?.length
208
+ ? command.collectionRoots
209
+ : command.repoRoot
210
+ ? [command.repoRoot]
211
+ : [];
212
+ const seen = new Set();
213
+ const deduped = [];
214
+ for (const root of roots) {
215
+ if (seen.has(root))
216
+ continue;
217
+ seen.add(root);
218
+ deduped.push(root);
219
+ }
220
+ return deduped;
221
+ }
222
+ export async function runRelease(command, io) {
223
+ const releaseRoot = await findPublicReleaseRoot(process.cwd());
224
+ if (!releaseRoot) {
225
+ writeLine(io.stderr, "cockpit release must be run inside the bli-cockpit repo checkout (missing publish:public script).");
226
+ return 1;
227
+ }
228
+ const exec = io.exec ?? defaultExec();
229
+ const gitReady = await prepareReleaseMainBranch(releaseRoot, exec, io);
230
+ if (!gitReady)
231
+ return 1;
232
+ writeLine(io.stdout, "Running Cockpit public package release...");
233
+ const npmArgs = ["--prefix", releaseRoot, "run", "publish:public"];
234
+ if (command.args.length > 0)
235
+ npmArgs.push("--", ...command.args);
236
+ const releaseExec = io.interactiveExec ?? defaultInteractiveExec();
237
+ const result = await releaseExec("npm", npmArgs);
238
+ writeExecOutput(io, result, { stdout: true, stderr: true });
239
+ return result.code;
240
+ }
241
+ /** Publishing happens from a clean, current `main` or it does not happen. */
242
+ async function prepareReleaseMainBranch(releaseRoot, exec, io) {
243
+ const branch = await exec("git", [
244
+ "-C",
245
+ releaseRoot,
246
+ "rev-parse",
247
+ "--abbrev-ref",
248
+ "HEAD",
249
+ ]);
250
+ writeExecOutput(io, branch, { stdout: false, stderr: true });
251
+ if (branch.code !== 0) {
252
+ writeLine(io.stderr, "BLOCKED: cockpit release could not read the current git branch.");
253
+ return false;
254
+ }
255
+ const currentBranch = branch.stdout.trim();
256
+ if (currentBranch !== "main") {
257
+ writeLine(io.stderr, `BLOCKED: cockpit release only publishes from main. Current branch is ${currentBranch || "unknown"}.`);
258
+ writeLine(io.stderr, "Merge the release changes, switch to main, then rerun `cockpit release`.");
259
+ return false;
260
+ }
261
+ const status = await exec("git", [
262
+ "-C",
263
+ releaseRoot,
264
+ "status",
265
+ "--porcelain",
266
+ ]);
267
+ writeExecOutput(io, status, { stdout: false, stderr: true });
268
+ if (status.code !== 0) {
269
+ writeLine(io.stderr, "BLOCKED: cockpit release could not inspect git status.");
270
+ return false;
271
+ }
272
+ if (status.stdout.trim()) {
273
+ writeLine(io.stderr, "BLOCKED: cockpit release requires a clean main checkout.");
274
+ writeLine(io.stderr, "Commit or discard local changes, then rerun `cockpit release`.");
275
+ return false;
276
+ }
277
+ writeLine(io.stdout, "Syncing main with git pull --ff-only...");
278
+ const pull = await exec("git", ["-C", releaseRoot, "pull", "--ff-only"]);
279
+ writeExecOutput(io, pull, { stdout: true, stderr: true });
280
+ if (pull.code !== 0) {
281
+ writeLine(io.stderr, "BLOCKED: git pull --ff-only failed; main is not safely current.");
282
+ return false;
283
+ }
284
+ return true;
285
+ }
286
+ /** Walks up for the checkout that owns the `publish:public` script. */
287
+ async function findPublicReleaseRoot(startDir) {
288
+ let current = path.resolve(startDir);
289
+ while (true) {
290
+ const packageJsonPath = path.join(current, "package.json");
291
+ try {
292
+ const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
293
+ if (packageJson.scripts?.["publish:public"] !== undefined)
294
+ return current;
295
+ }
296
+ catch {
297
+ // Keep walking: nested packages may be missing package.json or have one
298
+ // without the release script.
299
+ }
300
+ const parent = path.dirname(current);
301
+ if (parent === current)
302
+ return null;
303
+ current = parent;
304
+ }
305
+ }