@coderook/cli 0.29.5 → 0.30.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.
@@ -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, from Claude Code.",
5
- "version": "0.29.5",
5
+ "version": "0.30.0",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -35,6 +35,7 @@ const skill_command_js_1 = require("./skill_command.js");
35
35
  const licence_commands_js_1 = require("./licence_commands.js");
36
36
  const service_commands_js_1 = require("./service_commands.js");
37
37
  const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
38
+ const rules_js_1 = require("../../desktop-app/src/main/rules.js");
38
39
  const upload_js_1 = require("../../desktop-app/src/main/upload.js");
39
40
  const download_js_1 = require("../../desktop-app/src/main/download.js");
40
41
  const faults_js_1 = require("../../desktop-app/src/main/faults.js");
@@ -349,7 +350,22 @@ async function commandStatus(parsed) {
349
350
  console.log(dim("Offline — comparing with the last Version cached for this folder."));
350
351
  }
351
352
  const rules = await (0, worktree_js_1.readRules)(folder);
352
- const files = await (0, worktree_js_1.changedFiles)(folder, rules, baseline);
353
+ /*
354
+ Scanned the way `cbx diff` scans, and reported the way `cbx submit` acts.
355
+
356
+ Status used to ask only for additions and updates, which is exactly what a
357
+ plain save sends — so it was not wrong. But a deleted file then appeared
358
+ nowhere: `cbx diff` showed it as removed, `cbx status` said nothing, and
359
+ the two commands disagreed about the same folder. Somebody who had deleted
360
+ a file read that as CodeRook not having noticed it.
361
+
362
+ So deletions are found and named here, kept apart from what a save will
363
+ send, with the flag that would send them spelled out. The safe default
364
+ does not change: a save adds and updates.
365
+ */
366
+ const found = await (0, worktree_js_1.changedFiles)(folder, rules, baseline, "synchronize");
367
+ const gone = found.filter((file) => file.deleted);
368
+ const files = found.filter((file) => !file.deleted);
353
369
  console.log(bold(node_path_1.default.basename(folder)) + dim(` ${folder}`));
354
370
  console.log(link
355
371
  ? `Account holds ${accent(`v${link.sequence}`)} · ${Object.keys(link.manifest).length} files ${dim(`(${link.slug})`)}`
@@ -370,18 +386,85 @@ async function commandStatus(parsed) {
370
386
  for (const [path] of stale.slice(0, 10))
371
387
  console.log(dim(` behind ${path}`));
372
388
  }
389
+ /** The deletions, and what a plain save will do about them. */
390
+ /*
391
+ What this folder is, and what its own rules never heard about.
392
+
393
+ A real .gitignore always wins — that is not negotiable — but it also meant
394
+ that a Unity project whose file was written before the engine rules
395
+ existed went on sending its entire import cache, and nothing anywhere
396
+ said so. One real upload of forty-seven thousand files was forty-five
397
+ thousand of rebuildable cache, and it broke the project on the machine it
398
+ was downloaded to.
399
+ */
400
+ const engine = async () => {
401
+ const { kind, missing } = await (0, worktree_js_1.missingEngineRules)(folder);
402
+ if (!kind.length || !missing.length)
403
+ return;
404
+ /*
405
+ Only the rules that would actually leave something out.
406
+
407
+ Naming every line the template has and this folder does not produced
408
+ sixty-odd of them, most matching nothing at all — a Visual Studio
409
+ template mentions `[Dd]ebug/x64/` whether or not you have one. What a
410
+ person needs is the two that are costing them forty thousand files, so
411
+ each candidate is measured against what this folder is currently
412
+ sending and the ones worth nothing are not mentioned.
413
+ */
414
+ const sending = found.map((one) => one.path);
415
+ const worth = missing
416
+ .map((rule) => {
417
+ const layers = [{ base: "", rules: (0, rules_js_1.parseRules)(rule) }];
418
+ return {
419
+ rule,
420
+ files: sending.filter((one) => (0, rules_js_1.excludes)(one, false, layers)).length,
421
+ };
422
+ })
423
+ .filter((one) => one.files > 0)
424
+ .sort((left, right) => right.files - left.files);
425
+ if (!worth.length)
426
+ return;
427
+ const total = worth.reduce((sum, one) => sum + one.files, 0);
428
+ console.log(dim(`\nThis looks like a ${kind.join(" + ")} project, and its own rules are ` +
429
+ `missing ${worth.length} that would leave out ` +
430
+ `${total.toLocaleString()} file${total === 1 ? "" : "s"} you are sending — ` +
431
+ `${accent("cbx ignore --template " + kind[0])} adds them.`));
432
+ for (const one of worth.slice(0, 6)) {
433
+ console.log(dim(` ${String(one.files).padStart(7)} ${one.rule}`));
434
+ }
435
+ };
436
+ const deletions = () => {
437
+ if (!gone.length)
438
+ return;
439
+ const many = gone.length === 1 ? "" : "s";
440
+ console.log(dim(`\n${gone.length} file${many} deleted here ${gone.length === 1 ? "is" : "are"} ` +
441
+ `still in the saved version. A save adds and updates, so ` +
442
+ `${gone.length === 1 ? "it stays" : "they stay"} until you pass ` +
443
+ `${accent("--sync")}.`));
444
+ for (const file of gone.slice(0, 10)) {
445
+ console.log(` ${red("deleted")} ${file.path}`);
446
+ }
447
+ if (gone.length > 10)
448
+ console.log(dim(` …and ${gone.length - 10} more`));
449
+ };
373
450
  if (!files.length) {
374
451
  console.log(stale.length
375
452
  ? "Nothing to submit; your own work is all saved."
376
- : "Nothing to submit; this folder matches the saved version.");
453
+ : gone.length
454
+ ? "Nothing here to add or update."
455
+ : "Nothing to submit; this folder matches the saved version.");
456
+ deletions();
457
+ await engine();
377
458
  return 0;
378
459
  }
