@coderook/cli 0.22.0 → 0.22.2

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, from Claude Code.",
5
- "version": "0.22.0",
5
+ "version": "0.22.1",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -265,7 +265,18 @@ async function commandProjects() {
265
265
  }
266
266
  async function commandStatus(parsed) {
267
267
  const folder = folderFor(parsed);
268
- const { link, baseline } = await reconcile(folder);
268
+ let link;
269
+ let baseline;
270
+ try {
271
+ ({ link, baseline } = await reconcile(folder));
272
+ }
273
+ catch (error) {
274
+ link = await (0, config_js_1.readLink)(folder);
275
+ if (!link?.manifest)
276
+ throw error;
277
+ baseline = new Map(Object.entries(link.local ?? link.manifest));
278
+ console.log(dim("Offline — comparing with the last Version cached for this folder."));
279
+ }
269
280
  const rules = await (0, worktree_js_1.readRules)(folder);
270
281
  const files = await (0, worktree_js_1.changedFiles)(folder, rules, baseline);
271
282
  console.log(bold(node_path_1.default.basename(folder)) + dim(` ${folder}`));
@@ -463,12 +474,6 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
463
474
  }
464
475
  console.log(dim("Sending them anyway, because --allow-secrets was given."));
465
476
  }
466
- if (hasFlag(parsed, "dry-run", "n")) {
467
- console.log(`${files.length} file${files.length === 1 ? "" : "s"} would be sent:`);
468
- for (const file of files)
469
- console.log(` ${file.path}`);
470
- return 0;
471
- }
472
477
  if (added) {
473
478
  console.log(`${accent("Added an MIT licence")}, so other people may use this.`);
474
479
  console.log(dim(` Change it with `) +
@@ -480,42 +485,46 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
480
485
  }
481
486
  const line = progressLine();
482
487
  const uploader = new upload_js_1.Uploader(config_js_1.credentials);
488
+ const uploadRequest = {
489
+ localPath: folder,
490
+ include: files.map((file) => file.path),
491
+ deletions: files.filter((file) => file.deleted).map((file) => file.path),
492
+ message,
493
+ projectName: link?.slug ?? node_path_1.default.basename(folder),
494
+ repositoryId: link?.repositoryId ?? null,
495
+ // Every current CLI publish states its ancestry. A brand-new project is
496
+ // explicitly based on an empty Track; a linked folder names the immutable
497
+ // Version it was last reconciled with.
498
+ baseVersionId: link?.baseVersionId ?? null,
499
+ track: flagText(parsed, "track") ?? (await (0, track_commands_js_1.trackFor)(folder)),
500
+ ...(link?.baseVersionId
501
+ ? { expectedHeadVersionId: link.baseVersionId }
502
+ : {}),
503
+ ...(link ? { known: link.local ?? link.manifest ?? {} } : {}),
504
+ };
483
505
  let result;
484
506
  try {
485
- result = await uploader.run({
486
- localPath: folder,
487
- include: files.map((file) => file.path),
488
- deletions: files.filter((file) => file.deleted).map((file) => file.path),
489
- message,
490
- projectName: link?.slug ?? node_path_1.default.basename(folder),
491
- repositoryId: link?.repositoryId ?? null,
492
- // Every current CLI publish states its ancestry. A brand-new project
493
- // is explicitly based on an empty Track; a linked folder names the
494
- // immutable Version it was last reconciled with.
495
- baseVersionId: link?.baseVersionId ?? null,
496
- /*
497
- The line this save belongs on: named on the command, or whichever one
498
- the folder is set to. Sent by name rather than resolved here, because
499
- the service owns the rules about which names exist and which may be
500
- written to a rule enforced in two places is one that will disagree
501
- with itself.
502
- */
503
- track: flagText(parsed, "track") ?? (await (0, track_commands_js_1.trackFor)(folder)),
504
- /*
505
- Only claimed when this folder has actually been reconciled with a
506
- known version. A folder linked before versions were recorded says
507
- nothing rather than guessing, and publishes as it always did.
508
- */
509
- ...(link?.baseVersionId
510
- ? { expectedHeadVersionId: link.baseVersionId }
511
- : {}),
512
- /*
513
- What this folder believed the project held. It is how the service
514
- tells a file this person deleted from one they never had, and
515
- without it somebody else's work disappears at the next save.
516
- */
517
- ...(link ? { known: link.local ?? link.manifest ?? {} } : {}),
518
- }, (progress) => {
507
+ const plan = await uploader.plan(uploadRequest, (progress) => {
508
+ line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
509
+ `${"plan".padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
510
+ dim(progress.path.slice(-40)));
511
+ });
512
+ done(line);
513
+ console.log(`${accent("Upload plan")} ${bytes(plan.sourceBytes)} selected → ` +
514
+ `${bytes(plan.compactedBytes)} compacted; ${bytes(plan.chargeableBytes)} new storage.`);
515
+ console.log(plan.allowance.exempt
516
+ ? dim("Annual plan: the monthly staged upload allowance does not apply.")
517
+ : dim(`Monthly stage ${plan.allowance.stage}: ${plan.allowance.unlockedPercent}% unlocked, ` +
518
+ `${bytes(plan.allowance.remainingBytes)} remains after this reservation.`));
519
+ if (hasFlag(parsed, "dry-run", "n")) {
520
+ await uploader.cancelPlan(plan, true);
521
+ console.log(`${files.length} file${files.length === 1 ? "" : "s"} would be sent or reused:`);
522
+ for (const file of files)
523
+ console.log(` ${file.path}`);
524
+ console.log("Nothing was uploaded and no Version was created.");
525
+ return 0;
526
+ }
527
+ result = await uploader.execute(uploadRequest, plan, (progress) => {
519
528
  line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
520
529
  `${progress.stage.padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
521
530
  dim(progress.path.slice(-40)));
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.queueOfflineCandidate = queueOfflineCandidate;
7
+ exports.readOfflineCandidate = readOfflineCandidate;
8
+ const promises_1 = require("node:fs/promises");
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ async function queueOfflineCandidate(folder, candidate) {
11
+ const directory = node_path_1.default.join(folder, ".coderook", "outbox");
12
+ await (0, promises_1.mkdir)(directory, { recursive: true });
13
+ const body = {
14
+ format: "coderook-offline-candidate-v1",
15
+ createdAt: new Date().toISOString(),
16
+ ...candidate,
17
+ };
18
+ const name = `${Date.now()}-${crypto.randomUUID()}.json`;
19
+ const destination = node_path_1.default.join(directory, name);
20
+ await (0, promises_1.writeFile)(destination, JSON.stringify(body, null, 2), { encoding: "utf8", mode: 0o600 });
21
+ return destination;
22
+ }
23
+ async function readOfflineCandidate(file) {
24
+ const value = JSON.parse(await (0, promises_1.readFile)(file, "utf8"));
25
+ if (value.format !== "coderook-offline-candidate-v1")
26
+ throw new Error("Unsupported offline candidate");
27
+ return value;
28
+ }
@@ -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);