@coderook/cli 0.34.6 → 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.
@@ -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.6",
5
+ "version": "0.34.7",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -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: held.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
- const shown = dropped.slice(0, 5).join(", ");
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
  /*
@@ -1016,7 +1092,7 @@ class Uploader {
1016
1092
  const manifest = Object.keys(acceptedManifest).length
1017
1093
  ? acceptedManifest
1018
1094
  : {
1019
- ...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])),
1020
1096
  ...Object.fromEntries(declarations.map((declaration) => [declaration.path, declaration.sha256])),
1021
1097
  };
1022
1098
  /*
@@ -1031,6 +1107,16 @@ class Uploader {
1031
1107
  the very end of a save.
1032
1108
  */
1033
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
+ }
1034
1120
  const onDisk = new Set(everything.map((file) => file.path));
1035
1121
  const local = Object.fromEntries(everything.flatMap((file) => {
1036
1122
  const sent = sentDigest.get(file.path);
@@ -1104,6 +1190,8 @@ class Uploader {
1104
1190
  * rather than before.
1105
1191
  */
1106
1192
  async sendObjects(at) {
1193
+ if (!this.planning)
1194
+ this.sendingBegan = true;
1107
1195
  const { request, repositoryId, prior,
1108
1196
  /*
1109
1197
  Bound under a distinct name on purpose. `runSection` declares its own
@@ -1708,6 +1796,12 @@ class Uploader {
1708
1796
  than from the disk.
1709
1797
  */
1710
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);
1711
1805
  /*
1712
1806
  A file that is exactly what the last version held is kept, not sent.
1713
1807
 
@@ -1723,31 +1817,48 @@ class Uploader {
1723
1817
  same bytes, and the previous version's object for them — a pack slice,
1724
1818
  its chunks, or a plain object — is named again instead.
1725
1819
  */
1726
- const keptAsItWas = (declaration) => {
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) => {
1727
1837
  const was = prior.get(declaration.path);
1728
- return (!!was &&
1729
- was.sha256 === declaration.sha256 &&
1730
- !deletions.has(declaration.path) &&
1731
- !!(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;
1732
1841
  };
1842
+ const keptAsItWas = (declaration) => storedAs(declaration) !== null;
1733
1843
  const declarations = hashed.declarations.filter((one) => !keptAsItWas(one));
1734
1844
  let totalBytes = hashed.totalBytes;
1735
1845
  for (const one of hashed.declarations) {
1736
- if (!keptAsItWas(one))
1846
+ const held = storedAs(one);
1847
+ if (!held)
1737
1848
  continue;
1738
1849
  totalBytes -= one.size;
1739
- const held = prior.get(one.path);
1740
1850
  reused.push({
1741
1851
  path: one.path,
1742
1852
  sourceSize: held.sourceSize,
1743
1853
  storedSize: held.storedSize,
1744
- mediaType: held.mediaType,
1854
+ mediaType: one.mediaType,
1745
1855
  added: 0,
1746
1856
  removed: 0,
1747
1857
  included: true,
1748
1858
  deleted: false,
1749
1859
  binary: false,
1750
1860
  lines: 0,
1861
+ ...(held === prior.get(one.path) ? {} : { storedAs: held }),
1751
1862
  });
1752
1863
  }
1753
1864
  (0, profile_js_1.counted)("files kept as they were", hashed.declarations.length - declarations.length);
@@ -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
- console.log(` ${accent("changed")} ${file.path}`);
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))}`);
@@ -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} versions, ` +
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.6",
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",