@coderook/cli 0.34.8 → 0.34.9

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.8",
5
+ "version": "0.34.9",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -113,6 +113,24 @@ class UploadCancelled extends Error {
113
113
  }
114
114
  }
115
115
  exports.UploadCancelled = UploadCancelled;
116
+ /*
117
+ Version listings already read by this process, by repository and version.
118
+
119
+ A save read the version it started from twice — once to plan, once to
120
+ send — and then read the version it had just made, each time the whole
121
+ list: three downloads of 6,628 rows to change one line of one file. A
122
+ version never changes once it exists, so a listing read once is the
123
+ listing for good.
124
+ */
125
+ const versionListings = new Map();
126
+ /*
127
+ Delta signatures, for the same reason: one describes a file of a version
128
+ that will never change, and the planning pass and the sending pass each
129
+ asked for it.
130
+ */
131
+ const signatures = new Map();
132
+ /** How many version listings a folder keeps in `.coderook/versions`. */
133
+ const KEPT_LISTINGS = 3;
116
134
  class Uploader {
117
135
  credentials;
118
136
  aborted = false;
@@ -127,6 +145,7 @@ class Uploader {
127
145
  deltaProtocol = null;
128
146
  /** Batch delta is separate so V2 single-file support remains compatible. */
129
147
  deltaBatchSupported = null;
148
+ versionDeltaSupported = null;
130
149
  microchunkSupported = null;
131
150
  /** One shared preflight, so parallel upload lanes cannot race /health. */
132
151
  capabilityRequest = null;
@@ -524,7 +543,7 @@ class Uploader {
524
543
  if (!ticked.size)
525
544
  throw new Error("Nothing is selected to upload");
526
545
  const prior = request.repositoryId
527
- ? await this.versionFiles(request.repositoryId, request.baseVersionId)
546
+ ? await this.versionFiles(request.repositoryId, request.baseVersionId, request.localPath)
528
547
  : new Map();
529
548
  // Ticked files are sent; everything else the project still has keeps the
530
549
  // copy already stored, and a file that is new but unticked is left out.
@@ -1013,7 +1032,56 @@ class Uploader {
1013
1032
  : null;
1014
1033
  const linesAdded = counted ? counted.added : request.linesAdded;
1015
1034
  const linesRemoved = counted ? counted.removed : request.linesRemoved;
1016
- const completed = await (0, profile_js_1.timed)("publish the version", () => this.call(`/v1/repositories/${repositoryId}/versions`, {
1035
+ /*
1036
+ The version's whole file list, as the service will hold it.
1037
+ */
1038
+ const wire = contents.map((item) => ({
1039
+ path: item.path,
1040
+ ...(item.pack
1041
+ ? {
1042
+ pack: {
1043
+ objectId: item.pack.packObjectId,
1044
+ offset: item.pack.offset,
1045
+ length: item.pack.length,
1046
+ },
1047
+ sha256: item.sha256,
1048
+ }
1049
+ : item.chunks
1050
+ ? { chunks: item.chunks, sha256: item.sha256 }
1051
+ : { objectId: item.objectId }),
1052
+ sourceSize: item.sourceSize,
1053
+ storedSize: item.storedSize,
1054
+ mediaType: item.mediaType,
1055
+ executable: item.executable === true,
1056
+ }));
1057
+ /*
1058
+ Or only what changed (version-delta-v1).
1059
+
1060
+ The list above names every file in the project, so a save of one line
1061
+ in a 6,628-file project sent 2.3 MB describing 6,627 files the service
1062
+ already held exactly that way. When the service can take it, the save
1063
+ names the version it started from, the paths that went, and only the
1064
+ entries that differ from that version; the service fills in the rest
1065
+ from its own record before it checks anything.
1066
+ */
1067
+ const startedFrom = typeof request.baseVersionId === "string" &&
1068
+ prior.size &&
1069
+ (await this.versionDeltaAllowed())
1070
+ ? request.baseVersionId
1071
+ : null;
1072
+ const changesOnly = startedFrom
1073
+ ? {
1074
+ files: wire.filter((entry) => {
1075
+ const before = prior.get(entry.path);
1076
+ return !before || !sameEntry(entry, wireFromPrior(entry.path, before));
1077
+ }),
1078
+ filesFrom: {
1079
+ versionId: startedFrom,
1080
+ removed: [...prior.keys()].filter((held) => !named.has(held)),
1081
+ },
1082
+ }
1083
+ : null;
1084
+ const send = (asChanges) => (0, profile_js_1.timed)("publish the version", () => this.call(`/v1/repositories/${repositoryId}/versions`, {
1017
1085
  method: "POST",
1018
1086
  contentType: "application/json",
1019
1087
  body: JSON.stringify({
@@ -1057,27 +1125,21 @@ class Uploader {
1057
1125
  One shape or the other, never both — the service refuses a file that
1058
1126
  names an object and a chunk list, and so does its database.
1059
1127
  */
1060
- files: contents.map((item) => ({
1061
- path: item.path,
1062
- ...(item.pack
1063
- ? {
1064
- pack: {
1065
- objectId: item.pack.packObjectId,
1066
- offset: item.pack.offset,
1067
- length: item.pack.length,
1068
- },
1069
- sha256: item.sha256,
1070
- }
1071
- : item.chunks
1072
- ? { chunks: item.chunks, sha256: item.sha256 }
1073
- : { objectId: item.objectId }),
1074
- sourceSize: item.sourceSize,
1075
- storedSize: item.storedSize,
1076
- mediaType: item.mediaType,
1077
- executable: item.executable === true,
1078
- })),
1128
+ ...(asChanges && changesOnly ? changesOnly : { files: wire }),
1079
1129
  }),
1080
1130
  }));
1131
+ /*
1132
+ A service that cannot build on the version named (it was taken down,
1133
+ say) answers files_from_*; the whole list is then sent instead, which
1134
+ every service accepts.
1135
+ */
1136
+ const completed = await send(true).catch((error) => {
1137
+ const code = error.code;
1138
+ if (changesOnly && typeof code === "string" && code.startsWith("files_from_")) {
1139
+ return send(false);
1140
+ }
1141
+ throw error;
1142
+ });
1081
1143
  /*
1082
1144
  A clean stale-base save may contain a server-side merge. The candidate
1083
1145
  manifest built above is then not the Version that landed: it lacks the
@@ -1086,8 +1148,21 @@ class Uploader {
1086
1148
  Version once rather than guessing.
1087
1149
  */
1088
1150
  let acceptedManifest = (0, publication_js_1.acceptedManifestDigests)(completed.manifest?.files);
1089
- if (!completed.mergeTrack && !Object.keys(acceptedManifest).length) {
1090
- acceptedManifest = Object.fromEntries([...(await this.versionFiles(repositoryId, completed.version.id))].map(([file, descriptor]) => [file, descriptor.sha256]));
1151
+ /*
1152
+ A service that lists version-delta-v1 also says when it combined this
1153
+ save with somebody else's (`combined`). Without that, and for a repeated
1154
+ attempt, the version that landed may not be the list sent here, so it is
1155
+ read back. Otherwise it is exactly that list, and reading 6,628 rows to
1156
+ learn what was just sent is the third download this used to cost.
1157
+ */
1158
+ const landedAsSent = !completed.mergeTrack &&
1159
+ !completed.repeated &&
1160
+ !completed.combined &&
1161
+ /* A key published on purpose is stored as its covered copy, not as sent. */
1162
+ !request.allowSecrets &&
1163
+ (await this.versionDeltaAllowed());
1164
+ if (!completed.mergeTrack && !landedAsSent && !Object.keys(acceptedManifest).length) {
1165
+ acceptedManifest = Object.fromEntries([...(await this.versionFiles(repositoryId, completed.version.id, request.localPath))].map(([file, descriptor]) => [file, descriptor.sha256]));
1091
1166
  }
1092
1167
  const manifest = Object.keys(acceptedManifest).length
1093
1168
  ? acceptedManifest
@@ -1146,6 +1221,19 @@ class Uploader {
1146
1221
  if (held)
1147
1222
  local[path] = held;
1148
1223
  }
1224
+ /*
1225
+ The next save starts from this version, so its listing is kept in the
1226
+ folder now, from the list just sent — every entry with its digest — and
1227
+ that save does not have to download it.
1228
+ */
1229
+ if (landedAsSent && request.localPath) {
1230
+ const rows = wire.map((entry) => ({ ...entry, sha256: manifest[entry.path] }));
1231
+ if (rows.every((row) => typeof row.sha256 === "string" && row.sha256.length === 64)) {
1232
+ const key = `${repositoryId}/${completed.version.id}`;
1233
+ versionListings.set(key, Promise.resolve(rows));
1234
+ await writeListing(request.localPath, repositoryId, completed.version.id, rows);
1235
+ }
1236
+ }
1149
1237
  report({
1150
1238
  stage: "done",
1151
1239
  files: declarations.length,
@@ -2000,7 +2088,9 @@ class Uploader {
2000
2088
  });
2001
2089
  }
2002
2090
  /** What the newest version holds, so unchanged files can point at it. */
2003
- async versionFiles(repositoryId, versionId) {
2091
+ async versionFiles(repositoryId, versionId,
2092
+ /** The project folder, whose `.coderook/versions` keeps listings between runs. */
2093
+ localPath) {
2004
2094
  const held = new Map();
2005
2095
  try {
2006
2096
  // Explicit null is a genuinely empty base. Undefined belongs only to
@@ -2014,10 +2104,8 @@ class Uploader {
2014
2104
  }
2015
2105
  if (!wanted)
2016
2106
  return held;
2017
- const body = await this.call(`/v1/repositories/${repositoryId}/versions/${wanted}/files`, {
2018
- method: "GET",
2019
- });
2020
- for (const row of body.files ?? []) {
2107
+ const rows = await this.listing(repositoryId, wanted, localPath);
2108
+ for (const row of rows) {
2021
2109
  const pieces = Array.isArray(row.chunks)
2022
2110
  ? row.chunks.map((chunk) => ({
2023
2111
  objectId: String(chunk.objectId ?? ""),
@@ -2052,6 +2140,37 @@ class Uploader {
2052
2140
  }
2053
2141
  return held;
2054
2142
  }
2143
+ /**
2144
+ * A version's files, as the service lists them: from this process's memo,
2145
+ * then the folder's own cache, and only then from the service.
2146
+ *
2147
+ * The folder cache is what makes the next save cheap: the version a save
2148
+ * starts from is nearly always the one the previous save made, and that
2149
+ * one is written here by the save that made it (rememberListing).
2150
+ */
2151
+ async listing(repositoryId, versionId, localPath) {
2152
+ const key = `${repositoryId}/${versionId}`;
2153
+ const known = versionListings.get(key);
2154
+ if (known)
2155
+ return known;
2156
+ const reading = (async () => {
2157
+ const cached = localPath
2158
+ ? await readListing(localPath, repositoryId, versionId)
2159
+ : null;
2160
+ if (cached)
2161
+ return cached;
2162
+ const body = await this.call(`/v1/repositories/${repositoryId}/versions/${versionId}/files`, {
2163
+ method: "GET",
2164
+ });
2165
+ const rows = body.files ?? [];
2166
+ if (localPath)
2167
+ await writeListing(localPath, repositoryId, versionId, rows);
2168
+ return rows;
2169
+ })();
2170
+ versionListings.set(key, reading);
2171
+ reading.catch(() => versionListings.delete(key));
2172
+ return reading;
2173
+ }
2055
2174
  /** The account's repository with this slug, if it has one. */
2056
2175
  async findBySlug(slug) {
2057
2176
  try {
@@ -2372,7 +2491,17 @@ class Uploader {
2372
2491
  return null;
2373
2492
  }
2374
2493
  }
2375
- async deltaSignature(repositoryId, baseVersionId, filePath, baseSha256, version) {
2494
+ deltaSignature(repositoryId, baseVersionId, filePath, baseSha256, version) {
2495
+ const key = [repositoryId, baseVersionId, filePath, baseSha256, version].join("\u0000");
2496
+ const known = signatures.get(key);
2497
+ if (known)
2498
+ return known;
2499
+ const asking = this.fetchDeltaSignature(repositoryId, baseVersionId, filePath, baseSha256, version);
2500
+ signatures.set(key, asking);
2501
+ asking.catch(() => signatures.delete(key));
2502
+ return asking;
2503
+ }
2504
+ async fetchDeltaSignature(repositoryId, baseVersionId, filePath, baseSha256, version) {
2376
2505
  const token = await this.credentials.token();
2377
2506
  if (!token)
2378
2507
  throw new Error("Sign in again before uploading");
@@ -2841,6 +2970,20 @@ class Uploader {
2841
2970
  : 0;
2842
2971
  return this.deltaProtocol;
2843
2972
  }
2973
+ /**
2974
+ * Whether a save may name only what changed (`filesFrom`).
2975
+ *
2976
+ * Asked, never assumed, and for a stronger reason than the others: a
2977
+ * service that does not know the field would drop it and publish the
2978
+ * changed files alone as the whole project. Only a service that lists
2979
+ * `version-delta-v1` gets anything but the full list.
2980
+ */
2981
+ async versionDeltaAllowed() {
2982
+ if (this.versionDeltaSupported !== null)
2983
+ return this.versionDeltaSupported;
2984
+ this.versionDeltaSupported = (await this.serviceFeatures()).includes("version-delta-v1");
2985
+ return this.versionDeltaSupported;
2986
+ }
2844
2987
  async deltaBatchAllowed() {
2845
2988
  if (this.deltaBatchSupported !== null)
2846
2989
  return this.deltaBatchSupported;
@@ -2975,3 +3118,84 @@ async function executableFor(request, relative, before) {
2975
3118
  return before;
2976
3119
  }
2977
3120
  }
3121
+ /*
3122
+ The folder's cache of version listings: `.coderook/versions/<id>.json`.
3123
+
3124
+ `.coderook` is never uploaded and ignores itself, so this never reaches a
3125
+ project. A version never changes, so an entry is either right or for a
3126
+ different version; it is checked against both names it was written under,
3127
+ and anything unreadable is simply not used.
3128
+ */
3129
+ async function readListing(localPath, repositoryId, versionId) {
3130
+ try {
3131
+ const kept = JSON.parse(await (0, promises_1.readFile)(node_path_1.default.join(localPath, ".coderook", "versions", `${versionId}.json`), "utf8"));
3132
+ if (kept.repositoryId !== repositoryId ||
3133
+ kept.versionId !== versionId ||
3134
+ !Array.isArray(kept.files)) {
3135
+ return null;
3136
+ }
3137
+ return kept.files;
3138
+ }
3139
+ catch {
3140
+ return null;
3141
+ }
3142
+ }
3143
+ async function writeListing(localPath, repositoryId, versionId, files) {
3144
+ const folder = node_path_1.default.join(localPath, ".coderook", "versions");
3145
+ try {
3146
+ await (0, promises_1.mkdir)(folder, { recursive: true });
3147
+ await (0, promises_1.writeFile)(node_path_1.default.join(folder, `${versionId}.json`), JSON.stringify({ repositoryId, versionId, files }));
3148
+ /* Only the newest few: the next save needs one, a retry another. */
3149
+ const entries = await Promise.all((await (0, promises_1.readdir)(folder))
3150
+ .filter((name) => name.endsWith(".json"))
3151
+ .map(async (name) => ({
3152
+ name,
3153
+ at: (await (0, promises_1.stat)(node_path_1.default.join(folder, name))).mtimeMs,
3154
+ })));
3155
+ entries.sort((left, right) => right.at - left.at);
3156
+ for (const old of entries.slice(KEPT_LISTINGS)) {
3157
+ await (0, promises_1.rm)(node_path_1.default.join(folder, old.name), { force: true });
3158
+ }
3159
+ }
3160
+ catch {
3161
+ /* A cache that cannot be written costs one download next time, nothing more. */
3162
+ }
3163
+ }
3164
+ /** A file the previous version holds, in the shape a save names it. */
3165
+ function wireFromPrior(filePath, held) {
3166
+ return {
3167
+ path: filePath,
3168
+ ...(held.pack
3169
+ ? {
3170
+ pack: {
3171
+ objectId: held.pack.objectId,
3172
+ offset: held.pack.offset,
3173
+ length: held.pack.length,
3174
+ },
3175
+ sha256: held.sha256,
3176
+ }
3177
+ : held.chunks?.length
3178
+ ? { chunks: held.chunks, sha256: held.sha256 }
3179
+ : { objectId: held.objectId }),
3180
+ sourceSize: held.sourceSize,
3181
+ storedSize: held.storedSize,
3182
+ mediaType: held.mediaType,
3183
+ executable: held.executable === true,
3184
+ };
3185
+ }
3186
+ /** Whether two entries name the same file stored the same way. */
3187
+ function sameEntry(left, right) {
3188
+ return canonical(left) === canonical(right);
3189
+ }
3190
+ function canonical(value) {
3191
+ if (Array.isArray(value))
3192
+ return `[${value.map(canonical).join(",")}]`;
3193
+ if (value && typeof value === "object") {
3194
+ return `{${Object.keys(value)
3195
+ .filter((key) => value[key] !== undefined)
3196
+ .sort()
3197
+ .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
3198
+ .join(",")}}`;
3199
+ }
3200
+ return JSON.stringify(value);
3201
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.34.8",
3
+ "version": "0.34.9",
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",