379
460
  console.log(`\n${files.length} file${files.length === 1 ? "" : "s"} to submit:`);
380
461
  for (const file of files.slice(0, 50)) {
381
- console.log(` ${file.deleted ? red("deleted") : accent("changed")} ${file.path}`);
462
+ console.log(` ${accent("changed")} ${file.path}`);
382
463
  }
383
464
  if (files.length > 50)
384
465
  console.log(dim(` …and ${files.length - 50} more`));
466
+ deletions();
467
+ await engine();
385
468
  return 0;
386
469
  }
387
470
  /**
@@ -1311,11 +1394,14 @@ async function commandRules(parsed) {
1311
1394
  return suggestRules(folder, hasFlag(parsed, "apply"));
1312
1395
  }
1313
1396
  if (hasFlag(parsed, "init")) {
1397
+ /*
1398
+ Whatever `readRules` decided, which is now the template that matches the
1399
+ folder rather than one generic list. Writing STARTER_IGNORE here would
1400
+ have handed a Unity project the JavaScript rules, which is how an upload
1401
+ came back missing things inside Assets.
1402
+ */
1314
1403
  const existing = await (0, worktree_js_1.readRules)(folder);
1315
- await (0, worktree_js_1.writeRules)(folder, {
1316
- shared: existing.shared.trim() ? existing.shared : worktree_js_1.STARTER_IGNORE,
1317
- local: existing.local,
1318
- });
1404
+ await (0, worktree_js_1.writeRules)(folder, existing);
1319
1405
  console.log(`Wrote ${node_path_1.default.join(folder, ".gitignore")}`);
1320
1406
  return 0;
1321
1407
  }
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.STARTER_TOPUP_HEADING = exports.SUGGESTED_HEADING = void 0;
7
+ exports.templatesFor = templatesFor;
7
8
  exports.suggestExclusions = suggestExclusions;
8
9
  exports.suggestBulkDirectories = suggestBulkDirectories;
9
10
  exports.rulesFromSuggestions = rulesFromSuggestions;
@@ -122,6 +123,83 @@ const norm = (value) => value.split("\\").join("/");
122
123
  * their upload will take and the biggest item answers it. Anything matching
123
124
  * nothing is simply absent — silence is the right output for a tidy folder.
124
125
  */
