@coderook/cli 0.36.0 → 0.37.0

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.
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.maskSecrets = maskSecrets;
6
7
  exports.withoutCredentials = withoutCredentials;
7
8
  exports.claim = claim;
8
9
  exports.defaultLabels = defaultLabels;
@@ -38,6 +39,15 @@ const download_js_1 = require("../../cbx/src/core/download.js");
38
39
  const identify_js_1 = require("../../cbx/src/core/identify.js");
39
40
  const config_js_1 = require("./config.js");
40
41
  const progress_js_1 = require("./progress.js");
42
+ /** Replace each secret's value with ***, longest first. */
43
+ function maskSecrets(text, secrets) {
44
+ let out = text;
45
+ for (const value of secrets) {
46
+ if (value.length >= 4 && out.includes(value))
47
+ out = out.split(value).join("***");
48
+ }
49
+ return out;
50
+ }
41
51
  /** How often to tell the service the machine is still working. */
42
52
  const HEARTBEAT_MS = 30_000;
43
53
  /** How often log lines are sent up while a command is running. */
@@ -65,7 +75,9 @@ async function call(route, options = {}) {
65
75
  const text = await response.text();
66
76
  const body = text ? JSON.parse(text) : {};
67
77
  if (!response.ok) {
68
- throw new Error(body?.error?.message ?? `${route} failed (${response.status})`);
78
+ const error = new Error(body?.error?.message ?? `${route} failed (${response.status})`);
79
+ error.code = body?.error?.code;
80
+ throw error;
69
81
  }
70
82
  return body;
71
83
  }
