@coderook/cli 0.27.0 → 0.28.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.
@@ -2,7 +2,7 @@
2
2
  "name": "coderook",
3
3
  "displayName": "CodeRook",
4
4
  "description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, from Claude Code.",
5
- "version": "0.27.0",
5
+ "version": "0.28.0",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -1,5 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.putObject = putObject;
4
+ exports.beginUpload = beginUpload;
5
+ exports.uploadPart = uploadPart;
6
+ exports.completeUpload = completeUpload;
7
+ exports.attachToVersion = attachToVersion;
3
8
  exports.whoami = whoami;
4
9
  exports.projects = projects;
5
10
  exports.findPublicProject = findPublicProject;
@@ -23,6 +28,7 @@ exports.invite = invite;
23
28
  exports.watch = watch;
24
29
  exports.setWatch = setWatch;
25
30
  exports.workflows = workflows;
31
+ exports.changeWorkflow = changeWorkflow;
26
32
  exports.runs = runs;
27
33
  exports.runLogs = runLogs;
28
34
  exports.tokens = tokens;
@@ -36,6 +42,10 @@ exports.reviewVersion = reviewVersion;
36
42
  exports.removeVersionContent = removeVersionContent;
37
43
  exports.versionAttachments = versionAttachments;
38
44
  exports.undoTo = undoTo;
45
+ exports.webhooks = webhooks;
46
+ exports.addWebhook = addWebhook;
47
+ exports.removeWebhook = removeWebhook;
48
+ exports.collisionCheck = collisionCheck;
39
49
  /** The small part of the API the command-line tool needs directly. */
40
50
  const identify_js_1 = require("../../desktop-app/src/main/identify.js");
41
51
  const config_js_1 = require("./config.js");
@@ -65,6 +75,73 @@ async function call(route, options = {}) {
65
75
  }
66
76
  return body;
67
77
  }
78
+ /**
79
+ * Send raw bytes, rather than JSON.
80
+ *
81
+ * `call` is built for a JSON request and a JSON reply; an object upload is a
82
+ * body of bytes with the digest in the path, so it needs its own door rather
83
+ * than a flag threaded through that one.
84
+ */
85
+ async function send(route, method, bytes, contentType) {
86
+ const token = await (0, config_js_1.loadToken)();
87
+ if (!token)
88
+ throw new Error("Not signed in. Run: cbx sign-in");
89
+ const response = await fetch(`${(0, config_js_1.apiOrigin)()}${route}`, {
90
+ method,
91
+ headers: {
92
+ accept: "application/json",
93
+ authorization: `Bearer ${token}`,
94
+ "user-agent": "CodeRook-CLI/0.1",
95
+ ...(0, identify_js_1.clientHeaders)(),
96
+ "content-type": contentType,
97
+ "content-length": String(bytes.byteLength),
98
+ },
99
+ body: bytes,
100
+ });
101
+ const text = await response.text();
102
+ const body = text ? JSON.parse(text) : {};
103
+ if (!response.ok) {
104
+ throw new Error(body?.error?.message ??
105
+ `${route} failed (${response.status})`);
106
+ }
107
+ return body;
108
+ }
109
+ /** One object, small enough for a single request. Returns its id. */
110
+ async function putObject(repositoryId, sha256, bytes, mediaType) {
111
+ const body = await send(`/v1/repositories/${encodeURIComponent(repositoryId)}/objects/${sha256}` +
112
+ `?kind=chunk&role=chunk&mediaType=${encodeURIComponent(mediaType)}`, "PUT", bytes, "application/octet-stream");
113
+ return String(body.objectId ?? "");
114
+ }
115
+ /** Start a multipart upload, for anything the direct route will not take. */
116
+ async function beginUpload(repositoryId, definition) {
117
+ const body = await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/uploads`, {
118
+ method: "POST",
119
+ body: definition,
120
+ });
121
+ const id = body.uploadSessionId ?? body.id;
122
+ if (!id)
123
+ throw new Error("The service did not return an upload session");
124
+ return {
125
+ uploadSessionId: String(id),
126
+ maximumPartBytes: Number(body.maximumPartBytes ?? 95 * 1024 * 1024),
127
+ };
128
+ }
129
+ /** One numbered part of a multipart upload. */
130
+ async function uploadPart(uploadSessionId, partNumber, bytes) {
131
+ await send(`/v1/uploads/${encodeURIComponent(uploadSessionId)}/parts/${partNumber}`, "PUT", bytes, "application/octet-stream");
132
+ }
133
+ /** Close a multipart upload and take the object it became. */
134
+ async function completeUpload(uploadSessionId) {
135
+ const body = await call(`/v1/uploads/${encodeURIComponent(uploadSessionId)}/complete`, { method: "POST" });
136
+ if (!body.objectId)
137
+ throw new Error("The upload did not produce an object");
138
+ return String(body.objectId);
139
+ }
140
+ /** Make an object already on the project a download on one of its versions. */
141
+ async function attachToVersion(repositoryId, versionId, name, objectId) {
142
+ await call(`/v1/repositories/${encodeURIComponent(repositoryId)}` +
143
+ `/versions/${encodeURIComponent(versionId)}/attachments`, { method: "POST", body: { name, objectId } });
144
+ }
68
145
  async function whoami() {
69
146
  const body = await call("/v1/auth/session");
70
147
  if (!body.user)
@@ -313,7 +390,15 @@ async function createIssue(repositoryId, input) {
313
390
  itself, and exactly the kind of small wrongness that makes somebody stop
314
391
  trusting the rest of the output.
315
392
  */
316
- const created = await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/issues`, { method: "POST", body: { title: input.title, body: input.body ?? "" } });
393
+ const created = await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/issues`, {
394
+ method: "POST",
395
+ body: {
396
+ title: input.title,
397
+ body: input.body ?? "",
398
+ ...(input.labels?.length ? { labels: input.labels } : {}),
399
+ ...(input.versionId ? { versionId: input.versionId } : {}),
400
+ },
401
+ });
317
402
  return { number: Number(created.number ?? 0) };
318
403
  }
319
404
  async function releases(repositoryId) {
@@ -367,15 +452,29 @@ async function setWatch(repositoryId, change) {
367
452
  async function workflows(repositoryId) {
368
453
  const body = await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/workflows`);
