@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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli/src/cli.js +231 -58
- package/dist/cli/src/import_command.js +150 -0
- package/dist/cli/src/offline.js +28 -0
- package/dist/desktop-app/src/main/compress.js +6 -31
- package/dist/desktop-app/src/main/download.js +200 -4
- package/dist/desktop-app/src/main/upload.js +307 -34
- package/dist/desktop-app/src/shared/chunking.js +21 -1
- package/dist/desktop-app/src/shared/compression_policy.js +35 -0
- package/dist/desktop-app/src/shared/telemetry.js +47 -0
- package/package.json +51 -51
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.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 (
|
|
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 =
|
|
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 =
|
|
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(
|
|
273
|
-
|
|
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);
|