@coderook/cli 0.12.0 → 0.14.0
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/README.md +71 -2
- package/dist/cli/src/cli.js +167 -10
- package/dist/cli/src/mcp.js +349 -0
- package/dist/cli/src/track_commands.js +157 -0
- package/dist/desktop-app/src/main/compress.js +95 -0
- package/dist/desktop-app/src/main/detect.js +137 -0
- package/dist/desktop-app/src/main/download.js +162 -15
- package/dist/desktop-app/src/main/profile.js +73 -0
- package/dist/desktop-app/src/main/retry.js +96 -0
- package/dist/desktop-app/src/main/solid.js +103 -0
- package/dist/desktop-app/src/main/staging.js +140 -0
- package/dist/desktop-app/src/main/tracks.js +132 -0
- package/dist/desktop-app/src/main/upload.js +1039 -55
- package/dist/desktop-app/src/main/worktree.js +214 -25
- package/package.json +1 -1
|
@@ -12,7 +12,9 @@ exports.collectLayers = collectLayers;
|
|
|
12
12
|
exports.assertReadable = assertReadable;
|
|
13
13
|
exports.branchName = branchName;
|
|
14
14
|
exports.changedFiles = changedFiles;
|
|
15
|
+
exports.surveyFiles = surveyFiles;
|
|
15
16
|
exports.totalSize = totalSize;
|
|
17
|
+
exports.fileSizes = fileSizes;
|
|
16
18
|
exports.fileDiff = fileDiff;
|
|
17
19
|
exports.projectTree = projectTree;
|
|
18
20
|
exports.evaluateRules = evaluateRules;
|
|
@@ -20,6 +22,7 @@ exports.detectSecrets = detectSecrets;
|
|
|
20
22
|
/** Reading a project folder: changed files, diffs, and rule measurement. */
|
|
21
23
|
const node_child_process_1 = require("node:child_process");
|
|
22
24
|
const node_crypto_1 = require("node:crypto");
|
|
25
|
+
const node_fs_1 = require("node:fs");
|
|
23
26
|
const promises_1 = require("node:fs/promises");
|
|
24
27
|
const node_path_1 = __importDefault(require("node:path"));
|
|
25
28
|
const node_util_1 = require("node:util");
|
|
@@ -47,24 +50,49 @@ async function git(root, ...args) {
|
|
|
47
50
|
* file would produce an incomplete project that looked backed up, so likely
|
|
48
51
|
* secrets are warned about instead (docs/UPLOAD_POLICY.md).
|
|
49
52
|
*/
|
|
50
|
-
exports.STARTER_IGNORE = `# Dependencies and generated output
|
|
51
|
-
node_modules/
|
|
52
|
-
dist/
|
|
53
|
-
build/
|
|
54
|
-
out/
|
|
55
|
-
.next/
|
|
56
|
-
target/
|
|
57
|
-
__pycache__/
|
|
58
|
-
.venv/
|
|
59
|
-
venv/
|
|
60
|
-
*.log
|
|
61
|
-
|
|
62
|
-
# Large model weights
|
|
63
|
-
models/**
|
|
64
|
-
*.safetensors
|
|
65
|
-
*.ckpt
|
|
66
|
-
*.pt
|
|
67
|
-
*.pth
|
|
53
|
+
exports.STARTER_IGNORE = `# Dependencies and generated output
|
|
54
|
+
node_modules/
|
|
55
|
+
dist/
|
|
56
|
+
build/
|
|
57
|
+
out/
|
|
58
|
+
.next/
|
|
59
|
+
target/
|
|
60
|
+
__pycache__/
|
|
61
|
+
.venv/
|
|
62
|
+
venv/
|
|
63
|
+
*.log
|
|
64
|
+
|
|
65
|
+
# Large model weights
|
|
66
|
+
models/**
|
|
67
|
+
*.safetensors
|
|
68
|
+
*.ckpt
|
|
69
|
+
*.pt
|
|
70
|
+
*.pth
|
|
71
|
+
|
|
72
|
+
# Caches and local state
|
|
73
|
+
.venv/
|
|
74
|
+
.pytest_cache/
|
|
75
|
+
.mypy_cache/
|
|
76
|
+
.ruff_cache/
|
|
77
|
+
.cache/
|
|
78
|
+
*.pyc
|
|
79
|
+
|
|
80
|
+
# A browser or Electron profile that has been left in the project folder.
|
|
81
|
+
# These hold files another program keeps open — a LOCK that cannot be read
|
|
82
|
+
# while it runs will stop a save outright — and nothing in them is the work.
|
|
83
|
+
IndexedDB/
|
|
84
|
+
Local Storage/
|
|
85
|
+
Session Storage/
|
|
86
|
+
Service Worker/
|
|
87
|
+
Network/
|
|
88
|
+
GPUCache/
|
|
89
|
+
Code Cache/
|
|
90
|
+
blob_storage/
|
|
91
|
+
Local State
|
|
92
|
+
Preferences
|
|
93
|
+
|
|
94
|
+
# Archives of the project, inside the project
|
|
95
|
+
*.cbx
|
|
68
96
|
`;
|
|
69
97
|
/** The shared rules file, committed with the project. */
|
|
70
98
|
exports.IGNORE_FILE = ".gitignore";
|
|
@@ -196,10 +224,73 @@ async function branchName(root) {
|
|
|
196
224
|
const LINE_COUNT_LIMIT = 4 * 1024 * 1024;
|
|
197
225
|
/** Enough rows for any real project; past it the rail would be unusable. */
|
|
198
226
|
const LISTED_FILE_LIMIT = 20_000;
|
|
199
|
-
/**
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
227
|
+
/**
|
|
228
|
+
* The digest of a file, without holding it.
|
|
229
|
+
*
|
|
230
|
+
* Streamed because the files this exists for are the large ones, and reading
|
|
231
|
+
* a gigabyte into memory in order to hash it is the reason it was not being
|
|
232
|
+
* done at all.
|
|
233
|
+
*/
|
|
234
|
+
async function digestOf(full) {
|
|
235
|
+
try {
|
|
236
|
+
const hash = (0, node_crypto_1.createHash)("sha256");
|
|
237
|
+
for await (const block of (0, node_fs_1.createReadStream)(full, {
|
|
238
|
+
highWaterMark: 4 * 1024 * 1024,
|
|
239
|
+
})) {
|
|
240
|
+
hash.update(block);
|
|
241
|
+
}
|
|
242
|
+
return hash.digest("hex");
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return "";
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* How a file reads: its line count, whether it is binary, and its digest.
|
|
250
|
+
*
|
|
251
|
+
* The size decides only whether the lines are counted. It used to decide
|
|
252
|
+
* whether the digest was taken as well, and those are not the same question:
|
|
253
|
+
* counting lines needs the whole file in memory, which is a real reason to
|
|
254
|
+
* stop at a few megabytes, while the digest is what decides whether the file
|
|
255
|
+
* changed at all.
|
|
256
|
+
*
|
|
257
|
+
* Returning no digest for a large file therefore did not mean "not measured",
|
|
258
|
+
* it meant "cannot match" — the comparison below skips a file only when its
|
|
259
|
+
* digest equals the recorded one, and an empty string never does. So every
|
|
260
|
+
* file over four megabytes was reported as changed on every scan, forever,
|
|
261
|
+
* however untouched it was. A project holding two large files offered to
|
|
262
|
+
* upload them again after each save, said "2 not uploaded yet" beside a panel
|
|
263
|
+
* confirming the account already held them, and could not be talked out of it
|
|
264
|
+
* by refreshing, because refreshing recomputed the same empty answer.
|
|
265
|
+
*/
|
|
266
|
+
async function measure(full, size, known) {
|
|
267
|
+
/*
|
|
268
|
+
Unchanged on disk, so unchanged in content. Checked before the size test
|
|
269
|
+
because the whole point is to answer without reading, and the files worth
|
|
270
|
+
not reading are the large ones.
|
|
271
|
+
*/
|
|
272
|
+
const cached = known?.cached;
|
|
273
|
+
if (cached && cached.size === size && cached.mtimeMs === known.mtimeMs) {
|
|
274
|
+
return { lines: 0, binary: true, hash: cached.sha256 };
|
|
275
|
+
}
|
|
276
|
+
if (size > LINE_COUNT_LIMIT) {
|
|
277
|
+
/*
|
|
278
|
+
Read only when the answer could be "unchanged".
|
|
279
|
+
|
|
280
|
+
The digest exists to be compared against a recorded one. A file the
|
|
281
|
+
record has never heard of is new whatever it hashes to, so taking the
|
|
282
|
+
digest decides nothing and costs a full read of the file.
|
|
283
|
+
|
|
284
|
+
That is most of what a first scan is. Hashing large files was necessary
|
|
285
|
+
to stop them reading as changed forever, but doing it for files nothing
|
|
286
|
+
could match turned opening a six hundred megabyte project for the first
|
|
287
|
+
time into two minutes of reading to learn what the absence of a record
|
|
288
|
+
already said.
|
|
289
|
+
*/
|
|
290
|
+
if (!known?.recorded)
|
|
291
|
+
return { lines: 0, binary: true, hash: "" };
|
|
292
|
+
return { lines: 0, binary: true, hash: await digestOf(full) };
|
|
293
|
+
}
|
|
203
294
|
try {
|
|
204
295
|
const contents = await (0, promises_1.readFile)(full);
|
|
205
296
|
const binary = contents.subarray(0, 8192).includes(0);
|
|
@@ -222,7 +313,13 @@ async function measure(full, size) {
|
|
|
222
313
|
* upload when no version has ever been saved. The filter rules decide what
|
|
223
314
|
* is a candidate; the baseline decides what is new.
|
|
224
315
|
*/
|
|
225
|
-
async function changedFiles(root, rules, baseline = null, mode = "add-and-update"
|
|
316
|
+
async function changedFiles(root, rules, baseline = null, mode = "add-and-update",
|
|
317
|
+
/*
|
|
318
|
+
Digests already taken, and what the files looked like when they were.
|
|
319
|
+
Read and written in place, so the caller keeps whatever this learns and
|
|
320
|
+
the next scan does not read the same unchanged gigabyte again.
|
|
321
|
+
*/
|
|
322
|
+
stats) {
|
|
226
323
|
const layers = await collectLayers(root, rules);
|
|
227
324
|
const files = [];
|
|
228
325
|
const present = new Set();
|
|
@@ -258,14 +355,28 @@ async function changedFiles(root, rules, baseline = null, mode = "add-and-update
|
|
|
258
355
|
if ((0, rules_js_1.excludes)(relative, false, layers))
|
|
259
356
|
continue;
|
|
260
357
|
let size;
|
|
358
|
+
let mtimeMs;
|
|
261
359
|
try {
|
|
262
|
-
|
|
360
|
+
const info = await (0, promises_1.stat)(full);
|
|
361
|
+
size = info.size;
|
|
362
|
+
mtimeMs = info.mtimeMs;
|
|
263
363
|
}
|
|
264
364
|
catch {
|
|
265
365
|
continue;
|
|
266
366
|
}
|
|
267
367
|
present.add(relative);
|
|
268
|
-
const measured = await measure(full, size
|
|
368
|
+
const measured = await measure(full, size, {
|
|
369
|
+
mtimeMs,
|
|
370
|
+
cached: stats?.get(relative),
|
|
371
|
+
recorded: baseline?.has(relative) ?? false,
|
|
372
|
+
});
|
|
373
|
+
/*
|
|
374
|
+
Remembered whether or not it changed: the next scan wants to skip
|
|
375
|
+
reading this file again, and that is just as true of one that was
|
|
376
|
+
edited a moment ago as of one that never moves.
|
|
377
|
+
*/
|
|
378
|
+
if (measured.hash)
|
|
379
|
+
stats?.set(relative, { size, mtimeMs, sha256: measured.hash });
|
|
269
380
|
const saved = baseline?.get(relative);
|
|
270
381
|
if (saved && measured.hash && saved === measured.hash)
|
|
271
382
|
continue;
|
|
@@ -300,6 +411,61 @@ async function changedFiles(root, rules, baseline = null, mode = "add-and-update
|
|
|
300
411
|
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
301
412
|
}
|
|
302
413
|
/** The size on disk of everything the rules would upload. */
|
|
414
|
+
/**
|
|
415
|
+
* Every file the rules would upload, with its size and nothing else.
|
|
416
|
+
*
|
|
417
|
+
* Deliberately not `changedFiles`. That function stops at twenty thousand rows
|
|
418
|
+
* because past it a list is unusable — a correct limit for a scrollbar and a
|
|
419
|
+
* catastrophic one for deciding what gets backed up, which is what it was also
|
|
420
|
+
* being used for. A project past that many files was silently uploaded in
|
|
421
|
+
* part.
|
|
422
|
+
*
|
|
423
|
+
* This walk has no cap and reads nothing: no line counting, no hashing, no
|
|
424
|
+
* content at all. That is what makes it affordable to run over everything
|
|
425
|
+
* before deciding anything.
|
|
426
|
+
*/
|
|
427
|
+
async function surveyFiles(root, rules) {
|
|
428
|
+
const layers = await collectLayers(root, rules);
|
|
429
|
+
const found = [];
|
|
430
|
+
const pending = [root];
|
|
431
|
+
while (pending.length) {
|
|
432
|
+
const directory = pending.pop();
|
|
433
|
+
let entries;
|
|
434
|
+
try {
|
|
435
|
+
entries = await (0, promises_1.readdir)(directory, { withFileTypes: true });
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
for (const entry of entries) {
|
|
441
|
+
const full = node_path_1.default.join(directory, entry.name);
|
|
442
|
+
if (entry.isSymbolicLink())
|
|
443
|
+
continue;
|
|
444
|
+
if ((0, rules_js_1.isUncounted)(entry.name))
|
|
445
|
+
continue;
|
|
446
|
+
const relative = node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
|
|
447
|
+
if (entry.isDirectory()) {
|
|
448
|
+
if ((0, rules_js_1.excludes)(relative, true, layers) &&
|
|
449
|
+
!(0, rules_js_1.negationReachesInto)(relative, layers)) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
pending.push(full);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
if (!entry.isFile())
|
|
456
|
+
continue;
|
|
457
|
+
if ((0, rules_js_1.excludes)(relative, false, layers))
|
|
458
|
+
continue;
|
|
459
|
+
try {
|
|
460
|
+
found.push({ path: relative, size: (await (0, promises_1.stat)(full)).size });
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
/* Gone or unreadable since the walk began; the upload reports it. */
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return found.sort((left, right) => left.path.localeCompare(right.path));
|
|
468
|
+
}
|
|
303
469
|
async function totalSize(root, files) {
|
|
304
470
|
let total = 0;
|
|
305
471
|
for (const file of files) {
|
|
@@ -312,6 +478,29 @@ async function totalSize(root, files) {
|
|
|
312
478
|
}
|
|
313
479
|
return total;
|
|
314
480
|
}
|
|
481
|
+
/**
|
|
482
|
+
* The same walk, keeping each file's size rather than only the sum.
|
|
483
|
+
*
|
|
484
|
+
* Used where something has to reason about which files are large — the
|
|
485
|
+
* exclusion suggestions, whose whole value is saying which folder is costing
|
|
486
|
+
* the upload its time. Separate from totalSize so that the common path still
|
|
487
|
+
* carries nothing it does not need.
|
|
488
|
+
*/
|
|
489
|
+
async function fileSizes(root, files) {
|
|
490
|
+
const sized = [];
|
|
491
|
+
for (const file of files) {
|
|
492
|
+
try {
|
|
493
|
+
sized.push({
|
|
494
|
+
path: file.path,
|
|
495
|
+
size: (await (0, promises_1.stat)(node_path_1.default.join(root, file.path))).size,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
/* deleted since the scan; it cannot be measured and does not count */
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return sized;
|
|
503
|
+
}
|
|
315
504
|
/** The unified diff for one file, including files git does not track yet. */
|
|
316
505
|
async function fileDiff(root, file, ignoreWhitespace = false) {
|
|
317
506
|
const output = await git(root, "diff", "--no-ext-diff", "--unified=3", ...(ignoreWhitespace ? ["--ignore-all-space"] : []), "--", file);
|