369
454
  return (body.workflows ?? []).map((row) => ({
455
+ /*
456
+ The id was dropped here for as long as this only ever printed a list.
457
+ Anything that acts on one workflow needs it, and a list that has thrown
458
+ away the identity of its rows can only be re-fetched.
459
+ */
460
+ id: String(row.id ?? ""),
370
461
  name: String(row.name ?? ""),
371
462
  trigger: String(row.trigger ?? ""),
372
463
  command: String(row.command ?? ""),
373
464
  runsOn: String(row.runsOn ?? "hosted"),
465
+ artifactPaths: Array.isArray(row.artifactPaths)
466
+ ? row.artifactPaths.map((one) => String(one))
467
+ : [],
468
+ attachArtifacts: row.attachArtifacts === true,
374
469
  runs: Number(row.runs ?? 0),
375
470
  passing: Number(row.passing ?? 0),
376
471
  archivedAt: row.archivedAt ? String(row.archivedAt) : null,
377
472
  }));
378
473
  }
474
+ /** Change a workflow. Only what is named is touched. */
475
+ async function changeWorkflow(repositoryId, workflowId, change) {
476
+ await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/workflows/${encodeURIComponent(workflowId)}`, { method: "PATCH", body: change });
477
+ }
379
478
  /**
380
479
  * What has run on a project.
381
480
  *
@@ -496,3 +595,58 @@ async function versionAttachments(repositoryId, versionId) {
496
595
  async function undoTo(repositoryId, to) {
497
596
  return await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/undo`, { method: "POST", body: to ? { to } : {} });
498
597
  }
