@coderook/cli 0.30.2 → 0.31.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.
@@ -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.30.2",
5
+ "version": "0.31.0",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -623,7 +623,21 @@ async function commandSubmit(parsed) {
623
623
  which is the wrong way round — so this is that capability, asked for
624
624
  plainly.
625
625
  */
626
- const files = await (0, worktree_js_1.changedFiles)(folder, rules, baseline, hasFlag(parsed, "sync") ? "synchronize" : "add-and-update");
626
+ /*
627
+ Digests this machine has taken before, read before the scan rather than
628
+ before the upload.
629
+
630
+ The uploader has always accepted these and the desktop has always passed
631
+ them, but on the command line the expensive part happens earlier: the
632
+ scan below reads and hashes the whole folder to decide what changed, and
633
+ that is where a 21,071-file project spent 169 of its 299 seconds. The
634
+ same map serves both — `changedFiles` fills it in place, so what the scan
635
+ learns is what the upload is handed.
636
+ */
637
+ const knownDigests = link?.repositoryId
638
+ ? await (0, config_js_1.readDigests)(link.repositoryId)
639
+ : new Map();
640
+ const files = await (0, worktree_js_1.changedFiles)(folder, rules, baseline, hasFlag(parsed, "sync") ? "synchronize" : "add-and-update", knownDigests);
627
641
  /*
628
642
  An upgraded folder with no materialisation record and something that
629
643
  differs from its recorded version is ambiguous in the one way that
@@ -788,6 +802,7 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
788
802
  */
789
803
  const uploadRequest = (0, publish_js_1.uploadRequestFor)({
790
804
  localPath: folder,
805
+ ...(knownDigests.size ? { knownDigests } : {}),
791
806
  changed: files.filter((file) => !file.deleted).map((file) => file.path),
792
807
  deleted: files.filter((file) => file.deleted).map((file) => file.path),
793
808
  /*
@@ -994,6 +1009,15 @@ The connection failed: ${text}`));
994
1009
  local: result.local,
995
1010
  manifest: result.manifest,
996
1011
  });
1012
+ /*
1013
+ What this save hashed, kept for the next one.
1014
+
1015
+ Merged rather than replaced: `digestsTaken` holds only the files actually
1016
+ read this time, and everything served from the existing record has to
1017
+ survive or the next save reads it all again — which is precisely the cost
1018
+ this exists to avoid.
1019
+ */
1020
+ await (0, config_js_1.writeDigests)(result.repositoryId, new Map([...knownDigests, ...uploader.digestsTaken()]));
997
1021
  if (result.repeated) {
998
1022
  console.log(`Already saved as ${accent(`v${result.sequence}`)} by an earlier attempt;` +
999
1023
  ` nothing was sent again.`);
@@ -13,6 +13,8 @@ exports.keyFor = keyFor;
13
13
  exports.readLink = readLink;
14
14
  exports.forgetLink = forgetLink;
15
15
  exports.writeLink = writeLink;
16
+ exports.readDigests = readDigests;
17
+ exports.writeDigests = writeDigests;
16
18
  /**
17
19
  * Where the command-line tool keeps its credential and its project links.
18
20
  *
@@ -182,3 +184,24 @@ async function writeLink(localPath, link) {
182
184
  };
183
185
  await writePrivate(linksFile(), JSON.stringify(links, null, 2));
184
186
  }
187
+ const digestsFile = (projectId) => node_path_1.default.join(configDirectory(), "digests", `${projectId}.json`);
188
+ async function readDigests(projectId) {
189
+ try {
190
+ const raw = await (0, promises_1.readFile)(digestsFile(projectId), "utf8");
191
+ return new Map(Object.entries(JSON.parse(raw)));
192
+ }
193
+ catch {
194
+ /* No record yet, or one this build cannot read: hash everything. */
195
+ return new Map();
196
+ }
197
+ }
198
+ async function writeDigests(projectId, digests) {
199
+ if (!digests.size)
200
+ return;
201
+ try {
202
+ await writePrivate(digestsFile(projectId), JSON.stringify(Object.fromEntries(digests)));
203
+ }
204
+ catch {
205
+ /* The next save hashes again, which is where this started. */
206
+ }
207
+ }
@@ -114,6 +114,7 @@ const node_os_1 = __importDefault(require("node:os"));
114
114
  const node_path_1 = __importDefault(require("node:path"));
115
115
  const node_process_1 = __importDefault(require("node:process"));
116
116
  const upload_js_1 = require("../../desktop-app/src/main/upload.js");
117
+ const stored_zip_js_1 = require("./stored_zip.js");
117
118
  const tracks_js_1 = require("../../desktop-app/src/main/tracks.js");
118
119
  const identify_js_1 = require("../../desktop-app/src/main/identify.js");
119
120
  const config_js_1 = require("./config.js");
@@ -533,6 +534,17 @@ async function recordPushedCommits(marks) {
533
534
  * Contents are deduplicated by SHA-256 across the whole history, so a file
534
535
  * unchanged across a thousand versions is downloaded and sent to git once.
535
536
  */
537
+ /*
538
+ How many blobs a clone fetches at once, and how much it will hold doing it.
539
+
540
+ Eight lanes for the same reason the folder walks use eight: these are
541
+ latency, not work, and the measured curve flattens there. The byte ceiling
542
+ is what stops a window of large files becoming a window of memory — one
543
+ oversized file still goes through on its own, because the count check comes
544
+ first and an empty window always accepts one more.
545
+ */
546
+ const BLOB_LANES = 8;
547
+ const BLOB_WINDOW_BYTES = 64 * 1024 * 1024;
536
548
  async function doImport(refs, url) {
537
549
  const { owner, slug } = (0, git_history_js_1.parseRemoteUrl)(url);
538
550
  const project = await (0, api_js_1.findProject)(owner ? `${owner}/${slug}` : slug);
@@ -639,22 +651,99 @@ async function doImport(refs, url) {
639
651
  let done = 0;
640
652
  for (const version of fresh) {
641
653
  const files = await downloader.files(repositoryId, version.id);
642
- // Blobs first: fast-import requires a mark to exist before a commit
643
- // names it.
644
- for (const file of files) {
645
- if (blobMark.has(file.sha256))
646
- continue;
647
- const target = node_path_1.default.join(scratch, "blob.bin");
648
- await (0, promises_1.rm)(target, { force: true });
649
- await downloader.fileTo(repositoryId, version.id, file, target);
650
- const bytes = await (0, promises_1.readFile)(target);
651
- const mark = nextMark++;
652
- blobMark.set(file.sha256, mark);
653
- send("blob");
654
- send(`mark :${mark}`);
655
- send(`data ${bytes.length}`);
656
- node_process_1.default.stdout.write(bytes);
657
- node_process_1.default.stdout.write("\n");
654
+ /*
655
+ Blobs first: fast-import requires a mark to exist before a commit
656
+ names it. Fetched a window at a time and written out in order.
657
+
658
+ This asked for one file, waited for it, wrote it to a scratch path,
659
+ read it back, and only then asked for the next — so a clone cost one
660
+ network round trip per file, in series. Against a 21,071-file project
661
+ that is 21,071 round trips at about seven tenths of a second each,
662
+ and a clone that never finished: measured over twenty seconds it was
663
+ using 0.17s of processor and moving two kilobytes.
664
+
665
+ The stream fast-import reads has to stay ordered, so the fetching is
666
+ what overlaps, not the writing. The window is bounded by count and by
667
+ bytes together, because sixteen source files and sixteen video files
668
+ are not the same amount to be holding at once.
669
+ */
670
+ /*
671
+ The whole version in one request, where that works.
672
+
673
+ Fetching per file is one round trip each: 21,071 of them on a real
674
+ Unity project, which is four hours in series and about half an hour
675
+ even eight at a time. The archive is the same bytes in one request.
676
+
677
+ Anything unexpected about it — an entry this cannot read, a short
678
+ download, a service that does not offer the route — falls through to
679
+ the per-file path below, which is slow and known to work. A clone
680
+ that is slow is a nuisance; a clone that writes the wrong bytes is
681
+ not recoverable by trying again.
682
+ */
683
+ const fromArchive = new Map();
684
+ try {
685
+ const packed = await downloader.archive(repositoryId, version.id);
686
+ for (const entry of (0, stored_zip_js_1.readStoredZip)(packed)) {
687
+ fromArchive.set(entry.path, entry.bytes);
688
+ }
689
+ }
690
+ catch (error) {
691
+ /*
692
+ Said out loud, not swallowed.
693
+
694
+ This fell back silently, and the silence cost an hour: a deadline
695
+ meant for ordinary requests was aborting the archive, the import
696
+ dropped to fetching every file on its own, and from the outside it
697
+ looked exactly like the slow clone the archive was added to fix.
698
+ A fallback nobody can see is a fallback nobody can diagnose.
699
+ */
700
+ say(` the archive for v${version.sequence} could not be used ` +
701
+ `(${error instanceof Error ? error.message : String(error)}); ` +
702
+ `fetching its files one by one`);
703
+ fromArchive.clear();
704
+ }
705
+ const seenBlob = new Set();
706
+ const wantedBlobs = files.filter((file) => {
707
+ if (blobMark.has(file.sha256) || seenBlob.has(file.sha256))
708
+ return false;
709
+ seenBlob.add(file.sha256);
710
+ return true;
711
+ });
712
+ for (let at = 0; at < wantedBlobs.length;) {
713
+ const window = [];
714
+ let held = 0;
715
+ while (at < wantedBlobs.length && window.length < BLOB_LANES) {
716
+ const size = Number(wantedBlobs[at].sourceSize ?? 0);
717
+ if (window.length > 0 && held + size > BLOB_WINDOW_BYTES)
718
+ break;
719
+ held += size;
720
+ window.push(wantedBlobs[at]);
721
+ at += 1;
722
+ }
723
+ const fetched = await Promise.all(window.map(async (file, lane) => {
724
+ /* Already in hand, if the archive carried it. */
725
+ const packed = fromArchive.get(file.path);
726
+ if (packed)
727
+ return { file, bytes: packed };
728
+ /*
729
+ A scratch path per lane. They shared one name, which is fine
730
+ while only one download is ever in flight and silently wrong
731
+ the moment two are.
732
+ */
733
+ const target = node_path_1.default.join(scratch, `blob-${lane}.bin`);
734
+ await (0, promises_1.rm)(target, { force: true });
735
+ await downloader.fileTo(repositoryId, version.id, file, target);
736
+ return { file, bytes: await (0, promises_1.readFile)(target) };
737
+ }));
738
+ for (const { file, bytes } of fetched) {
739
+ const mark = nextMark++;
740
+ blobMark.set(file.sha256, mark);
741
+ send("blob");
742
+ send(`mark :${mark}`);
743
+ send(`data ${bytes.length}`);
744
+ node_process_1.default.stdout.write(bytes);
745
+ node_process_1.default.stdout.write("\n");
746
+ }
658
747
  }
659
748
  const mark = nextMark++;
660
749
  commitMark.set(version.id, mark);
@@ -23,6 +23,11 @@ function uploadRequestFor(input) {
23
23
  ? { expectedHeadVersionId: input.baseVersionId }
24
24
  : {}),
25
25
  ...(input.known ? { known: input.known } : {}),
26
+ /*
27
+ Digests this machine has taken before, so an unchanged file is not read
28
+ again. The desktop has always passed these; the command line never did.
29
+ */
30
+ ...(input.knownDigests ? { knownDigests: input.knownDigests } : {}),
26
31
  };
27
32
  }
28
33
  function classifyPublishFailure(error) {
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ /**
3
+ * Reading the archive CodeRook builds, without a compression library.
4
+ *
5
+ * A clone fetched every file individually: one HTTP round trip per blob, in
6
+ * series. Against a 21,071-file project that is 21,071 round trips, which at
7
+ * seven tenths of a second each is four hours — and the clone that exposed
8
+ * this simply never finished. Fetching eight at a time cut it to about half
9
+ * an hour; asking for the version as one archive cuts it to one request.
10
+ *
11
+ * Written by hand for the same reason `backend/src/archive.ts` writes it by
12
+ * hand: the entries are *stored*, never deflated, so the whole job is a
13
+ * header, a body, and skipping the directory at the end. Pulling in a zip
14
+ * library to not decompress anything would be a poor trade.
15
+ *
16
+ * Only the format that writer produces is accepted. Anything else — a
17
+ * deflated entry, a size deferred to a data descriptor, a Zip64 field — is
18
+ * refused rather than guessed at, and the caller falls back to fetching the
19
+ * files one by one. A reader that quietly mangles a project is worse than a
20
+ * slow one.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.readStoredZip = readStoredZip;
24
+ const LOCAL_HEADER = 0x04034b50;
25
+ const CENTRAL_HEADER = 0x02014b50;
26
+ /* An archive of nothing has no entries and no directory records — it opens
27
+ with this, and a reader that only knew the other two would refuse it. */
28
+ const END_OF_DIRECTORY = 0x06054b50;
29
+ /** Set in the general-purpose flags when sizes come after the body. */
30
+ const SIZES_DEFERRED = 0x0008;
31
+ const STORED = 0;
32
+ /**
33
+ * Every entry in a stored archive, in the order it was written.
34
+ *
35
+ * Deliberately whole-buffer rather than streaming: the caller is holding one
36
+ * version's files to hand to fast-import, which needs them in order anyway,
37
+ * and a version large enough for this to matter is one the archive path
38
+ * should not be taking.
39
+ */
40
+ function readStoredZip(archive) {
41
+ const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength);
42
+ const names = new TextDecoder("utf-8", { fatal: false });
43
+ const entries = [];
44
+ let at = 0;
45
+ while (at + 4 <= archive.length) {
46
+ const signature = view.getUint32(at, true);
47
+ /* Either record marks the end of the entries. */
48
+ if (signature === CENTRAL_HEADER || signature === END_OF_DIRECTORY)
49
+ break;
50
+ if (signature !== LOCAL_HEADER) {
51
+ throw new Error(`Not a stored archive: unexpected record at byte ${at}`);
52
+ }
53
+ if (at + 30 > archive.length)
54
+ throw new Error("Archive ends inside a header");
55
+ const flags = view.getUint16(at + 6, true);
56
+ const method = view.getUint16(at + 8, true);
57
+ const compressed = view.getUint32(at + 18, true);
58
+ const nameLength = view.getUint16(at + 26, true);
59
+ const extraLength = view.getUint16(at + 28, true);
60
+ if (method !== STORED) {
61
+ throw new Error(`Archive entry is compressed (method ${method})`);
62
+ }
63
+ if (flags & SIZES_DEFERRED) {
64
+ throw new Error("Archive entry defers its size to a data descriptor");
65
+ }
66
+ /*
67
+ 0xffffffff is Zip64's marker for "the real size is in an extra field".
68
+ This writer never emits one, so seeing it means the archive did not come
69
+ from where it is assumed to have come from.
70
+ */
71
+ if (compressed === 0xffffffff)
72
+ throw new Error("Archive entry needs Zip64");
73
+ const nameAt = at + 30;
74
+ const bodyAt = nameAt + nameLength + extraLength;
75
+ const end = bodyAt + compressed;
76
+ if (end > archive.length)
77
+ throw new Error("Archive ends inside an entry");
78
+ entries.push({
79
+ path: names.decode(archive.subarray(nameAt, nameAt + nameLength)),
80
+ bytes: archive.subarray(bodyAt, end),
81
+ });
82
+ at = end;
83
+ }
84
+ return entries;
85
+ }
@@ -61,6 +61,23 @@ async function pruneEmpty(root, removed) {
61
61
  directory = node_path_1.default.dirname(directory);
62
62
  }
63
63
  }
64
+ /**
65
+ * How long one request may go unanswered before it is treated as lost.
66
+ *
67
+ * The same three minutes the uploader uses, and for the same reason: what
68
+ * follows a deadline here is a retry, so the cost of it firing early on a
69
+ * genuinely slow call is one repeated request.
70
+ */
71
+ const REQUEST_DEADLINE_MS = 180_000;
72
+ /**
73
+ * And how long a whole-version archive may take to arrive.
74
+ *
75
+ * The service reads every file in the version out of object storage to build
76
+ * it, so the wait is proportional to the project rather than to the network.
77
+ * Fifteen minutes is far beyond any archive measured and still an answer
78
+ * rather than an indefinite wait.
79
+ */
80
+ const ARCHIVE_DEADLINE_MS = 900_000;
64
81
  /**
65
82
  * A percentage for work that is still going.
66
83
  *
@@ -127,7 +144,18 @@ class Downloader {
127
144
  }
128
145
  }
129
146
  }
130
- async attempt(route) {
147
+ async attempt(route,
148
+ /*
149
+ Longer where the service has to build the answer before it can send it.
150
+
151
+ A deadline sized for an ordinary request aborts a large archive halfway
152
+ through making it, and the git import quietly falls back to fetching
153
+ every file on its own — so the fix for a slow clone became the cause of
154
+ one. The archive for a 21,071-file version takes about ninety seconds
155
+ to assemble and longer under load; three minutes is a request that has
156
+ failed, not one that is still working.
157
+ */
158
+ deadline = REQUEST_DEADLINE_MS) {
131
159
  const token = await this.credentials.token();
132
160
  if (!token)
133
161
  throw new Error("Sign in again before downloading");
@@ -137,7 +165,19 @@ class Downloader {
137
165
  "user-agent": "CodeRook/0.1",
138
166
  ...(0, identify_js_1.clientHeaders)(),
139
167
  },
140
- signal: this.controller.signal,
168
+ /*
169
+ The download's own cancellation, and a deadline.
170
+
171
+ This carried only the cancel signal, so a request the service never
172
+ answered waited for ever — the same defect that was fixed in the
173
+ uploader and in the command line's own client, and missed here. A
174
+ restore that stops with no bytes moving and no message is the worse
175
+ half of it: the person is left without the thing they were restoring.
176
+ */
177
+ signal: AbortSignal.any([
178
+ this.controller.signal,
179
+ AbortSignal.timeout(deadline),
180
+ ]),
141
181
  });
142
182
  if (!response.ok) {
143
183
  const text = await response.text().catch(() => "");
@@ -217,6 +257,22 @@ class Downloader {
217
257
  authorName: String(row.authorName ?? ""),
218
258
  }));
219
259
  }
