@coderook/cli 0.34.9 → 0.34.11
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/README.md +26 -0
- package/dist/cbx/src/core/download.js +370 -96
- package/dist/cbx/src/core/rules.js +65 -4
- package/dist/cbx/src/core/worktree.js +8 -2
- package/dist/cli/src/api.js +15 -0
- package/dist/cli/src/cli.js +40 -9
- package/dist/cli/src/network.js +11 -0
- package/dist/cli/src/service_commands.js +54 -10
- package/package.json +1 -1
- package/skills/coderook/SKILL.md +12 -2
|
@@ -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, with cbx or plain git, from Claude Code.",
|
|
5
|
-
"version": "0.34.
|
|
5
|
+
"version": "0.34.11",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ACCA Gaming Productions",
|
|
8
8
|
"url": "https://coderook.com"
|
package/README.md
CHANGED
|
@@ -436,6 +436,32 @@ artifacts or with `cbx attach <file>`, so a release made by pushing a tag
|
|
|
436
436
|
simply has none until something adds them — which is what a release without a
|
|
437
437
|
build has always looked like.
|
|
438
438
|
|
|
439
|
+
## Releases and app downloads
|
|
440
|
+
|
|
441
|
+
```
|
|
442
|
+
cbx release 1.4.0 name the newest save as a release
|
|
443
|
+
cbx attach ./dist/MyApp-Setup-1.4.0.exe put the installer on it
|
|
444
|
+
cbx release --downloads public offer its downloads, keep the source private
|
|
445
|
+
cbx releases each release, its files, and who can take them
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
In a public project, `--downloads public` offers a release's notes,
|
|
449
|
+
attachments and update feed to anybody while the version's own files keep
|
|
450
|
+
their visibility, so one project holds the code and ships the app. There is
|
|
451
|
+
no need for a second project that only holds releases. `--downloads private`
|
|
452
|
+
takes them back. Without a release name it changes an existing release and
|
|
453
|
+
leaves its notes alone.
|
|
454
|
+
|
|
455
|
+
`cbx releases` marks each one `public`, `downloads public` or `private`, and
|
|
456
|
+
for a public project prints the addresses an app or a script reads, which
|
|
457
|
+
need no account:
|
|
458
|
+
|
|
459
|
+
```
|
|
460
|
+
https://api.coderook.com/v1/public/projects/OWNER/SLUG/releases/latest
|
|
461
|
+
https://api.coderook.com/v1/public/projects/OWNER/SLUG/releases
|
|
462
|
+
https://api.coderook.com/v1/public/projects/OWNER/SLUG/updates electron-updater's generic provider
|
|
463
|
+
```
|
|
464
|
+
|
|
439
465
|
## Building for every platform
|
|
440
466
|
|
|
441
467
|
```
|
|
@@ -3,8 +3,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.Downloader = exports.DownloadCancelled = void 0;
|
|
6
|
+
exports.Downloader = exports.DownloadFailed = exports.DownloadCancelled = void 0;
|
|
7
7
|
exports.underway = underway;
|
|
8
|
+
exports.plainReason = plainReason;
|
|
8
9
|
exports.applyMode = applyMode;
|
|
9
10
|
/**
|
|
10
11
|
* Bringing a project down: the file list for a version, then its bytes.
|
|
@@ -34,6 +35,25 @@ async function exists(target) {
|
|
|
34
35
|
return false;
|
|
35
36
|
}
|
|
36
37
|
}
|
|
38
|
+
/** How many bytes are at `target`, or nothing when there is no file. */
|
|
39
|
+
async function sizeOnDisk(target) {
|
|
40
|
+
try {
|
|
41
|
+
return (await (0, promises_1.stat)(target)).size;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A running hash of what is already at `target`, to carry on from. Streamed,
|
|
49
|
+
* so resuming a large file does not first hold all of it.
|
|
50
|
+
*/
|
|
51
|
+
async function digestPrefix(target) {
|
|
52
|
+
const hash = (0, node_crypto_1.createHash)("sha256");
|
|
53
|
+
for await (const block of (0, node_fs_1.createReadStream)(target))
|
|
54
|
+
hash.update(block);
|
|
55
|
+
return hash;
|
|
56
|
+
}
|
|
37
57
|
/** The digest of what is on disk, or null when nothing is there to read. */
|
|
38
58
|
async function digestOf(target) {
|
|
39
59
|
try {
|
|
@@ -101,6 +121,90 @@ class DownloadCancelled extends Error {
|
|
|
101
121
|
}
|
|
102
122
|
}
|
|
103
123
|
exports.DownloadCancelled = DownloadCancelled;
|
|
124
|
+
/**
|
|
125
|
+
* One transfer that could not be completed, named, with the reason in words.
|
|
126
|
+
*
|
|
127
|
+
* What people saw instead was the single word `terminated` — undici's message
|
|
128
|
+
* for a body the other end stopped sending — with no file, no cause and no
|
|
129
|
+
* hint that anything had been tried. Eight clones started seconds after the
|
|
130
|
+
* API was redeployed lost their connections mid-file, and four of them ended
|
|
131
|
+
* on exactly that. The status and code travel with it, because callers ask
|
|
132
|
+
* whether a failure was a refusal or the network.
|
|
133
|
+
*/
|
|
134
|
+
class DownloadFailed extends Error {
|
|
135
|
+
what;
|
|
136
|
+
reason;
|
|
137
|
+
attempts;
|
|
138
|
+
status;
|
|
139
|
+
code;
|
|
140
|
+
constructor(what, reason, attempts, cause) {
|
|
141
|
+
super(`Could not download ${what}: ${reason}` +
|
|
142
|
+
(attempts > 1 ? ` (tried ${attempts} times).` : "."), { cause });
|
|
143
|
+
this.what = what;
|
|
144
|
+
this.reason = reason;
|
|
145
|
+
this.attempts = attempts;
|
|
146
|
+
this.status = cause.status;
|
|
147
|
+
this.code = cause.code;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
exports.DownloadFailed = DownloadFailed;
|
|
151
|
+
/** What each network failure means, for somebody who has not met the code. */
|
|
152
|
+
const PLAIN = {
|
|
153
|
+
ENOTFOUND: "CodeRook's address could not be looked up, so this computer may be offline",
|
|
154
|
+
EAI_AGAIN: "CodeRook's address could not be looked up, so this computer may be offline",
|
|
155
|
+
ECONNREFUSED: "the connection was refused",
|
|
156
|
+
ECONNRESET: "the connection was dropped",
|
|
157
|
+
EPIPE: "the connection was dropped",
|
|
158
|
+
ETIMEDOUT: "the connection timed out",
|
|
159
|
+
ENETUNREACH: "there is no route to the internet from here",
|
|
160
|
+
EHOSTUNREACH: "there is no route to the internet from here",
|
|
161
|
+
UND_ERR_CONNECT_TIMEOUT: "the connection timed out",
|
|
162
|
+
UND_ERR_SOCKET: "the connection closed before it finished arriving",
|
|
163
|
+
UND_ERR_RES_CONTENT_LENGTH_MISMATCH: "the connection closed before it finished arriving",
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* Why one request failed, said so that a person can act on it.
|
|
167
|
+
*
|
|
168
|
+
* Node keeps the real reason in `cause`, and for a failed happy-eyeballs
|
|
169
|
+
* connection in `cause.errors`; the top-level message is `fetch failed` or
|
|
170
|
+
* `terminated` whatever happened. The code goes in brackets after the words,
|
|
171
|
+
* for whoever needs to tell a dead network from a server that hung up.
|
|
172
|
+
*/
|
|
173
|
+
function plainReason(error) {
|
|
174
|
+
if (!(error instanceof Error))
|
|
175
|
+
return String(error);
|
|
176
|
+
const status = error.status;
|
|
177
|
+
if (typeof status === "number") {
|
|
178
|
+
/* The fallback message is only the route; the service's own words are better. */
|
|
179
|
+
return error.message.startsWith("/v1/")
|
|
180
|
+
? `CodeRook answered ${status}`
|
|
181
|
+
: `CodeRook answered ${status}: ${error.message}`;
|
|
182
|
+
}
|
|
183
|
+
if (error.name === "TimeoutError") {
|
|
184
|
+
return `it had not finished arriving after ${REQUEST_DEADLINE_MS / 60_000} minutes`;
|
|
185
|
+
}
|
|
186
|
+
const cause = error.cause;
|
|
187
|
+
const causes = cause?.errors?.length ? cause.errors : cause ? [cause] : [];
|
|
188
|
+
const codes = [...new Set(causes.map((one) => one.code).filter(Boolean))];
|
|
189
|
+
const said = codes.map((code) => PLAIN[code]).find(Boolean);
|
|
190
|
+
const detail = causes
|
|
191
|
+
.map((one) => [one.code, one.message].filter(Boolean).join(": "))
|
|
192
|
+
.filter(Boolean)[0];
|
|
193
|
+
if (error.message === "terminated") {
|
|
194
|
+
return (`${said ?? "the connection closed before it finished arriving"}` +
|
|
195
|
+
(detail ? ` (${detail})` : ""));
|
|
196
|
+
}
|
|
197
|
+
if (error.message === "fetch failed") {
|
|
198
|
+
return (`CodeRook could not be reached${said ? `: ${said}` : ""}` +
|
|
199
|
+
(detail ? ` (${detail})` : ""));
|
|
200
|
+
}
|
|
201
|
+
return error.message;
|
|
202
|
+
}
|
|
203
|
+
/** Where `content-range` says a partial answer starts, or null. */
|
|
204
|
+
function rangeStart(headers) {
|
|
205
|
+
const match = /^bytes (\d+)-\d+\/(?:\d+|\*)$/.exec(headers.get("content-range") ?? "");
|
|
206
|
+
return match ? Number(match[1]) : null;
|
|
207
|
+
}
|
|
104
208
|
class Downloader {
|
|
105
209
|
credentials;
|
|
106
210
|
aborted = false;
|
|
@@ -117,36 +221,73 @@ class Downloader {
|
|
|
117
221
|
throw new DownloadCancelled();
|
|
118
222
|
}
|
|
119
223
|
/**
|
|
120
|
-
*
|
|
224
|
+
* Do `work` until it succeeds, it fails in a way repeating cannot help, or
|
|
225
|
+
* the attempts run out.
|
|
121
226
|
*
|
|
122
227
|
* A restore makes at least one request per file and, for a file kept in
|
|
123
228
|
* pieces, one per piece — three thousand seven hundred and fifty-six for a
|
|
124
229
|
* single file here. At those numbers a dropped connection is not a risk but
|
|
125
230
|
* a certainty, and without this the whole restore ends on the first one.
|
|
231
|
+
*
|
|
232
|
+
* `work` is the whole transfer, body included. Retrying only until the
|
|
233
|
+
* headers arrived was the gap that ended clones: `fetch()` resolves on the
|
|
234
|
+
* headers, so a connection cut while the bytes were still coming — which is
|
|
235
|
+
* what a redeploy does to every connection it holds — escaped the loop and
|
|
236
|
+
* ended the whole run on one file.
|
|
237
|
+
*
|
|
238
|
+
* `progressed` lets a transfer that moved forward before failing start its
|
|
239
|
+
* count again, so a long file over a connection that keeps dropping is
|
|
240
|
+
* finished rather than abandoned; one that makes no progress still stops
|
|
241
|
+
* after `RETRY_ATTEMPTS`. `halt` stops it without retrying, for a run that
|
|
242
|
+
* has already failed elsewhere. `what` names the transfer in the error a
|
|
243
|
+
* person finally sees.
|
|
126
244
|
*/
|
|
127
|
-
async
|
|
245
|
+
async repeat(work, options = {}) {
|
|
128
246
|
let wait = retry_js_1.RETRY_FIRST_WAIT_MS;
|
|
129
|
-
|
|
247
|
+
let tries = 0;
|
|
248
|
+
for (let attempt = 1, stalled = 1;; attempt += 1, stalled += 1) {
|
|
130
249
|
this.check();
|
|
250
|
+
options.halt?.throwIfAborted();
|
|
251
|
+
tries = attempt;
|
|
131
252
|
try {
|
|
132
|
-
return await
|
|
253
|
+
return await work(attempt);
|
|
133
254
|
}
|
|
134
255
|
catch (error) {
|
|
135
256
|
// Its own cancellation first: `worthRetrying` knows nothing of it.
|
|
136
|
-
if (error instanceof DownloadCancelled)
|
|
137
|
-
throw
|
|
138
|
-
if (
|
|
139
|
-
throw
|
|
257
|
+
if (error instanceof DownloadCancelled || this.aborted)
|
|
258
|
+
throw new DownloadCancelled();
|
|
259
|
+
if (options.halt?.aborted)
|
|
260
|
+
throw options.halt.reason;
|
|
261
|
+
if (await options.progressed?.()) {
|
|
262
|
+
stalled = 0;
|
|
263
|
+
wait = retry_js_1.RETRY_FIRST_WAIT_MS;
|
|
264
|
+
}
|
|
265
|
+
if (stalled >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error)) {
|
|
266
|
+
if (!options.what)
|
|
267
|
+
throw error;
|
|
268
|
+
throw new DownloadFailed(options.what, plainReason(error), tries, error);
|
|
269
|
+
}
|
|
140
270
|
try {
|
|
141
|
-
await (0, retry_js_1.pauseFor)(
|
|
271
|
+
await (0, retry_js_1.pauseFor)(options.halt
|
|
272
|
+
? AbortSignal.any([this.controller.signal, options.halt])
|
|
273
|
+
: this.controller.signal, wait);
|
|
142
274
|
}
|
|
143
275
|
catch {
|
|
144
|
-
|
|
276
|
+
if (this.aborted)
|
|
277
|
+
throw new DownloadCancelled();
|
|
278
|
+
throw options.halt?.reason ?? error;
|
|
145
279
|
}
|
|
146
280
|
wait *= 2;
|
|
147
281
|
}
|
|
148
282
|
}
|
|
149
283
|
}
|
|
284
|
+
/** A whole response body and its headers, under the retry rule. */
|
|
285
|
+
async bytes(route, what, halt) {
|
|
286
|
+
return this.repeat(async () => {
|
|
287
|
+
const reply = await this.attempt(route, REQUEST_DEADLINE_MS, halt);
|
|
288
|
+
return { body: Buffer.from(await reply.arrayBuffer()), headers: reply.headers };
|
|
289
|
+
}, { what, halt });
|
|
290
|
+
}
|
|
150
291
|
async attempt(route,
|
|
151
292
|
/*
|
|
152
293
|
Longer where the service has to build the answer before it can send it.
|
|
@@ -158,7 +299,9 @@ class Downloader {
|
|
|
158
299
|
to assemble and longer under load; three minutes is a request that has
|
|
159
300
|
failed, not one that is still working.
|
|
160
301
|
*/
|
|
161
|
-
deadline = REQUEST_DEADLINE_MS
|
|
302
|
+
deadline = REQUEST_DEADLINE_MS,
|
|
303
|
+
/* The run's own stop, when a sibling transfer has already failed it. */
|
|
304
|
+
halt, extra) {
|
|
162
305
|
const token = await this.credentials.token();
|
|
163
306
|
if (!token)
|
|
164
307
|
throw new Error("Sign in again before downloading");
|
|
@@ -167,6 +310,7 @@ class Downloader {
|
|
|
167
310
|
authorization: `Bearer ${token}`,
|
|
168
311
|
"user-agent": "CodeRook/0.1",
|
|
169
312
|
...(0, identify_js_1.clientHeaders)(),
|
|
313
|
+
...extra,
|
|
170
314
|
},
|
|
171
315
|
/*
|
|
172
316
|
The download's own cancellation, and a deadline.
|
|
@@ -180,6 +324,7 @@ class Downloader {
|
|
|
180
324
|
signal: AbortSignal.any([
|
|
181
325
|
this.controller.signal,
|
|
182
326
|
AbortSignal.timeout(deadline),
|
|
327
|
+
...(halt ? [halt] : []),
|
|
183
328
|
]),
|
|
184
329
|
});
|
|
185
330
|
if (!response.ok) {
|
|
@@ -190,8 +335,9 @@ class Downloader {
|
|
|
190
335
|
*/
|
|
191
336
|
const tooOld = (0, identify_js_1.readRefusal)(response.status, text);
|
|
192
337
|
if (tooOld) {
|
|
193
|
-
|
|
194
|
-
|
|
338
|
+
/* With its status, so it is not retried as though it were the network. */
|
|
339
|
+
throw Object.assign(new Error(`${tooOld.message} You are running ${tooOld.yourVersion ?? "an older build"}; ` +
|
|
340
|
+
`update from ${tooOld.upgradeUrl}.`), { status: response.status });
|
|
195
341
|
}
|
|
196
342
|
let message = `${route} failed (${response.status})`;
|
|
197
343
|
let code;
|
|
@@ -226,30 +372,29 @@ class Downloader {
|
|
|
226
372
|
* These routes are reads, so repeating the whole request is safe and is the
|
|
227
373
|
* only way to replace a truncated body.
|
|
228
374
|
*/
|
|
229
|
-
async json(route) {
|
|
230
|
-
let
|
|
231
|
-
|
|
232
|
-
this.
|
|
233
|
-
|
|
234
|
-
return await (await this.attempt(route)).json();
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
375
|
+
async json(route, what) {
|
|
376
|
+
let tries = 0;
|
|
377
|
+
try {
|
|
378
|
+
return await this.repeat(async (attempt) => {
|
|
379
|
+
tries = attempt;
|
|
380
|
+
return (await (await this.attempt(route)).json());
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
/*
|
|
385
|
+
A refusal keeps its own words: "not found" is the answer, and callers
|
|
386
|
+
read its status. Only a failure that never reached an answer is
|
|
387
|
+
renamed, because that is the one that arrived as a bare `terminated`.
|
|
388
|
+
*/
|
|
389
|
+
if (error instanceof DownloadCancelled)
|
|
390
|
+
throw error;
|
|
391
|
+
if (typeof error.status === "number")
|
|
392
|
+
throw error;
|
|
393
|
+
throw new DownloadFailed(what, plainReason(error), tries, error);
|
|
249
394
|
}
|
|
250
395
|
}
|
|
251
396
|
async versions(repositoryId) {
|
|
252
|
-
const body = await this.json(`/v1/repositories/${repositoryId}/versions
|
|
397
|
+
const body = await this.json(`/v1/repositories/${repositoryId}/versions`, "the list of versions");
|
|
253
398
|
return (body.versions ?? []).map((row) => ({
|
|
254
399
|
id: String(row.id ?? ""),
|
|
255
400
|
sequence: Number(row.sequence ?? 0),
|
|
@@ -277,11 +422,17 @@ class Downloader {
|
|
|
277
422
|
* files to `git fast-import`, which wants them one after another anyway.
|
|
278
423
|
*/
|
|
279
424
|
async archive(repositoryId, versionId) {
|
|
280
|
-
|
|
281
|
-
|
|
425
|
+
/*
|
|
426
|
+
Under the retry rule like everything else. It had none: one dropped
|
|
427
|
+
connection during an archive of hundreds of megabytes ended the import.
|
|
428
|
+
*/
|
|
429
|
+
return this.repeat(async () => {
|
|
430
|
+
const response = await this.attempt(`/v1/repositories/${repositoryId}/versions/${versionId}/archive`, ARCHIVE_DEADLINE_MS);
|
|
431
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
432
|
+
}, { what: "the version's archive" });
|
|
282
433
|
}
|
|
283
434
|
async files(repositoryId, versionId) {
|
|
284
|
-
const body = await this.json(`/v1/repositories/${repositoryId}/versions/${versionId}/files
|
|
435
|
+
const body = await this.json(`/v1/repositories/${repositoryId}/versions/${versionId}/files`, "the version's list of files");
|
|
285
436
|
return (body.files ?? []).map((row) => {
|
|
286
437
|
const pieces = Array.isArray(row.chunks) ? row.chunks : null;
|
|
287
438
|
const packed = row.pack && typeof row.pack === "object"
|
|
@@ -394,8 +545,8 @@ class Downloader {
|
|
|
394
545
|
if (packId && restoredPacks.has(packId))
|
|
395
546
|
continue;
|
|
396
547
|
if (packId) {
|
|
397
|
-
const reply = await this.
|
|
398
|
-
const body =
|
|
548
|
+
const reply = await this.bytes(`/v1/repositories/${repositoryId}/objects/${packId}`, packName(packed.get(packId) ?? []));
|
|
549
|
+
const body = reply.body;
|
|
399
550
|
const packDigest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
|
|
400
551
|
const expectedPackDigest = reply.headers.get("x-coderook-sha256");
|
|
401
552
|
if (expectedPackDigest && packDigest !== expectedPackDigest) {
|
|
@@ -463,8 +614,8 @@ class Downloader {
|
|
|
463
614
|
try {
|
|
464
615
|
for (const [packId, members] of packs) {
|
|
465
616
|
this.check();
|
|
466
|
-
const reply = await this.
|
|
467
|
-
const body =
|
|
617
|
+
const reply = await this.bytes(`/v1/repositories/${repositoryId}/objects/${packId}`, packName(members));
|
|
618
|
+
const body = reply.body;
|
|
468
619
|
const expected = reply.headers.get("x-coderook-sha256");
|
|
469
620
|
if (expected && (0, node_crypto_1.createHash)("sha256").update(body).digest("hex") !== expected) {
|
|
470
621
|
throw new Error("A solid pack did not arrive intact");
|
|
@@ -506,8 +657,7 @@ class Downloader {
|
|
|
506
657
|
can say it happened.
|
|
507
658
|
*/
|
|
508
659
|
recoveredFromGzip = [];
|
|
509
|
-
async fetchInto(repositoryId, versionId, file, target) {
|
|
510
|
-
const whole = (0, node_crypto_1.createHash)("sha256");
|
|
660
|
+
async fetchInto(repositoryId, versionId, file, target, halt) {
|
|
511
661
|
/*
|
|
512
662
|
A sealed file is read whole, by path, whatever shape it is stored in.
|
|
513
663
|
|
|
@@ -516,51 +666,55 @@ class Downloader {
|
|
|
516
666
|
that cannot match the digest this file was listed with.
|
|
517
667
|
*/
|
|
518
668
|
const pieces = file.sealed ? [] : (file.chunks ?? []);
|
|
669
|
+
if (!pieces.length) {
|
|
670
|
+
const digest = await this.streamTo(file.objectId && !file.sealed
|
|
671
|
+
? `/v1/repositories/${repositoryId}/objects/${file.objectId}`
|
|
672
|
+
: `/v1/repositories/${repositoryId}/versions/${versionId}/file` +
|
|
673
|
+
`?path=${encodeURIComponent(file.path)}`, file.path, target, halt);
|
|
674
|
+
if (file.sha256 && digest !== file.sha256) {
|
|
675
|
+
if (await recoverGzipFile(target, file.sha256)) {
|
|
676
|
+
this.recoveredFromGzip.push(file.path);
|
|
677
|
+
return file.sha256;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return digest;
|
|
681
|
+
}
|
|
682
|
+
const whole = (0, node_crypto_1.createHash)("sha256");
|
|
519
683
|
let recovered = false;
|
|
520
|
-
// Captured so the
|
|
521
|
-
const
|
|
684
|
+
// Captured so the generator below does not need `this` rebound.
|
|
685
|
+
const fetchPiece = (route, what) => this.bytes(route, what, halt);
|
|
522
686
|
const check = () => this.check();
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
687
|
+
/*
|
|
688
|
+
Each piece is its own retried request, so a connection lost partway
|
|
689
|
+
through a large file costs the piece that was arriving and no more: the
|
|
690
|
+
ones already written stay written, and the file carries on from there.
|
|
691
|
+
*/
|
|
692
|
+
const source = (async function* () {
|
|
693
|
+
for (let at = 0; at < pieces.length; at += 1) {
|
|
694
|
+
check();
|
|
695
|
+
const piece = pieces[at];
|
|
696
|
+
const reply = await fetchPiece(`/v1/repositories/${repositoryId}/objects/${piece.objectId}`, `${file.path} (piece ${at + 1} of ${pieces.length})`);
|
|
697
|
+
let body = reply.body;
|
|
698
|
+
/*
|
|
699
|
+
Checked against what the upload recorded for this piece, before
|
|
700
|
+
a byte of it reaches the file. Without the digest the only
|
|
701
|
+
available check was the whole file's, which says that something
|
|
702
|
+
in seven gigabytes is wrong and nothing about what.
|
|
703
|
+
*/
|
|
704
|
+
const digest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
|
|
705
|
+
if (piece.sha256 && digest !== piece.sha256) {
|
|
706
|
+
const plain = gunzippedMatching(body, piece.sha256);
|
|
707
|
+
if (!plain) {
|
|
708
|
+
throw new Error(`${file.path}: piece ${at + 1} of ${pieces.length} ` +
|
|
709
|
+
`did not arrive intact`);
|
|
545
710
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
})()
|
|
550
|
-
: (async function* () {
|
|
551
|
-
const reply = await request(file.objectId && !file.sealed
|
|
552
|
-
? `/v1/repositories/${repositoryId}/objects/${file.objectId}`
|
|
553
|
-
: `/v1/repositories/${repositoryId}/versions/${versionId}/file` +
|
|
554
|
-
`?path=${encodeURIComponent(file.path)}`);
|
|
555
|
-
if (!reply.body)
|
|
556
|
-
return;
|
|
557
|
-
for await (const block of node_stream_1.Readable.fromWeb(reply.body)) {
|
|
558
|
-
check();
|
|
559
|
-
const buffer = Buffer.from(block);
|
|
560
|
-
whole.update(buffer);
|
|
561
|
-
yield buffer;
|
|
711
|
+
recovered = true;
|
|
712
|
+
body = plain;
|
|
562
713
|
}
|
|
563
|
-
|
|
714
|
+
whole.update(body);
|
|
715
|
+
yield body;
|
|
716
|
+
}
|
|
717
|
+
})();
|
|
564
718
|
/*
|
|
565
719
|
`pipeline` rather than a write loop: it applies backpressure, and it
|
|
566
720
|
destroys the file handle when the source throws. A partially written
|
|
@@ -568,16 +722,91 @@ class Downloader {
|
|
|
568
722
|
up holding something that looks finished.
|
|
569
723
|
*/
|
|
570
724
|
await (0, promises_2.pipeline)(source, (0, node_fs_1.createWriteStream)(target));
|
|
571
|
-
const digest = whole.digest("hex");
|
|
572
725
|
if (recovered)
|
|
573
726
|
this.recoveredFromGzip.push(file.path);
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
727
|
+
return whole.digest("hex");
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Stream one whole object to `target`, carrying on where a dropped
|
|
731
|
+
* connection left it, and answer with the digest of what was written.
|
|
732
|
+
*
|
|
733
|
+
* The body is part of the attempt. A connection cut halfway through used to
|
|
734
|
+
* escape the retry loop entirely, because `fetch()` had already resolved on
|
|
735
|
+
* the headers.
|
|
736
|
+
*
|
|
737
|
+
* Where the service said it serves byte ranges, a retry asks only for what
|
|
738
|
+
* is missing: what reached the disk is measured and hashed again from
|
|
739
|
+
* there, rather than trusted from a count that the failed stream may not
|
|
740
|
+
* have flushed. Anything other than a partial answer starting exactly at
|
|
741
|
+
* that byte is taken from the beginning, so a resumed file is never two
|
|
742
|
+
* different answers spliced together; the digest is checked by the caller
|
|
743
|
+
* either way.
|
|
744
|
+
*/
|
|
745
|
+
async streamTo(route, what, target, halt) {
|
|
746
|
+
let resumable = false;
|
|
747
|
+
let before = 0;
|
|
748
|
+
let hash = (0, node_crypto_1.createHash)("sha256");
|
|
749
|
+
const check = () => this.check();
|
|
750
|
+
return this.repeat(async (attempt) => {
|
|
751
|
+
let from = 0;
|
|
752
|
+
if (attempt > 1 && resumable) {
|
|
753
|
+
from = await sizeOnDisk(target);
|
|
578
754
|
}
|
|
579
|
-
|
|
580
|
-
|
|
755
|
+
let reply;
|
|
756
|
+
try {
|
|
757
|
+
reply = await this.attempt(route, REQUEST_DEADLINE_MS, halt, from > 0 ? { range: `bytes=${from}-` } : undefined);
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
/*
|
|
761
|
+
Nothing left to send from there — the whole body had landed and
|
|
762
|
+
only its ending was lost. Taken again from the start rather than
|
|
763
|
+
guessed complete, because only the digest can say it is.
|
|
764
|
+
*/
|
|
765
|
+
if (from === 0 || error.status !== 416)
|
|
766
|
+
throw error;
|
|
767
|
+
from = 0;
|
|
768
|
+
reply = await this.attempt(route, REQUEST_DEADLINE_MS, halt);
|
|
769
|
+
}
|
|
770
|
+
before = from;
|
|
771
|
+
const append = from > 0 && reply.status === 206 && rangeStart(reply.headers) === from;
|
|
772
|
+
if (reply.status !== 206) {
|
|
773
|
+
resumable = reply.headers.get("accept-ranges") === "bytes";
|
|
774
|
+
}
|
|
775
|
+
hash = append ? await digestPrefix(target) : (0, node_crypto_1.createHash)("sha256");
|
|
776
|
+
const sink = (0, node_fs_1.createWriteStream)(target, { flags: append ? "a" : "w" });
|
|
777
|
+
if (!reply.body) {
|
|
778
|
+
sink.end();
|
|
779
|
+
await new Promise((resolve, reject) => {
|
|
780
|
+
sink.once("finish", resolve).once("error", reject);
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
await (0, promises_2.pipeline)(node_stream_1.Readable.fromWeb(reply.body), new node_stream_1.Transform({
|
|
785
|
+
transform(block, _encoding, done) {
|
|
786
|
+
try {
|
|
787
|
+
check();
|
|
788
|
+
}
|
|
789
|
+
catch (error) {
|
|
790
|
+
done(error);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
const buffer = Buffer.from(block);
|
|
794
|
+
hash.update(buffer);
|
|
795
|
+
done(null, buffer);
|
|
796
|
+
},
|
|
797
|
+
}), sink);
|
|
798
|
+
}
|
|
799
|
+
return hash.digest("hex");
|
|
800
|
+
}, {
|
|
801
|
+
what,
|
|
802
|
+
halt,
|
|
803
|
+
/*
|
|
804
|
+
Counted again from nothing when this attempt moved the file on —
|
|
805
|
+
judged by what reached the disk, since that is where the next
|
|
806
|
+
attempt starts.
|
|
807
|
+
*/
|
|
808
|
+
progressed: async () => resumable && (await sizeOnDisk(target)) > before,
|
|
809
|
+
});
|
|
581
810
|
}
|
|
582
811
|
/**
|
|
583
812
|
* Write a version into `destination`.
|
|
@@ -609,6 +838,15 @@ class Downloader {
|
|
|
609
838
|
const staging = `${destination}.incoming`;
|
|
610
839
|
await (0, promises_1.rm)(staging, { recursive: true, force: true });
|
|
611
840
|
const manifest = {};
|
|
841
|
+
/*
|
|
842
|
+
Stops the other lanes once one transfer has failed for good.
|
|
843
|
+
|
|
844
|
+
Without it the first failure rejected the run while five other lanes
|
|
845
|
+
went on writing into the staging folder — so the cleanup below removed
|
|
846
|
+
it, and the lanes still running made it again. That is how failed clones
|
|
847
|
+
left `.incoming` folders behind.
|
|
848
|
+
*/
|
|
849
|
+
const halt = new AbortController();
|
|
612
850
|
try {
|
|
613
851
|
let written = 0;
|
|
614
852
|
let bytes = 0;
|
|
@@ -688,8 +926,8 @@ class Downloader {
|
|
|
688
926
|
so fetch the decoded pack once, verify it, and materialise every
|
|
689
927
|
member before releasing the buffer.
|
|
690
928
|
*/
|
|
691
|
-
const reply = await this.
|
|
692
|
-
const body =
|
|
929
|
+
const reply = await this.bytes(`/v1/repositories/${repositoryId}/objects/${packId}`, packName(packed.get(packId) ?? []), halt.signal);
|
|
930
|
+
const body = reply.body;
|
|
693
931
|
const packDigest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
|
|
694
932
|
const expectedPackDigest = reply.headers.get("x-coderook-sha256");
|
|
695
933
|
if (expectedPackDigest && packDigest !== expectedPackDigest) {
|
|
@@ -719,7 +957,7 @@ class Downloader {
|
|
|
719
957
|
const restoreFile = async (file) => {
|
|
720
958
|
const full = (0, safe_path_js_1.inside)(staging, file.path);
|
|
721
959
|
await (0, promises_1.mkdir)(node_path_1.default.dirname(full), { recursive: true });
|
|
722
|
-
const digest = await this.fetchInto(repositoryId, versionId, file, full);
|
|
960
|
+
const digest = await this.fetchInto(repositoryId, versionId, file, full, halt.signal);
|
|
723
961
|
/*
|
|
724
962
|
The whole file's digest, checked after every piece has already been
|
|
725
963
|
checked individually. Both are needed: the pieces catch a corrupt or
|
|
@@ -782,30 +1020,53 @@ class Downloader {
|
|
|
782
1020
|
});
|
|
783
1021
|
}
|
|
784
1022
|
};
|
|
1023
|
+
/*
|
|
1024
|
+
The first failure that outlasted its retries, which is what the run
|
|
1025
|
+
reports. Whatever the other lanes throw while being stopped is only
|
|
1026
|
+
the echo of it.
|
|
1027
|
+
*/
|
|
1028
|
+
let failure = null;
|
|
785
1029
|
const lane = async () => {
|
|
786
1030
|
for (;;) {
|
|
1031
|
+
if (halt.signal.aborted)
|
|
1032
|
+
return;
|
|
787
1033
|
const at = next;
|
|
788
1034
|
next += 1;
|
|
789
1035
|
if (at >= jobs.length)
|
|
790
1036
|
return;
|
|
791
|
-
this.check();
|
|
792
1037
|
const job = jobs[at];
|
|
793
1038
|
const size = sizeOf(job);
|
|
794
1039
|
await roomFor(size);
|
|
1040
|
+
if (halt.signal.aborted)
|
|
1041
|
+
return;
|
|
795
1042
|
flying += size;
|
|
796
1043
|
try {
|
|
1044
|
+
this.check();
|
|
797
1045
|
if (job.kind === "pack")
|
|
798
1046
|
await restorePack(job.id);
|
|
799
1047
|
else
|
|
800
1048
|
await restoreFile(job.file);
|
|
801
1049
|
}
|
|
1050
|
+
catch (error) {
|
|
1051
|
+
if (!failure)
|
|
1052
|
+
failure = { error };
|
|
1053
|
+
halt.abort(new Error("stopped: another file failed"));
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
802
1056
|
finally {
|
|
803
1057
|
flying -= size;
|
|
804
1058
|
wakeAll();
|
|
805
1059
|
}
|
|
806
1060
|
}
|
|
807
1061
|
};
|
|
1062
|
+
/*
|
|
1063
|
+
Every lane settled before anything else happens — on failure most of
|
|
1064
|
+
all, so that nothing is still writing into the staging folder when it
|
|
1065
|
+
is removed.
|
|
1066
|
+
*/
|
|
808
1067
|
await Promise.all(Array.from({ length: Math.min(LANES, jobs.length) }, () => lane()));
|
|
1068
|
+
if (failure)
|
|
1069
|
+
throw failure.error;
|
|
809
1070
|
/*
|
|
810
1071
|
Everything has arrived and verified, so it can go into place. Each
|
|
811
1072
|
file is renamed over its destination individually: a rename within
|
|
@@ -882,6 +1143,19 @@ class Downloader {
|
|
|
882
1143
|
}
|
|
883
1144
|
}
|
|
884
1145
|
exports.Downloader = Downloader;
|
|
1146
|
+
/**
|
|
1147
|
+
* A solid pack named by what is in it. Its object id means nothing to the
|
|
1148
|
+
* person reading the error; the files they will be missing do.
|
|
1149
|
+
*/
|
|
1150
|
+
function packName(members) {
|
|
1151
|
+
const first = members[0]?.path;
|
|
1152
|
+
if (!first)
|
|
1153
|
+
return "a pack of small files";
|
|
1154
|
+
const others = members.length - 1;
|
|
1155
|
+
return others > 0
|
|
1156
|
+
? `${first} and ${others} other small file${others === 1 ? "" : "s"} stored with it`
|
|
1157
|
+
: first;
|
|
1158
|
+
}
|
|
885
1159
|
/** The gunzipped bytes when they hash to `expected`, otherwise null. */
|
|
886
1160
|
function gunzippedMatching(body, expected) {
|
|
887
1161
|
if (body.length < 2 || body[0] !== 0x1f || body[1] !== 0x8b)
|
|
@@ -170,11 +170,70 @@ function ruleMatches(relativePath, isDirectory, rule) {
|
|
|
170
170
|
}
|
|
171
171
|
return relativePath.split("/").some((segment) => expression.test(segment));
|
|
172
172
|
}
|
|
173
|
+
const NONE = [];
|
|
174
|
+
const indexes = new WeakMap();
|
|
175
|
+
function indexFor(rules) {
|
|
176
|
+
let index = indexes.get(rules);
|
|
177
|
+
if (index)
|
|
178
|
+
return index;
|
|
179
|
+
const kinds = rules.map((rule) => {
|
|
180
|
+
if (rule.directoryOnly) {
|
|
181
|
+
const bare = trailingSlashOff(rule.pattern);
|
|
182
|
+
return rule.anchored || bare.includes("/") ? "wholePath" : "ownName";
|
|
183
|
+
}
|
|
184
|
+
return rule.anchored || rule.pattern.includes("/") ? "wholePath" : "segment";
|
|
185
|
+
});
|
|
186
|
+
const named = kinds.flatMap((kind, at) => (kind === "wholePath" ? [] : [at]));
|
|
187
|
+
index = { kinds, named, answers: new Map() };
|
|
188
|
+
indexes.set(rules, index);
|
|
189
|
+
return index;
|
|
190
|
+
}
|
|
191
|
+
function namedMatches(index, rules, name) {
|
|
192
|
+
let found = index.answers.get(name);
|
|
193
|
+
if (found)
|
|
194
|
+
return found;
|
|
195
|
+
found = NONE;
|
|
196
|
+
for (const at of index.named) {
|
|
197
|
+
if (matcher(rules[at].pattern).test(name)) {
|
|
198
|
+
if (found === NONE)
|
|
199
|
+
found = [];
|
|
200
|
+
found.push(at);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
index.answers.set(name, found);
|
|
204
|
+
return found;
|
|
205
|
+
}
|
|
173
206
|
/** The last matching rule wins, as it does in gitignore. */
|
|
174
207
|
function lastMatchingRule(relativePath, isDirectory, rules) {
|
|
208
|
+
if (!rules.length)
|
|
209
|
+
return null;
|
|
210
|
+
const index = indexFor(rules);
|
|
211
|
+
const segments = relativePath.split("/");
|
|
212
|
+
/* Which named rules some segment satisfies, and which the last one does. */
|
|
213
|
+
const anySegment = new Uint8Array(rules.length);
|
|
214
|
+
const ownName = new Uint8Array(rules.length);
|
|
215
|
+
for (let at = 0; at < segments.length; at += 1) {
|
|
216
|
+
const hits = namedMatches(index, rules, segments[at]);
|
|
217
|
+
for (const hit of hits)
|
|
218
|
+
anySegment[hit] = 1;
|
|
219
|
+
if (at === segments.length - 1)
|
|
220
|
+
for (const hit of hits)
|
|
221
|
+
ownName[hit] = 1;
|
|
222
|
+
}
|
|
175
223
|
let found = null;
|
|
176
|
-
for (
|
|
177
|
-
|
|
224
|
+
for (let at = 0; at < rules.length; at += 1) {
|
|
225
|
+
const rule = rules[at];
|
|
226
|
+
const kind = index.kinds[at];
|
|
227
|
+
let matched;
|
|
228
|
+
if (kind === "segment")
|
|
229
|
+
matched = anySegment[at] === 1;
|
|
230
|
+
else if (kind === "ownName")
|
|
231
|
+
matched = isDirectory && ownName[at] === 1;
|
|
232
|
+
else if (rule.directoryOnly && !isDirectory)
|
|
233
|
+
matched = false;
|
|
234
|
+
else
|
|
235
|
+
matched = matcher(rule.pattern).test(relativePath);
|
|
236
|
+
if (matched)
|
|
178
237
|
found = rule;
|
|
179
238
|
}
|
|
180
239
|
return found;
|
|
@@ -223,8 +282,10 @@ function decidingRule(relativePath, isDirectory, layers, remembered) {
|
|
|
223
282
|
}
|
|
224
283
|
return decide(relativePath, isDirectory, layers);
|
|
225
284
|
}
|
|
226
|
-
function excludes(relativePath, isDirectory, layers
|
|
227
|
-
|
|
285
|
+
function excludes(relativePath, isDirectory, layers,
|
|
286
|
+
/* Folder answers to reuse; see FolderAnswers. Only for one fixed set of rules. */
|
|
287
|
+
remembered) {
|
|
288
|
+
const rule = decidingRule(relativePath, isDirectory, layers, remembered);
|
|
228
289
|
return Boolean(rule && !rule.negated);
|
|
229
290
|
}
|
|
230
291
|
/**
|
|
@@ -1110,6 +1110,12 @@ onFound) {
|
|
|
1110
1110
|
const layers = await collectLayers(root, rules);
|
|
1111
1111
|
const tracked = await gitTracked(root);
|
|
1112
1112
|
const found = [];
|
|
1113
|
+
/*
|
|
1114
|
+
Every entry asks about each folder above it; those answers do not change
|
|
1115
|
+
during one walk, so each folder is decided once rather than once per file
|
|
1116
|
+
beneath it.
|
|
1117
|
+
*/
|
|
1118
|
+
const folderAnswers = new Map();
|
|
1113
1119
|
/*
|
|
1114
1120
|
A level at a time, eight in flight.
|
|
1115
1121
|
|
|
@@ -1142,7 +1148,7 @@ onFound) {
|
|
|
1142
1148
|
continue;
|
|
1143
1149
|
const relative = node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
|
|
1144
1150
|
if (entry.isDirectory()) {
|
|
1145
|
-
if ((0, rules_js_1.excludes)(relative, true, layers) &&
|
|
1151
|
+
if ((0, rules_js_1.excludes)(relative, true, layers, folderAnswers) &&
|
|
1146
1152
|
!(0, rules_js_1.negationReachesInto)(relative, layers) &&
|
|
1147
1153
|
!tracked?.folders.has(relative)) {
|
|
1148
1154
|
continue;
|
|
@@ -1152,7 +1158,7 @@ onFound) {
|
|
|
1152
1158
|
}
|
|
1153
1159
|
if (!entry.isFile())
|
|
1154
1160
|
continue;
|
|
1155
|
-
if ((0, rules_js_1.excludes)(relative, false, layers) && !tracked?.files.has(relative))
|
|
1161
|
+
if ((0, rules_js_1.excludes)(relative, false, layers, folderAnswers) && !tracked?.files.has(relative))
|
|
1156
1162
|
continue;
|
|
1157
1163
|
here.push({ full, relative });
|
|
1158
1164
|
}
|
package/dist/cli/src/api.js
CHANGED
|
@@ -31,6 +31,7 @@ exports.compareVersions = compareVersions;
|
|
|
31
31
|
exports.fileTextAt = fileTextAt;
|
|
32
32
|
exports.versions = versions;
|
|
33
33
|
exports.markRelease = markRelease;
|
|
34
|
+
exports.setReleaseDownloadsPublic = setReleaseDownloadsPublic;
|
|
34
35
|
exports.issues = issues;
|
|
35
36
|
exports.createIssue = createIssue;
|
|
36
37
|
exports.releases = releases;
|
|
@@ -628,6 +629,17 @@ async function markRelease(repositoryId, versionId, name, notes) {
|
|
|
628
629
|
versionId: String(body.release?.versionId ?? versionId),
|
|
629
630
|
};
|
|
630
631
|
}
|
|
632
|
+
/**
|
|
633
|
+
* Make a release's downloads public, or private again, apart from its source.
|
|
634
|
+
*
|
|
635
|
+
* What is attached to the release, its notes and the update feed are offered
|
|
636
|
+
* to anybody while the version's files keep their own visibility, so one
|
|
637
|
+
* project can ship its installers without a second public project for them.
|
|
638
|
+
*/
|
|
639
|
+
async function setReleaseDownloadsPublic(repositoryId, versionId, value) {
|
|
640
|
+
await call(`/v1/repositories/${encodeURIComponent(repositoryId)}` +
|
|
641
|
+
`/versions/${encodeURIComponent(versionId)}/release/downloads`, { method: "PUT", body: { public: value } });
|
|
642
|
+
}
|
|
631
643
|
async function issues(repositoryId) {
|
|
632
644
|
const body = await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/issues`);
|
|
633
645
|
return {
|
|
@@ -671,6 +683,9 @@ async function releases(repositoryId) {
|
|
|
671
683
|
releasedAt: String(row.releasedAt ?? ""),
|
|
672
684
|
releasedBy: String(row.releasedBy ?? ""),
|
|
673
685
|
assets: Array.isArray(row.assets) ? row.assets : [],
|
|
686
|
+
attachments: Array.isArray(row.attachments) ? row.attachments : [],
|
|
687
|
+
downloadsPublic: row.downloadsPublic === true,
|
|
688
|
+
sourcePublic: row.sourcePublic === true,
|
|
674
689
|
}));
|
|
675
690
|
}
|
|
676
691
|
async function collaborators(repositoryId) {
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -1656,14 +1656,33 @@ trackName) {
|
|
|
1656
1656
|
});
|
|
1657
1657
|
}
|
|
1658
1658
|
const line = progressLine();
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1659
|
+
let result;
|
|
1660
|
+
try {
|
|
1661
|
+
result = await downloader.run(repositoryId, latest.id, destination, (progress) => {
|
|
1662
|
+
line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
|
|
1663
|
+
`${progress.files}/${progress.totalFiles} ` +
|
|
1664
|
+
dim(progress.path.slice(-40)));
|
|
1665
|
+
},
|
|
1666
|
+
// What this folder received last time, so a file the project has since
|
|
1667
|
+
// dropped is removed while everything untracked is left alone.
|
|
1668
|
+
held, mode);
|
|
1669
|
+
}
|
|
1670
|
+
catch (error) {
|
|
1671
|
+
done(line);
|
|
1672
|
+
if (!(error instanceof download_js_1.DownloadFailed))
|
|
1673
|
+
throw error;
|
|
1674
|
+
/*
|
|
1675
|
+
Named, and with what it did not do. A transfer fails before anything
|
|
1676
|
+
is put in place, so the folder is as it was and the staging copy is
|
|
1677
|
+
gone — which is what someone deciding whether to run it again needs.
|
|
1678
|
+
*/
|
|
1679
|
+
console.error(red(error.message));
|
|
1680
|
+
console.error(dim(((0, node_fs_1.existsSync)(destination)
|
|
1681
|
+
? `Nothing in ${destination} was changed.`
|
|
1682
|
+
: `No folder was made at ${destination}.`) +
|
|
1683
|
+
" Run the same command again to retry."));
|
|
1684
|
+
return 1;
|
|
1685
|
+
}
|
|
1667
1686
|
done(line);
|
|
1668
1687
|
/*
|
|
1669
1688
|
Said, not hidden. The files are right, but the service sent them still
|
|
@@ -2801,6 +2820,10 @@ const SPECS = [
|
|
|
2801
2820
|
group: "Your projects",
|
|
2802
2821
|
summary: "what has been released, and its files",
|
|
2803
2822
|
usage: "releases [project]",
|
|
2823
|
+
detail: "Each release with its files and who can take them: public (source and\n" +
|
|
2824
|
+
"downloads), downloads public (the source stays private) or private.\n" +
|
|
2825
|
+
"For a public project it also prints the addresses an app or a script\n" +
|
|
2826
|
+
"reads them from, which need no account.",
|
|
2804
2827
|
run: service_commands_js_1.commandReleases,
|
|
2805
2828
|
},
|
|
2806
2829
|
{
|
|
@@ -3060,14 +3083,22 @@ const SPECS = [
|
|
|
3060
3083
|
"Nothing is uploaded: a release is a version with a name on it, so the\n" +
|
|
3061
3084
|
"version has to exist already.\n\n" +
|
|
3062
3085
|
"The same thing happens when a git tag is pushed to a CodeRook remote —\n" +
|
|
3063
|
-
"`git push cbx v1.0` and `cbx release v1.0` are one action
|
|
3086
|
+
"`git push cbx v1.0` and `cbx release v1.0` are one action.\n\n" +
|
|
3087
|
+
"Put the installers on it with `cbx attach`. In a public project,\n" +
|
|
3088
|
+
"--downloads public offers the release's notes, attachments and update\n" +
|
|
3089
|
+
"feed to anybody while the version's source stays private, so one\n" +
|
|
3090
|
+
"project can ship its own app without a second, public project for the\n" +
|
|
3091
|
+
"installers. Without a name, --downloads changes an existing release.",
|
|
3064
3092
|
options: [
|
|
3065
3093
|
{ flags: "--version <n>", description: "name this version instead of the newest" },
|
|
3066
3094
|
{ flags: "--notes <text>", description: "what changed, for the release page" },
|
|
3095
|
+
{ flags: "--downloads <public|private>", description: "who can take what is attached, apart from the source" },
|
|
3067
3096
|
],
|
|
3068
3097
|
examples: [
|
|
3069
3098
|
"cbx release v1.0",
|
|
3070
3099
|
'cbx release v1.2 --version 41 --notes "Fixes the export dialog"',
|
|
3100
|
+
"cbx release 1.4.0 --downloads public",
|
|
3101
|
+
"cbx release --downloads private --version 41",
|
|
3071
3102
|
],
|
|
3072
3103
|
run: service_commands_js_1.commandRelease,
|
|
3073
3104
|
},
|
package/dist/cli/src/network.js
CHANGED
|
@@ -60,6 +60,17 @@ function describeError(error) {
|
|
|
60
60
|
return "CodeRook did not answer in time. Check the connection, then try again.";
|
|
61
61
|
}
|
|
62
62
|
const cause = error.cause;
|
|
63
|
+
if (error.message === "terminated") {
|
|
64
|
+
/*
|
|
65
|
+
undici's word for an answer whose body stopped arriving: the server
|
|
66
|
+
hung up mid-reply, or something between here and it did. On its own it
|
|
67
|
+
read as though the command had been killed.
|
|
68
|
+
*/
|
|
69
|
+
const detail = [cause?.code, cause?.message].filter(Boolean).join(": ");
|
|
70
|
+
return ("The connection to CodeRook closed before its answer finished arriving" +
|
|
71
|
+
(detail ? ` (${detail})` : "") +
|
|
72
|
+
". Check the connection, then try again.");
|
|
73
|
+
}
|
|
63
74
|
if (error.message === "fetch failed") {
|
|
64
75
|
/*
|
|
65
76
|
Said in words first, with the code after for whoever needs it. The most
|
|
@@ -119,12 +119,22 @@ async function commandIssues(parsed) {
|
|
|
119
119
|
* would be a feature that exists twice and agrees by luck.
|
|
120
120
|
*/
|
|
121
121
|
async function commandRelease(parsed) {
|
|
122
|
+
/*
|
|
123
|
+
`--downloads public|private` decides who can take what is attached,
|
|
124
|
+
apart from the source. Given without a name it changes an existing
|
|
125
|
+
release and nothing else: naming it again would rewrite its notes.
|
|
126
|
+
*/
|
|
127
|
+
const downloads = parsed.flags.get("downloads");
|
|
128
|
+
if (downloads !== undefined && downloads !== "public" && downloads !== "private") {
|
|
129
|
+
console.error((0, colour_js_1.red)("--downloads is public or private: cbx release v1.0 --downloads public"));
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
122
132
|
const name = parsed.positional[0];
|
|
123
|
-
if (!name) {
|
|
133
|
+
if (!name && downloads === undefined) {
|
|
124
134
|
console.error((0, colour_js_1.red)("Name the release: cbx release v1.0"));
|
|
125
135
|
return 1;
|
|
126
136
|
}
|
|
127
|
-
const project = await (0, project_commands_js_1.resolveProject)(parsed.positional[1]);
|
|
137
|
+
const project = await (0, project_commands_js_1.resolveProject)(name ? parsed.positional[1] : parsed.positional[0]);
|
|
128
138
|
if (!project)
|
|
129
139
|
return 1;
|
|
130
140
|
const all = await (0, api_js_1.versions)(project.id);
|
|
@@ -161,15 +171,32 @@ async function commandRelease(parsed) {
|
|
|
161
171
|
return 1;
|
|
162
172
|
}
|
|
163
173
|
const notes = parsed.flags.get("notes");
|
|
164
|
-
|
|
165
|
-
|
|
174
|
+
if (name) {
|
|
175
|
+
try {
|
|
176
|
+
await (0, api_js_1.markRelease)(project.id, target.id, name, typeof notes === "string" ? notes : null);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
console.error((0, colour_js_1.red)(error instanceof Error ? error.message : String(error)));
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
console.log(`${(0, colour_js_1.green)("Released")} ${(0, colour_js_1.bold)(name)} ${(0, colour_js_1.dim)("at")} v${target.sequence}` +
|
|
183
|
+
`${(0, colour_js_1.dim)(" — ")}${target.message.split("\n")[0].slice(0, 48)}`);
|
|
166
184
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
185
|
+
if (downloads !== undefined) {
|
|
186
|
+
try {
|
|
187
|
+
await (0, api_js_1.setReleaseDownloadsPublic)(project.id, target.id, downloads === "public");
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
console.error((0, colour_js_1.red)(error instanceof Error ? error.message : String(error)));
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
console.log(downloads === "public"
|
|
194
|
+
? `${(0, colour_js_1.green)("Downloads public")} ${(0, colour_js_1.dim)("on")} v${target.sequence}${(0, colour_js_1.dim)(": anybody can take what is attached; the source keeps its own visibility.")}`
|
|
195
|
+
: `${(0, colour_js_1.green)("Downloads private")} ${(0, colour_js_1.dim)("on")} v${target.sequence}${(0, colour_js_1.dim)(": only people on this project can take them now.")}`);
|
|
196
|
+
if (downloads === "public" && project.visibility !== "public") {
|
|
197
|
+
console.log((0, colour_js_1.dim)(" The project itself is not public, so nobody outside it can reach them yet: cbx visibility public."));
|
|
198
|
+
}
|
|
170
199
|
}
|
|
171
|
-
console.log(`${(0, colour_js_1.green)("Released")} ${(0, colour_js_1.bold)(name)} ${(0, colour_js_1.dim)("at")} v${target.sequence}` +
|
|
172
|
-
`${(0, colour_js_1.dim)(" — ")}${target.message.split("\n")[0].slice(0, 48)}`);
|
|
173
200
|
return 0;
|
|
174
201
|
}
|
|
175
202
|
async function commandReleases(parsed) {
|
|
@@ -186,13 +213,30 @@ async function commandReleases(parsed) {
|
|
|
186
213
|
const when = release.releasedAt
|
|
187
214
|
? new Date(release.releasedAt).toLocaleDateString()
|
|
188
215
|
: "";
|
|
216
|
+
const who = release.sourcePublic
|
|
217
|
+
? "public"
|
|
218
|
+
: release.downloadsPublic
|
|
219
|
+
? "downloads public"
|
|
220
|
+
: "private";
|
|
189
221
|
console.log(` ${(0, colour_js_1.accent)(`v${release.sequence}`).padEnd(16)} ` +
|
|
190
222
|
`${(release.name || "—").padEnd(28)} ${when.padEnd(14)} ` +
|
|
191
|
-
`${release.assets.length} files`);
|
|
223
|
+
`${`${release.assets.length + release.attachments.length} files`.padEnd(10)} ${(0, colour_js_1.dim)(who)}`);
|
|
192
224
|
const firstLine = release.notes.split("\n")[0];
|
|
193
225
|
if (firstLine)
|
|
194
226
|
console.log(` ${(0, colour_js_1.dim)(firstLine)}`);
|
|
195
227
|
}
|
|
228
|
+
/* Where an app or a script reads them, for a project anybody can reach. */
|
|
229
|
+
const owner = project.visibility === "public" && published.some((one) => one.downloadsPublic || one.sourcePublic)
|
|
230
|
+
? await (0, api_js_1.whoami)().then((me) => me.username).catch(() => null)
|
|
231
|
+
: null;
|
|
232
|
+
if (owner) {
|
|
233
|
+
const base = `https://api.coderook.com/v1/public/projects/${encodeURIComponent(owner)}/${encodeURIComponent(project.slug)}`;
|
|
234
|
+
console.log("");
|
|
235
|
+
console.log((0, colour_js_1.dim)(" From code, no account needed:"));
|
|
236
|
+
console.log(` ${(0, colour_js_1.dim)("latest release")} ${base}/releases/latest`);
|
|
237
|
+
console.log(` ${(0, colour_js_1.dim)("all releases")} ${base}/releases`);
|
|
238
|
+
console.log(` ${(0, colour_js_1.dim)("update feed")} ${base}/updates`);
|
|
239
|
+
}
|
|
196
240
|
return 0;
|
|
197
241
|
}
|
|
198
242
|
/** Who can reach a project, and who has been asked. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coderook/cli",
|
|
3
|
-
"version": "0.34.
|
|
3
|
+
"version": "0.34.11",
|
|
4
4
|
"description": "The command line for CodeRook (coderook.com): version control and project hosting for projects of any size. Every save is a compressed snapshot, private or public, never used to train AI.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://coderook.com",
|
package/skills/coderook/SKILL.md
CHANGED
|
@@ -246,11 +246,13 @@ line's source to find these — this is the whole of it:
|
|
|
246
246
|
GET /projects?q= public projects, newest first
|
|
247
247
|
GET /projects/{owner}/{slug} one project
|
|
248
248
|
GET /projects/{owner}/{slug}/versions the versions it offers
|
|
249
|
+
GET /projects/{owner}/{slug}/releases every release, with its downloads
|
|
249
250
|
GET /projects/{owner}/{slug}/releases/latest newest release and its files
|
|
250
251
|
GET /projects/{owner}/{slug}/updates/latest.yml electron-updater feed
|
|
251
252
|
GET /projects/{owner}/{slug}/versions/{v}/files every file, with digests
|
|
252
253
|
GET /projects/{owner}/{slug}/versions/{v}/file?path= one file
|
|
253
254
|
GET /projects/{owner}/{slug}/versions/{v}/archive the version as a zip
|
|
255
|
+
GET /projects/{owner}/{slug}/versions/{v}/last-changes?path= which version last changed each entry
|
|
254
256
|
GET /projects/{owner}/{slug}/versions/{v}/attachments files attached to it
|
|
255
257
|
GET /projects/{owner}/{slug}/versions/{v}/attachments/{id} download one
|
|
256
258
|
```
|
|
@@ -279,8 +281,16 @@ The feed serves `latest.yml`, `latest-mac.yml` and `latest-linux.yml` with
|
|
|
279
281
|
each file's size and SHA-512, and redirects file names to their downloads.
|
|
280
282
|
Anything else can read `releases/latest` as JSON: version, notes, and every
|
|
281
283
|
file with its size, SHA-256 and `url`. Updaters have no account, so the
|
|
282
|
-
project they read must be public
|
|
283
|
-
|
|
284
|
+
project they read must be public, but its source need not be:
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
cbx release 1.4.0 --downloads public # notes, attachments and feed public; files stay private
|
|
288
|
+
cbx releases # public / downloads public / private, and the addresses
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
**Ask before `--downloads public`**: it publishes the attached files to anyone.
|
|
292
|
+
One project holds the code and ships the app; a second "releases" project is
|
|
293
|
+
no longer needed.
|
|
284
294
|
|
|
285
295
|
## Building executables for every platform
|
|
286
296
|
|