598
+ /** Where this project tells somebody else that something happened. */
599
+ async function webhooks(repositoryId) {
600
+ const body = await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/webhooks`);
601
+ return (body.webhooks ?? []).map((row) => ({
602
+ id: String(row.id ?? ""),
603
+ url: String(row.url ?? ""),
604
+ events: Array.isArray(row.events) ? row.events.map((one) => String(one)) : [],
605
+ active: row.active !== false,
606
+ lastDeliveredAt: row.last_delivered_at
607
+ ? String(row.last_delivered_at)
608
+ : null,
609
+ consecutiveFailures: Number(row.consecutive_failures ?? 0),
610
+ }));
611
+ }
612
+ /**
613
+ * Add one, and answer with the signing secret.
614
+ *
615
+ * The secret is generated by the service and returned exactly once — it is
616
+ * not readable from any route afterwards — so whatever prints this is the
617
+ * only chance anybody gets to write it down.
618
+ */
619
+ async function addWebhook(repositoryId, url, events) {
620
+ const created = await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/webhooks`, { method: "POST", body: events.length ? { url, events } : { url } });
621
+ return { id: String(created.id ?? ""), secret: String(created.secret ?? "") };
622
+ }
623
+ async function removeWebhook(repositoryId, webhookId) {
624
+ await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/webhooks/${encodeURIComponent(webhookId)}`, { method: "DELETE" });
625
+ }
626
+ /**
627
+ * What would collide if this folder published right now.
628
+ *
629
+ * Advice, asked before anything is sent. The service has offered this since
630
+ * Merge Tracks shipped and no client asked, so the first anybody heard that
631
+ * their upload was walking into a merge was after it had finished — which on
632
+ * a slow line is the worst possible moment to find out.
633
+ *
634
+ * The answer can be stale by the time the publish arrives; the publish path
635
+ * decides for real. That is why this warns rather than refuses.
636
+ */
637
+ async function collisionCheck(repositoryId, baseVersionId, paths) {
638
+ const body = await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/collision-check`, {
639
+ method: "POST",
640
+ /*
641
+ Capped, because this is advice and a folder can hold a hundred thousand
642
+ paths. The colliding ones are what matter and a long list of them is
643
+ already a merge nobody wants to read on a terminal.
644
+ */
645
+ body: { baseVersionId, paths: paths.slice(0, 5000) },
646
+ });
647
+ return {
648
+ behind: body.behind === true,
649
+ movedPaths: body.movedPaths ?? [],
650
+ collidingPaths: body.collidingPaths ?? [],
651
+ };
652
+ }
@@ -0,0 +1,217 @@
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.commandAttach = commandAttach;
7
+ /**
8
+ * `cbx attach` — put a file people can download on a version.
9
+ *
10
+ * A version could already hold downloads and there was no way to add one. The
11
+ * service accepted an attachment naming any object already on the project,
12
+ * and the only thing that ever put an object there through this door was a
13
+ * workflow run — so shipping a build made anywhere else meant a person
14
+ * clicking through the website, and shipping one built on this machine meant
15
+ * nothing at all.
16
+ *
17
+ * The size is the interesting part. A single PUT is capped at 95 MiB by the
18
+ * service, and the installer this was written for is 95.5 MB — so the
19
+ * comfortable path is the one that does not work for the thing people most
20
+ * want to attach. Anything above the cap goes up in parts instead, which is
21
+ * the same route the desktop uploader uses.
22
+ */
23
+ const node_crypto_1 = require("node:crypto");
24
+ const node_fs_1 = require("node:fs");
25
+ const promises_1 = require("node:fs/promises");
26
+ const node_path_1 = __importDefault(require("node:path"));
27
+ const api_js_1 = require("./api.js");
28
+ const project_commands_js_1 = require("./project_commands.js");
29
+ const version_commands_js_1 = require("./version_commands.js");
30
+ const api_js_2 = require("./api.js");
31
+ const dim = (value) => `${value}`;
32
+ const bold = (value) => `${value}`;
33
+ const red = (value) => `${value}`;
34
+ const green = (value) => `${value}`;
35
+ const accent = (value) => `${value}`;
36
+ /** What the service will take in one request. */
37
+ const DIRECT_LIMIT = 95 * 1024 * 1024;
38
+ /** Comfortably under the cap, so one slow part does not hold the rest up. */
39
+ const PART_SIZE = 32 * 1024 * 1024;
40
+ function readable(bytes) {
41
+ const units = ["B", "KB", "MB", "GB"];
42
+ let value = bytes;
43
+ let unit = 0;
44
+ while (value >= 1024 && unit < units.length - 1) {
45
+ value /= 1024;
46
+ unit += 1;
47
+ }
48
+ return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
49
+ }
50
+ /**
51
+ * The digest, read a piece at a time.
52
+ *
53
+ * A ninety-five megabyte installer read into memory to be hashed and then
54
+ * read again to be sent is two hundred megabytes of a small machine's memory
55
+ * spent saying something a stream can say for nothing.
56
+ */
57
+ async function digestOf(file) {
58
+ const hash = (0, node_crypto_1.createHash)("sha256");
59
+ await new Promise((resolve, reject) => {
60
+ const stream = (0, node_fs_1.createReadStream)(file);
61
+ stream.on("data", (piece) => hash.update(piece));
62
+ stream.on("error", reject);
63
+ stream.on("end", () => resolve());
64
+ });
65
+ return hash.digest("hex");
66
+ }
67
+ /** A media type from the name, because the service records one either way. */
68
+ function mediaTypeOf(name) {
69
+ const known = {
70
+ ".exe": "application/vnd.microsoft.portable-executable",
71
+ ".dmg": "application/x-apple-diskimage",
72
+ ".deb": "application/vnd.debian.binary-package",
73
+ ".rpm": "application/x-rpm",
74
+ ".appimage": "application/x-executable",
75
+ ".zip": "application/zip",
76
+ ".gz": "application/gzip",
77
+ ".tgz": "application/gzip",
78
+ ".7z": "application/x-7z-compressed",
79
+ ".pdf": "application/pdf",
80
+ ".md": "text/markdown",
81
+ ".txt": "text/plain",
82
+ ".json": "application/json",
83
+ };
84
+ return known[node_path_1.default.extname(name).toLowerCase()] ?? "application/octet-stream";
85
+ }
86
+ /**
87
+ * Send the bytes and return the object they became.
88
+ *
89
+ * Two routes, one answer. Which one is used is a property of the size and not
90
+ * something a caller should have to think about, so it is decided here.
91
+ */
92
+ async function sendFile(repositoryId, file, size, sha256, mediaType, say) {
93
+ if (size <= DIRECT_LIMIT) {
94
+ const handle = await (0, promises_1.open)(file, "r");
95
+ try {
96
+ const bytes = await handle.readFile();
97
+ return await (0, api_js_1.putObject)(repositoryId, sha256, bytes, mediaType);
98
+ }
99
+ finally {
100
+ await handle.close();
101
+ }
102
+ }
103
+ /*
104
+ Parts, because the service will not take this in one request. Sent one at
105
+ a time rather than at once: the point of this path is a file too big to
106
+ hold comfortably, and holding four of its pieces to go faster gives that
107
+ back.
108
+ */
109
+ const session = await (0, api_js_1.beginUpload)(repositoryId, {
110
+ sha256,
111
+ size,
112
+ mediaType,
113
+ kind: "chunk",
114
+ repositoryRole: "chunk",
115
+ });
116
+ const parts = Math.ceil(size / PART_SIZE);
117
+ say(dim(` ${readable(size)} in ${parts} parts`));
118
+ const handle = await (0, promises_1.open)(file, "r");
119
+ try {
120
+ for (let index = 0; index < parts; index += 1) {
121
+ const start = index * PART_SIZE;
122
+ const length = Math.min(PART_SIZE, size - start);
123
+ const buffer = Buffer.alloc(length);
124
+ await handle.read(buffer, 0, length, start);
125
+ await (0, api_js_1.uploadPart)(session.uploadSessionId, index + 1, buffer);
126
+ say(dim(` part ${index + 1}/${parts}`));
127
+ }
128
+ }
129
+ finally {
130
+ await handle.close();
131
+ }
132
+ return await (0, api_js_1.completeUpload)(session.uploadSessionId);
133
+ }
134
+ async function commandAttach(parsed) {
135
+ const file = parsed.positional[0];
136
+ if (!file) {
137
+ console.error(red("Which file?") + dim(" cbx attach ./release/Installer.exe"));
138
+ return 1;
139
+ }
140
+ let size;
141
+ try {
142
+ const info = await (0, promises_1.stat)(file);
143
+ if (!info.isFile()) {
144
+ console.error(red(`${file} is not a file.`));
145
+ return 1;
146
+ }
147
+ size = info.size;
148
+ }
149
+ catch {
150
+ console.error(red(`There is no file at ${file}.`));
151
+ return 1;
152
+ }
153
+ if (size === 0) {
154
+ console.error(red("That file is empty."));
155
+ return 1;
156
+ }
157
+ /*
158
+ The version is the second positional, so `cbx attach build.exe 41` reads
159
+ the way the other version commands do. Without one it is the newest save,
160
+ which is almost always the one just built against.
161
+ */
162
+ const which = (0, version_commands_js_1.split)({
163
+ ...parsed,
164
+ positional: parsed.positional.slice(1),
165
+ });
166
+ const project = await (0, project_commands_js_1.resolveProject)(which.project);
167
+ if (!project)
168
+ return 1;
169
+ const all = await (0, api_js_2.versions)(project.id);
170
+ if (!all.length) {
171
+ console.error(red("This project has no versions yet."));
172
+ return 1;
173
+ }
174
+ const target = which.n
175
+ ? all.find((one) => String(one.sequence) === which.n.replace(/^v/i, ""))
176
+ : all.reduce((newest, one) => (one.sequence > newest.sequence ? one : newest));
177
+ if (!target) {
178
+ console.error(red(`This project has no version ${which.n}.`));
179
+ return 1;
180
+ }
181
+ const named = parsed.flags.get("name");
182
+ const name = typeof named === "string" && named.trim() ? named.trim() : node_path_1.default.basename(file);
183
+ console.log(`${bold(name)} ${dim(`(${readable(size)})`)} → ${accent(`v${target.sequence}`)} of ${project.name}`);
184
+ let objectId;
185
+ try {
186
+ const sha256 = await digestOf(file);
187
+ objectId = await sendFile(project.id, file, size, sha256, mediaTypeOf(name), (text) => console.log(text));
188
+ }
189
+ catch (error) {
190
+ console.error(red(error instanceof Error ? error.message : String(error)));
191
+ return 1;
192
+ }
193
+ try {
194
+ await (0, api_js_1.attachToVersion)(project.id, target.id, name, objectId);
195
+ }
196
+ catch (error) {
197
+ const message = error instanceof Error ? error.message : String(error);
198
+ console.error(red(message));
199
+ if (/already has something called/i.test(message)) {
200
+ console.error(dim(" Use --name to give this one a different name on the version."));
201
+ }
202
+ return 1;
203
+ }
204
+ console.log(green(`Attached. It is a download on v${target.sequence}.`));
205
+ if (!target.name) {
206
+ /*
207
+ Said because it is the difference between attaching and publishing. A
208
+ commit is not offered to anybody, so the download exists and nobody
209
+ outside the project can reach it yet.
210
+ */
211
+ console.log(dim(" v") +
212
+ dim(String(target.sequence)) +
213
+ dim(" is a commit, so nobody outside the project can take it yet."));
214
+ console.log(dim(` Make it a version: cbx mark ${target.sequence} --name v1.0`));
215
+ }
216
+ return 0;
217
+ }
@@ -25,6 +25,7 @@ const registry_js_1 = require("./registry.js");
25
25
  const help_js_1 = require("./help.js");
