@coderook/cli 0.34.4 → 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.4",
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
  }
@@ -88,10 +88,14 @@ function mediaTypeOf(file) {
88
88
  return MEDIA_TYPES[node_path_1.default.extname(file).toLowerCase()] ?? "application/octet-stream";
89
89
  }
90
90
  /** Hash without holding the file in memory; some of these are large. */
91
- async function digestOf(full) {
91
+ async function digestOf(full, onBytes) {
92
92
  const hash = (0, node_crypto_1.createHash)("sha256");
93
- for await (const chunk of (0, node_fs_1.createReadStream)(full))
93
+ let read = 0;
94
+ for await (const chunk of (0, node_fs_1.createReadStream)(full)) {
94
95
  hash.update(chunk);
96
+ read += chunk.byteLength;
97
+ onBytes?.(read);
98
+ }
95
99
  return hash.digest("hex");
96
100
  }
97
101
  /**
@@ -243,6 +247,38 @@ class Uploader {
243
247
  /** A single attempt. Everything about repeating it lives in `call`. */
244
248
  async attempt(route, init) {
245
249
  const started = performance.now();
250
+ /*
251
+ A body that says how much of itself has gone.
252
+
253
+ A batch of small files is one request of a few megabytes, and on an
254
+ ordinary home connection three of them in flight take ten seconds each.
255
+ Counted only when the answer came back, the bar stood still for those
256
+ ten seconds and then jumped — and the last few batches of a save looked
257
+ exactly like a save that had stopped. Handed over in slices, each slice
258
+ taken is counted as it is taken, which is what the network has accepted.
259
+ A fresh stream per attempt, so a retry counts from nothing again.
260
+ */
261
+ const streamed = init.onSent && init.body instanceof Uint8Array && init.body.byteLength > 0
262
+ ? (() => {
263
+ const whole = init.body;
264
+ const report = init.onSent;
265
+ const SLICE = 256 * 1024;
266
+ let at = 0;
267
+ report(0);
268
+ return new ReadableStream({
269
+ pull(controller) {
270
+ if (at >= whole.byteLength) {
271
+ controller.close();
272
+ return;
273
+ }
274
+ const next = whole.subarray(at, Math.min(at + SLICE, whole.byteLength));
275
+ at += next.byteLength;
276
+ controller.enqueue(next);
277
+ report(at / whole.byteLength);
278
+ },
279
+ });
280
+ })()
281
+ : null;
246
282
  const token = await this.credentials.token();
247
283
  if (!token)
248
284
  throw new Error("Sign in again before uploading");
@@ -258,7 +294,8 @@ class Uploader {
258
294
  : {}),
259
295
  ...(init.contentType ? { "content-type": init.contentType } : {}),
260
296
  },
261
- body: init.body,
297
+ body: streamed ?? init.body,
298
+ ...(streamed ? { duplex: "half" } : {}),
262
299
  /*
263
300
  The upload's own cancellation, and a deadline.
264
301
 
@@ -575,6 +612,42 @@ class Uploader {
575
612
  let cursor = 0;
576
613
  /** Throttled: twenty thousand sends is its own cost, and nobody reads them. */
577
614
  let told = 0;
615
+ /*
616
+ Bytes, not just files.
617
+
618
+ This said how many files were done and nothing else, so a project whose
619
+ weight is four 1.6 GB builds sat on "1 of 11" for three minutes on a
620
+ hard disk and then leapt: every report came after a whole file. The
621
+ sizes are known from the survey, so how much has been read is a share of
622
+ a known total, and the lanes say how far into a large file they are.
623
+ */
624
+ const readTotal = sending.reduce((sum, file) => sum + file.size, 0);
625
+ let readDone = 0;
626
+ const readingNow = new Map();
627
+ const tell = (path, force = false) => {
628
+ const now = Date.now();
629
+ if (!force && now - told <= 250)
630
+ return;
631
+ told = now;
632
+ let bytes = readDone;
633
+ for (const partial of readingNow.values())
634
+ bytes += partial;
635
+ report({
636
+ stage: "hash",
637
+ files: hashed,
638
+ totalFiles: sending.length,
639
+ bytes: Math.min(bytes, readTotal),
640
+ totalBytes: readTotal,
641
+ path,
642
+ /*
643
+ Reading is the stretch from just past the walk to a fifth of
644
+ the way through. Starting it at nought put the bar behind where
645
+ it had already been and it went backwards between stages.
646
+ */
647
+ percent: 8 + Math.round((hashed / sending.length) * 12),
648
+ bytesPerSecond: 0,
649
+ });
650
+ };
578
651
  const lane = async () => {
579
652
  for (;;) {
580
653
  const index = cursor;
@@ -597,6 +670,7 @@ class Uploader {
597
670
  vanished.push(file.path);
598
671
  readFailures.set(file.path, error instanceof Error ? error.message : String(error));
599
672
  hashed += 1;
673
+ readDone += file.size;
600
674
  continue;
601
675
  }
602
676
  /*
@@ -630,13 +704,19 @@ class Uploader {
630
704
  mediaType: mediaTypeOf(file.path),
631
705
  };
632
706
  hashed += 1;
707
+ readDone += file.size;
633
708
  continue;
634
709
  }
635
710
  let digest;
636
711
  try {
637
- digest = await (0, profile_js_1.timed)("hash files", () => digestOf(full));
712
+ digest = await (0, profile_js_1.timed)("hash files", () => digestOf(full, (read) => {
713
+ readingNow.set(index, read);
714
+ tell(file.path);
715
+ }));
638
716
  }
639
717
  catch (error) {
718
+ readingNow.delete(index);
719
+ readDone += file.size;
640
720
  vanished.push(file.path);
641
721
  readFailures.set(file.path, error instanceof Error ? error.message : String(error));
642
722
  hashed += 1;
@@ -651,25 +731,9 @@ class Uploader {
651
731
  mediaType: mediaTypeOf(file.path),
652
732
  };
653
733
  hashed += 1;
654
- const now = Date.now();
655
- if (now - told > 80 || hashed === sending.length) {
656
- told = now;
657
- report({
658
- stage: "hash",
659
- files: hashed,
660
- totalFiles: sending.length,
661
- bytes: 0,
662
- totalBytes: 0,
663
- path: file.path,
664
- /*
665
- Reading is the stretch from just past the walk to a fifth of
666
- the way through. Starting it at nought put the bar behind where
667
- it had already been and it went backwards between stages.
668
- */
669
- percent: 8 + Math.round((hashed / sending.length) * 12),
670
- bytesPerSecond: 0,
671
- });
672
- }
734
+ readingNow.delete(index);
735
+ readDone += file.size;
736
+ tell(file.path, hashed === sending.length);
673
737
  }
