@coderook/cli 0.34.5 → 0.34.7

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.7",
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
  }
@@ -180,6 +180,8 @@ class Uploader {
180
180
  return new Map(this.plannedDigests);
181
181
  }
182
182
  planningCreatedRepositoryId = null;
183
+ /** Set once the send has begun to move objects; see execute. */
184
+ sendingBegan = false;
183
185
  /** Attached to every object write after the server admits the exact plan. */
184
186
  activeQuoteId = null;
185
187
  constructor(credentials) {
@@ -409,9 +411,22 @@ class Uploader {
409
411
  /** Execute a still-active quote produced by {@link plan}. */
410
412
  async execute(request, plan, report) {
411
413
  this.activeQuoteId = plan.quoteId;
414
+ this.sendingBegan = false;
412
415
  try {
413
416
  return await this.runPass({ ...request, repositoryId: plan.repositoryId }, report);
414
417
  }
418
+ catch (error) {
419
+ /*
420
+ A first save that stops before a single object has moved leaves no
421
+ project behind. The plan created it, and it used to stay — empty, at
422
+ v0 — after a refusal that had just said nothing was changed. Once
423
+ objects have gone up it stays, because a retry reuses them.
424
+ */
425
+ if (plan.repositoryCreated && !this.sendingBegan) {
426
+ await this.cancelPlan(plan, true).catch(() => undefined);
427
+ }
428
+ throw error;
429
+ }
415
430
  finally {
416
431
  this.activeQuoteId = null;
417
432
  }
@@ -609,6 +624,17 @@ class Uploader {
609
624
  const HASH_LANES = 8;
610
625
  const measured = new Array(sending.length).fill(null);
611
626
  let hashed = 0;
627
+ /*
628
+ Files whose bytes are no longer the bytes the plan priced.
629
+
630
+ The service holds a save to what its plan named, so a file written to
631
+ between the plan and the send went up as an object nobody had asked
632
+ for and was refused as "a file that was not part of what it asked to
633
+ send", with advice to update the app. Found by saving a folder while a
634
+ program appended to a text file in it. Caught here instead, before
635
+ anything is sent, and named for what it is.
636
+ */
637
+ const changedSincePlan = [];
612
638
  let cursor = 0;
613
639
  /** Throttled: twenty thousand sends is its own cost, and nobody reads them. */
614
640
  let told = 0;
@@ -691,6 +717,7 @@ class Uploader {
691
717
  and a match there means the digest already taken still describes the
692
718
  bytes. Reading is what costs; deciding not to read is free.
693
719
  */
720
+ const fromPlan = this.planning ? undefined : this.plannedDigests.get(file.path);
694
721
  const remembered = this.plannedDigests.get(file.path) ??
695
722
  request.knownDigests?.get(file.path);
696
723
  if (remembered &&
@@ -722,6 +749,8 @@ class Uploader {
722
749
  hashed += 1;
723
750
  continue;
724
751
  }
752
+ if (fromPlan && fromPlan.sha256 !== digest)
753
+ changedSincePlan.push(file.path);
725
754
  this.plannedDigests.set(file.path, { size, mtimeMs, sha256: digest });
726
755
  measured[index] = {
727
756
  path: file.path,
@@ -752,6 +781,16 @@ class Uploader {
752
781
  if (!declarations.length && !deletions.size) {
753
782
  throw new Error("Nothing is selected to upload");
754
783
  }
784
+ if (changedSincePlan.length) {
785
+ const one = changedSincePlan.length === 1;
786
+ const shown = changedSincePlan.slice(0, 5).join(", ");
787
+ const rest = changedSincePlan.length > 5 ? ` and ${changedSincePlan.length - 5} more` : "";
788
+ throw new Error(`${changedSincePlan.length} file${one ? "" : "s"} changed while the save was running, ` +
789
+ `so nothing was sent: ${shown}${rest}. Something is still writing to ${one ? "it" : "them"}. ` +
790
+ `Save again once ${one ? "it has" : "they have"} stopped changing, or leave ${one ? "it" : "them"} ` +
791
+ `out — untick ${one ? "it" : "them"} in the app, or add ${one ? "it" : "them"} to .gitignore. ` +
792
+ `Nothing was changed on your account.`);
793
+ }
755
794
  return { declarations, totalBytes };
756
795
  }
757
796
  /**
@@ -809,6 +848,66 @@ class Uploader {
809
848
  * that did not arrive, while past here there is a version. Keeping the
810
849
  * boundary a function call rather than a comment makes that legible.
811
850
  */
851
+ /**
852
+ * Refuse a save that would leave out files somebody chose, naming why.
853
+ *
854
+ * Called as soon as the reading is done as well as before the version is
855
+ * named. Only the second existed, after the plan had already created the
856
+ * project, so a first save refused for one locked file left an empty
857
+ * project behind while saying "Nothing was changed on your account".
858
+ */
859
+ refuseUnreadable(dropped, readFailures) {
860
+ const shown = dropped.slice(0, 5).join(", ");
861
+ const rest = dropped.length > 5 ? ` and ${dropped.length - 5} more` : "";
862
+ const details = dropped
863
+ .map((name) => readFailures.get(name) ? `${name}: ${readFailures.get(name)}` : "")
864
+ .filter(Boolean)
865
+ .slice(0, 3)
866
+ .join("; ");
867
+ /*
868
+ Say what to do about it. Refusing is right — a version quietly missing
869
+ a file somebody chose is the one failure a backup tool must never have
870
+ — but the message stopped at the refusal, and the person is left with
871
+ a list of paths and no idea that the fix is either closing whatever
872
+ holds them or unticking them. Almost every case is a file another
873
+ program has open, so that is what it says.
874
+ */
875
+ /*
876
+ Each cause named for what it is. Every failure used to be put down to
877
+ "another program may have a file open", which was wrong for a file
878
+ this account may not read and for one deleted mid-save, and "untick"
879
+ means nothing on the command line, where there is nothing to tick.
880
+ Found by saving a folder with one file held open and one with
881
+ reading denied.
882
+ */
883
+ const reasonOf = (name) => readFailures.get(name) ?? "";
884
+ const causes = new Set(dropped.map((name) => {
885
+ const reason = reasonOf(name);
886
+ if (reason.includes("ignore rules"))
887
+ return "ignored";
888
+ if (/EACCES|EPERM|permission|access is denied/i.test(reason))
889
+ return "permission";
890
+ if (/ENOENT|no such file/i.test(reason))
891
+ return "gone";
892
+ return "busy";
893
+ }));
894
+ const said = [];
895
+ if (causes.has("busy"))
896
+ said.push("Another program has a file open: close it.");
897
+ if (causes.has("permission"))
898
+ said.push("This account is not allowed to read a file: change its permissions.");
899
+ if (causes.has("gone"))
900
+ said.push("A file was deleted or moved while the save was running.");
901
+ if (causes.has("ignored"))
902
+ said.push("A file is excluded by the project's ignore rules: change the rules.");
903
+ throw new Error(`${dropped.length} selected file${dropped.length === 1 ? "" : "s"} could not be included, ` +
904
+ `so nothing was saved: ${shown}${rest}. ` +
905
+ `${said.join(" ")} Or leave ${dropped.length === 1 ? "it" : "them"} out — untick ` +
906
+ `${dropped.length === 1 ? "it" : "them"} in the app, or add ${dropped.length === 1 ? "it" : "them"} ` +
907
+ `to .gitignore — and save again. ` +
908
+ `Nothing was changed on your account.` +
909
+ (details ? ` Details: ${details}` : ""));
910
+ }
812
911
  async publishVersion(at) {
813
912
  const { request, repositoryId, declarations, uploaded, reused, prior, everything, ticked, deletions, readFailures, vanished, totalBytes, progress, report, } = at;
814
913
  // 4. Name the version, which is what makes the upload visible.
@@ -837,7 +936,7 @@ class Uploader {
837
936
  const contents = [
838
937
  ...uploaded,
839
938
  ...reused.map((file) => {
840
- const held = prior.get(file.path);
939
+ const held = file.storedAs ?? prior.get(file.path);
841
940
  /*
842
941
  Republished in the shape it was already stored in. A file kept in
843
942
  pieces names the same pieces; flattening it to one object here would
@@ -872,7 +971,7 @@ class Uploader {
872
971
  : { objectId: held.objectId }),
873
972
  sourceSize: held.sourceSize,
874
973
  storedSize: held.storedSize,
875
- mediaType: held.mediaType,
974
+ mediaType: file.mediaType,
876
975
  executable: request.executable?.get(file.path) ?? held.executable,
877
976
  };
878
977
  }),
@@ -899,31 +998,8 @@ class Uploader {
899
998
  ...[...ticked].filter((chosen) => !named.has(chosen) && !deletions.has(chosen)),
900
999
  ]),
901
1000
  ];
902
- if (dropped.length) {
903
- const shown = dropped.slice(0, 5).join(", ");
904
- const rest = dropped.length > 5 ? ` and ${dropped.length - 5} more` : "";
905
- const details = dropped
906
- .map((name) => readFailures.get(name) ? `${name}: ${readFailures.get(name)}` : "")
907
- .filter(Boolean)
908
- .slice(0, 3)
909
- .join("; ");
910
- /*
911
- Say what to do about it. Refusing is right — a version quietly missing
912
- a file somebody chose is the one failure a backup tool must never have
913
- — but the message stopped at the refusal, and the person is left with
914
- a list of paths and no idea that the fix is either closing whatever
915
- holds them or unticking them. Almost every case is a file another
916
- program has open, so that is what it says.
917
- */
918
- const excluded = dropped.some((name) => readFailures.get(name)?.includes("ignore rules"));
919
- throw new Error(`${dropped.length} selected file${dropped.length === 1 ? "" : "s"} could not be included, ` +
920
- `so nothing was saved: ${shown}${rest}. ` +
921
- (excluded
922
- ? `At least one is excluded by the project's ignore rules. Change the rules or untick it and try again. `
923
- : `Another program may have a file open. Close it or untick the file and try again. `) +
924
- `Nothing was changed on your account.` +
925
- (details ? ` Details: ${details}` : ""));
926
- }
1001
+ if (dropped.length)
1002
+ this.refuseUnreadable(dropped, readFailures);
927
1003
  const sourceBytes = contents.reduce((total, item) => total + item.sourceSize, 0);
928
1004
  const storedBytes = contents.reduce((total, item) => total + item.storedSize, 0);
929
1005
  /*
@@ -932,6 +1008,11 @@ class Uploader {
932
1008
  objects are immutable and content-addressed, so a retry reuses them.
933
1009
  */
934
1010
  (0, faults_js_1.maybeFail)("submit:after-objects");
1011
+ const counted = request.countLines
1012
+ ? await (0, profile_js_1.timed)("count the changed lines", () => request.countLines()).catch(() => null)
1013
+ : null;
1014
+ const linesAdded = counted ? counted.added : request.linesAdded;
1015
+ const linesRemoved = counted ? counted.removed : request.linesRemoved;
935
1016
  const completed = await (0, profile_js_1.timed)("publish the version", () => this.call(`/v1/repositories/${repositoryId}/versions`, {
936
1017
  method: "POST",
937
1018
  contentType: "application/json",
@@ -950,10 +1031,8 @@ class Uploader {
950
1031
  ...(request.allowIgnored ? { allowIgnored: true } : {}),
951
1032
  ...(this.trackedByGit.length ? { trackedByGit: this.trackedByGit } : {}),
952
1033
  ...(request.allowSecrets ? { allowSecrets: true } : {}),
953
- ...(request.linesAdded === undefined ? {} : { linesAdded: request.linesAdded }),
954
- ...(request.linesRemoved === undefined
955
- ? {}
956
- : { linesRemoved: request.linesRemoved }),
1034
+ ...(linesAdded === undefined ? {} : { linesAdded }),
1035
+ ...(linesRemoved === undefined ? {} : { linesRemoved }),
957
1036
  /*
958
1037
  Names this attempt so a retry after a lost connection is answered
959
1038
  with the version already made, rather than making a second one.
@@ -1013,7 +1092,7 @@ class Uploader {
1013
1092
  const manifest = Object.keys(acceptedManifest).length
1014
1093
  ? acceptedManifest
1015
1094
  : {
1016
- ...Object.fromEntries(reused.map((file) => [file.path, prior.get(file.path).sha256])),
1095
+ ...Object.fromEntries(reused.map((file) => [file.path, (file.storedAs ?? prior.get(file.path)).sha256])),
1017
1096
  ...Object.fromEntries(declarations.map((declaration) => [declaration.path, declaration.sha256])),
1018
1097
  };
1019
1098
  /*
@@ -1028,6 +1107,16 @@ class Uploader {
1028
1107
  the very end of a save.
1029
1108
  */
1030
1109
  const sentDigest = new Map(declarations.map((declaration) => [declaration.path, declaration.sha256]));
1110
+ /*
1111
+ A file named from bytes already stored, at a path the last version did
1112
+ not have, holds those bytes here too. Without this a renamed folder was
1113
+ saved correctly and then reported as "behind" file by file, because this
1114
+ record looked its old digest up by its new path and found nothing.
1115
+ */
1116
+ for (const file of reused) {
1117
+ if (file.storedAs)
1118
+ sentDigest.set(file.path, file.storedAs.sha256);
1119
+ }
1031
1120
  const onDisk = new Set(everything.map((file) => file.path));
1032
1121
  const local = Object.fromEntries(everything.flatMap((file) => {
1033
1122
  const sent = sentDigest.get(file.path);
@@ -1101,6 +1190,8 @@ class Uploader {
1101
1190
  * rather than before.
1102
1191
  */
1103
1192
  async sendObjects(at) {
1193
+ if (!this.planning)
1194
+ this.sendingBegan = true;
1104
1195
  const { request, repositoryId, prior,
1105
1196
  /*
1106
1197
  Bound under a distinct name on purpose. `runSection` declares its own
@@ -1155,6 +1246,22 @@ class Uploader {
1155
1246
  question rather than a transfer.
1156
1247
  */
1157
1248
  const inFlightByDigest = new Map();
1249
+ /*
1250
+ What the account already holds, asked once for the whole save.
1251
+
1252
+ Asked per section, the answer changed under the save's feet. The plan
1253
+ stores nothing, so every section of the plan heard the same answer; the
1254
+ send stores as it goes and runs sections side by side, so a later
1255
+ section holding a copy of a file an earlier one had just sent heard
1256
+ "already held" and left it out of its pack. The pack's bytes, and so its
1257
+ digest, were then not the pack the plan had priced, and the service
1258
+ refused it as "a file that was not part of what it asked to send". A
1259
+ copy of Blender 4.5 — 326 contents present more than once — failed this
1260
+ way every time. One answer, taken before anything is sent, is the answer
1261
+ the plan was given.
1262
+ */
1263
+ let heldOnce = null;
1264
+ const heldAtStart = () => (heldOnce ??= (0, profile_js_1.timed)("ask what is already held", () => this.storedInBulk(repositoryId, everyDeclaration.map((one) => one.sha256))));
1158
1265
  const runSection = async (section) => {
1159
1266
  const declarations = section.files.map((file) => declarationByPath.get(file.path));
1160
1267
  /*
@@ -1214,7 +1321,7 @@ class Uploader {
1214
1321
  asking took longer than the transfer. The answer per digest is a boolean
1215
1322
  and an identifier, so the whole question fits in one request.
1216
1323
  */
1217
- const alreadyHeld = await (0, profile_js_1.timed)("ask what is already held", () => this.storedInBulk(repositoryId, declarations.map((one) => one.sha256)));
1324
+ const alreadyHeld = await heldAtStart();
1218
1325
  /*
1219
1326
  The small files go first, packed together.
1220
1327
 
@@ -1689,6 +1796,12 @@ class Uploader {
1689
1796
  than from the disk.
1690
1797
  */
1691
1798
  const hashed = await this.hashSelection(request, sending, deletions, readFailures, vanished, report);
1799
+ /*
1800
+ Before the project is created or anything priced: see refuseUnreadable.
1801
+ `vanished` holds every chosen file that is gone or could not be read.
1802
+ */
1803
+ if (vanished.length)
1804
+ this.refuseUnreadable([...new Set(vanished)], readFailures);
1692
1805
  /*
1693
1806
  A file that is exactly what the last version held is kept, not sent.
1694
1807
 
@@ -1704,31 +1817,48 @@ class Uploader {
1704
1817
  same bytes, and the previous version's object for them — a pack slice,
1705
1818
  its chunks, or a plain object — is named again instead.
1706
1819
  */
1707
- const keptAsItWas = (declaration) => {
1820
+ const stored = (was) => !!was && !!(was.pack?.objectId || was.chunks?.length || was.objectId);
1821
+ /*
1822
+ Matched by what a file is, not only by where it sits.
1823
+
1824
+ Same path and same bytes was the only reuse, so renaming a folder sent
1825
+ every file in it again: the service recognises a stored object by its
1826
+ digest but not a slice of a pack, and small files live in packs. Moving
1827
+ a 2,192-file folder in a copy of the Cheat Engine sources re-sent all of
1828
+ it, 5.5 MB, to record bytes the project already held. Any file in the
1829
+ previous version with the same digest is the same bytes, wherever it was.
1830
+ */
1831
+ const priorByDigest = new Map();
1832
+ for (const was of prior.values()) {
1833
+ if (stored(was) && !priorByDigest.has(was.sha256))
1834
+ priorByDigest.set(was.sha256, was);
1835
+ }
1836
+ const storedAs = (declaration) => {
1708
1837
  const was = prior.get(declaration.path);
1709
- return (!!was &&
1710
- was.sha256 === declaration.sha256 &&
1711
- !deletions.has(declaration.path) &&
1712
- !!(was.pack?.objectId || was.chunks?.length || was.objectId));
1838
+ if (stored(was) && was.sha256 === declaration.sha256)
1839
+ return was;
1840
+ return priorByDigest.get(declaration.sha256) ?? null;
1713
1841
  };
1842
+ const keptAsItWas = (declaration) => storedAs(declaration) !== null;
1714
1843
  const declarations = hashed.declarations.filter((one) => !keptAsItWas(one));
1715
1844
  let totalBytes = hashed.totalBytes;
1716
1845
  for (const one of hashed.declarations) {
1717
- if (!keptAsItWas(one))
1846
+ const held = storedAs(one);
1847
+ if (!held)
1718
1848
  continue;
1719
1849
  totalBytes -= one.size;
1720
- const held = prior.get(one.path);
1721
1850
  reused.push({
1722
1851
  path: one.path,
1723
1852
  sourceSize: held.sourceSize,
1724
1853
  storedSize: held.storedSize,
1725
- mediaType: held.mediaType,
1854
+ mediaType: one.mediaType,
1726
1855
  added: 0,
1727
1856
  removed: 0,
1728
1857
  included: true,
1729
1858
  deleted: false,
1730
1859
  binary: false,
1731
1860
  lines: 0,
1861
+ ...(held === prior.get(one.path) ? {} : { storedAs: held }),
1732
1862
  });
1733
1863
  }
1734
1864
  (0, profile_js_1.counted)("files kept as they were", hashed.declarations.length - declarations.length);
@@ -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
  };
@@ -376,7 +376,7 @@ async function commandProjects() {
376
376
  const width = Math.max(...all.map((project) => project.slug.length));
377
377
  for (const project of all) {
378
378
  console.log(`${accent(project.slug.padEnd(width))} v${String(project.versionCount).padEnd(4)}` +
379
- `${String(project.fileCount).padStart(6)} files ${bytes(project.storedBytes).padStart(9)}` +
379
+ `${String(project.fileCount).padStart(6)} ${project.fileCount === 1 ? "file " : "files"} ${bytes(project.storedBytes).padStart(9)}` +
380
380
  ` ${dim(project.visibility)}`);
381
381
  }
382
382
  return 0;
@@ -454,7 +454,7 @@ async function commandStatus(parsed) {
454
454
  const files = found.filter((file) => !file.deleted);
455
455
  console.log(bold(node_path_1.default.basename(folder)) + dim(` ${folder}`));
456
456
  console.log(link
457
- ? `Account holds ${accent(`v${link.sequence}`)} · ${Object.keys(link.manifest).length} files ${dim(`(${link.slug})`)}`
457
+ ? `Account holds ${accent(`v${link.sequence}`)} · ${Object.keys(link.manifest).length} ${Object.keys(link.manifest).length === 1 ? "file" : "files"} ${dim(`(${link.slug})`)}`
458
458
  : dim("This folder is not linked to a project."));
459
459
  if (!link) {
460
460
  const likes = (0, folder_match_js_1.similarProjects)(await (0, api_js_2.projects)().catch(() => []), node_path_1.default.basename(folder));
@@ -574,8 +574,10 @@ async function commandStatus(parsed) {
574
574
  return 0;
575
575
  }
576
576
  console.log(`\n${files.length} file${files.length === 1 ? "" : "s"} to submit:`);
577
+ /* New is not changed: a file the saved version never had was listed as "changed". */
577
578
  for (const file of files.slice(0, 50)) {
578
- console.log(` ${accent("changed")} ${file.path}`);
579
+ const fresh = !baseline?.has(file.path);
580
+ console.log(` ${fresh ? green("new ") : accent("changed")} ${file.path}`);
579
581
  }
580
582
  if (files.length > 50)
581
583
  console.log(dim(` …and ${files.length - 50} more`));
@@ -728,13 +730,14 @@ async function digestOfFile(full) {
728
730
  */
729
731
  function noteKeptDeletions(folder, baseline, syncing) {
730
732
  if (syncing || !baseline?.size)
731
- return;
733
+ return 0;
732
734
  const gone = [...baseline.keys()].filter((file) => !(0, node_fs_1.existsSync)(node_path_1.default.join(folder, ...file.split("/"))));
733
735
  if (!gone.length)
734
- return;
736
+ return 0;
735
737
  console.log(dim(`${gone.length} file${gone.length === 1 ? "" : "s"} deleted here ${gone.length === 1 ? "is" : "are"} still in the saved version` +
736
738
  ` (${gone.slice(0, 3).join(", ")}${gone.length > 3 ? ", …" : ""}). A save adds and updates;` +
737
739
  ` run cbx submit --sync to remove ${gone.length === 1 ? "it" : "them"}.`));
740
+ return gone.length;
738
741
  }
739
742
  /*
740
743
  The service names each clash by its kind ("text_region", "add_add", …).
@@ -914,6 +917,20 @@ async function commandSubmit(parsed) {
914
917
  }
915
918
  }
916
919
  if (!files.length) {
920
+ /*
921
+ Deleted files are not "nothing". A plain save never removes, so a
922
+ folder with 1,723 files deleted from it was told it "matches the saved
923
+ version" straight after status had listed every one of them — found by
924
+ deleting a folder from a copy of the Cheat Engine sources.
925
+ */
926
+ if (!hasFlag(parsed, "sync")) {
927
+ const kept = [...(baseline?.keys() ?? [])].some((file) => !(0, node_fs_1.existsSync)(node_path_1.default.join(folder, ...file.split("/"))));
928
+ if (kept) {
929
+ console.log("Nothing new to submit.");
930
+ noteKeptDeletions(folder, baseline, false);
931
+ return 0;
932
+ }
933
+ }
917
934
  // Having nothing to send is not the same as holding the whole version:
918
935
  // a merge can leave this folder with an older copy of somebody else's
919
936
  // file. Saying so is what stops "nothing to do" from reading as "level".
@@ -1263,7 +1280,7 @@ The connection failed: ${text}`));
1263
1280
  */
1264
1281
  console.log(`\nSomebody else saved first, and ${result.mergeTrack.conflicts.length === 1
1265
1282
  ? "one file overlaps"
1266
- : `${result.mergeTrack.conflicts.length} files overlap`}.`);
1283
+ : `${result.mergeTrack.conflicts.length} ${result.mergeTrack.conflicts.length === 1 ? "file overlaps" : "files overlap"}`}.`);
1267
1284
  console.log(`Your work is complete and kept as ${accent(result.mergeTrack.reference)}:`);
1268
1285
  for (const conflict of result.mergeTrack.conflicts.slice(0, 20)) {
1269
1286
  console.log(` ${red(conflict.path)} ${dim(conflictWords(conflict.kind))}`);
@@ -136,7 +136,7 @@ const TOOLS = [
136
136
  if (!saved.length)
137
137
  return "No versions saved yet.";
138
138
  return saved
139
- .map((one) => `v${one.sequence} ${one.createdAt.slice(0, 10)} ${one.fileCount} files ` +
139
+ .map((one) => `v${one.sequence} ${one.createdAt.slice(0, 10)} ${one.fileCount} ${one.fileCount === 1 ? "file" : "files"} ` +
140
140
  `${bytes(one.storedSize)} ${one.message || "(no message)"}`)
141
141
  .join("\n");
142
142
  },
@@ -167,7 +167,7 @@ const TOOLS = [
167
167
  if (files.length > shown.length) {
168
168
  lines.push(`… and ${files.length - shown.length} more`);
169
169
  }
170
- return `v${version.sequence} · ${files.length} files\n${lines.join("\n")}`;
170
+ return `v${version.sequence} · ${files.length} ${files.length === 1 ? "file" : "files"}\n${lines.join("\n")}`;
171
171
  },
172
172
  },
173
173
  {
@@ -385,7 +385,7 @@ async function commandVersions(parsed) {
385
385
  ...version.labels.map((label) => (0, colour_js_1.accent)(label.name)),
386
386
  ].filter(Boolean);
387
387
  console.log(` ${(0, colour_js_1.accent)(`v${version.sequence}`).padEnd(16)} ${when.padEnd(22)}` +
388
- `${String(version.fileCount).padStart(5)} files ${bytes(version.storedSize)}` +
388
+ `${String(version.fileCount).padStart(5)} ${version.fileCount === 1 ? "file " : "files"} ${bytes(version.storedSize)}` +
389
389
  (marks.length ? ` ${marks.join(" ")}` : ""));
390
390
  if (version.message)
391
391
  console.log(` ${(0, colour_js_1.dim)(version.message)}`);
@@ -569,7 +569,8 @@ async function commandDelete(parsed) {
569
569
  if (!project)
570
570
  return 1;
571
571
  if (parsed.flags.get("yes") !== true) {
572
- console.log(`This deletes ${(0, colour_js_1.bold)(project.name)} — ${project.versionCount} versions, ` +
572
+ console.log(`This deletes ${(0, colour_js_1.bold)(project.name)} — ${project.versionCount} ` +
573
+ `${project.versionCount === 1 ? "version" : "versions"}, ` +
573
574
  `${bytes(project.storedBytes)}.`);
574
575
  console.log((0, colour_js_1.dim)("It is kept for 30 days before the storage is reclaimed."));
575
576
  const prompt = (0, promises_1.createInterface)({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.34.5",
3
+ "version": "0.34.7",
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",