@coderook/cli 0.34.10 → 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.
@@ -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.10",
5
+ "version": "0.34.11",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -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
- * One request, repeated while repeating it might help.
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 request(route) {
245
+ async repeat(work, options = {}) {
128
246
  let wait = retry_js_1.RETRY_FIRST_WAIT_MS;
129
- for (let attempt = 1;; attempt += 1) {
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 this.attempt(route);
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 error;
138
- if (attempt >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error))
139
- throw error;
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)(this.controller.signal, wait);
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
- throw new DownloadCancelled();
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
- throw new Error(`${tooOld.message} You are running ${tooOld.yourVersion ?? "an older build"}; ` +
194
- `update from ${tooOld.upgradeUrl}.`);
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 wait = retry_js_1.RETRY_FIRST_WAIT_MS;
231
- for (let attempt = 1;; attempt += 1) {
232
- this.check();
233
- try {
234
- return await (await this.attempt(route)).json();
235
- }
236
- catch (error) {
237
- if (error instanceof DownloadCancelled)
238
- throw error;
239
- if (attempt >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error))
240
- throw error;
241
- try {
242
- await (0, retry_js_1.pauseFor)(this.controller.signal, wait);
243
- }
244
- catch {
245
- throw new DownloadCancelled();
246
- }
247
- wait *= 2;
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
- const response = await this.attempt(`/v1/repositories/${repositoryId}/versions/${versionId}/archive`, ARCHIVE_DEADLINE_MS);
281
- return new Uint8Array(await response.arrayBuffer());
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.request(`/v1/repositories/${repositoryId}/objects/${packId}`);
398
- const body = Buffer.from(await reply.arrayBuffer());
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.request(`/v1/repositories/${repositoryId}/objects/${packId}`);
467
- const body = Buffer.from(await reply.arrayBuffer());
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 generators below do not need `this` rebound.
521
- const request = (route) => this.request(route);
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
- const source = pieces.length
524
- ? (async function* () {
525
- for (let at = 0; at < pieces.length; at += 1) {
526
- check();
527
- const piece = pieces[at];
528
- const reply = await request(`/v1/repositories/${repositoryId}/objects/${piece.objectId}`);
529
- let body = Buffer.from(await reply.arrayBuffer());
530
- /*
531
- Checked against what the upload recorded for this piece, before
532
- a byte of it reaches the file. Without the digest the only
533
- available check was the whole file's, which says that something
534
- in seven gigabytes is wrong and nothing about what.
535
- */
536
- const digest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
537
- if (piece.sha256 && digest !== piece.sha256) {
538
- const plain = gunzippedMatching(body, piece.sha256);
539
- if (!plain) {
540
- throw new Error(`${file.path}: piece ${at + 1} of ${pieces.length} ` +
541
- `did not arrive intact`);
542
- }
543
- recovered = true;
544
- body = plain;
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
- whole.update(body);
547
- yield body;
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
- if (!pieces.length && file.sha256 && digest !== file.sha256) {
575
- if (await recoverGzipFile(target, file.sha256)) {
576
- this.recoveredFromGzip.push(file.path);
577
- return file.sha256;
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
- return digest;
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.request(`/v1/repositories/${repositoryId}/objects/${packId}`);
692
- const body = Buffer.from(await reply.arrayBuffer());
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 (const rule of rules) {
177
- if (ruleMatches(relativePath, isDirectory, rule))
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
- const rule = decidingRule(relativePath, isDirectory, layers);
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
  }
@@ -1656,14 +1656,33 @@ trackName) {
1656
1656
  });
1657
1657
  }
1658
1658
  const line = progressLine();
1659
- const result = await downloader.run(repositoryId, latest.id, destination, (progress) => {
1660
- line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
1661
- `${progress.files}/${progress.totalFiles} ` +
1662
- dim(progress.path.slice(-40)));
1663
- },
1664
- // What this folder received last time, so a file the project has since
1665
- // dropped is removed while everything untracked is left alone.
1666
- held, mode);
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
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.34.10",
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",