@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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli/src/api.js +155 -1
- package/dist/cli/src/attach_command.js +217 -0
- package/dist/cli/src/cli.js +125 -2
- package/dist/cli/src/service_commands.js +201 -1
- package/dist/desktop-app/src/main/tracks.js +153 -6
- package/package.json +1 -1
|
@@ -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.
|
|
5
|
+
"version": "0.28.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ACCA Gaming Productions",
|
|
8
8
|
"url": "https://coderook.com"
|
package/dist/cli/src/api.js
CHANGED
|
@@ -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`, {
|
|
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) => `[2m${value}[0m`;
|
|
32
|
+
const bold = (value) => `[1m${value}[0m`;
|
|
33
|
+
const red = (value) => `[31m${value}[0m`;
|
|
34
|
+
const green = (value) => `[32m${value}[0m`;
|
|
35
|
+
const accent = (value) => `[33m${value}[0m`;
|
|
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
|
+
}
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
184
|
-
|
|
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
|
/**
|