@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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cbx/src/core/licences.js +30 -3
- package/dist/cbx/src/core/upload.js +171 -41
- package/dist/cbx/src/core/worktree.js +51 -4
- package/dist/cli/src/cli.js +23 -6
- package/dist/cli/src/mcp.js +2 -2
- package/dist/cli/src/project_commands.js +1 -1
- package/dist/cli/src/service_commands.js +2 -1
- package/package.json +1 -1
|
@@ -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.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
|
-
|
|
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
|
}
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
...(
|
|
954
|
-
...(
|
|
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 (
|
|
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
|
|
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
|
-
|
|
1710
|
-
was
|
|
1711
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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/dist/cli/src/cli.js
CHANGED
|
@@ -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
|
-
|
|
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))}`);
|
package/dist/cli/src/mcp.js
CHANGED
|
@@ -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}
|
|
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.
|
|
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",
|