@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.
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
...(
|
|
890
|
-
...(
|
|
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
|
|
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 (
|
|
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(() =>
|
|
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 =
|
|
1523
|
+
const earlier = inFlightByDigest.get(declaration.sha256);
|
|
1363
1524
|
if (earlier)
|
|
1364
1525
|
await earlier;
|
|
1365
1526
|
let release = () => { };
|
|
1366
1527
|
if (!earlier) {
|
|
1367
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|