674
738
  };
675
739
  await Promise.all(Array.from({ length: Math.min(HASH_LANES, sending.length) }, () => lane()));
@@ -868,6 +932,11 @@ class Uploader {
868
932
  objects are immutable and content-addressed, so a retry reuses them.
869
933
  */
870
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;
871
940
  const completed = await (0, profile_js_1.timed)("publish the version", () => this.call(`/v1/repositories/${repositoryId}/versions`, {
872
941
  method: "POST",
873
942
  contentType: "application/json",
@@ -886,10 +955,8 @@ class Uploader {
886
955
  ...(request.allowIgnored ? { allowIgnored: true } : {}),
887
956
  ...(this.trackedByGit.length ? { trackedByGit: this.trackedByGit } : {}),
888
957
  ...(request.allowSecrets ? { allowSecrets: true } : {}),
889
- ...(request.linesAdded === undefined ? {} : { linesAdded: request.linesAdded }),
890
- ...(request.linesRemoved === undefined
891
- ? {}
892
- : { linesRemoved: request.linesRemoved }),
958
+ ...(linesAdded === undefined ? {} : { linesAdded }),
959
+ ...(linesRemoved === undefined ? {} : { linesRemoved }),
893
960
  /*
894
961
  Names this attempt so a retry after a lost connection is answered
895
962
  with the version already made, rather than making a second one.
@@ -1090,7 +1157,23 @@ class Uploader {
1090
1157
  after which the service already holds the bytes and the copy costs a
1091
1158
  question rather than a transfer.
1092
1159
  */
1093
- const sending = new Map();
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))));
1094
1177
  const runSection = async (section) => {
1095
1178
  const declarations = section.files.map((file) => declarationByPath.get(file.path));
1096
1179
  /*
@@ -1150,7 +1233,7 @@ class Uploader {
1150
1233
  asking took longer than the transfer. The answer per digest is a boolean
1151
1234
  and an identifier, so the whole question fits in one request.
1152
1235
  */
1153
- 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();
1154
1237
  /*
1155
1238
  The small files go first, packed together.
1156
1239
 
@@ -1202,6 +1285,19 @@ class Uploader {
1202
1285
  if (smallForPacking.length > 1) {
1203
1286
  let pack = [];
1204
1287
  let packBytes = 0;
1288
+ /* How many batches have had a lane of their own on the bar. */
1289
+ let batchLanes = 0;
1290
+ /*
1291
+ How long a source byte takes to land in a batch on this connection,
1292
+ learned from the batches that have landed.
1293
+
1294
+ Counting the body as it is handed over is not enough on its own: the
1295
+ operating system takes a few megabytes into its send buffer at once,
1296
+ so a whole batch reads as sent within a moment and then waits seconds
1297
+ for the wire. A batch in flight is shown no further along than its
1298
+ time so far at the pace earlier batches actually managed.
1299
+ */
1300
+ let secondsPerByte = 0;
1205
1301
  /*
1206
1302
  Batches overlap rather than queue.
1207
1303
 
@@ -1216,6 +1312,15 @@ class Uploader {
1216
1312
  that is reasonable to have outstanding.
1217
1313
  */
1218
1314
  const BATCH_LANES = 3;
1315
+ /*
1316
+ Time against the expected time, as a share of a batch: in step until
1317
+ nine tenths, then closing on the end without reaching it, so a batch
1318
+ that runs late keeps moving and never reads as landed before its
1319
+ answer says so.
1320
+ */
1321
+ const creep = (expectedShare) => expectedShare <= 0.9
1322
+ ? Math.max(0, expectedShare)
1323
+ : 0.9 + 0.09 * (1 - Math.exp(-(expectedShare - 0.9) / 0.3));
1219
1324
  const flying = new Set();
1220
1325
  const flush = async () => {
1221
1326
  if (!pack.length)
@@ -1223,6 +1328,52 @@ class Uploader {
1223
1328
  const taking = pack;
1224
1329
  pack = [];
1225
1330
  packBytes = 0;
1331
+ /*
1332
+ This batch's own lane on the bar, keyed below zero so it can never
1333
+ collide with a file's lane. Its source bytes are shared out as its
1334
+ body goes, and taken back off when the batch lands and is counted
1335
+ for real.
1336
+ */
1337
+ batchLanes += 1;
1338
+ const lane = -batchLanes;
1339
+ const batchSource = taking.reduce((sum, item) => sum + item.declaration.size, 0);
1340
+ let toldAt = 0;
1341
+ let handed = 0;
1342
+ const sentAt = Date.now();
1343
+ const show = () => {
1344
+ /*
1345
+ Until a batch has landed there is no pace of this connection to go
1346
+ by, and the first batches read as sent within a moment and then
1347
+ stood still for seconds. A cautious guess stands in until the first
1348
+ answer: a megabyte and a half a second, shared by the batches in
1349
+ flight. Too slow a guess costs only a jump forward when the batch
1350
+ lands; too fast a one would be the stall again.
1351
+ */
1352
+ const pace = secondsPerByte > 0 ? secondsPerByte : BATCH_LANES / 1_500_000;
1353
+ const elapsed = (Date.now() - sentAt) / 1000;
1354
+ const share = batchSource > 0 ? Math.min(handed, creep(elapsed / (pace * batchSource))) : handed;
1355
+ progress.lane(lane, Math.round(batchSource * share));
1356
+ const now = Date.now();
1357
+ if (now - toldAt < 250)
1358
+ return;
1359
+ toldAt = now;
1360
+ report({
1361
+ stage: "upload",
1362
+ files: advance(0),
1363
+ totalFiles: everyDeclaration.length,
1364
+ bytes: progress.bytes(),
1365
+ totalBytes,
1366
+ path: taking[taking.length - 1].declaration.path,
1367
+ percent: 20 + Math.round((progress.bytes() / Math.max(totalBytes, 1)) * 72),
1368
+ bytesPerSecond: progress.rate(),
1369
+ });
1370
+ };
1371
+ const onSent = (fraction) => {
1372
+ handed = fraction;
1373
+ show();
1374
+ };
1375
+ /* Keeps the estimate moving while the batch waits on the wire. */
1376
+ const pacing = setInterval(show, 300);
1226
1377
  const task = (async () => {
1227
1378
  let exactWireBytes = null;
1228
1379
  try {
@@ -1254,7 +1405,7 @@ class Uploader {
1254
1405
  batched.set(digest, one);
1255
1406
  }
1256
1407
  const placed = !delta && built
1257
- ? await (0, profile_js_1.timed)("send packs", () => this.putPack(repositoryId, packItems, built))
1408
+ ? await (0, profile_js_1.timed)("send packs", () => this.putPack(repositoryId, packItems, built, onSent))
1258
1409
  : null;
1259
1410
  if (placed) {
1260
1411
  (0, profile_js_1.counted)("files sent in packs", taking.length);
@@ -1267,7 +1418,7 @@ class Uploader {
1267
1418
  Not worth packing — already-compressed content, or too few files.
1268
1419
  They still travel together, just as separate objects.
1269
1420
  */
1270
- const answer = await (0, profile_js_1.timed)("send batches", () => this.putBatch(repositoryId, taking));
1421
+ const answer = await (0, profile_js_1.timed)("send batches", () => this.putBatch(repositoryId, taking, onSent));
1271
1422
  (0, profile_js_1.counted)("files sent in batches", taking.length);
1272
1423
  for (const [digest, one] of answer)
1273
1424
  batched.set(digest, one);
@@ -1297,6 +1448,13 @@ class Uploader {
1297
1448
  below and counted a second time — which is how the progress bar came
1298
1449
  to report a hundred and twenty percent of a forty megabyte upload.
1299
1450
  */
1451
+ /* Landed or refused, its estimate comes off: what landed is counted below. */
1452
+ clearInterval(pacing);
1453
+ progress.laneDone(lane);
1454
+ if (batchSource > 0 && taking.some((item) => batched.has(item.declaration.sha256) || packedInto.has(item.declaration.sha256))) {
1455
+ const took = (Date.now() - sentAt) / 1000 / batchSource;
1456
+ secondsPerByte = secondsPerByte > 0 ? secondsPerByte * 0.6 + took * 0.4 : took;
1457
+ }
1300
1458
  for (const item of taking) {
1301
1459
  const digest = item.declaration.sha256;
1302
1460
  if (!batched.has(digest) && !packedInto.has(digest))
@@ -1317,7 +1475,10 @@ class Uploader {
1317
1475
  percent: 20 + Math.round((progress.bytes() / Math.max(totalBytes, 1)) * 72),
1318
1476
  bytesPerSecond: progress.rate(),
1319
1477
  });
1320
- })().finally(() => flying.delete(task));
1478
+ })().finally(() => {
1479
+ clearInterval(pacing);
1480
+ flying.delete(task);
1481
+ });
1321
1482
  flying.add(task);
1322
1483
  if (flying.size >= BATCH_LANES)
1323
1484
  await Promise.race(flying);
@@ -1359,12 +1520,12 @@ class Uploader {
1359
1520
  const queue = declarations.entries();
1360
1521
  const runWorker = async () => {
1361
1522
  for (const [index, declaration] of queue) {
1362
- const earlier = sending.get(declaration.sha256);
1523
+ const earlier = inFlightByDigest.get(declaration.sha256);
1363
1524
  if (earlier)
1364
1525
  await earlier;
1365
1526
  let release = () => { };
1366
1527
  if (!earlier) {
1367
- sending.set(declaration.sha256, new Promise((done) => {
1528
+ inFlightByDigest.set(declaration.sha256, new Promise((done) => {
1368
1529
  release = done;
1369
1530
  }));
1370
1531
  }
@@ -1831,7 +1992,7 @@ class Uploader {
1831
1992
  * service would not take it — in which case the caller falls back to sending
1832
1993
  * them separately, which is slower and larger but always works.
1833
1994
  */
1834
- async putPack(repositoryId, items, alreadyBuilt) {
1995
+ async putPack(repositoryId, items, alreadyBuilt, onSent) {
1835
1996
  const built = alreadyBuilt ?? await (0, solid_js_1.buildSolidPack)(items.map((item) => ({
1836
1997
  sha256: item.declaration.sha256,
1837
1998
  body: item.body,
@@ -1867,6 +2028,7 @@ class Uploader {
1867
2028
  method: "POST",
1868
2029
  contentType: "application/octet-stream",
1869
2030
  body: packed,
2031
+ ...(onSent ? { onSent } : {}),
1870
2032
  });
1871
2033
  const placed = new Map();
1872
2034
  /*
@@ -2128,7 +2290,7 @@ class Uploader {
2128
2290
  * content-addressed, so a retry is free and a partial batch costs nothing
2129
2291
  * but the objects it missed.
2130
2292
  */
2131
- async putBatch(repositoryId, items) {
2293
+ async putBatch(repositoryId, items, onSent) {
2132
2294
  const landed = new Map();
2133
2295
  if (!items.length)
2134
2296
  return landed;
@@ -2170,6 +2332,7 @@ class Uploader {
2170
2332
  method: "POST",
2171
2333
  contentType: "application/octet-stream",
2172
2334
  body: packed,
2335
+ ...(onSent ? { onSent } : {}),
2173
2336
  });
2174
2337
  for (const one of answer.objects ?? []) {
2175
2338
  landed.set(one.sha256, {
@@ -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.4",
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",