260
+ /**
261
+ * A whole version as one archive, for a caller that wants all of it.
262
+ *
263
+ * The git import asked for each file in its own request, which on a
264
+ * 21,071-file project is 21,071 round trips — four hours in series, and
265
+ * about half an hour even eight at a time. The service already builds the
266
+ * whole version as a single stored archive for the download button; this
267
+ * is that, for a caller that would otherwise ask file by file.
268
+ *
269
+ * Returned whole rather than streamed because the caller is handing the
270
+ * files to `git fast-import`, which wants them one after another anyway.
271
+ */
272
+ async archive(repositoryId, versionId) {
273
+ const response = await this.attempt(`/v1/repositories/${repositoryId}/versions/${versionId}/archive`, ARCHIVE_DEADLINE_MS);
274
+ return new Uint8Array(await response.arrayBuffer());
275
+ }
220
276
  async files(repositoryId, versionId) {
221
277
  const body = await this.json(`/v1/repositories/${repositoryId}/versions/${versionId}/files`);
222
278
  return (body.files ?? []).map((row) => {
@@ -145,6 +145,20 @@ class Uploader {
145
145
  /** Whether this save has already said that packing was refused. */
146
146
  packRefusalSaid = false;
147
147
  plannedDigests = new Map();
148
+ /**
149
+ * Every digest this save actually took, for a caller that keeps a record.
150
+ *
151
+ * The desktop keeps one and hands it back through `knownDigests`, which is
152
+ * what stops an unchanged file being read twice. The command line had no
153
+ * way to keep one because this was private, so it re-read and re-hashed
154
+ * whole projects on every save.
155
+ *
156
+ * Only files that were read are in here — an entry served from the caller's
157
+ * own record was never taken and does not need writing down again.
158
+ */
159
+ digestsTaken() {
160
+ return new Map(this.plannedDigests);
161
+ }
148
162
  planningCreatedRepositoryId = null;
149
163
  /** Attached to every object write after the server admits the exact plan. */
150
164
  activeQuoteId = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.30.2",
3
+ "version": "0.31.0",
4
4
  "description": "CodeRook from the command line, on any operating system",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "homepage": "https://coderook.com",