126
+ /**
127
+ * Which of github/gitignore's templates this folder is asking for.
128
+ *
129
+ * The templates have been vendored all along — three hundred of them, taken
130
+ * from github/gitignore — but nothing ever chose one. A folder with no rules
131
+ * of its own got the generic starter instead, and for a game project that is
132
+ * the wrong list: it carries `build/`, `dist/`, `out/` and `models/**` from
133
+ * the JavaScript and Python worlds, none of which are anchored, all of which
134
+ * can reach into somewhere like `Assets/Art/models`.
135
+ *
136
+ * Recognised from the files a project must have to be that kind of project,
137
+ * rather than from anything a person configures. Order matters: an engine is
138
+ * decided first, because a Unity project also has a package.json somewhere
139
+ * and the engine's own template is the one that knows about `Assets`.
140
+ */
141
+ function templatesFor(entries) {
142
+ const here = new Set(entries.map((name) => name.toLowerCase()));
143
+ const any = (test) => entries.some(test);
144
+ const picked = [];
145
+ const take = (key) => {
146
+ if (!picked.includes(key))
147
+ picked.push(key);
148
+ };
149
+ /* An engine first, and at most one: their templates overlap and disagree. */
150
+ if (here.has("assets") && here.has("projectsettings"))
151
+ take("unity");
152
+ else if (any((name) => name.toLowerCase().endsWith(".uproject"))) {
153
+ take("unrealengine");
154
+ }
155
+ else if (here.has("project.godot"))
156
+ take("godot");
157
+ /*
158
+ Cocos before Node, and this order is the whole reason it is written down.
159
+
160
+ A Cocos project carries a package.json, so recognising Node first called
161
+ it a Node project and handed it Node's rules — which know nothing about
162
+ `library` or `temp`, the caches Cocos rebuilds. Measured on the soak
163
+ shape: eight and a half thousand cache files went from left out to sent.
164
+ */
165
+ else if (here.has("project.json") && here.has("assets"))
166
+ take("cocos");
167
+ /* Then the languages, of which a folder may honestly be several. */
168
+ if (here.has("package.json"))
169
+ take("node");
170
+ if (here.has("pyproject.toml") ||
171
+ here.has("requirements.txt") ||
172
+ here.has("setup.py")) {
173
+ take("python");
174
+ }
175
+ if (here.has("cargo.toml"))
176
+ take("rust");
177
+ if (here.has("go.mod"))
178
+ take("go");
179
+ if (here.has("build.gradle") || here.has("build.gradle.kts"))
180
+ take("gradle");
181
+ /*
182
+ An engine's own project files are not a second kind of project.
183
+
184
+ Unity writes the .sln and the .csproj itself, and every engine here does
185
+ something similar. Reading that as "also a Visual Studio project" and
186
+ stacking GitHub's template on top is not a harmless extra: that template
187
+ ignores `*.meta`, which in the .NET world is build output and in Unity is
188
+ the sidecar carrying every asset's GUID. On a real project it would have
189
+ dropped 13,689 of them, and a Unity project without its metas is exactly
190
+ the broken-materials mess this was all started by.
191
+ */
192
+ const engine = picked.length > 0;
193
+ if (!engine && any((name) => name.toLowerCase().endsWith(".sln"))) {
194
+ take("visualstudio");
195
+ }
196
+ if (!engine && any((name) => name.toLowerCase().endsWith(".xcodeproj"))) {
197
+ take("objective-c");
198
+ }
199
+ if (!engine && here.has("cmakelists.txt"))
200
+ take("cmake");
201
+ return picked;
202
+ }
125
203
  function suggestExclusions(files) {
126
204
  const totals = new Map();
127
205
  for (const entry of files) {
@@ -7,6 +7,7 @@ exports.parseRules = parseRules;
7
7
  exports.ruleMatches = ruleMatches;
8
8
  exports.lastMatchingRule = lastMatchingRule;
9
9
  exports.decide = decide;
10
+ exports.decidingRule = decidingRule;
10
11
  exports.excludes = excludes;
11
12
  exports.negationReachesInto = negationReachesInto;
12
13
  exports.isIgnored = isIgnored;
@@ -198,7 +199,21 @@ function decide(relativePath, isDirectory, layers) {
198
199
  * is the dangerous direction to differ in: it uploads what somebody thought
199
200
  * they had excluded.
200
201
  */
201
- function excludes(relativePath, isDirectory, layers) {
202
+ /**
203
+ * The rule that settles this path, counting the directories above it.
204
+ *
205
+ * A directory rule is written about the directory — `/[Ll]ibrary/` matches
206
+ * `Library`, not `Library/Artifacts/f1.dat` — so anything asking only about
207
+ * the file finds no rule and calls it included. The upload has always walked
208
+ * the ancestors; the filter screen asked about the file alone, and so showed
209
+ * a Unity project's whole import cache as going up when the save would have
210
+ * left every byte of it behind.
211
+ *
212
+ * Returned as the rule rather than a yes or no, because the screen names the
213
+ * line responsible — "Library/ — 400 files" is the useful form, and a boolean
214
+ * cannot say it.
215
+ */
216
+ function decidingRule(relativePath, isDirectory, layers) {
202
217
  /*
203
218
  Ancestors first, outermost in. The moment one is excluded the answer is
204
219
  settled and nothing below it can appeal.
@@ -208,9 +223,12 @@ function excludes(relativePath, isDirectory, layers) {
208
223
  const ancestor = parts.slice(0, depth).join("/");
209
224
  const above = decide(ancestor, true, layers);
210
225
  if (above && !above.negated)
211
- return true;
226
+ return above;
212
227
  }
213
- const rule = decide(relativePath, isDirectory, layers);
228
+ return decide(relativePath, isDirectory, layers);
229
+ }
230
+ function excludes(relativePath, isDirectory, layers) {
231
+ const rule = decidingRule(relativePath, isDirectory, layers);
214
232
  return Boolean(rule && !rule.negated);
215
233
  }
216
234
  /**
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TransferProgress = void 0;
4
+ /**
5
+ * How much of an upload has happened, and how fast it is going.
6
+ *
7
+ * Lifted out of the upload pass because nearly every number a person watches
8
+ * during a save comes from here, and three separate faults have come out of it
9
+ * being a handful of loose counters in the middle of a very long function:
10
+ *
11
+ * - Six lanes each added their own offset to one shared total, so the figure
12
+ * jumped between six different answers and the bar ran backwards.
13
+ * - The rate was `transferred` over the whole elapsed time while the bar
14
+ * beside it showed something else, so one real upload sat at
15
+ * "152 MB of 1.1 GB · 10 B/s" — not a slow upload, a number describing a
16
+ * different thing.
17
+ * - A lifetime average meant a slow first minute went on dragging the figure
18
+ * down long after the link had recovered, which is the opposite of what
19
+ * somebody watching it wants to know.
20
+ *
21
+ * So the counters and the arithmetic over them live together, with names, and
22
+ * can be tested without starting an upload.
23
+ */
24
+ class TransferProgress {
25
+ windowMs;
26
+ now;
27
+ /** Bytes of files that have finished, whether they travelled or not. */
28
+ settled = 0;
29
+ /** Bytes that actually crossed the wire. */
30
+ moved = 0;
31
+ /** Files the service already held, so nothing crossed the wire for them. */
32
+ reused = 0;
33
+ /*
34
+ How far each lane is through the file it currently holds, kept apart so the
35
+ total is the sum of real work rather than six lanes' guesses about one
36
+ shared number.
37
+ */
38
+ inFlight = new Map();
39
+ samples = [];
40
+ constructor(
41
+ /** How long a window the rate is measured over. */
42
+ windowMs = 5000,
43
+ /** Injected so a test can move time without waiting for it. */
44
+ now = Date.now) {
45
+ this.windowMs = windowMs;
46
+ this.now = now;
47
+ }
48
+ /** A file finished: its own size, and what it cost to send. */
49
+ finished(sourceBytes, wireBytes) {
50
+ this.settled += sourceBytes;
51
+ this.moved += wireBytes;
52
+ }
53
+ /** A file the service already had. Counted apart so "sent" stays honest. */
54
+ alreadyHeld() {
55
+ this.reused += 1;
56
+ }
57
+ /** Where one lane has got to inside the file it is sending. */
58
+ lane(index, offset) {
59
+ this.inFlight.set(index, offset);
60
+ }
61
+ /** That lane has finished, so its offset is now part of the settled total. */
62
+ laneDone(index) {
63
+ this.inFlight.delete(index);
64
+ }
65
+ /** Everything settled, plus how far the open lanes have got. */
66
+ bytes() {
67
+ let total = this.settled;
68
+ for (const offset of this.inFlight.values())
69
+ total += offset;
70
+ return total;
71
+ }
72
+ get sentBytes() {
73
+ return this.settled;
74
+ }
75
+ get transferred() {
76
+ return this.moved;
77
+ }
78
+ get alreadyOnAccount() {
79
+ return this.reused;
80
+ }
81
+ /**
82
+ * How fast the thing on screen is moving, over a short trailing window.
83
+ *
84
+ * The same counter the bar uses, so the two agree. Sampled rather than
85
+ * recorded per call: this is asked once per report, and reports arrive per
86
+ * file, which for small files is thousands a second.
87
+ */
88
+ rate() {
89
+ const now = this.now();
90
+ const bytes = this.bytes();
91
+ const last = this.samples[this.samples.length - 1];
92
+ if (!last || now - last.at >= 250)
93
+ this.samples.push({ at: now, bytes });
94
+ while (this.samples.length > 2 && now - this.samples[0].at > this.windowMs) {
95
+ this.samples.shift();
96
+ }
97
+ const oldest = this.samples[0];
98
+ const seconds = (now - oldest.at) / 1000;
99
+ /*
100
+ Nothing until there is something to divide. Answering zero keeps the
101
+ window quiet rather than showing a figure computed from a fraction of a
102
+ second, which reads as a stall on an upload that has barely begun.
103
+ */
104
+ if (seconds < 0.5)
105
+ return 0;
106
+ return Math.max(0, Math.round((bytes - oldest.bytes) / seconds));
107
+ }
108
+ }
109
+ exports.TransferProgress = TransferProgress;