@coderook/cli 0.22.1 → 0.23.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.
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.looksCompressed = looksCompressed;
3
+ exports.looksCompressed = void 0;
4
4
  exports.encodeForUpload = encodeForUpload;
5
5
  /**
6
6
  * Compressing an object before it is sent.
@@ -29,6 +29,9 @@ exports.encodeForUpload = encodeForUpload;
29
29
  const node_crypto_1 = require("node:crypto");
30
30
  const node_zlib_1 = require("node:zlib");
31
31
  const node_util_1 = require("node:util");
32
+ const compression_policy_1 = require("../shared/compression_policy");
33
+ var compression_policy_2 = require("../shared/compression_policy");
34
+ Object.defineProperty(exports, "looksCompressed", { enumerable: true, get: function () { return compression_policy_2.looksCompressed; } });
32
35
  const deflate = (0, node_util_1.promisify)(node_zlib_1.gzip);
33
36
  /*
34
37
  Extensions whose contents are already compressed. Matching on the name is a
@@ -36,30 +39,6 @@ const deflate = (0, node_util_1.promisify)(node_zlib_1.gzip);
36
39
  is measured rather than guessed, so a wrong entry here costs a little time
37
40
  and never a wrong result.
38
41
  */
39
- const ALREADY_COMPRESSED = new Set([
40
- ".7z", ".aac", ".avi", ".br", ".bz2", ".cbx", ".docx", ".flac", ".gif",
41
- ".gz", ".heic", ".jpeg", ".jpg", ".jxl", ".m4a", ".m4v", ".mkv", ".mov",
42
- ".mp3", ".mp4", ".odp", ".ods", ".odt", ".ogg", ".opus", ".pdf", ".png",
43
- ".pptx", ".rar", ".safetensors", ".webm", ".webp", ".xlsx", ".xz", ".zip",
44
- ".zst",
45
- ]);
46
- /**
47
- * Below this, compression is not worth the round trip through zlib — the
48
- * saving is measured in bytes and every file pays the CPU.
49
- */
50
- const SMALLEST_WORTH_TRYING = 4 * 1024;
51
- /**
52
- * Keep the compressed form only if it saved something worth having. A file
53
- * that shrinks by two percent is not worth the service decompressing it on
54
- * every read for the rest of its life.
55
- */
56
- const WORTHWHILE = 0.95;
57
- function looksCompressed(relativePath) {
58
- const dot = relativePath.lastIndexOf(".");
59
- if (dot < 0)
60
- return false;
61
- return ALREADY_COMPRESSED.has(relativePath.slice(dot).toLowerCase());
62
- }
63
42
  /**
64
43
  * Decide how one object should travel.
65
44
  *
@@ -73,15 +52,11 @@ async function encodeForUpload(original, relativePath, allowGzip) {
73
52
  encoding: "identity",
74
53
  storedSha256: "",
75
54
  };
76
- if (!allowGzip)
77
- return plain;
78
- if (original.byteLength < SMALLEST_WORTH_TRYING)
79
- return plain;
80
- if (looksCompressed(relativePath))
55
+ if (!(0, compression_policy_1.shouldTryGzip)(relativePath, original.byteLength, allowGzip))
81
56
  return plain;
82
57
  try {
83
58
  const packed = await deflate(original, { level: 6 });
84
- if (packed.byteLength >= original.byteLength * WORTHWHILE)
59
+ if (!(0, compression_policy_1.gzipIsWorthKeeping)(original.byteLength, packed.byteLength))
85
60
  return plain;
86
61
  return {
87
62
  body: new Uint8Array(packed),
@@ -174,8 +174,39 @@ class Downloader {
174
174
  }
175
175
  return response;
176
176
  }
177
+ /**
178
+ * Read a complete JSON response under the same retry rule as the request.
179
+ *
180
+ * `fetch()` resolves as soon as the headers arrive. A connection can still
181
+ * disappear while `.json()` is consuming the body; treating the response
182
+ * object as success meant a large file list failed outside the retry loop.
183
+ * These routes are reads, so repeating the whole request is safe and is the
184
+ * only way to replace a truncated body.
185
+ */
186
+ async json(route) {
187
+ let wait = retry_js_1.RETRY_FIRST_WAIT_MS;
188
+ for (let attempt = 1;; attempt += 1) {
189
+ this.check();
190
+ try {
191
+ return await (await this.attempt(route)).json();
192
+ }
193
+ catch (error) {
194
+ if (error instanceof DownloadCancelled)
195
+ throw error;
196
+ if (attempt >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error))
197
+ throw error;
198
+ try {
199
+ await (0, retry_js_1.pauseFor)(this.controller.signal, wait);
200
+ }
201
+ catch {
202
+ throw new DownloadCancelled();
203
+ }
204
+ wait *= 2;
205
+ }
206
+ }
207
+ }
177
208
  async versions(repositoryId) {
178
- const body = (await (await this.request(`/v1/repositories/${repositoryId}/versions`)).json());
209
+ const body = await this.json(`/v1/repositories/${repositoryId}/versions`);
179
210
  return (body.versions ?? []).map((row) => ({
180
211
  id: String(row.id ?? ""),
181
212
  sequence: Number(row.sequence ?? 0),
@@ -187,13 +218,26 @@ class Downloader {
187
218
  }));
188
219
  }
189
220
  async files(repositoryId, versionId) {
190
- const body = (await (await this.request(`/v1/repositories/${repositoryId}/versions/${versionId}/files`)).json());
221
+ const body = await this.json(`/v1/repositories/${repositoryId}/versions/${versionId}/files`);
191
222
  return (body.files ?? []).map((row) => {
192
223
  const pieces = Array.isArray(row.chunks) ? row.chunks : null;
224
+ const packed = row.pack && typeof row.pack === "object"
225
+ ? row.pack
226
+ : null;
193
227
  return {
194
228
  path: String(row.path ?? ""),
229
+ ...(row.objectId ? { objectId: String(row.objectId) } : {}),
195
230
  sha256: String(row.sha256 ?? ""),
196
231
  sourceSize: Number(row.sourceSize ?? 0),
232
+ ...(packed?.objectId
233
+ ? {
234
+ pack: {
235
+ objectId: String(packed.objectId),
236
+ offset: Number(packed.offset ?? 0),
237
+ length: Number(packed.length ?? 0),
238
+ },
239
+ }
240
+ : {}),
197
241
  ...(pieces && pieces.length
198
242
  ? {
199
243
  chunks: pieces.map((piece) => {
@@ -240,6 +284,92 @@ class Downloader {
240
284
  await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
241
285
  return this.fetchInto(repositoryId, versionId, file, target);
242
286
  }
287
+ /**
288
+ * Reconstruct and verify every file in a Version without materialising the
289
+ * whole project at once. Solid packs are fetched once and large files use a
290
+ * single reusable scratch path, so the disk requirement is bounded by the
291
+ * largest individual file rather than the complete snapshot.
292
+ */
293
+ async verify(repositoryId, versionId, scratch, report) {
294
+ const files = await this.files(repositoryId, versionId);
295
+ if (!files.length)
296
+ throw new Error("That version has no files");
297
+ const totalBytes = files.reduce((total, file) => total + file.sourceSize, 0);
298
+ const packed = new Map();
299
+ for (const file of files) {
300
+ const id = file.pack?.objectId;
301
+ if (!id)
302
+ continue;
303
+ const group = packed.get(id) ?? [];
304
+ group.push(file);
305
+ packed.set(id, group);
306
+ }
307
+ await (0, promises_1.rm)(scratch, { recursive: true, force: true });
308
+ await (0, promises_1.mkdir)(scratch, { recursive: true });
309
+ const temporary = node_path_1.default.join(scratch, "current-file.partial");
310
+ const restoredPacks = new Set();
311
+ const manifest = {};
312
+ let written = 0;
313
+ let bytes = 0;
314
+ const progress = (filePath) => report({
315
+ files: written,
316
+ totalFiles: files.length,
317
+ bytes,
318
+ totalBytes,
319
+ path: filePath,
320
+ percent: underway(bytes, totalBytes),
321
+ });
322
+ try {
323
+ for (const file of files) {
324
+ this.check();
325
+ progress(file.path);
326
+ const packId = file.pack?.objectId;
327
+ if (packId && restoredPacks.has(packId))
328
+ continue;
329
+ if (packId) {
330
+ const reply = await this.request(`/v1/repositories/${repositoryId}/objects/${packId}`);
331
+ const body = Buffer.from(await reply.arrayBuffer());
332
+ const packDigest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
333
+ const expectedPackDigest = reply.headers.get("x-coderook-sha256");
334
+ if (expectedPackDigest && packDigest !== expectedPackDigest) {
335
+ throw new Error("A solid pack did not arrive intact");
336
+ }
337
+ for (const member of packed.get(packId) ?? []) {
338
+ const offset = member.pack.offset;
339
+ const length = member.pack.length;
340
+ if (offset < 0 || length < 0 || offset + length > body.byteLength) {
341
+ throw new Error(`${member.path} points outside its solid pack`);
342
+ }
343
+ const digest = (0, node_crypto_1.createHash)("sha256")
344
+ .update(body.subarray(offset, offset + length))
345
+ .digest("hex");
346
+ if (member.sha256 && digest !== member.sha256) {
347
+ throw new Error(`${member.path} did not arrive intact`);
348
+ }
349
+ manifest[member.path] = digest;
350
+ written += 1;
351
+ bytes += member.sourceSize;
352
+ progress(member.path);
353
+ }
354
+ restoredPacks.add(packId);
355
+ continue;
356
+ }
357
+ const digest = await this.fetchInto(repositoryId, versionId, file, temporary);
358
+ if (file.sha256 && digest !== file.sha256) {
359
+ throw new Error(`${file.path} did not arrive intact`);
360
+ }
361
+ manifest[file.path] = digest;
362
+ written += 1;
363
+ bytes += file.sourceSize;
364
+ await (0, promises_1.rm)(temporary, { force: true });
365
+ }
366
+ progress("Done");
367
+ return { files: written, bytes, manifest };
368
+ }
369
+ finally {
370
+ await (0, promises_1.rm)(scratch, { recursive: true, force: true });
371
+ }
372
+ }
243
373
  async fetchInto(repositoryId, versionId, file, target) {
244
374
  const whole = (0, node_crypto_1.createHash)("sha256");
245
375
  const pieces = file.chunks ?? [];
@@ -269,8 +399,10 @@ class Downloader {
269
399
  }
270
400
  })()
271
401
  : (async function* () {
272
- const reply = await request(`/v1/repositories/${repositoryId}/versions/${versionId}/file` +
273
- `?path=${encodeURIComponent(file.path)}`);
402
+ const reply = await request(file.objectId
403
+ ? `/v1/repositories/${repositoryId}/objects/${file.objectId}`
404
+ : `/v1/repositories/${repositoryId}/versions/${versionId}/file` +
405
+ `?path=${encodeURIComponent(file.path)}`);
274
406
  if (!reply.body)
275
407
  return;
276
408
  for await (const block of node_stream_1.Readable.fromWeb(reply.body)) {
@@ -322,6 +454,16 @@ class Downloader {
322
454
  try {
323
455
  let written = 0;
324
456
  let bytes = 0;
457
+ const packed = new Map();
458
+ for (const file of files) {
459
+ const id = file.pack?.objectId;
460
+ if (!id)
461
+ continue;
462
+ const group = packed.get(id) ?? [];
463
+ group.push(file);
464
+ packed.set(id, group);
465
+ }
466
+ const restoredPacks = new Set();
325
467
  for (const file of files) {
326
468
  this.check();
327
469
  report({
@@ -337,6 +479,60 @@ class Downloader {
337
479
  if (parts.some((part) => part === ".." || part.includes("\0"))) {
338
480
  throw new Error(`That version contains an unsafe path: ${file.path}`);
339
481
  }
482
+ const packId = file.pack?.objectId;
483
+ if (packId && restoredPacks.has(packId))
484
+ continue;
485
+ if (packId) {
486
+ /*
487
+ A solid pack is one compressed object containing many small files.
488
+
489
+ Asking the single-file route for every member made a large restore
490
+ fetch and decompress the same pack thousands of times. The files
491
+ listing already gives us the pack id and each member's exact
492
+ window, so fetch the decoded pack once, verify it, and materialise
493
+ every member before releasing the bounded section-sized buffer.
494
+ */
495
+ const reply = await this.request(`/v1/repositories/${repositoryId}/objects/${packId}`);
496
+ const body = Buffer.from(await reply.arrayBuffer());
497
+ const packDigest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
498
+ const expectedPackDigest = reply.headers.get("x-coderook-sha256");
499
+ if (expectedPackDigest && packDigest !== expectedPackDigest) {
500
+ throw new Error("A solid pack did not arrive intact");
501
+ }
502
+ for (const member of packed.get(packId) ?? []) {
503
+ this.check();
504
+ const memberParts = member.path.split("/").filter(Boolean);
505
+ if (memberParts.some((part) => part === ".." || part.includes("\0"))) {
506
+ throw new Error(`That version contains an unsafe path: ${member.path}`);
507
+ }
508
+ const offset = member.pack.offset;
509
+ const length = member.pack.length;
510
+ if (offset < 0 || length < 0 || offset + length > body.byteLength) {
511
+ throw new Error(`${member.path} points outside its solid pack`);
512
+ }
513
+ const contents = body.subarray(offset, offset + length);
514
+ const digest = (0, node_crypto_1.createHash)("sha256").update(contents).digest("hex");
515
+ if (member.sha256 && digest !== member.sha256) {
516
+ throw new Error(`${member.path} did not arrive intact`);
517
+ }
518
+ const full = node_path_1.default.join(staging, ...memberParts);
519
+ await (0, promises_1.mkdir)(node_path_1.default.dirname(full), { recursive: true });
520
+ await (0, promises_1.writeFile)(full, contents);
521
+ manifest[member.path] = digest;
522
+ written += 1;
523
+ bytes += member.sourceSize;
524
+ report({
525
+ files: written,
526
+ totalFiles: files.length,
527
+ bytes,
528
+ totalBytes,
529
+ path: member.path,
530
+ percent: underway(bytes, totalBytes),
531
+ });
532
+ }
533
+ restoredPacks.add(packId);
534
+ continue;
535
+ }
340
536
  const full = node_path_1.default.join(staging, ...parts);
341
537
  await (0, promises_1.mkdir)(node_path_1.default.dirname(full), { recursive: true });
342
538
  const digest = await this.fetchInto(repositoryId, versionId, file, full);