@coderook/cli 0.34.5 → 0.34.6

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.5",
5
+ "version": "0.34.6",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -21,6 +21,7 @@ exports.LICENCE_FILE = exports.DEFAULT_LICENCE = exports.LICENCES = void 0;
21
21
  exports.licenceById = licenceById;
22
22
  exports.licenceText = licenceText;
23
23
  exports.detectLicence = detectLicence;
24
+ exports.existingLicence = existingLicence;
24
25
  exports.licenceNewProject = licenceNewProject;
25
26
  const promises_1 = require("node:fs/promises");
26
27
  const node_path_1 = __importDefault(require("node:path"));
@@ -281,6 +282,23 @@ function detectLicence(text) {
281
282
  *
282
283
  * Returns the licence written, or null when nothing was.
283
284
  */
285
+ /*
286
+ The names a licence goes by, as a file or as a folder of them.
287
+
288
+ Only the exact name LICENSE was looked for, which missed nearly every way a
289
+ project actually carries its licence — LICENSE.md, LICENCE.txt, COPYING,
290
+ the LICENSE-MIT and LICENSE-APACHE pair, a licenses folder. Found by saving
291
+ a copy of Blender: its licences live in a folder called `license`, which on
292
+ Windows answers to "LICENSE" as well, so the check opened a directory and
293
+ the save failed. Had the folder been named anything else, the save would
294
+ have put an MIT licence beside Blender's GPL one.
295
+ */
296
+ const LICENCE_NAME = /^(licen[cs]es?|copying|copyright|unlicen[cs]e)([-._ ].*)?$/i;
297
+ /** The licence file or folder this folder already has, if any. */
298
+ async function existingLicence(folder) {
299
+ const names = await (0, promises_1.readdir)(folder).catch(() => []);
300
+ return names.find((name) => LICENCE_NAME.test(name)) ?? null;
301
+ }
284
302
  async function licenceNewProject(folder, holder, skip = false) {
285
303
  /*
286
304
  No name, no licence. A copyright line naming nobody is worse than the
@@ -289,10 +307,19 @@ async function licenceNewProject(folder, holder, skip = false) {
289
307
  */
290
308
  if (skip || !holder.trim())
291
309
  return null;
310
+ /* Any licence at all is somebody's decision, whatever it is called. */
311
+ if (await existingLicence(folder))
312
+ return null;
292
313
  const target = node_path_1.default.join(folder, exports.LICENCE_FILE);
293
- const already = await (0, promises_1.readFile)(target, "utf8").catch(() => null);
294
- if (already !== null)
314
+ /*
315
+ A nudge, never a reason to fail. A folder that cannot be written to is
316
+ saved without one, exactly as it would have been before this existed.
317
+ */
318
+ try {
319
+ await (0, promises_1.writeFile)(target, licenceText(exports.DEFAULT_LICENCE, holder.trim()), { encoding: "utf8", flag: "wx" });
320
+ }
321
+ catch {
295
322
  return null;
296
- await (0, promises_1.writeFile)(target, licenceText(exports.DEFAULT_LICENCE, holder.trim()), "utf8");
323
+ }
297
324
  return exports.DEFAULT_LICENCE;
298
325
  }
@@ -932,6 +932,11 @@ class Uploader {
932
932
  objects are immutable and content-addressed, so a retry reuses them.
933
933
  */
934
934
  (0, faults_js_1.maybeFail)("submit:after-objects");
935
+ const counted = request.countLines
936
+ ? await (0, profile_js_1.timed)("count the changed lines", () => request.countLines()).catch(() => null)
937
+ : null;
938
+ const linesAdded = counted ? counted.added : request.linesAdded;
939
+ const linesRemoved = counted ? counted.removed : request.linesRemoved;
935
940
  const completed = await (0, profile_js_1.timed)("publish the version", () => this.call(`/v1/repositories/${repositoryId}/versions`, {
936
941
  method: "POST",
937
942
  contentType: "application/json",
@@ -950,10 +955,8 @@ class Uploader {
950
955
  ...(request.allowIgnored ? { allowIgnored: true } : {}),
951
956
  ...(this.trackedByGit.length ? { trackedByGit: this.trackedByGit } : {}),
952
957
  ...(request.allowSecrets ? { allowSecrets: true } : {}),
953
- ...(request.linesAdded === undefined ? {} : { linesAdded: request.linesAdded }),
954
- ...(request.linesRemoved === undefined
955
- ? {}
956
- : { linesRemoved: request.linesRemoved }),
958
+ ...(linesAdded === undefined ? {} : { linesAdded }),
959
+ ...(linesRemoved === undefined ? {} : { linesRemoved }),
957
960
  /*
958
961
  Names this attempt so a retry after a lost connection is answered
959
962
  with the version already made, rather than making a second one.
@@ -1155,6 +1158,22 @@ class Uploader {
1155
1158
  question rather than a transfer.
1156
1159
  */
1157
1160
  const inFlightByDigest = new Map();
1161
+ /*
1162
+ What the account already holds, asked once for the whole save.
1163
+
1164
+ Asked per section, the answer changed under the save's feet. The plan
1165
+ stores nothing, so every section of the plan heard the same answer; the
1166
+ send stores as it goes and runs sections side by side, so a later
1167
+ section holding a copy of a file an earlier one had just sent heard
1168
+ "already held" and left it out of its pack. The pack's bytes, and so its
1169
+ digest, were then not the pack the plan had priced, and the service
1170
+ refused it as "a file that was not part of what it asked to send". A
1171
+ copy of Blender 4.5 — 326 contents present more than once — failed this
1172
+ way every time. One answer, taken before anything is sent, is the answer
1173
+ the plan was given.
1174
+ */
1175
+ let heldOnce = null;
1176
+ const heldAtStart = () => (heldOnce ??= (0, profile_js_1.timed)("ask what is already held", () => this.storedInBulk(repositoryId, everyDeclaration.map((one) => one.sha256))));
1158
1177
  const runSection = async (section) => {
1159
1178
  const declarations = section.files.map((file) => declarationByPath.get(file.path));
1160
1179
  /*
@@ -1214,7 +1233,7 @@ class Uploader {
1214
1233
  asking took longer than the transfer. The answer per digest is a boolean
1215
1234
  and an identifier, so the whole question fits in one request.
1216
1235
  */
1217
- const alreadyHeld = await (0, profile_js_1.timed)("ask what is already held", () => this.storedInBulk(repositoryId, declarations.map((one) => one.sha256)));
1236
+ const alreadyHeld = await heldAtStart();
1218
1237
  /*
1219
1238
  The small files go first, packed together.
1220
1239
 
@@ -33,6 +33,7 @@ exports.detectPrivateDirectories = detectPrivateDirectories;
33
33
  exports.detectPastedCredentials = detectPastedCredentials;
34
34
  exports.uploadConcerns = uploadConcerns;
35
35
  exports.isCredentialByName = isCredentialByName;
36
+ exports.holdsOnlyCertificates = holdsOnlyCertificates;
36
37
  exports.detectSecrets = detectSecrets;
37
38
  /** Reading a project folder: changed files, diffs, and rule measurement. */
38
39
  const node_child_process_1 = require("node:child_process");
@@ -1737,6 +1738,13 @@ onScanned) {
1737
1738
  const wanted = include.filter((relative) => (0, secret_patterns_js_1.worthReading)(relative.slice(relative.lastIndexOf("/") + 1)));
1738
1739
  let cursor = 0;
1739
1740
  let stop = false;
1741
+ /*
1742
+ Every file looked at, read or passed over for its size. Reporting only
1743
+ those read left the count short of its own total: a refused save of
1744
+ Blender stopped on "3700 of 3747 files", which reads as a scan that gave
1745
+ up rather than one that finished.
1746
+ */
1747
+ let looked = 0;
1740
1748
  const lane = async () => {
1741
1749
  for (;;) {
1742
1750
  if (stop)
@@ -1745,6 +1753,9 @@ onScanned) {
1745
1753
  cursor += 1;
1746
1754
  if (at >= wanted.length)
1747
1755
  return;
1756
+ looked += 1;
1757
+ if (onScanned && looked % 50 === 0)
1758
+ onScanned(looked, wanted.length);
1748
1759
  if (found.length >= MOST_FINDINGS) {
1749
1760
  stop = true;
1750
1761
  return;
@@ -1768,8 +1779,6 @@ onScanned) {
1768
1779
  }
1769
1780
  read += 1;
1770
1781
  bytes += contents.byteLength;
1771
- if (onScanned && read % 50 === 0)
1772
- onScanned(read, wanted.length);
1773
1782
  if (!(0, secret_patterns_js_1.looksLikeText)(contents))
1774
1783
  continue;
1775
1784
  const credentials = (0, secret_patterns_js_1.findCredentials)(contents.toString("utf8"));
@@ -1778,6 +1787,8 @@ onScanned) {
1778
1787
  }
1779
1788
  };
1780
1789
  await Promise.all(Array.from({ length: Math.min(LANES, wanted.length) }, () => lane()));
1790
+ if (onScanned && wanted.length)
1791
+ onScanned(looked, wanted.length);
1781
1792
  /*
1782
1793
  `stop` is also set by the findings ceiling, which is a different statement
1783
1794
  from running out of budget — forty findings is plenty to act on, and the
@@ -1847,6 +1858,38 @@ function isCredentialByName(relativePath) {
1847
1858
  SECRET_SUFFIXES.some((suffix) => name.endsWith(suffix)) ||
1848
1859
  parts.slice(0, -1).includes("secrets"));
1849
1860
  }
1861
+ /*
1862
+ A `.pem` is a container, not a kind of file.
1863
+
1864
+ It holds a private key as readily as a certificate, and the name cannot
1865
+ tell the two apart — so every one was refused. That refused Blender: its
1866
+ bundled Python carries `certifi/cacert.pem` twice, Mozilla's list of the
1867
+ certificate authorities a program should trust, which ships inside every
1868
+ pip there is and is public by design. A certificate is the half of a key
1869
+ pair that exists to be handed out.
1870
+
1871
+ So a `.pem` is opened and judged by its blocks. Only certificates (and
1872
+ revocation lists) clear it; a private key anywhere in it, a block of any
1873
+ other kind, no blocks at all, or a file too large or unreadable to look
1874
+ at all leave it a credential, as before.
1875
+ */
1876
+ const CERTIFICATE_BUNDLE_LIMIT = 4 * 1024 * 1024;
1877
+ async function holdsOnlyCertificates(full) {
1878
+ try {
1879
+ const info = await (0, promises_1.stat)(full);
1880
+ if (info.size > CERTIFICATE_BUNDLE_LIMIT)
1881
+ return false;
1882
+ const text = await (0, promises_1.readFile)(full, "latin1");
1883
+ if (/PRIVATE KEY/i.test(text))
1884
+ return false;
1885
+ const blocks = text.match(/-----BEGIN [A-Z0-9 ]+-----/g) ?? [];
1886
+ return (blocks.length > 0 &&
1887
+ blocks.every((block) => /^-----BEGIN (TRUSTED |X509 )?(CERTIFICATE|CRL)-----$/.test(block)));
1888
+ }
1889
+ catch {
1890
+ return false;
1891
+ }
1892
+ }
1850
1893
  async function detectSecrets(root) {
1851
1894
  const found = [];
1852
1895
  let seen = 0;
@@ -1896,8 +1939,12 @@ async function detectSecrets(root) {
1896
1939
  that templates are not credentials.
1897
1940
  */
1898
1941
  const relative = node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
1899
- if (isCredentialByName(relative))
1900
- found.push(relative);
1942
+ if (!isCredentialByName(relative))
1943
+ continue;
1944
+ if (entry.name.toLowerCase().endsWith(".pem") && (await holdsOnlyCertificates(full))) {
1945
+ continue;
1946
+ }
1947
+ found.push(relative);
1901
1948
  }
1902
1949
  }
1903
1950
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.34.5",
3
+ "version": "0.34.6",
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",