26
26
  const progress_js_1 = require("./progress.js");
27
27
  const publish_js_1 = require("./publish.js");
28
+ const attach_command_js_1 = require("./attach_command.js");
28
29
  const project_commands_js_1 = require("./project_commands.js");
29
30
  const track_commands_js_1 = require("./track_commands.js");
30
31
  const tracks_js_1 = require("../../desktop-app/src/main/tracks.js");
@@ -693,6 +694,48 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
693
694
  ...(hasFlag(parsed, "allow-secrets") ? { allowSecrets: true } : {}),
694
695
  ...(link ? { known: link.local ?? link.manifest ?? {} } : {}),
695
696
  });
697
+ /*
698
+ Whether this is walking into a merge, asked before anything is sent.
699
+
700
+ The service has offered this since Merge Tracks shipped and nothing ever
701
+ asked, so the first anybody heard that their upload would be diverted was
702
+ after it had finished — which on a slow line is the worst possible moment
703
+ to find out. It is advice: the answer can be stale by the time the
704
+ publish lands and the publish path decides for real, so this says what is
705
+ coming and gets out of the way.
706
+ */
707
+ if (link?.repositoryId && link.baseVersionId) {
708
+ try {
709
+ const ahead = await (0, api_js_1.collisionCheck)(link.repositoryId, link.baseVersionId, files.filter((file) => !file.deleted).map((file) => file.path));
710
+ if (ahead.collidingPaths.length) {
711
+ console.log(accent("Heads up") +
712
+ ` somebody has saved since you last caught up, and ` +
713
+ `${ahead.collidingPaths.length} of your file` +
714
+ `${ahead.collidingPaths.length === 1 ? "" : "s"} ` +
715
+ `${ahead.collidingPaths.length === 1 ? "is" : "are"} among what they changed.`);
716
+ for (const path of ahead.collidingPaths.slice(0, 5)) {
717
+ console.log(dim(` ${path}`));
718
+ }
719
+ if (ahead.collidingPaths.length > 5) {
720
+ console.log(dim(` and ${ahead.collidingPaths.length - 5} more`));
721
+ }
722
+ console.log(dim(" Your upload will be kept whole and put on a merge for you to settle."));
723
+ console.log(dim(" Run cbx get first if you would rather build on theirs."));
724
+ console.log("");
725
+ }
726
+ else if (ahead.behind) {
727
+ console.log(dim("Somebody has saved since you last caught up, but not to any file " +
728
+ "you changed — this will combine on its own."));
729
+ console.log("");
730
+ }
731
+ }
732
+ catch {
733
+ /*
734
+ Advice that cannot be fetched is not a reason to refuse a publish.
735
+ The service checks properly at the point it matters.
736
+ */
737
+ }
738
+ }
696
739
  let result;
