@coderook/cli 0.36.0 → 0.38.1

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.
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.commandPrivate = commandPrivate;
4
+ /**
5
+ * `cbx private` — files and folders only the project's own people may see.
6
+ *
7
+ * Patterns in the shape of a .gitignore, kept on the project rather than in a
8
+ * file inside it, so they cover every version from the moment they are set,
9
+ * old ones included, and a save can never undo them. Visitors, the public
10
+ * API, git clone, zips and Pages leave those files out; members see them.
11
+ */
12
+ const api_js_1 = require("./api.js");
13
+ const colour_js_1 = require("./colour.js");
14
+ const project_commands_js_1 = require("./project_commands.js");
15
+ async function commandPrivate(parsed) {
16
+ const sub = parsed.positional[0] ?? "list";
17
+ const named = parsed.flags.get("project");
18
+ const project = await (0, project_commands_js_1.resolveProject)(typeof named === "string" ? named : undefined);
19
+ if (!project)
20
+ return 1;
21
+ const current = await (0, api_js_1.getPrivatePaths)(project.id);
22
+ if (sub === "list") {
23
+ if (!current.length) {
24
+ console.log(`Nothing in ${(0, colour_js_1.bold)(project.slug)} is private on its own.`);
25
+ console.log((0, colour_js_1.dim)(` Keep files to the project's people: cbx private add "design/" "*.psd"`));
26
+ return 0;
27
+ }
28
+ console.log((0, colour_js_1.bold)(`Private in ${project.slug}`) + (0, colour_js_1.dim)(" — only the project's people see these"));
29
+ for (const pattern of current)
30
+ console.log(` ${pattern}`);
31
+ return 0;
32
+ }
33
+ const given = parsed.positional.slice(1);
34
+ if (!given.length) {
35
+ console.error((0, colour_js_1.red)(`Name at least one pattern: cbx private ${sub} "design/"`));
36
+ return 1;
37
+ }
38
+ let next;
39
+ if (sub === "add") {
40
+ next = [...current, ...given.filter((one) => !current.includes(one))];
41
+ }
42
+ else if (sub === "remove") {
43
+ const missing = given.filter((one) => !current.includes(one));
44
+ if (missing.length) {
45
+ console.error((0, colour_js_1.red)(`Not in the list: ${missing.join(", ")}. See: cbx private list`));
46
+ return 1;
47
+ }
48
+ next = current.filter((one) => !given.includes(one));
49
+ }
50
+ else if (sub === "set") {
51
+ next = given;
52
+ }
53
+ else {
54
+ console.error((0, colour_js_1.red)(`cbx private has no "${sub}". `) + "See: " + (0, colour_js_1.accent)("cbx help private"));
55
+ return 1;
56
+ }
57
+ const saved = await (0, api_js_1.putPrivatePaths)(project.id, next);
58
+ console.log(`${(0, colour_js_1.green)("Saved.")} ${saved.length ? `Private in ${(0, colour_js_1.bold)(project.slug)}:` : `Nothing in ${project.slug} is private on its own now.`}`);
59
+ for (const pattern of saved)
60
+ console.log(` ${pattern}`);
61
+ if (sub !== "remove" && saved.length) {
62
+ console.log((0, colour_js_1.dim)(" Every version obeys this now, old ones included. Members still see everything."));
63
+ }
64
+ return 0;
65
+ }
@@ -42,7 +42,8 @@ function bytes(value) {
42
42
  * its name every time.
43
43
  */
44
44
  async function resolveProject(reference) {
45
- const wanted = reference ?? (await (0, config_js_1.readLink)(node_process_1.default.cwd()))?.slug;
45
+ /* Inside a run, the project it is running for (CODEROOK_PROJECT, handed to every run). */
46
+ const wanted = reference ?? (await (0, config_js_1.readLink)(node_process_1.default.cwd()))?.slug ?? node_process_1.default.env.CODEROOK_PROJECT?.trim();
46
47
  if (!wanted) {
47
48
  console.error((0, colour_js_1.red)("Which project? Name one, or run this inside a linked folder."));
48
49
  return null;
@@ -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;
@@ -31,6 +32,9 @@ exports.report = report;
31
32
  const node_child_process_1 = require("node:child_process");
32
33
  const node_crypto_1 = require("node:crypto");
33
34
  const promises_1 = require("node:fs/promises");
35
+ const node_fs_1 = require("node:fs");
36
+ const node_stream_1 = require("node:stream");
37
+ const promises_2 = require("node:stream/promises");
34
38
  const node_os_1 = require("node:os");
35
39
  const node_os_2 = __importDefault(require("node:os"));
36
40
  const node_path_1 = __importDefault(require("node:path"));
@@ -38,6 +42,15 @@ const download_js_1 = require("../../cbx/src/core/download.js");
38
42
  const identify_js_1 = require("../../cbx/src/core/identify.js");
39
43
  const config_js_1 = require("./config.js");
40
44
  const progress_js_1 = require("./progress.js");
45
+ /** Replace each secret's value with ***, longest first. */
46
+ function maskSecrets(text, secrets) {
47
+ let out = text;
48
+ for (const value of secrets) {
49
+ if (value.length >= 4 && out.includes(value))
50
+ out = out.split(value).join("***");
51
+ }
52
+ return out;
53
+ }
41
54
  /** How often to tell the service the machine is still working. */
42
55
  const HEARTBEAT_MS = 30_000;
43
56
  /** How often log lines are sent up while a command is running. */
@@ -65,7 +78,9 @@ async function call(route, options = {}) {
65
78
  const text = await response.text();
66
79
  const body = text ? JSON.parse(text) : {};
67
80
  if (!response.ok) {
68
- throw new Error(body?.error?.message ?? `${route} failed (${response.status})`);
81
+ const error = new Error(body?.error?.message ?? `${route} failed (${response.status})`);
82
+ error.code = body?.error?.code;
83
+ throw error;
69
84
  }
70
85
  return body;
71
86
  }
@@ -333,6 +348,32 @@ function patternMatcher(pattern) {
333
348
  */
334
349
  async function performRun(repositoryId, run, runner) {
335
350
  const progress = new progress_js_1.RunProgress();
351
+ /* Set when somebody cancels the run on CodeRook: the work stops now rather than when it ends. */
352
+ let cancelled = false;
353
+ /* Set when the workflow's time limit passes on this machine. */
354
+ let timedOut = false;
355
+ let limitTimer;
356
+ const secrets = (run.secretNames ?? [])
357
+ .map((name) => run.environment?.[name] ?? "")
358
+ .filter(Boolean)
359
+ .sort((a, b) => b.length - a.length);
360
+ let child = null;
361
+ const stopChild = () => {
362
+ if (!child?.pid || child.exitCode !== null)
363
+ return;
364
+ if (process.platform === "win32") {
365
+ /* The command runs through a shell; the whole tree goes, not only the shell. */
366
+ (0, node_child_process_1.spawn)("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
367
+ }
368
+ else {
369
+ try {
370
+ process.kill(-child.pid, "SIGTERM");
371
+ }
372
+ catch {
373
+ child.kill("SIGTERM");
374
+ }
375
+ }
376
+ };
336
377
  const logs = new LogShipper(repositoryId, run.runId, (line) => progress.say(line));
337
378
  const started = Date.now();
338
379
  const workspace = await (0, promises_1.mkdtemp)(node_path_1.default.join((0, node_os_1.tmpdir)(), "coderook-run-"));
@@ -343,16 +384,52 @@ async function performRun(repositoryId, run, runner) {
343
384
  void call(`/v1/repositories/${repositoryId}/runs/${run.runId}/heartbeat`, {
344
385
  method: "POST",
345
386
  body: { runner },
346
- }).catch(() => {
387
+ }).catch((error) => {
347
388
  /*
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.
389
+ Cancelled: end the work now. A lost lease otherwise means the service
390
+ has given this run to somebody else, and the local process is doing
391
+ work nobody will accept; the report at the end changes nothing, and
392
+ the run that matters is the one the other machine is doing.
352
393
  */
394
+ if (error?.code === "run_cancelled" && !cancelled) {
395
+ cancelled = true;
396
+ logs.add("Cancelled on CodeRook. Stopping.", "stderr");
397
+ stopChild();
398
+ }
353
399
  });
354
400
  }, HEARTBEAT_MS);
355
- if (run.versionId) {
401
+ if (run.workspaceInput) {
402
+ const input = await call(`/v1/repositories/${repositoryId}/workspace-runs/${run.runId}/input`);
403
+ run.command = input.command;
404
+ run.workingDirectory = input.workingDirectory;
405
+ run.artifactPaths = input.artifactPaths;
406
+ logs.add(`Fetching ${input.files.length} selected files from frozen workspace revision ${input.revision}…`, 'stdout');
407
+ const token = await (0, config_js_1.loadToken)();
408
+ for (const file of input.files) {
409
+ if (cancelled)
410
+ throw new Error('Cancelled while fetching workspace input');
411
+ if (!/^[a-f0-9]{64}$/.test(file.sha256 ?? ''))
412
+ throw new Error(`The frozen file ${file.path} has no verified content digest`);
413
+ const destination = insideWorkspace(workspace, file.path);
414
+ await (0, promises_1.mkdir)(node_path_1.default.dirname(destination), { recursive: true });
415
+ const response = await fetch(`${(0, config_js_1.apiOrigin)()}/v1/repositories/${repositoryId}/workspace-runs/${run.runId}/input/${file.index}`, { headers: { authorization: `Bearer ${token}`, ...(0, identify_js_1.clientHeaders)() }, signal: AbortSignal.timeout(REQUEST_DEADLINE_MS) });
416
+ if (!response.ok || !response.body)
417
+ throw new Error(`Could not fetch frozen workspace file ${file.path} (${response.status})`);
418
+ const hash = (0, node_crypto_1.createHash)('sha256');
419
+ let bytes = 0;
420
+ const verify = new node_stream_1.Transform({ transform(chunk, _encoding, callback) { bytes += chunk.length; if (bytes > file.size) {
421
+ callback(new Error('Frozen workspace file exceeds its declared size'));
422
+ return;
423
+ } hash.update(chunk); callback(null, chunk); } });
424
+ await (0, promises_2.pipeline)(node_stream_1.Readable.fromWeb(response.body), verify, (0, node_fs_1.createWriteStream)(destination));
425
+ if (bytes !== file.size || hash.digest('hex') !== file.sha256)
426
+ throw new Error(`Frozen content verification failed for ${file.path}; credential-bearing source must use separately configured credentials`);
427
+ if (file.executable && process.platform !== 'win32')
428
+ await (0, promises_1.chmod)(destination, 0o755);
429
+ }
430
+ logs.add('Selected workspace inputs verified.', 'stdout');
431
+ }
432
+ else if (run.versionId) {
356
433
  logs.add(`Fetching v${run.versionSequence ?? "?"}…`, "stdout");
357
434
  /*
358
435
  The one phase that knows its own size. The version says how many files
@@ -384,11 +461,18 @@ async function performRun(repositoryId, run, runner) {
384
461
  Which shell is the platform's own, so a Windows machine reads it the
385
462
  way a Windows user wrote it.
386
463
  */
387
- const child = (0, node_child_process_1.spawn)(command, {
464
+ if (cancelled) {
465
+ settle(1);
466
+ return;
467
+ }
468
+ child = (0, node_child_process_1.spawn)(command, {
388
469
  cwd: directory,
389
470
  shell: true,
471
+ /* Its own process group off Windows, so cancelling can stop everything it started. */
472
+ detached: process.platform !== "win32",
390
473
  env: {
391
474
  ...withoutCredentials(process.env),
475
+ ...(run.environment ?? {}),
392
476
  CI: "true",
393
477
  CODEROOK_RUN: String(run.number),
394
478
  CODEROOK_VERSION: run.versionSequence
@@ -402,7 +486,7 @@ async function performRun(repositoryId, run, runner) {
402
486
  can tell a slow build from a stuck one.
403
487
  */
404
488
  const tee = (chunk, stream) => {
405
- const text = chunk.toString("utf8");
489
+ const text = maskSecrets(chunk.toString("utf8"), secrets);
406
490
  logs.add(text, stream);
407
491
  progress.say(text);
408
492
  };
@@ -413,7 +497,16 @@ async function performRun(repositoryId, run, runner) {
413
497
  settle(1);
414
498
  });
415
499
  child.on("close", (status) => settle(status ?? 1));
500
+ if (run.timeoutMinutes) {
501
+ limitTimer = setTimeout(() => {
502
+ timedOut = true;
503
+ logs.add(`Stopped: past this workflow's time limit of ${run.timeoutMinutes} minutes.`, "stderr");
504
+ stopChild();
505
+ }, run.timeoutMinutes * 60_000);
506
+ }
416
507
  });
508
+ if (limitTimer)
509
+ clearTimeout(limitTimer);
417
510
  /*
418
511
  What it built, before the folder goes.
419
512
 
@@ -433,6 +526,10 @@ async function performRun(repositoryId, run, runner) {
433
526
  }
434
527
  await logs.flush();
435
528
  const seconds = Math.round((Date.now() - started) / 1000);
529
+ if (timedOut)
530
+ return { status: "failed", summary: `Stopped after its time limit of ${run.timeoutMinutes} minutes` };
531
+ if (cancelled)
532
+ return { status: "cancelled", summary: `Cancelled after ${seconds}s` };
436
533
  return code === 0
437
534
  ? { status: "passed", summary: `Passed in ${seconds}s` }
438
535
  : { status: "failed", summary: `Exited ${code} after ${seconds}s` };
@@ -448,6 +545,8 @@ async function performRun(repositoryId, run, runner) {
448
545
  goes out would overwrite the verdict the caller is about to print.
449
546
  */
450
547
  progress.done();
548
+ if (limitTimer)
549
+ clearTimeout(limitTimer);
451
550
  if (beating)
452
551
  clearInterval(beating);
453
552
  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
+ }