@@ -333,6 +345,32 @@ function patternMatcher(pattern) {
333
345
  */
334
346
  async function performRun(repositoryId, run, runner) {
335
347
  const progress = new progress_js_1.RunProgress();
348
+ /* Set when somebody cancels the run on CodeRook: the work stops now rather than when it ends. */
349
+ let cancelled = false;
350
+ /* Set when the workflow's time limit passes on this machine. */
351
+ let timedOut = false;
352
+ let limitTimer;
353
+ const secrets = (run.secretNames ?? [])
354
+ .map((name) => run.environment?.[name] ?? "")
355
+ .filter(Boolean)
356
+ .sort((a, b) => b.length - a.length);
357
+ let child = null;
358
+ const stopChild = () => {
359
+ if (!child?.pid || child.exitCode !== null)
360
+ return;
361
+ if (process.platform === "win32") {
362
+ /* The command runs through a shell; the whole tree goes, not only the shell. */
363
+ (0, node_child_process_1.spawn)("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
364
+ }
365
+ else {
366
+ try {
367
+ process.kill(-child.pid, "SIGTERM");
368
+ }
369
+ catch {
370
+ child.kill("SIGTERM");
371
+ }
372
+ }
373
+ };
336
374
  const logs = new LogShipper(repositoryId, run.runId, (line) => progress.say(line));
337
375
  const started = Date.now();
338
376
  const workspace = await (0, promises_1.mkdtemp)(node_path_1.default.join((0, node_os_1.tmpdir)(), "coderook-run-"));
@@ -343,13 +381,18 @@ async function performRun(repositoryId, run, runner) {
343
381
  void call(`/v1/repositories/${repositoryId}/runs/${run.runId}/heartbeat`, {
344
382
  method: "POST",
345
383
  body: { runner },
346
- }).catch(() => {
384
+ }).catch((error) => {
347
385
  /*
348
- A lost lease means the service has given this run to somebody else,
349
- and the local process is now doing work nobody will accept. Stopping
350
- the heartbeat is enough: the report at the end will be refused too,
351
- and the run that matters is the one the other machine is doing.
386
+ Cancelled: end the work now. A lost lease otherwise means the service
387
+ has given this run to somebody else, and the local process is doing
388
+ work nobody will accept; the report at the end changes nothing, and
389
+ the run that matters is the one the other machine is doing.
352
390
  */
391
+ if (error?.code === "run_cancelled" && !cancelled) {
392
+ cancelled = true;
393
+ logs.add("Cancelled on CodeRook. Stopping.", "stderr");
394
+ stopChild();
395
+ }
353
396
  });
354
397
  }, HEARTBEAT_MS);
355
398
  if (run.versionId) {
@@ -384,11 +427,18 @@ async function performRun(repositoryId, run, runner) {
384
427
  Which shell is the platform's own, so a Windows machine reads it the
385
428
  way a Windows user wrote it.
386
429
  */
387
- const child = (0, node_child_process_1.spawn)(command, {
430
+ if (cancelled) {
431
+ settle(1);
432
+ return;
433
+ }
434
+ child = (0, node_child_process_1.spawn)(command, {
388
435
  cwd: directory,
389
436
  shell: true,
437
+ /* Its own process group off Windows, so cancelling can stop everything it started. */
438
+ detached: process.platform !== "win32",
390
439
  env: {
391
440
  ...withoutCredentials(process.env),
441
+ ...(run.environment ?? {}),
392
442
  CI: "true",
393
443
  CODEROOK_RUN: String(run.number),
394
444
  CODEROOK_VERSION: run.versionSequence
@@ -402,7 +452,7 @@ async function performRun(repositoryId, run, runner) {
402
452
  can tell a slow build from a stuck one.
403
453
  */
404
454
  const tee = (chunk, stream) => {
405
- const text = chunk.toString("utf8");
455
+ const text = maskSecrets(chunk.toString("utf8"), secrets);
406
456
  logs.add(text, stream);
407
457
  progress.say(text);
408
458
  };
@@ -413,7 +463,16 @@ async function performRun(repositoryId, run, runner) {
413
463
  settle(1);
414
464
  });
415
465
  child.on("close", (status) => settle(status ?? 1));
466
+ if (run.timeoutMinutes) {
467
+ limitTimer = setTimeout(() => {
468
+ timedOut = true;
469
+ logs.add(`Stopped: past this workflow's time limit of ${run.timeoutMinutes} minutes.`, "stderr");
470
+ stopChild();
471
+ }, run.timeoutMinutes * 60_000);
472
+ }
416
473
  });
474
+ if (limitTimer)
475
+ clearTimeout(limitTimer);
417
476
  /*
418
477
  What it built, before the folder goes.
419
478
 
@@ -433,6 +492,10 @@ async function performRun(repositoryId, run, runner) {
433
492
  }
434
493
  await logs.flush();
435
494
  const seconds = Math.round((Date.now() - started) / 1000);
495
+ if (timedOut)
496
+ return { status: "failed", summary: `Stopped after its time limit of ${run.timeoutMinutes} minutes` };
497
+ if (cancelled)
498
+ return { status: "cancelled", summary: `Cancelled after ${seconds}s` };
436
499
  return code === 0
437
500
  ? { status: "passed", summary: `Passed in ${seconds}s` }
438
501
  : { status: "failed", summary: `Exited ${code} after ${seconds}s` };
@@ -448,6 +511,8 @@ async function performRun(repositoryId, run, runner) {
448
511
  goes out would overwrite the verdict the caller is about to print.
449
512
  */
450
513
  progress.done();
514
+ if (limitTimer)
515
+ clearTimeout(limitTimer);
451
516
  if (beating)
452
517
  clearInterval(beating);
453
518
  if (flushing)
@@ -19,9 +19,12 @@ exports.commandWatch = commandWatch;
19
19
  exports.commandWorkflows = commandWorkflows;
20
20
  exports.commandRuns = commandRuns;
21
21
  exports.commandLogs = commandLogs;
22
+ exports.commandCancel = commandCancel;
23
+ exports.commandRerun = commandRerun;
22
24
  exports.commandTokens = commandTokens;
23
25
  exports.commandDelete = commandDelete;
24
26
  exports.commandHooks = commandHooks;
27
+ const actions_command_js_1 = require("./actions_command.js");
25
28
  const node_process_1 = __importDefault(require("node:process"));
26
29
  const promises_1 = require("node:readline/promises");
27
30
  const api_js_1 = require("./api.js");
@@ -129,14 +132,33 @@ async function commandRelease(parsed) {
129
132
  console.error((0, colour_js_1.red)("--downloads is public or private: cbx release v1.0 --downloads public"));
130
133
  return 1;
131
134
  }
132
- const name = parsed.positional[0];
133
- if (!name && downloads === undefined) {
135
+ /* `cbx release publish` publishes a draft (backend release_extras.ts). */
136
+ const publishing = parsed.positional[0] === "publish";
137
+ const name = publishing ? undefined : parsed.positional[0];
138
+ if (!name && downloads === undefined && !publishing) {
134
139
  console.error((0, colour_js_1.red)("Name the release: cbx release v1.0"));
135
140
  return 1;
136
141
  }
137
- const project = await (0, project_commands_js_1.resolveProject)(name ? parsed.positional[1] : parsed.positional[0]);
142
+ const project = await (0, project_commands_js_1.resolveProject)(name || publishing ? parsed.positional[1] : parsed.positional[0]);
138
143
  if (!project)
139
144
  return 1;
145
+ if (publishing) {
146
+ const listing = await (0, api_js_1.callApi)(`/v1/repositories/${encodeURIComponent(project.id)}/releases`);
147
+ const drafts = listing.drafts ?? [];
148
+ const asked = parsed.flags.get("version");
149
+ const draft = typeof asked === "string"
150
+ ? drafts.find((one) => String(one.sequence) === asked.replace(/^v/i, ""))
151
+ : drafts.length === 1
152
+ ? drafts[0]
153
+ : undefined;
154
+ if (!draft) {
155
+ console.error((0, colour_js_1.red)(drafts.length ? `Which draft? ${drafts.map((one) => `${one.name} (--version ${one.sequence})`).join(", ")}` : "This project has no draft releases."));
156
+ return 1;
157
+ }
158
+ const published = await (0, api_js_1.callApi)(`/v1/repositories/${encodeURIComponent(project.id)}/versions/${encodeURIComponent(draft.versionId)}/release-draft/publish`, { method: "POST" });
159
+ console.log(`${(0, colour_js_1.green)("Published")} ${(0, colour_js_1.bold)(published.name)} ${(0, colour_js_1.dim)("at")} v${published.sequence}`);
160
+ return 0;
161
+ }
140
162
  const all = await (0, api_js_1.versions)(project.id);
141
163
  if (!all.length) {
142
164
  console.error((0, colour_js_1.red)("This project has no versions to release."));
@@ -170,10 +192,29 @@ async function commandRelease(parsed) {
170
192
  console.error((0, colour_js_1.red)(`This project has no version ${String(wanted)}.`));
171
193
  return 1;
172
194
  }
173
- const notes = parsed.flags.get("notes");
195
+ let notes = parsed.flags.get("notes");
196
+ /* Notes written from what changed since the last release (release_extras.ts). */
197
+ if (parsed.flags.has("generate-notes")) {
198
+ const written = await (0, api_js_1.callApi)(`/v1/repositories/${encodeURIComponent(project.id)}/versions/${encodeURIComponent(target.id)}/release-notes`);
199
+ notes = typeof notes === "string" ? `${notes}\n\n${written.notes}` : written.notes;
200
+ }
201
+ const channelFlag = parsed.flags.get("channel");
202
+ const options = {
203
+ ...(parsed.flags.has("prerelease") ? { prerelease: true } : {}),
204
+ ...(typeof channelFlag === "string" ? { channel: channelFlag } : {}),
205
+ };
206
+ if (name && parsed.flags.has("draft")) {
207
+ await (0, api_js_1.callApi)(`/v1/repositories/${encodeURIComponent(project.id)}/versions/${encodeURIComponent(target.id)}/release-draft`, {
208
+ method: "PUT",
209
+ body: { name, notes: typeof notes === "string" ? notes : null, ...options },
210
+ });
211
+ console.log(`${(0, colour_js_1.green)("Drafted")} ${(0, colour_js_1.bold)(name)} ${(0, colour_js_1.dim)("at")} v${target.sequence}${(0, colour_js_1.dim)(" — nothing is public yet.")}`);
212
+ console.log((0, colour_js_1.dim)(` Attach builds with cbx attach, then: cbx release publish --version ${target.sequence}`));
213
+ return 0;
214
+ }
174
215
  if (name) {
175
216
  try {
176
- await (0, api_js_1.markRelease)(project.id, target.id, name, typeof notes === "string" ? notes : null);
217
+ await (0, api_js_1.markRelease)(project.id, target.id, name, typeof notes === "string" ? notes : null, options);
177
218
  }
178
219
  catch (error) {
179
220
  console.error((0, colour_js_1.red)(error instanceof Error ? error.message : String(error)));
@@ -346,6 +387,11 @@ async function commandWatch(parsed) {
346
387
  }
347
388
  /** The automations a project has, and how they have been going. */
348
389
  async function commandWorkflows(parsed) {
390
+ /* `cbx actions check …` and `cbx actions set …` (actions_command.ts). */
391
+ if (parsed.positional[0] === "check")
392
+ return (0, actions_command_js_1.commandCheckWorkflows)(parsed);
393
+ if (parsed.positional[0] === "set")
394
+ return (0, actions_command_js_1.commandSetWorkflow)(parsed);
349
395
  const project = await (0, project_commands_js_1.resolveProject)(typeof parsed.flags.get("project") === "string"
350
396
  ? String(parsed.flags.get("project"))
351
397
  : parsed.positional[0]);
@@ -559,6 +605,27 @@ async function commandLogs(parsed) {
559
605
  }
560
606
  return 1;
561
607
  }
608
+ /*
609
+ --follow: keep printing as the runner sends, until the run ends. A build
610
+ that takes ten minutes shows its progress rather than nothing.
611
+ */
612
+ if (parsed.flags.has("follow")) {
613
+ console.log(`${(0, colour_js_1.accent)(`#${run.number}`)} ${(0, colour_js_1.bold)(run.workflow)} — following until it ends`);
614
+ let shown = 0;
615
+ let state = run;
616
+ for (;;) {
617
+ const lines = await (0, api_js_1.runLogs)(project.id, run.id);
618
+ for (const line of lines.slice(shown))
619
+ console.log(line.stream === "stderr" ? (0, colour_js_1.red)(line.line) : line.line);
620
+ shown = Math.max(shown, lines.length);
621
+ if (state.status !== "queued" && state.status !== "running")
622
+ break;
623
+ await new Promise((resolve) => setTimeout(resolve, 2000));
624
+ state = (await (0, api_js_1.runs)(project.id)).find((one) => one.id === run.id) ?? state;
625
+ }
626
+ console.log(`${statusText(state.status)}${state.summary ? ` · ${(0, colour_js_1.dim)(state.summary)}` : ""}`);
627
+ return state.status === "failed" ? 1 : 0;
628
+ }
562
629
  const lines = await (0, api_js_1.runLogs)(project.id, run.id);
563
630
  console.log(`${(0, colour_js_1.accent)(`#${run.number}`)} ${(0, colour_js_1.bold)(run.workflow)} — ${statusText(run.status)}` +
564
631
  `${run.summary ? ` · ${(0, colour_js_1.dim)(run.summary)}` : ""}`);
@@ -578,6 +645,55 @@ async function commandLogs(parsed) {
578
645
  }
579
646
  return run.status === "failed" ? 1 : 0;
580
647
  }
648
+ /** Stop a run that is waiting or working, by the number people say. */
649
+ async function commandCancel(parsed) {
650
+ const asked = parsed.positional[0];
651
+ const number = Number((asked ?? "").replace(/^#/, ""));
652
+ if (!asked || !Number.isInteger(number) || number < 1) {
653
+ console.error((0, colour_js_1.red)("Which run? Try: cbx cancel 12"));
654
+ return 1;
655
+ }
656
+ const project = await (0, project_commands_js_1.resolveProject)(parsed.positional[1]);
657
+ if (!project)
658
+ return 1;
659
+ const run = (await (0, api_js_1.runs)(project.id)).find((one) => one.number === number);
660
+ if (!run) {
661
+ console.error((0, colour_js_1.red)(`${project.name} has no run #${number}.`));
662
+ return 1;
663
+ }
664
+ if (run.status !== "queued" && run.status !== "running") {
665
+ console.error((0, colour_js_1.red)(`#${number} has already finished (${run.status}).`));
666
+ return 1;
667
+ }
668
+ await (0, api_js_1.cancelRun)(project.id, run.id);
669
+ console.log(`${(0, colour_js_1.accent)(`#${number}`)} cancelled. A runner working on it stops at its next heartbeat.`);
670
+ return 0;
671
+ }
672
+ /** Run a finished run again, against the same save. */
673
+ async function commandRerun(parsed) {
674
+ const asked = parsed.positional[0];
675
+ const number = Number((asked ?? "").replace(/^#/, ""));
676
+ if (!asked || !Number.isInteger(number) || number < 1) {
677
+ console.error((0, colour_js_1.red)("Which run? Try: cbx rerun 12"));
678
+ return 1;
679
+ }
680
+ const project = await (0, project_commands_js_1.resolveProject)(parsed.positional[1]);
681
+ if (!project)
682
+ return 1;
683
+ const run = (await (0, api_js_1.runs)(project.id)).find((one) => one.number === number);
684
+ if (!run) {
685
+ console.error((0, colour_js_1.red)(`${project.name} has no run #${number}.`));
686
+ return 1;
687
+ }
688
+ if (run.status === "queued" || run.status === "running") {
689
+ console.error((0, colour_js_1.red)(`#${number} is still going. Cancel it first with cbx cancel ${number}.`));
690
+ return 1;
691
+ }
692
+ const queued = await (0, api_js_1.rerunRun)(project.id, run.id);
693
+ console.log(`${(0, colour_js_1.accent)(`#${queued.number}`)} queued: #${number} again, against the same save.`);
694
+ console.log((0, colour_js_1.dim)(`Follow it with: cbx logs ${queued.number} --follow`));
695
+ return 0;
696
+ }
581
697
  /** The tokens that can act as this account. */
582
698
  async function commandTokens(parsed) {
583
699
  const revoke = parsed.flags.get("revoke");
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.PINNED_RELEASE_KEY = void 0;
7
+ exports.manifestBytes = manifestBytes;
8
+ exports.signatureValid = signatureValid;
9
+ exports.commandVerify = commandVerify;
10
+ exports.commandDeps = commandDeps;
11
+ /**
12
+ * `cbx verify` — check a downloaded file against its release's signed manifest.
13
+ * `cbx deps` — known vulnerabilities in a save's dependencies.
14
+ *
15
+ * verify fetches the release's manifest (every file with its size and SHA-256,
16
+ * signed with CodeRook's Ed25519 release key), checks the signature, then
17
+ * hashes the local file and compares. The key is the one this copy of cbx was
18
+ * built with when it has one, otherwise the one the service publishes, and
19
+ * --key pins another.
20
+ */
21
+ const node_crypto_1 = require("node:crypto");
22
+ const node_fs_1 = require("node:fs");
23
+ const promises_1 = require("node:fs/promises");
24
+ const node_path_1 = __importDefault(require("node:path"));
25
+ const api_js_1 = require("./api.js");
26
+ const colour_js_1 = require("./colour.js");
27
+ const config_js_1 = require("./config.js");
28
+ const project_commands_js_1 = require("./project_commands.js");
29
+ /** CodeRook's release key as published when this version of cbx was built; empty means ask the service. */
30
+ exports.PINNED_RELEASE_KEY = "H8/DZlJXqNjUBOT7OXIoR4aEM+gcfhxurfdCrk83wNA=";
31
+ /** The exact bytes the service signs: the same key order, files by name. */
32
+ function manifestBytes(manifest) {
33
+ return new TextEncoder().encode(JSON.stringify({
34
+ format: manifest.format,
35
+ project: manifest.project,
36
+ release: manifest.release,
37
+ version: manifest.version,
38
+ channel: manifest.channel,
39
+ prerelease: manifest.prerelease,
40
+ publishedAt: manifest.publishedAt,
41
+ files: [...manifest.files]
42
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
43
+ .map((file) => ({ name: file.name, size: file.size, sha256: file.sha256 })),
44
+ }));
45
+ }
46
+ async function signatureValid(publicKey, manifest, signature) {
47
+ const key = await node_crypto_1.webcrypto.subtle.importKey("raw", Buffer.from(publicKey, "base64"), { name: "Ed25519" }, false, ["verify"]);
48
+ return node_crypto_1.webcrypto.subtle.verify({ name: "Ed25519" }, key, Buffer.from(signature, "base64"), manifestBytes(manifest));
49
+ }
50
+ async function sha256File(file) {
51
+ const hash = (0, node_crypto_1.createHash)("sha256");
52
+ await new Promise((resolve, reject) => {
53
+ (0, node_fs_1.createReadStream)(file)
54
+ .on("data", (chunk) => hash.update(chunk))
55
+ .on("end", () => resolve())
56
+ .on("error", reject);
57
+ });
58
+ return hash.digest("hex");
59
+ }
60
+ async function commandVerify(parsed) {
61
+ const [project, release, ...files] = parsed.positional;
62
+ if (!project || !project.includes("/") || !release || !files.length) {
63
+ console.error((0, colour_js_1.red)("Try: cbx verify owner/project v1.0 ./app-setup.exe"));
64
+ return 1;
65
+ }
66
+ const [owner, slug] = project.split("/");
67
+ const base = `${(0, config_js_1.apiOrigin)()}/v1/public/projects/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}/releases/${encodeURIComponent(release)}`;
68
+ const response = await fetch(`${base}/manifest.json`, { signal: AbortSignal.timeout(60_000) });
69
+ if (!response.ok) {
70
+ console.error((0, colour_js_1.red)(`No signed manifest for ${project} ${release} (${response.status}).`));
71
+ return 1;
72
+ }
73
+ const answer = (await response.json());
74
+ const pinned = parsed.flags.get("key");
75
+ const key = typeof pinned === "string" ? pinned : exports.PINNED_RELEASE_KEY && (0, config_js_1.apiOrigin)() === config_js_1.DEFAULT_API ? exports.PINNED_RELEASE_KEY : answer.publicKey;
76
+ if (!(await signatureValid(key, answer.manifest, answer.signature))) {
77
+ console.error((0, colour_js_1.red)("The manifest's signature does not check out. Do not trust these files."));
78
+ return 1;
79
+ }
80
+ console.log(`${(0, colour_js_1.green)("✓")} Signed manifest for ${(0, colour_js_1.bold)(`${answer.manifest.project} ${answer.manifest.release}`)} ${(0, colour_js_1.dim)(`(key ${key.slice(0, 12)}…)`)}`);
81
+ let failed = false;
82
+ for (const file of files) {
83
+ const name = node_path_1.default.basename(file);
84
+ const entry = answer.manifest.files.find((one) => one.name === name);
85
+ if (!entry) {
86
+ console.log(`${(0, colour_js_1.red)("×")} ${name}: not one of this release's files`);
87
+ failed = true;
88
+ continue;
89
+ }
90
+ const size = (await (0, promises_1.stat)(file)).size;
91
+ const digest = await sha256File(file);
92
+ if (size !== entry.size || digest !== entry.sha256) {
93
+ console.log(`${(0, colour_js_1.red)("×")} ${name}: does not match (${size} bytes, ${digest.slice(0, 16)}…; expected ${entry.size} bytes, ${entry.sha256.slice(0, 16)}…)`);
94
+ failed = true;
95
+ }
96
+ else {
97
+ console.log(`${(0, colour_js_1.green)("✓")} ${name} ${(0, colour_js_1.dim)(`${digest.slice(0, 16)}…`)}`);
98
+ }
99
+ }
100
+ return failed ? 1 : 0;
101
+ }
102
+ async function commandDeps(parsed) {
103
+ const named = parsed.flags.get("project");
104
+ const project = await (0, project_commands_js_1.resolveProject)(typeof named === "string" ? named : undefined);
105
+ if (!project)
106
+ return 1;
107
+ const all = await (0, api_js_1.versions)(project.id);
108
+ const asked = parsed.positional[0];
109
+ const target = asked
110
+ ? all.find((one) => String(one.sequence) === asked.replace(/^v/i, ""))
111
+ : all.reduce((newest, one) => (!newest || one.sequence > newest.sequence ? one : newest), undefined);
112
+ if (!target) {
113
+ console.error((0, colour_js_1.red)(asked ? `${project.name} has no version ${asked}.` : "This project has no saves yet."));
114
+ return 1;
115
+ }
116
+ const report = await (0, api_js_1.callApi)(`/v1/repositories/${encodeURIComponent(project.id)}/versions/${encodeURIComponent(target.id)}/dependencies${parsed.flags.has("fresh") ? "?fresh=1" : ""}`);
117
+ if (report.state === "none") {
118
+ console.log((0, colour_js_1.dim)(`v${target.sequence} has no lockfile to check.`));
119
+ return 0;
120
+ }
121
+ if (report.state === "failed") {
122
+ console.error((0, colour_js_1.red)(`Could not ask OSV: ${report.error ?? "unknown"}`));
123
+ return 1;
124
+ }
125
+ if (!report.findings.length) {
126
+ console.log(`${(0, colour_js_1.green)("✓")} v${target.sequence}: ${report.packages} packages, no known vulnerabilities.`);
127
+ return 0;
128
+ }
129
+ console.log((0, colour_js_1.red)(`v${target.sequence}: ${report.findings.length} of ${report.packages} packages have known vulnerabilities`));
130
+ for (const finding of report.findings) {
131
+ console.log(` ${(0, colour_js_1.accent)(`${finding.name}@${finding.version}`)} ${(0, colour_js_1.dim)(`${finding.ecosystem} · ${finding.file}`)}`);
132
+ console.log(` ${finding.ids.slice(0, 4).join(", ")}${finding.summary ? (0, colour_js_1.dim)(` — ${finding.summary}`) : ""}`);
133
+ }
134
+ return 1;
135
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "The command line for CodeRook (coderook.com): version control and project hosting for projects of any size. Every save is a compressed snapshot, private or public, never used to train AI.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://coderook.com",
@@ -306,24 +306,56 @@ unasked. `--ship` attaches the results to the newest save, so **ask before
306
306
  `--ship`**, as before any save. A project's own build description lives in
307
307
  `coderook.build.json`.
308
308
 
309
- ## Reviewing proposals with GitHub tools
309
+ ## Pull requests
310
310
 
311
- When someone wants CodeRabbit, Blacksmith or GitHub Actions on their CodeRook
312
- proposals, that is `cbx github`: a GitHub repository mirrors each open
313
- proposal as a pull request, and the results come back as checks named
314
- `github/<check>` and `review/<reviewer>`.
311
+ A pull request is a proposal: a line offered to another line (`cbx propose`
312
+ opens one). Read and review them with `cbx pr`:
315
313
 
316
314
  ```bash
317
- cbx github setup OWNER/REPO # token limited to this project, secret, workflow file
318
- cbx submit # the workflow has to be saved into the project
319
- cbx github sync --repo OWNER/REPO
315
+ cbx pr list # what is open on this project
316
+ cbx pr view M-3 # reviews, checks, conversation, what blocks merging
317
+ cbx pr files M-3 # what it changes
318
+ cbx pr comment M-3 --line src/app.ts:42 -m "…"
319
+ cbx pr approve M-3 # or request-changes M-3 -m "…"
320
+ cbx pr merge M-3
320
321
  ```
321
322
 
322
- **Ask before `setup`**: it creates a token and stores it in GitHub. The person
323
- adds the second secret, `CODEROOK_BRIDGE_PUSH` (a GitHub fine-grained token
324
- with Contents and Pull requests read/write), themselves, because it is their
325
- GitHub credential. Every proposal also carries a `coderook/malware` check; a
326
- failure names the file. Do not try to get round it.
323
+ **Ask before** `approve`, `request-changes`, `merge`, `close` and any comment:
324
+ each is the person speaking or acting, and is seen by others. An approval
325
+ stops counting when new saves land on the line, so re-check after pushing.
326
+ Every pull request carries a `coderook/malware` check: what the line adds or
327
+ changes is scanned before it can land, and a failure names the file. Do not
328
+ try to get round it.
329
+
330
+ ## Private files
331
+
332
+ `cbx private add "design/"` keeps matching files out of everything outside the
333
+ project (listings, zips, clone, Pages). Ask before changing the list: it
334
+ decides what strangers can see.
335
+
336
+ ## Runs
337
+
338
+ `cbx runs`, `cbx logs 12 --follow` to watch one, `cbx cancel 12` to stop one,
339
+ `cbx rerun 12` to run it again, `cbx approve 14` for one waiting for approval
340
+ (ask before each). Workflows can be YAML files in `.rook/workflows`; check
341
+ them with `cbx actions check` before saving. Never print or echo secrets:
342
+ `cbx secret set NAME` asks for the value itself, so let the person type it.
343
+
344
+ Inside a run, `CODEROOK_TOKEN`, `CODEROOK_API` and `CODEROOK_PROJECT` are set,
345
+ so `cbx issue comment`, `cbx pr label` and `cbx release` work with no sign-in.
346
+
347
+ ## Issues, forks and security
348
+
349
+ `cbx issue view|comment|close|label|assign`, `cbx fork`, `cbx security`. Ask
350
+ before closing, labelling, assigning, forking, or sending anything: each is the
351
+ person acting where others see it. A security report is private to the
352
+ project's owner; never put vulnerability details in a public issue.
353
+
354
+ ## Releases
355
+
356
+ `cbx release v2.0 --draft --generate-notes` then `cbx release publish`;
357
+ `cbx verify owner/project v2.0 ./file` checks a download; `cbx deps` lists
358
+ known vulnerabilities in the lockfiles. Ask before publishing.
327
359
 
328
360
  ## Mod repositories for games
329
361