@coderook/cli 0.26.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.26.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)
@@ -251,6 +328,7 @@ async function versions(repositoryId) {
251
328
  : [],
252
329
  authorName: String(row.author?.displayName ?? ""),
253
330
  name: row.name == null ? null : String(row.name),
331
+ notes: row.notes == null ? null : String(row.notes),
254
332
  /*
255
333
  Falls back to the older field, so this keeps working against a service
256
334
  that has not been updated yet rather than reporting everything private.
@@ -312,7 +390,15 @@ async function createIssue(repositoryId, input) {
312
390
  itself, and exactly the kind of small wrongness that makes somebody stop
313
391
  trusting the rest of the output.
314
392
  */
315
- 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
+ });
316
402
  return { number: Number(created.number ?? 0) };
317
403
  }
318
404
  async function releases(repositoryId) {
@@ -366,15 +452,29 @@ async function setWatch(repositoryId, change) {
366
452
  async function workflows(repositoryId) {
367
453
  const body = await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/workflows`);
368
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 ?? ""),
369
461
  name: String(row.name ?? ""),
370
462
  trigger: String(row.trigger ?? ""),
371
463
  command: String(row.command ?? ""),
372
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,
373
469
  runs: Number(row.runs ?? 0),
374
470
  passing: Number(row.passing ?? 0),
375
471
  archivedAt: row.archivedAt ? String(row.archivedAt) : null,
376
472
  }));
377
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
+ }
378
478
  /**
379
479
  * What has run on a project.
380
480
  *
@@ -495,3 +595,58 @@ async function versionAttachments(repositoryId, versionId) {
495
595
  async function undoTo(repositoryId, to) {
496
596
  return await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/undo`, { method: "POST", body: to ? { to } : {} });
497
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
+ }