697
740
  try {
698
741
  const plan = await uploader.plan(uploadRequest, (progress) => {
@@ -1847,11 +1890,21 @@ const SPECS = [
1847
1890
  group: "Your projects",
1848
1891
  summary: "issues on a project, or open one",
1849
1892
  usage: "issues [project]",
1893
+ detail: "Labels are given when the issue is opened rather than added\n" +
1894
+ "afterwards, and any name that does not exist yet is created.\n\n" +
1895
+ "--version says which save it is about. \"It broke\" and \"it broke in\n" +
1896
+ "v41\" are different reports, and the second is the one somebody can\n" +
1897
+ "act on.",
1850
1898
  options: [
1851
1899
  { flags: '--new "<title>"', description: "open a new issue" },
1852
1900
  { flags: "--body <text>", description: "the description for a new one" },
1901
+ { flags: "--labels <a,b>", description: "labels for a new one, comma separated" },
1902
+ { flags: "--version <n>", description: "which save it is about" },
1903
+ ],
1904
+ examples: [
1905
+ 'cbx issues my-project --new "Crash on export"',
1906
+ 'cbx issues --new "Installer will not run" --labels bug,windows --version 41',
1853
1907
  ],
1854
- examples: ['cbx issues my-project --new "Crash on export"'],
1855
1908
  run: service_commands_js_1.commandIssues,
1856
1909
  },
1857
1910
  {
@@ -1862,6 +1915,37 @@ const SPECS = [
1862
1915
  usage: "releases [project]",
1863
1916
  run: service_commands_js_1.commandReleases,
1864
1917
  },
1918
+ {
1919
+ /*
1920
+ The service has been able to notify something else since Merge Tracks
1921
+ shipped — queue an event, sign it, deliver it after the request that
1922
+ caused it — and nothing could reach it, so no project could be told to
1923
+ tell a build box or a status page anything.
1924
+ */
1925
+ name: "hooks",
1926
+ aliases: ["webhooks"],
1927
+ group: "Your projects",
1928
+ summary: "where this project tells something else what happened",
1929
+ usage: "hooks [project]",
1930
+ detail: "A signed POST to an address of yours when something happens here.\n" +
1931
+ "The signing secret is generated by the service and shown once, when\n" +
1932
+ "the hook is added — no route hands it back afterwards.\n\n" +
1933
+ "Events: version.published, merge.opened, merge.applied,\n" +
1934
+ "check.reported, change_request.opened, change_request.reviewed.\n" +
1935
+ "Naming none of them sends all of them.",
1936
+ options: [
1937
+ { flags: "--add <url>", description: "notify this address; https only" },
1938
+ { flags: "--events <a,b>", description: "only these, comma separated" },
1939
+ { flags: "--remove <id>", description: "stop notifying it" },
1940
+ { flags: "--project <name>", description: "which project" },
1941
+ ],
1942
+ examples: [
1943
+ "cbx hooks",
1944
+ "cbx hooks --add https://example.com/coderook",
1945
+ "cbx hooks --add https://example.com/builds --events version.published",
1946
+ ],
1947
+ run: service_commands_js_1.commandHooks,
1948
+ },
1865
1949
  {
1866
1950
  /*
1867
1951
  The half that hiding never covered.
@@ -1925,6 +2009,30 @@ const SPECS = [
1925
2009
  that works until somebody scripts it. This marks a save — pins it,
1926
2010
  hides it, labels it — which is what it does anyway.
1927
2011
  */
2012
+ name: "attach",
2013
+ group: "Your projects",
2014
+ summary: "put a file people can download on a version",
2015
+ usage: "attach <file> [n] [project]",
2016
+ detail: "A version can carry downloads — an installer, a build, a changelog —\n" +
2017
+ "and until now the only thing that could put one there was a workflow\n" +
2018
+ "run. This attaches a file from this machine.\n\n" +
2019
+ "Attaching is not publishing. A download on a commit is reachable by\n" +
2020
+ "nobody until that commit is made a version; on a version already\n" +
2021
+ "offered it is public the moment it lands.\n\n" +
2022
+ "Large files go up in parts, so a 95 MB installer works the same way a\n" +
2023
+ "text file does.",
2024
+ options: [
2025
+ { flags: "--name <text>", description: "call it something else on the version" },
2026
+ { flags: "--project <name>", description: "which project" },
2027
+ ],
2028
+ examples: [
2029
+ "cbx attach ./release/Installer-1.0.exe",
2030
+ "cbx attach ./release/app.dmg 41",
2031
+ 'cbx attach ./notes.pdf --name "Release notes.pdf"',
2032
+ ],
2033
+ run: attach_command_js_1.commandAttach,
2034
+ },
2035
+ {
1928
2036
  name: "notes",
1929
2037
  aliases: ["release-notes"],
1930
2038
  group: "Your projects",
@@ -2082,7 +2190,22 @@ const SPECS = [
2082
2190
  summary: "the automations a project has",
2083
2191
  usage: "actions [project]",
2084
2192
  detail: "Shows what each action runs, what it runs on, and how many of its\n" +
2085
- "runs have passed. Use `cbx runner` to execute them on this machine.",
2193
+ "runs have passed. Use `cbx runner` to execute them on this machine.\n\n" +
2194
+ "An action that keeps files can hand them straight to the version it\n" +
2195
+ "ran against, so a passing build becomes a download without anybody\n" +
2196
+ "opening the website. --ship turns that on for one action.\n\n" +
2197
+ "Attaching is publishing: what an action ships onto a public version\n" +
2198
+ "is a download anybody can take, from the moment the run passes.",
2199
+ options: [
2200
+ { flags: "--ship <action>", description: "its builds become downloads on the version" },
2201
+ { flags: "--no-ship <action>", description: "keep its files without attaching them" },
2202
+ { flags: "--project <name>", description: "which project" },
2203
+ ],
2204
+ examples: [
2205
+ "cbx actions",
2206
+ 'cbx actions --ship "Windows installer"',
2207
+ 'cbx actions --no-ship "Windows installer"',
2208
+ ],
2086
2209
  run: service_commands_js_1.commandWorkflows,
2087
2210
  },
2088
2211
  {
@@ -21,6 +21,7 @@ exports.commandRuns = commandRuns;
21
21
  exports.commandLogs = commandLogs;
22
22
  exports.commandTokens = commandTokens;
23
23
  exports.commandDelete = commandDelete;
24
+ exports.commandHooks = commandHooks;
24
25
  const node_process_1 = __importDefault(require("node:process"));
25
26
  const promises_1 = require("node:readline/promises");
26
27
  const api_js_1 = require("./api.js");
@@ -51,11 +52,47 @@ async function commandIssues(parsed) {
51
52
  }
52
53
  if (typeof title === "string" && title.trim()) {
53
54
  const body = parsed.flags.get("body");
55
+ /*
56
+ Labels at the moment it is opened, rather than in a second command
57
+ afterwards. The service has always taken them here; nothing had ever
58
+ sent any, so every issue arrived unlabelled and the label filter on the
59
+ website had nothing to filter by until somebody went back and tidied.
60
+ */
61
+ const named = parsed.flags.get("labels");
62
+ const labels = typeof named === "string"
63
+ ? named
64
+ .split(",")
65
+ .map((one) => one.trim())
66
+ .filter(Boolean)
67
+ : [];
68
+ /*
69
+ And which save it is about, when it is about one. "It broke" and "it
70
+ broke in v41" are different reports, and only the first could be
71
+ written down.
72
+ */
73
+ const about = parsed.flags.get("version");
74
+ let versionId;
75
+ if (typeof about === "string" && about.trim()) {
76
+ const wanted = about.trim().replace(/^v/i, "");
77
+ const all = await (0, api_js_1.versions)(project.id);
78
+ const found = all.find((one) => String(one.sequence) === wanted);
79
+ if (!found) {
80
+ console.error(red(`${project.name} has no version ${about.trim()}.`));
81
+ return 1;
82
+ }
83
+ versionId = found.id;
84
+ }
54
85
  const created = await (0, api_js_1.createIssue)(project.id, {
55
86
  title: title.trim(),
56
87
  body: typeof body === "string" ? body : "",
88
+ labels,
89
+ ...(versionId ? { versionId } : {}),
57
90
  });
58
91
  console.log(green(`Opened issue #${created.number} on ${project.name}.`));
92
+ if (labels.length)
93
+ console.log(dim(` labelled ${labels.join(", ")}`));
94
+ if (versionId)
95
+ console.log(dim(` about v${about}`));
59
96
  return 0;
60
97
  }
61
98
  const found = await (0, api_js_1.issues)(project.id);
@@ -216,10 +253,67 @@ async function commandWatch(parsed) {
216
253
  }
217
254
  /** The automations a project has, and how they have been going. */
218
255
  async function commandWorkflows(parsed) {
219
- const project = await (0, project_commands_js_1.resolveProject)(parsed.positional[0]);
256
+ const project = await (0, project_commands_js_1.resolveProject)(typeof parsed.flags.get("project") === "string"
257
+ ? String(parsed.flags.get("project"))
258
+ : parsed.positional[0]);
220
259
  if (!project)
221
260
  return 1;
222
261
  const found = (await (0, api_js_1.workflows)(project.id)).filter((workflow) => !workflow.archivedAt);
262
+ /*
263
+ Setting, before printing. `cbx actions --ship Build` reads as a sentence
264
+ and then shows the list it has just changed, which is the same shape
265
+ `cbx notes` uses: one command, print by default, flags to write.
266
+ */
267
+ const ship = parsed.flags.get("ship");
268
+ const stop = parsed.flags.get("no-ship");
269
+ if (ship !== undefined || stop !== undefined) {
270
+ const wanted = ship !== undefined;
271
+ const named = wanted ? ship : stop;
272
+ if (typeof named !== "string" || !named.trim()) {
273
+ console.error(red("Which action?") + dim(` cbx actions ${wanted ? "--ship" : "--no-ship"} "Build"`));
274
+ return 1;
275
+ }
276
+ const target = found.find((workflow) => workflow.name.toLowerCase() === named.trim().toLowerCase());
277
+ if (!target) {
278
+ console.error(red(`${project.name} has no action called ${named.trim()}.`));
279
+ if (found.length) {
280
+ console.error(dim(` It has: ${found.map((one) => one.name).join(", ")}`));
281
+ }
282
+ return 1;
283
+ }
284
+ if (wanted && !target.artifactPaths.length) {
285
+ /*
286
+ Refused rather than set. Shipping what a run collected means nothing
287
+ when the run collects nothing, and a flag that reported success and
288
+ then never produced a download would be indistinguishable from the
289
+ feature being broken.
290
+ */
291
+ console.error(red(`${target.name} keeps no files, so it has nothing to ship.`));
292
+ console.error(dim(" Give it something to keep on the Actions screen first, e.g. dist/*.exe"));
293
+ return 1;
294
+ }
295
+ try {
296
+ await (0, api_js_1.changeWorkflow)(project.id, target.id, { attachArtifacts: wanted });
297
+ }
298
+ catch (error) {
299
+ console.error(red(error instanceof Error ? error.message : String(error)));
300
+ return 1;
301
+ }
302
+ target.attachArtifacts = wanted;
303
+ console.log(wanted
304
+ ? green(`${target.name} will attach what it builds to the version it ran against.`)
305
+ : green(`${target.name} will keep its files without attaching them.`));
306
+ if (wanted) {
307
+ /*
308
+ Said plainly, because attaching is publishing. An attachment on a
309
+ public version is a download anybody can take, and somebody turning
310
+ this on for a workflow that builds an internal tool deserves to hear
311
+ that before the next run rather than after it.
312
+ */
313
+ console.log(dim(" Anything it attaches to a public version is public the moment it lands."));
314
+ }
315
+ console.log("");
316
+ }
223
317
  if (!found.length) {
224
318
  console.log(dim("No actions on this project."));
225
319
  return 0;
@@ -232,6 +326,12 @@ async function commandWorkflows(parsed) {
232
326
  console.log(` ${workflow.name.padEnd(24)} ${dim(workflow.trigger).padEnd(20)} ` +
233
327
  `${dim(workflow.runsOn).padEnd(18)} ${health}`);
234
328
  console.log(` ${dim(workflow.command)}`);
329
+ if (workflow.artifactPaths.length) {
330
+ console.log(` ${dim("keeps")} ${dim(workflow.artifactPaths.join(", "))}` +
331
+ (workflow.attachArtifacts
332
+ ? ` ${accent("→ downloads on the version")}`
333
+ : ""));
334
+ }
235
335
  }
236
336
  return 0;
237
337
  }
@@ -399,3 +499,103 @@ async function commandDelete(parsed) {
399
499
  console.log(green(`Deleted ${project.name}.`));
400
500
  return 0;
401
501
  }
502
+ /**
503
+ * Where a project tells something else that something happened.
504
+ *
505
+ * The service has been able to do this since Merge Tracks shipped — queue an
506
+ * event, sign it, deliver it after the request that caused it — and nothing
507
+ * could reach it. A project could not be told to notify a build box, a chat
508
+ * room or a status page, because no client asked.
509
+ */
510
+ async function commandHooks(parsed) {
511
+ const project = await (0, project_commands_js_1.resolveProject)(typeof parsed.flags.get("project") === "string"
512
+ ? String(parsed.flags.get("project"))
513
+ : parsed.positional[0]);
514
+ if (!project)
515
+ return 1;
516
+ const adding = parsed.flags.get("add");
517
+ if (adding !== undefined) {
518
+ if (typeof adding !== "string" || !adding.trim()) {
519
+ console.error(red("Which address?") + dim(" cbx hooks --add https://example.com/hook"));
520
+ return 1;
521
+ }
522
+ const url = adding.trim();
523
+ if (!/^https:\/\//i.test(url)) {
524
+ /*
525
+ Refused here as well as by the service. A webhook carries a signed
526
+ payload about a private project, and http would put it on the wire in
527
+ the clear — worth saying before the round trip rather than after.
528
+ */
529
+ console.error(red("A webhook address has to be https."));
530
+ return 1;
531
+ }
532
+ const named = parsed.flags.get("events");
533
+ const events = typeof named === "string"
534
+ ? named.split(",").map((one) => one.trim()).filter(Boolean)
535
+ : [];
536
+ try {
537
+ const made = await (0, api_js_1.addWebhook)(project.id, url, events);
538
+ console.log(green(`${project.name} will notify ${url}.`));
539
+ console.log("");
540
+ /*
541
+ Printed once because it exists once. The service generates it and no
542
+ route ever hands it back, so this is the only moment anybody can
543
+ write it down — and saying so is the difference between a person
544
+ copying it and a person losing it.
545
+ */
546
+ console.log(` ${bold("Signing secret")} ${made.secret}`);
547
+ console.log(dim(" Written once and never again. Use it to check the signature on"));
548
+ console.log(dim(" what arrives, so nobody else can post to your endpoint."));
549
+ if (events.length)
550
+ console.log(dim(` Sending: ${events.join(", ")}`));
551
+ else
552
+ console.log(dim(" Sending: everything this project announces"));
553
+ }
554
+ catch (error) {
555
+ console.error(red(error instanceof Error ? error.message : String(error)));
556
+ return 1;
557
+ }
558
+ return 0;
559
+ }
560
+ const removing = parsed.flags.get("remove");
561
+ if (removing !== undefined) {
562
+ if (typeof removing !== "string" || !removing.trim()) {
563
+ console.error(red("Which one?") + dim(" cbx hooks --remove <id>"));
564
+ return 1;
565
+ }
566
+ const wanted = removing.trim();
567
+ const found = (await (0, api_js_1.webhooks)(project.id)).find((one) => one.id === wanted || one.url === wanted);
568
+ if (!found) {
569
+ console.error(red(`${project.name} has no webhook called ${wanted}.`));
570
+ return 1;
571
+ }
572
+ await (0, api_js_1.removeWebhook)(project.id, found.id);
573
+ console.log(green(`${found.url} will not be notified any more.`));
574
+ return 0;
575
+ }
576
+ const found = await (0, api_js_1.webhooks)(project.id);
577
+ if (!found.length) {
578
+ console.log(dim("This project notifies nothing."));
579
+ console.log(dim(" cbx hooks --add https://example.com/coderook"));
580
+ return 0;
581
+ }
582
+ console.log(bold(project.name));
583
+ for (const hook of found) {
584
+ /*
585
+ A run of failures shown rather than hidden. A webhook that has been
586
+ failing for a week looks exactly like one that works until somebody
587
+ checks the receiving end, which is the wrong place to find out.
588
+ */
589
+ const health = !hook.active
590
+ ? red("off")
591
+ : hook.consecutiveFailures
592
+ ? red(`${hook.consecutiveFailures} failed in a row`)
593
+ : hook.lastDeliveredAt
594
+ ? green("delivering")
595
+ : dim("nothing sent yet");
596
+ console.log(` ${hook.url}`);
597
+ console.log(` ${dim(hook.id)} ${health}` +
598
+ (hook.events.length ? ` ${dim(hook.events.join(", "))}` : ` ${dim("everything")}`));
599
+ }
600
+ return 0;
601
+ }
@@ -1,8 +1,66 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Tracks = void 0;
4
+ /**
5
+ * The lines a project is saved on, and the merges waiting on a person.
6
+ *
7
+ * The service has had all of this since Tracks existed: a project has one or
8
+ * more lines of versions, and a publish that diverges from what the account
9
+ * holds is put on a Merge Track of its own rather than laid over work the
10
+ * uploader had not seen. That is the right behaviour and it already happens.
11
+ *
12
+ * What did not exist was any way to know. The app never asked for a track, so
13
+ * a save that diverted reported "done" and said nothing about where the work
14
+ * went — the version was safe, on a track nobody could see, and the only way
15
+ * to find it was the website. This is the half that was missing.
16
+ */
17
+ const node_crypto_1 = require("node:crypto");
4
18
  const identify_js_1 = require("./identify.js");
5
19
  const retry_js_1 = require("./retry.js");
20
+ /** A media type from the name, so an edited file is stored as what it is. */
21
+ function mediaTypeForPath(path) {
22
+ const known = {
23
+ css: "text/css",
24
+ html: "text/html",
25
+ js: "text/javascript",
26
+ json: "application/json",
27
+ md: "text/markdown",
28
+ py: "text/x-python",
29
+ ts: "text/typescript",
30
+ tsx: "text/typescript",
31
+ xml: "application/xml",
32
+ yaml: "application/yaml",
33
+ yml: "application/yaml",
34
+ };
35
+ return known[path.split(".").pop()?.toLowerCase() ?? ""] ?? "text/plain";
36
+ }
37
+ /**
38
+ * The error to raise for a failed request, with a refusal said properly.
39
+ *
40
+ * A build refused for being too old is not a network failure and must not
41
+ * read like one: the person can fix it, and only if they are told how. The
42
+ * two requests below build their own fetches rather than going through
43
+ * `call`, so without this a refused desktop was told only "(426)".
44
+ */
45
+ function refusalOrError(body, status, fallback) {
46
+ const tooOld = (0, identify_js_1.readRefusal)(status, body);
47
+ if (tooOld) {
48
+ return new Error(`${tooOld.message} You are running ${tooOld.yourVersion ?? "an older build"}; ` +
49
+ `update from ${tooOld.upgradeUrl}.`);
50
+ }
51
+ let message = `${fallback} (${status})`;
52
+ if (body.trimStart().startsWith("{")) {
53
+ try {
54
+ message =
55
+ JSON.parse(body).error?.message ??
56
+ message;
57
+ }
58
+ catch {
59
+ /* keep the generic message */
60
+ }
61
+ }
62
+ return Object.assign(new Error(message), { status });
63
+ }
6
64
  class Tracks {
7
65
  credentials;
8
66
  constructor(credentials) {
@@ -177,17 +235,106 @@ class Tracks {
177
235
  return [];
178
236
  }
179
237
  }
180
- /** One merge and every path it is waiting on. */
238
+ /**
239
+ * One merge and every path it is waiting on.
240
+ *
241
+ * The merge itself is nested under `mergeTrack`, which this used to spread
242
+ * flat — so `track.reference` was undefined and the window's title fell
243
+ * back to the word "Merge" on every merge there has ever been. The rest of
244
+ * the reply was thrown away with it, including the two version ids that
245
+ * make showing the conflict possible at all.
246
+ */
181
247
  async merge(mergeTrackId) {
182
248
  const body = await this.call(`/v1/merge-tracks/${mergeTrackId}`);
183
- const { conflicts = [], ...track } = body;
184
- return { track: track, conflicts };
249
+ return {
250
+ track: body.mergeTrack,
251
+ conflicts: body.conflicts ?? [],
252
+ repositoryId: body.mergeTrack.repositoryId,
253
+ candidateVersionId: body.mergeTrack.candidateVersionId ?? null,
254
+ currentTargetVersionId: body.currentTargetVersionId ?? null,
255
+ checks: body.checks ?? [],
256
+ ready: body.provisional?.ready ?? false,
257
+ unresolvedPaths: body.provisional?.unresolvedPaths ?? [],
258
+ blockedByChecks: body.provisional?.blockedByChecks ?? [],
259
+ };
260
+ }
261
+ /**
262
+ * One side of a conflict, as text.
263
+ *
264
+ * Both sides of a merge are versions, so this is the ordinary file route.
265
+ * Null means there is genuinely no file on that side, which is half of
266
+ * these conflicts: one person edited what another deleted.
267
+ */
268
+ async fileAt(repositoryId, versionId, path) {
269
+ if (!versionId)
270
+ return null;
271
+ const token = await this.credentials.token();
272
+ if (!token)
273
+ throw new Error("Sign in again");
274
+ const response = await fetch(`${this.credentials.origin()}/v1/repositories/${repositoryId}` +
275
+ `/versions/${versionId}/file?path=${encodeURIComponent(path)}`, {
276
+ headers: {
277
+ authorization: `Bearer ${token}`,
278
+ "user-agent": "CodeRook/0.1",
279
+ ...(0, identify_js_1.clientHeaders)(),
280
+ },
281
+ });
282
+ if (response.status === 404)
283
+ return null;
284
+ if (!response.ok) {
285
+ throw refusalOrError(await response.text().catch(() => ""), response.status, `Could not read ${path}`);
286
+ }
287
+ const bytes = new Uint8Array(await response.arrayBuffer());
288
+ /*
289
+ Decoded strictly, so a file that is not UTF-8 reports itself as binary
290
+ rather than arriving as a screenful of replacement characters somebody
291
+ might then save over the real thing.
292
+ */
293
+ let text = null;
294
+ try {
295
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
296
+ if (text.includes("\u0000"))
297
+ text = null;
298
+ }
299
+ catch {
300
+ text = null;
301
+ }
302
+ return { text, bytes: bytes.byteLength };
303
+ }
304
+ /**
305
+ * Send a file somebody wrote, and answer with the object it became.
306
+ *
307
+ * The other half of an edited resolution: the service takes an object id,
308
+ * and something has to put the bytes there first.
309
+ */
310
+ async putText(repositoryId, path, text) {
311
+ const token = await this.credentials.token();
312
+ if (!token)
313
+ throw new Error("Sign in again");
314
+ const bytes = new TextEncoder().encode(text);
315
+ const digest = (0, node_crypto_1.createHash)("sha256").update(bytes).digest("hex");
316
+ const response = await fetch(`${this.credentials.origin()}/v1/repositories/${repositoryId}` +
317
+ `/objects/${digest}?kind=chunk&role=chunk&logicalSize=${bytes.byteLength}` +
318
+ `&mediaType=${encodeURIComponent(mediaTypeForPath(path))}`, {
319
+ method: "PUT",
320
+ headers: {
321
+ authorization: `Bearer ${token}`,
322
+ "content-type": "application/octet-stream",
323
+ "user-agent": "CodeRook/0.1",
324
+ ...(0, identify_js_1.clientHeaders)(),
325
+ },
326
+ body: bytes,
327
+ });
328
+ const body = await response.text();
329
+ if (!response.ok)
330
+ throw refusalOrError(body, response.status, "Could not send the file");
331
+ return JSON.parse(body).objectId;
185
332
  }
186
- /** Settle one path. */
187
- async resolve(mergeTrackId, conflictId, resolution) {
333
+ /** Settle one path. An edit also names the object it produced. */
334
+ async resolve(mergeTrackId, conflictId, resolution, objectId) {
188
335
  await this.call(`/v1/merge-tracks/${mergeTrackId}/conflicts/${conflictId}`, {
189
336
  method: "PUT",
190
- body: JSON.stringify({ resolution }),
337
+ body: JSON.stringify(objectId ? { resolution, objectId } : { resolution }),
191
338
  });
192
339
  }
193
340
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "CodeRook from the command line, on any operating system",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "homepage": "https://coderook.com",