@coderook/cli 0.31.2 → 0.31.4

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.31.2",
5
+ "version": "0.31.4",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -39,6 +39,7 @@ const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
39
39
  const rules_js_1 = require("../../desktop-app/src/main/rules.js");
40
40
  const upload_js_1 = require("../../desktop-app/src/main/upload.js");
41
41
  const download_js_1 = require("../../desktop-app/src/main/download.js");
42
+ const safe_path_js_1 = require("../../desktop-app/src/shared/safe_path.js");
42
43
  const faults_js_1 = require("../../desktop-app/src/main/faults.js");
43
44
  const version_commands_js_1 = require("./version_commands.js");
44
45
  const identify_js_1 = require("../../desktop-app/src/main/identify.js");
@@ -1477,7 +1478,19 @@ async function commandUnbundle(parsed) {
1477
1478
  return 1;
1478
1479
  }
1479
1480
  const manifest = await (0, cbx_js_1.readManifest)(node_path_1.default.resolve(source));
1480
- const destination = node_path_1.default.resolve(parsed.positional[1] ?? manifest.source_name);
1481
+ /*
1482
+ The bundle's own name, never its path. `source_name` comes from the file
1483
+ being opened, and taken whole it could be `/home/you/.ssh` or `C:\Users` —
1484
+ and the folder it names is deleted and replaced. Only the last name
1485
+ survives, into the current folder, and only if it is a plain name.
1486
+ */
1487
+ const suggested = node_path_1.default.posix.basename((manifest.source_name ?? "").replace(/\\/g, "/"));
1488
+ const plainName = suggested && !(0, safe_path_js_1.whyUnsafe)(suggested) && !/^[.]+$/.test(suggested) && !/[:]/.test(suggested);
1489
+ if (!parsed.positional[1] && !plainName) {
1490
+ console.error(red("That bundle does not name a usable folder. Try: cbx unbundle <file.cbx> <folder>"));
1491
+ return 1;
1492
+ }
1493
+ const destination = node_path_1.default.resolve(parsed.positional[1] ?? suggested);
1481
1494
  const line = progressLine();
1482
1495
  const result = await (0, cbx_js_1.unpackBundle)(node_path_1.default.resolve(source), destination, (progress) => {
1483
1496
  line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
@@ -22,6 +22,8 @@ exports.stamp = stamp;
22
22
  exports.importRef = importRef;
23
23
  exports.quotePath = quotePath;
24
24
  exports.refPlacements = refPlacements;
25
+ exports.withinDepth = withinDepth;
26
+ exports.historyLeftOut = historyLeftOut;
25
27
  /**
26
28
  * Git's constant hash for the empty tree.
27
29
  *
@@ -262,3 +264,59 @@ function refPlacements(wanted, markOfVersion) {
262
264
  }
263
265
  return { placed, unplaceable };
264
266
  }
267
+ /**
268
+ * The versions within `depth` generations of these heads: 1 is the heads
269
+ * alone, 2 adds their parents, and so on.
270
+ *
271
+ * What `git clone --depth` means here. The oldest version taken is imported
272
+ * with no parent, so it becomes a root commit — which also means its commit id
273
+ * differs from the same version's id in a full clone, because a commit's id
274
+ * covers its parents. That is the same trade git's own shallow clones make.
275
+ */
276
+ function withinDepth(heads, all, depth) {
277
+ const byId = new Map(all.map((version) => [version.id, version]));
278
+ const kept = new Set();
279
+ let level = heads.filter((id) => byId.has(id));
280
+ for (let generation = 0; generation < depth && level.length; generation += 1) {
281
+ const next = [];
282
+ for (const id of level) {
283
+ if (kept.has(id))
284
+ continue;
285
+ kept.add(id);
286
+ for (const parent of byId.get(id).parentVersionIds) {
287
+ if (byId.has(parent) && !kept.has(parent))
288
+ next.push(parent);
289
+ }
290
+ }
291
+ level = next;
292
+ }
293
+ return kept;
294
+ }
295
+ /**
296
+ * History an earlier shallow clone chose not to take.
297
+ *
298
+ * Every ancestor of something already imported that was not itself imported.
299
+ * A full clone imported every ancestor, so for it this is always empty; for a
300
+ * shallow one it is everything below the cut. Without it the first ordinary
301
+ * `git fetch` after a `--depth 1` clone would import the whole history anyway,
302
+ * as a second, disconnected line of commits nobody asked for.
303
+ */
304
+ function historyLeftOut(all, imported) {
305
+ const byId = new Map(all.map((version) => [version.id, version]));
306
+ const have = new Set(imported);
307
+ const left = new Set();
308
+ const pending = [];
309
+ for (const id of have) {
310
+ for (const parent of byId.get(id)?.parentVersionIds ?? [])
311
+ pending.push(parent);
312
+ }
313
+ while (pending.length) {
314
+ const id = pending.pop();
315
+ if (have.has(id) || left.has(id) || !byId.has(id))
316
+ continue;
317
+ left.add(id);
318
+ for (const parent of byId.get(id).parentVersionIds)
319
+ pending.push(parent);
320
+ }
321
+ return left;
322
+ }
@@ -109,6 +109,7 @@ exports.main = main;
109
109
  * where the original branched.
110
110
  */
111
111
  const node_child_process_1 = require("node:child_process");
112
+ const safe_path_js_1 = require("../../desktop-app/src/shared/safe_path.js");
112
113
  const promises_1 = require("node:fs/promises");
113
114
  const node_os_1 = __importDefault(require("node:os"));
114
115
  const node_path_1 = __importDefault(require("node:path"));
@@ -136,6 +137,17 @@ class ImportFailed extends Error {
136
137
  class AlreadyReported extends Error {
137
138
  }
138
139
  exports.AlreadyReported = AlreadyReported;
140
+ /**
141
+ * A name as fast-import may carry it on an author, committer or tagger line.
142
+ *
143
+ * Every other line of the stream is built from values the helper made; these
144
+ * come from what people typed. A newline in a display name ended the line and
145
+ * began another — any command the stream accepts, a symlink or a ref update
146
+ * included — and `<` or `>` would end the name early. Each becomes a space.
147
+ */
148
+ function identity(name) {
149
+ return name.replace(/[\x00-\x1f\x7f<>]/g, " ").replace(/\s+/g, " ").trim() || "CodeRook";
150
+ }
139
151
  /** Everything the helper writes for a person goes to stderr; stdout is protocol. */
140
152
  function say(text) {
141
153
  node_process_1.default.stderr.write(`${text}\n`);
@@ -544,8 +556,14 @@ async function recordPushedCommits(marks) {
544
556
  first and an empty window always accepts one more.
545
557
  */
546
558
  const BLOB_LANES = 8;
559
+ /** Fewer new files than this, and one request for them each is quicker. */
560
+ const ARCHIVE_MIN_FILES = 64;
561
+ /** The share of a version's bytes that must be new before it is fetched whole. */
562
+ const ARCHIVE_MIN_SHARE = 0.5;
547
563
  const BLOB_WINDOW_BYTES = 64 * 1024 * 1024;
548
- async function doImport(refs, url) {
564
+ async function doImport(refs, url,
565
+ /** From `git clone --depth N`; only honoured on a first import. */
566
+ depth = null) {
549
567
  const { owner, slug } = (0, git_history_js_1.parseRemoteUrl)(url);
550
568
  const project = await (0, api_js_1.findProject)(owner ? `${owner}/${slug}` : slug);
551
569
  if (!project) {
@@ -593,6 +611,26 @@ async function doImport(refs, url) {
593
611
  let nextMark = 1;
594
612
  for (const mark of commitMark.values())
595
613
  nextMark = Math.max(nextMark, mark + 1);
614
+ /*
615
+ What this import takes, out of everything the heads can reach.
616
+
617
+ Never history an earlier shallow clone left out: its first ordinary fetch
618
+ would otherwise bring the lot in as a second line of commits. And on a
619
+ first import with `--depth`, only that many generations of each line.
620
+ */
621
+ const leftOut = (0, git_history_js_1.historyLeftOut)(all, commitMark.keys());
622
+ let chosen = order.filter((version) => !leftOut.has(version.id));
623
+ if (depth && depth > 0) {
624
+ if (commitMark.size) {
625
+ say(` --depth applies to a new clone; fetching everything newer than what is here.`);
626
+ }
627
+ else {
628
+ const keep = (0, git_history_js_1.withinDepth)([...wanted.values()], all, depth);
629
+ chosen = chosen.filter((version) => keep.has(version.id));
630
+ say(`Taking the newest ${depth} version${depth === 1 ? "" : "s"} of each line ` +
631
+ `(--depth ${depth}); ${order.length - chosen.length} older left out.`);
632
+ }
633
+ }
596
634
  /*
597
635
  Point every ref git asked for at the commit its line's head became.
598
636
 
@@ -610,7 +648,7 @@ async function doImport(refs, url) {
610
648
  say(` could not place ${ref}: no commit for its head version`);
611
649
  }
612
650
  };
613
- const fresh = order.filter((version) => !commitMark.has(version.id));
651
+ const fresh = chosen.filter((version) => !commitMark.has(version.id));
614
652
  if (!fresh.length) {
615
653
  say(`Already up to date with ${slug}.`);
616
654
  /*
@@ -631,8 +669,8 @@ async function doImport(refs, url) {
631
669
  return;
632
670
  }
633
671
  say(`Importing ${fresh.length} version${fresh.length === 1 ? "" : "s"} from ${slug}` +
634
- (order.length > fresh.length
635
- ? ` (${order.length - fresh.length} already here)`
672
+ (chosen.length > fresh.length
673
+ ? ` (${chosen.length - fresh.length} already here)`
636
674
  : "") +
637
675
  ".");
638
676
  const { Downloader } = await Promise.resolve().then(() => __importStar(require("../../desktop-app/src/main/download.js")));
@@ -667,41 +705,7 @@ async function doImport(refs, url) {
667
705
  bytes together, because sixteen source files and sixteen video files
668
706
  are not the same amount to be holding at once.
669
707
  */
670
- /*
671
- The whole version in one request, where that works.
672
-
673
- Fetching per file is one round trip each: 21,071 of them on a real
674
- Unity project, which is four hours in series and about half an hour
675
- even eight at a time. The archive is the same bytes in one request.
676
-
677
- Anything unexpected about it — an entry this cannot read, a short
678
- download, a service that does not offer the route — falls through to
679
- the per-file path below, which is slow and known to work. A clone
680
- that is slow is a nuisance; a clone that writes the wrong bytes is
681
- not recoverable by trying again.
682
- */
683
- const fromArchive = new Map();
684
- try {
685
- const packed = await downloader.archive(repositoryId, version.id);
686
- for (const entry of (0, stored_zip_js_1.readStoredZip)(packed)) {
687
- fromArchive.set(entry.path, entry.bytes);
688
- }
689
- }
690
- catch (error) {
691
- /*
692
- Said out loud, not swallowed.
693
-
694
- This fell back silently, and the silence cost an hour: a deadline
695
- meant for ordinary requests was aborting the archive, the import
696
- dropped to fetching every file on its own, and from the outside it
697
- looked exactly like the slow clone the archive was added to fix.
698
- A fallback nobody can see is a fallback nobody can diagnose.
699
- */
700
- say(` the archive for v${version.sequence} could not be used ` +
701
- `(${error instanceof Error ? error.message : String(error)}); ` +
702
- `fetching its files one by one`);
703
- fromArchive.clear();
704
- }
708
+ /* Only content not already imported: a blob is written once, by digest. */
705
709
  const seenBlob = new Set();
706
710
  const wantedBlobs = files.filter((file) => {
707
711
  if (blobMark.has(file.sha256) || seenBlob.has(file.sha256))
@@ -709,6 +713,57 @@ async function doImport(refs, url) {
709
713
  seenBlob.add(file.sha256);
710
714
  return true;
711
715
  });
716
+ /*
717
+ The whole version in one request — but only when most of it is new.
718
+
719
+ The archive is every file the version holds, and this used to fetch it
720
+ for every version and then keep only the files not already imported.
721
+ After the first, that is nearly none of them: a five-version clone of
722
+ a large project downloaded its whole snapshot five times over to use
723
+ the changed files out of each, which is why an archive added to speed
724
+ clones up left one taking eighty-two minutes.
725
+
726
+ So it is weighed per version: where the files still wanted are at
727
+ least half of the version's bytes, one request for the lot wins; where
728
+ they are a handful, fetching just those does. Below a few dozen files
729
+ the round trips are not worth avoiding at all.
730
+
731
+ Where it is worth it, fetching per file would be one round trip each:
732
+ 21,071 of them on a real Unity project, four hours in series and about
733
+ half an hour even eight at a time.
734
+
735
+ Anything unexpected about the archive — an entry this cannot read, a
736
+ short download, a service that does not offer the route — falls
737
+ through to the per-file path below, which is slow and known to work.
738
+ A clone that is slow is a nuisance; a clone that writes the wrong bytes
739
+ is not recoverable by trying again.
740
+ */
741
+ const bytesOf = (list) => list.reduce((sum, file) => sum + Number(file.sourceSize ?? 0), 0);
742
+ const archiveWorthIt = wantedBlobs.length >= ARCHIVE_MIN_FILES &&
743
+ bytesOf(wantedBlobs) >= bytesOf(files) * ARCHIVE_MIN_SHARE;
744
+ const fromArchive = new Map();
745
+ if (archiveWorthIt)
746
+ try {
747
+ const packed = await downloader.archive(repositoryId, version.id);
748
+ for (const entry of (0, stored_zip_js_1.readStoredZip)(packed)) {
749
+ fromArchive.set(entry.path, entry.bytes);
750
+ }
751
+ }
752
+ catch (error) {
753
+ /*
754
+ Said out loud, not swallowed.
755
+
756
+ This fell back silently, and the silence cost an hour: a deadline
757
+ meant for ordinary requests was aborting the archive, the import
758
+ dropped to fetching every file on its own, and from the outside it
759
+ looked exactly like the slow clone the archive was added to fix.
760
+ A fallback nobody can see is a fallback nobody can diagnose.
761
+ */
762
+ say(` the archive for v${version.sequence} could not be used ` +
763
+ `(${error instanceof Error ? error.message : String(error)}); ` +
764
+ `fetching its files one by one`);
765
+ fromArchive.clear();
766
+ }
712
767
  for (let at = 0; at < wantedBlobs.length;) {
713
768
  const window = [];
714
769
  let held = 0;
@@ -765,8 +820,8 @@ async function doImport(refs, url) {
765
820
  worse than an obviously synthetic one: it survives being copied into a
766
821
  mailing list or a CONTRIBUTORS file.
767
822
  */
768
- send(`author ${author} <noreply@coderook.com> ${when}`);
769
- send(`committer ${author} <noreply@coderook.com> ${when}`);
823
+ send(`author ${identity(author)} <noreply@coderook.com> ${when}`);
824
+ send(`committer ${identity(author)} <noreply@coderook.com> ${when}`);
770
825
  send(`data ${body.length}`);
771
826
  node_process_1.default.stdout.write(body);
772
827
  node_process_1.default.stdout.write("\n");
@@ -776,6 +831,13 @@ async function doImport(refs, url) {
776
831
  send(`merge :${extra}`);
777
832
  send("deleteall");
778
833
  for (const file of files) {
834
+ /*
835
+ Never into git's own directory. Git would refuse to check one out,
836
+ but it would sit in the history of every clone; new versions cannot
837
+ hold one, and this keeps older ones from carrying it into git.
838
+ */
839
+ if (file.path.split("/").some(safe_path_js_1.isGitDirectoryName))
840
+ continue;
779
841
  send(`M 100644 :${blobMark.get(file.sha256)} ${(0, git_history_js_1.quotePath)(file.path)}`);
780
842
  }
781
843
  send("");
@@ -837,9 +899,12 @@ async function doImport(refs, url) {
837
899
  if (mark === undefined)
838
900
  continue;
839
901
  const body = Buffer.from(release.notes || release.name, "utf8");
840
- send(`tag ${release.name}`);
902
+ const tagName = release.name.replace(/[\s~^:?*[\\\x00-\x1f]/g, "-");
903
+ if (!tagName || tagName.startsWith("-") || tagName.includes(".."))
904
+ continue;
905
+ send(`tag ${tagName}`);
841
906
  send(`from :${mark}`);
842
- send(`tagger ${release.releasedBy || "CodeRook"} <noreply@coderook.com> ` +
907
+ send(`tagger ${identity(release.releasedBy || "CodeRook")} <noreply@coderook.com> ` +
843
908
  `${(0, git_history_js_1.stamp)(release.releasedAt)} +0000`);
844
909
  send(`data ${body.length}`);
845
910
  node_process_1.default.stdout.write(body);
@@ -1393,6 +1458,8 @@ async function main(argv) {
1393
1458
  clone of a project you cannot see left an empty folder and exited zero.
1394
1459
  */
1395
1460
  let unservable = false;
1461
+ /** `git clone --depth N`, when git passed one. */
1462
+ let depth = null;
1396
1463
  for await (const line of lines()) {
1397
1464
  const command = line.trim();
1398
1465
  if (command === "capabilities") {
@@ -1406,9 +1473,19 @@ async function main(argv) {
1406
1473
  continue;
1407
1474
  }
1408
1475
  if (command.startsWith("option ")) {
1409
- // Nothing here changes behaviour yet, and claiming otherwise would make
1410
- // git believe a setting took effect.
1411
- send("unsupported");
1476
+ /*
1477
+ Only what is actually acted on is accepted: answering "ok" to anything
1478
+ else would make git believe a setting took effect. Depth is honoured
1479
+ by importing fewer versions; git passes it for `clone --depth`.
1480
+ */
1481
+ const [, name, value] = command.split(" ");
1482
+ if (name === "depth" && /^[1-9][0-9]*$/.test(value ?? "")) {
1483
+ depth = Number(value);
1484
+ send("ok");
1485
+ }
1486
+ else {
1487
+ send("unsupported");
1488
+ }
1412
1489
  continue;
1413
1490
  }
1414
1491
  if (command === "list" || command === "list for-push") {
@@ -1521,7 +1598,7 @@ async function main(argv) {
1521
1598
  if (importing.length) {
1522
1599
  const batch = importing.splice(0, importing.length);
1523
1600
  try {
1524
- await doImport(batch, url);
1601
+ await doImport(batch, url, depth);
1525
1602
  }
1526
1603
  catch (error) {
1527
1604
  /*
@@ -42,11 +42,14 @@ function readStoredZip(archive) {
42
42
  const names = new TextDecoder("utf-8", { fatal: false });
43
43
  const entries = [];
44
44
  let at = 0;
45
+ let ended = false;
45
46
  while (at + 4 <= archive.length) {
46
47
  const signature = view.getUint32(at, true);
47
48
  /* Either record marks the end of the entries. */
48
- if (signature === CENTRAL_HEADER || signature === END_OF_DIRECTORY)
49
+ if (signature === CENTRAL_HEADER || signature === END_OF_DIRECTORY) {
50
+ ended = true;
49
51
  break;
52
+ }
50
53
  if (signature !== LOCAL_HEADER) {
51
54
  throw new Error(`Not a stored archive: unexpected record at byte ${at}`);
52
55
  }
@@ -81,5 +84,42 @@ function readStoredZip(archive) {
81
84
  });
82
85
  at = end;
83
86
  }
87
+ /*
88
+ A zip is only whole once its directory arrives.
89
+
90
+ Running out of bytes exactly between two entries used to read as the end
91
+ of the archive. It is also exactly what a download cut off at a file
92
+ boundary looks like — and the service's archive of a 9,010-file version
93
+ was being cut off, by a Worker limit, after 4,978 of them. Every one of
94
+ those read back perfectly, so the clone took them and fetched the other
95
+ 4,032 one at a time without a word, which is why it took half an hour.
96
+ Now the directory has to be there, and has to count what was read.
97
+ */
98
+ if (!ended) {
99
+ throw new Error(`Archive was cut off after ${entries.length} entries: it has no directory`);
100
+ }
101
+ const counted = directoryCount(view);
102
+ if (counted === null) {
103
+ throw new Error("Archive has no end-of-directory record");
104
+ }
105
+ if (counted !== entries.length) {
106
+ throw new Error(`Archive says it holds ${counted} entries and ${entries.length} were read`);
107
+ }
84
108
  return entries;
85
109
  }
110
+ /**
111
+ * How many entries the archive's end-of-directory record says it holds.
112
+ *
113
+ * Searched for from the end, as the format intends: the record is the last
114
+ * thing in the file, followed only by a comment of up to 64 KB.
115
+ */
116
+ function directoryCount(view) {
117
+ const last = view.byteLength - 22;
118
+ const first = Math.max(0, last - 0xffff);
119
+ for (let at = last; at >= first; at -= 1) {
120
+ if (view.getUint32(at, true) === END_OF_DIRECTORY) {
121
+ return view.getUint16(at + 10, true);
122
+ }
123
+ }
124
+ return null;
125
+ }
@@ -29,6 +29,7 @@ const node_crypto_1 = require("node:crypto");
29
29
  const node_fs_1 = require("node:fs");
30
30
  const promises_1 = require("node:fs/promises");
31
31
  const node_path_1 = __importDefault(require("node:path"));
32
+ const safe_path_js_1 = require("../shared/safe_path.js");
32
33
  const node_zlib_1 = require("node:zlib");
33
34
  const download_js_1 = require("./download.js");
34
35
  const chunking_js_1 = require("../shared/chunking.js");
@@ -343,9 +344,9 @@ async function unpackBundle(source, destination, report) {
343
344
  let written = 0;
344
345
  let bytes = 0;
345
346
  for (const directory of manifest.directories ?? []) {
346
- const parts = safeParts(directory);
347
- if (parts.length)
348
- await (0, promises_1.mkdir)(node_path_1.default.join(staging, ...parts), { recursive: true });
347
+ if (!directory || directory === ".")
348
+ continue;
349
+ await (0, promises_1.mkdir)((0, safe_path_js_1.inside)(staging, directory), { recursive: true });
349
350
  }
350
351
  for (const record of manifest.files) {
351
352
  report?.({
@@ -356,10 +357,8 @@ async function unpackBundle(source, destination, report) {
356
357
  path: record.path,
357
358
  percent: (0, download_js_1.underway)(bytes, totalBytes),
358
359
  });
359
- const parts = safeParts(record.path);
360
- if (!parts.length)
361
- throw new Error(`Unsafe path in bundle: ${record.path}`);
362
- const full = node_path_1.default.join(staging, ...parts);
360
+ /* Refuses `..`, absolute paths and git's own directory; see safe_path.ts. */
361
+ const full = (0, safe_path_js_1.inside)(staging, record.path);
363
362
  await (0, promises_1.mkdir)(node_path_1.default.dirname(full), { recursive: true });
364
363
  const pieces = [];
365
364
  if (record.segments) {
@@ -405,15 +404,6 @@ async function unpackBundle(source, destination, report) {
405
404
  await handle.close();
406
405
  }
407
406
  }
408
- /** Reject anything that would escape the destination before it is used. */
409
- function safeParts(relative) {
410
- const parts = relative.split(/[\\/]/).filter((part) => part && part !== ".");
411
- if (parts.some((part) => part === ".." || part.includes("\0")))
412
- return [];
413
- if (node_path_1.default.isAbsolute(relative) || /^[a-zA-Z]:/.test(relative))
414
- return [];
415
- return parts;
416
- }
417
407
  /** Every file under `root`, relative and POSIX-separated, sorted. */
418
408
  async function collectEntries(root) {
419
409
  const found = [];
@@ -19,6 +19,7 @@ const promises_1 = require("node:fs/promises");
19
19
  const promises_2 = require("node:stream/promises");
20
20
  const node_stream_1 = require("node:stream");
21
21
  const node_path_1 = __importDefault(require("node:path"));
22
+ const safe_path_js_1 = require("../shared/safe_path.js");
22
23
  const identify_js_1 = require("./identify.js");
23
24
  const faults_js_1 = require("./faults.js");
24
25
  const retry_js_1 = require("./retry.js");
@@ -557,14 +558,18 @@ class Downloader {
557
558
  jobs.push({ kind: "file", file });
558
559
  }
559
560
  }
560
- /** Refused outright, wherever it appears — inside a pack or beside it. */
561
- const safeParts = (where) => {
562
- const parts = where.split("/").filter(Boolean);
563
- if (parts.some((part) => part === ".." || part.includes("\0"))) {
564
- throw new Error(`That version contains an unsafe path: ${where}`);
561
+ /*
562
+ Every path checked before a byte is fetched, so a version carrying one
563
+ it must not write is refused whole rather than half-written. The check
564
+ used to refuse `..` alone, which let `.git/config` through to be
565
+ renamed over a checkout's real one — see shared/safe_path.ts.
566
+ */
567
+ for (const file of files) {
568
+ const reason = (0, safe_path_js_1.whyUnsafe)(file.path);
569
+ if (reason) {
570
+ throw new Error(`That version contains an unsafe path (${reason}): ${file.path}`);
565
571
  }
566
- return parts;
567
- };
572
+ }
568
573
  const told = { at: 0 };
569
574
  const sayProgress = (where) => {
570
575
  const now = Date.now();
@@ -599,7 +604,6 @@ class Downloader {
599
604
  }
600
605
  for (const member of packed.get(packId) ?? []) {
601
606
  this.check();
602
- const memberParts = safeParts(member.path);
603
607
  const offset = member.pack.offset;
604
608
  const length = member.pack.length;
605
609
  if (offset < 0 || length < 0 || offset + length > body.byteLength) {
@@ -610,7 +614,7 @@ class Downloader {
610
614
  if (member.sha256 && digest !== member.sha256) {
611
615
  throw new Error(`${member.path} did not arrive intact`);
612
616
  }
613
- const full = node_path_1.default.join(staging, ...memberParts);
617
+ const full = (0, safe_path_js_1.inside)(staging, member.path);
614
618
  await (0, promises_1.mkdir)(node_path_1.default.dirname(full), { recursive: true });
615
619
  await (0, promises_1.writeFile)(full, contents);
616
620
  manifest[member.path] = digest;
@@ -620,8 +624,7 @@ class Downloader {
620
624
  }
621
625
  };
622
626
  const restoreFile = async (file) => {
623
- const parts = safeParts(file.path);
624
- const full = node_path_1.default.join(staging, ...parts);
627
+ const full = (0, safe_path_js_1.inside)(staging, file.path);
625
628
  await (0, promises_1.mkdir)(node_path_1.default.dirname(full), { recursive: true });
626
629
  const digest = await this.fetchInto(repositoryId, versionId, file, full);
627
630
  /*
@@ -721,8 +724,7 @@ class Downloader {
721
724
  await (0, promises_1.mkdir)(destination, { recursive: true });
722
725
  let placed = 0;
723
726
  for (const file of files) {
724
- const parts = file.path.split("/").filter(Boolean);
725
- const target = node_path_1.default.join(destination, ...parts);
727
+ const target = (0, safe_path_js_1.inside)(destination, file.path);
726
728
  /*
727
729
  Reconciling leaves alone whatever the incoming version did not
728
730
  change. Writing it anyway would replace an edit in progress with a
@@ -737,7 +739,7 @@ class Downloader {
737
739
  }
738
740
  await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
739
741
  await (0, promises_1.rm)(target, { recursive: true, force: true });
740
- await (0, promises_1.rename)(node_path_1.default.join(staging, ...parts), target);
742
+ await (0, promises_1.rename)((0, safe_path_js_1.inside)(staging, file.path), target);
741
743
  placed += 1;
742
744
  if (placed === 1)
743
745
  (0, faults_js_1.maybeFail)("get:after-first-file");
@@ -751,10 +753,10 @@ class Downloader {
751
753
  for (const [gone, digest] of Object.entries(held ?? {})) {
752
754
  if (manifest[gone] !== undefined)
753
755
  continue;
754
- const parts = gone.split("/").filter(Boolean);
755
- if (parts.some((part) => part === ".." || part.includes("\0")))
756
+ /* A path it must not write is not one it may delete either. */
757
+ if ((0, safe_path_js_1.whyUnsafe)(gone))
756
758
  continue;
757
- const target = node_path_1.default.join(destination, ...parts);
759
+ const target = (0, safe_path_js_1.inside)(destination, gone);
758
760
  /*
759
761
  The version dropped it — but if the bytes here are no longer the
760
762
  ones this folder received, somebody has been working on it, and
@@ -1478,7 +1478,50 @@ class Uploader {
1478
1478
  Read once, here. Everything below works from the declarations rather
1479
1479
  than from the disk.
1480
1480
  */
1481
- const { declarations, totalBytes } = await this.hashSelection(request, sending, deletions, readFailures, vanished, report);
1481
+ const hashed = await this.hashSelection(request, sending, deletions, readFailures, vanished, report);
1482
+ /*
1483
+ A file that is exactly what the last version held is kept, not sent.
1484
+
1485
+ Both callers select everything — the command line says so with an empty
1486
+ exclusion list, because its list of changes stops at twenty thousand —
1487
+ and a selected file used to be a file to send. Most of a project is
1488
+ small files, and small files are stored inside packs, so the service
1489
+ cannot recognise one as already held; the whole lot was packed again and
1490
+ sent again on every save. CodeBox's own saves sent 3.1 MB across 1,025
1491
+ files to record a change to eight.
1492
+
1493
+ The digest has just been taken, so the comparison is exact: same path,
1494
+ same bytes, and the previous version's object for them — a pack slice,
1495
+ its chunks, or a plain object — is named again instead.
1496
+ */
1497
+ const keptAsItWas = (declaration) => {
1498
+ const was = prior.get(declaration.path);
1499
+ return (!!was &&
1500
+ was.sha256 === declaration.sha256 &&
1501
+ !deletions.has(declaration.path) &&
1502
+ !!(was.pack?.objectId || was.chunks?.length || was.objectId));
1503
+ };
1504
+ const declarations = hashed.declarations.filter((one) => !keptAsItWas(one));
1505
+ let totalBytes = hashed.totalBytes;
1506
+ for (const one of hashed.declarations) {
1507
+ if (!keptAsItWas(one))
1508
+ continue;
1509
+ totalBytes -= one.size;
1510
+ const held = prior.get(one.path);
1511
+ reused.push({
1512
+ path: one.path,
1513
+ sourceSize: held.sourceSize,
1514
+ storedSize: held.storedSize,
1515
+ mediaType: held.mediaType,
1516
+ added: 0,
1517
+ removed: 0,
1518
+ included: true,
1519
+ deleted: false,
1520
+ binary: false,
1521
+ lines: 0,
1522
+ });
1523
+ }
1524
+ (0, profile_js_1.counted)("files kept as they were", hashed.declarations.length - declarations.length);
1482
1525
  // 2. The project needs somewhere on the account to live.
1483
1526
  const repositoryId = await this.ensureRepository(request);
1484
1527
  // Learn the wire contract once, before the two section pipelines begin.
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.isGitDirectoryName = isGitDirectoryName;
7
+ exports.whyUnsafe = whyUnsafe;
8
+ exports.safeParts = safeParts;
9
+ exports.inside = inside;
10
+ /**
11
+ * Whether a path from a version may be written into somebody's folder.
12
+ *
13
+ * Written after Gitea's CVE-2026-60004, where repository content landed in a
14
+ * hooks directory and git ran it. Nothing here runs git, but the folder a
15
+ * project is fetched into very often is a git checkout — and a version holding
16
+ * `.git/config` would have been written over the real one. A `core.fsmonitor`
17
+ * line in that file runs a command on the next `git status`, with no
18
+ * executable bit needed; a plain file named `.git` replaced the whole
19
+ * directory with a pointer to one the version could also supply.
20
+ *
21
+ * The service refuses these paths when a version is saved. They are refused
22
+ * here as well because this is the side that writes to disk: it must not rest
23
+ * on the server having been right, having been uncompromised, or having held
24
+ * the same rules when an older version was saved.
25
+ *
26
+ * Shared by every writer — fetching, restoring, unbundling — so there is one
27
+ * rule, not one per place that happened to think of it.
28
+ */
29
+ const node_path_1 = __importDefault(require("node:path"));
30
+ /**
31
+ * A name git treats as its own directory.
32
+ *
33
+ * The same comparison git's own `verify_path` makes with its NTFS and HFS
34
+ * protections on: any case, trailing dots and spaces dropped, an alternate
35
+ * data stream dropped, and the 8.3 short name Windows gives `.git`.
36
+ */
37
+ function isGitDirectoryName(part) {
38
+ const name = part.split(":")[0].replace(/[. ]+$/, "").toLowerCase();
39
+ return name === ".git" || /^git~\d+$/.test(name);
40
+ }
41
+ /** Why this path must not be written, or null when it may. */
42
+ function whyUnsafe(relative) {
43
+ if (!relative)
44
+ return "it is empty";
45
+ if (relative.includes("\0"))
46
+ return "it contains a NUL character";
47
+ if (/^[\\/]/.test(relative) || /^[a-zA-Z]:/.test(relative)) {
48
+ return "it is absolute";
49
+ }
50
+ for (const part of relative.split(/[\\/]/)) {
51
+ if (part === "..")
52
+ return "it climbs out of the folder";
53
+ if (isGitDirectoryName(part))
54
+ return "it writes into git's own directory";
55
+ /* A stream or a drive-relative name on Windows; refused by the service too. */
56
+ if (process.platform === "win32" && part.includes(":")) {
57
+ return "it names a Windows data stream";
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+ /**
63
+ * The path's parts, or an error naming the path and why.
64
+ *
65
+ * Both separators split it, because on Windows either one is a separator:
66
+ * `a\..\..\x` is three climbs there, whatever a check splitting on `/` saw.
67
+ */
68
+ function safeParts(relative) {
69
+ const reason = whyUnsafe(relative);
70
+ if (reason) {
71
+ throw new Error(`That version contains an unsafe path (${reason}): ${relative}`);
72
+ }
73
+ return relative.split(/[\\/]/).filter((part) => part && part !== ".");
74
+ }
75
+ /**
76
+ * Where a checked path lands under `root`, confirmed to be inside it.
77
+ *
78
+ * The part checks above should make this impossible to fail. It is here so
79
+ * that the next check somebody forgets does not become the next escape.
80
+ */
81
+ function inside(root, relative) {
82
+ const base = node_path_1.default.resolve(root);
83
+ const full = node_path_1.default.resolve(base, ...safeParts(relative));
84
+ if (full !== base && !full.startsWith(base + node_path_1.default.sep)) {
85
+ throw new Error(`That version contains a path outside the folder: ${relative}`);
86
+ }
87
+ return full;
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.31.2",
3
+ "version": "0.31.4",
4
4
  "description": "CodeRook from the command line, on any operating system",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "homepage": "https://coderook.com",
@@ -1,154 +1,200 @@
1
- ---
2
- name: coderook
3
- description: Save, browse and restore versions of a project on CodeRook — a version host where every version is a complete snapshot and there is no git to learn. Use when asked to save or submit work to CodeRook, check what has changed, look at a project's versions or files, fetch a project, start or switch a line of work, or resolve a save that landed at the same time as somebody else's.
4
- allowed-tools: Bash Read
5
- ---
6
-
7
- # CodeRook
8
-
9
- CodeRook stores whole snapshots of a folder. A version names every file the
10
- project had at that moment, so restoring one never depends on the versions
11
- around it, and there is no staging area, no branches to rebase and no history
12
- to rewrite.
13
-
14
- Everything here is the `coderook` command line. Run it with Bash.
15
-
16
- ## Before anything else
17
-
18
- `cbx whoami` says who this machine is signed in as. If it refuses, the
19
- person needs to run `cbx sign-in` themselves — it takes a personal access
20
- token, so do not attempt it on their behalf.
21
-
22
- If the command is not found at all, this machine has the skill but not the
23
- command line. Either install it once with
24
- `npm install --global @coderook/cli`, or put `npx -y @coderook/cli` where
25
- `coderook` appears below — the commands are identical, npx is just slower
26
- to start.
27
-
28
- ## Reading
29
-
30
- ```bash
31
- cbx status # what has changed in this folder since its last version
32
- cbx projects # every project on the account
33
- cbx versions [project] # what has been saved, newest first
34
- cbx tracks [project] # the lines a project has, and any waiting on a decision
35
- ```
36
-
37
- `status` is the one to reach for when somebody asks what is uncommitted, what
38
- changed, or whether anything needs saving. It reads the folder and changes
39
- nothing.
40
-
41
- ## Saving
42
-
43
- ```bash
44
- cbx submit -m "What changed, in a sentence"
45
- ```
46
-
47
- Send only what changed; the version still names every file. Write the message
48
- yourself from the actual diff rather than asking for one — a message like
49
- "update" helps nobody reading the history later.
50
-
51
- **Ask before running this.** Saving is the one thing here that leaves a mark on
52
- somebody's account, and a version saved by mistake is a version they have to
53
- explain. Propose it, say what it would send, and let them agree.
54
-
55
- `cbx submit -n` shows exactly what would be sent without sending it. Use
56
- that freely; it is safe and it is the honest way to answer "what would this
57
- upload?".
58
-
59
- ## Fetching
60
-
61
- ```bash
62
- cbx get # bring this folder up to date
63
- cbx clone <project> [dir] # fetch a project into a new folder
64
- ```
65
-
66
- `get` protects local edits by default. `cbx get --replace` discards them
67
- to make an exact copy — only run it when the person has said so in those terms.
68
-
69
- ## Lines of work
70
-
71
- A project can have more than one line, so two people can save without one
72
- landing on top of the other.
73
-
74
- ```bash
75
- cbx track # which line this folder saves to
76
- cbx track spike --new # start a line and switch to it
77
- cbx track main # switch back
78
- cbx submit --track spike -m "…"
79
- ```
80
-
81
- Switching says where the next save goes and nothing else — no files move.
82
- Run `cbx get` afterwards to bring that line's files in.
83
-
84
- ## Versions, and going back
85
-
86
- Every save is a commit. A commit becomes a *version* — the thing the public
87
- side of a project offers, and the thing a collaborator pulls — only when
88
- somebody promotes it.
89
-
90
- ```bash
91
- cbx promote # pick from the last ten and name one
92
- cbx mark 41 --pin # pin, hide, rename or label one save
93
- cbx labels add shipped green # the labels a project can wear
94
- cbx undo # put the project back on the save before this one
95
- ```
96
-
97
- `undo` moves where the project stands. Nothing is deleted and nothing is
98
- renumbered — the saves passed over stay in the history, and the next save
99
- carries on from wherever it now stands. It is the answer to "I pushed the
100
- wrong thing"; hiding a version stops strangers reading it but leaves the
101
- project standing on it, so the next person to pull still lands on the mistake.
102
-
103
- **Ask before `undo`, `promote` and `take-down`.** They change what other
104
- people see. `cbx versions` first, so the person can say which save they mean.
105
-
106
- ## When two saves collide
107
-
108
- If somebody saved while this folder was behind, the second save becomes a merge
109
- waiting on a decision rather than overwriting anything.
110
-
111
- ```bash
112
- cbx merges # this folder's saves waiting on a decision
113
- cbx merge <ref> # look at one, and decide
114
- ```
115
-
116
- Read the conflict out to the person and let them choose. Do not pick a side for
117
- them: the whole reason it stopped is that the service could not tell which copy
118
- was wanted.
119
-
120
- ## What is worth leaving out
121
-
122
- ```bash
123
- cbx ignore --suggest # dependency directories, build output, virtual environments
124
- cbx ignore --suggest --apply # add the confident ones to .gitignore
125
- ```
126
-
127
- Rules live in the project's own `.gitignore`, so CodeRook, git and the website
128
- all read one file.
129
-
130
- ## Bundles
131
-
132
- ```bash
133
- cbx bundle . project.cbx # the whole project, every version, as one file
134
- cbx inspect project.cbx # what is inside, without unpacking
135
- cbx unbundle project.cbx ./restored
136
- ```
137
-
138
- ## Things not to do
139
-
140
- - Do not run `cbx delete` unless the person has asked for that project to
141
- be deleted, by name, in this conversation.
142
- - Do not run `cbx sign-out`. It costs them a token they have to fetch
143
- again and gains nothing.
144
- - Do not guess a project name. `cbx projects` lists them; a folder that is
145
- already linked needs no name at all.
146
- - Do not run `cbx take-down`. It destroys the files in a version and they do
147
- not come back. Say it exists and let its owner run it.
148
-
149
- ## If something refuses
150
-
151
- The commands explain themselves — pass the message on rather than rewording it.
152
- A refusal that says a token was not accepted means signing in; one that says a
153
- project does not allow machines means its owner turned that off deliberately,
154
- and the answer is to say so, not to find another route in.
1
+ ---
2
+ name: coderook
3
+ description: Save, browse and restore versions of a project on CodeRook — a version host where every version is a complete snapshot and there is no git to learn. Use when asked to save or submit work to CodeRook, check what has changed, look at a project's versions or files, fetch a project, start or switch a line of work, or resolve a save that landed at the same time as somebody else's.
4
+ allowed-tools: Bash Read
5
+ ---
6
+
7
+ # CodeRook
8
+
9
+ CodeRook stores whole snapshots of a folder. A version names every file the
10
+ project had at that moment, so restoring one never depends on the versions
11
+ around it, and there is no staging area, no branches to rebase and no history
12
+ to rewrite.
13
+
14
+ Everything here is the `coderook` command line. Run it with Bash.
15
+
16
+ ## Before anything else
17
+
18
+ `cbx whoami` says who this machine is signed in as. If it refuses, the
19
+ person needs to run `cbx sign-in` themselves — it takes a personal access
20
+ token, so do not attempt it on their behalf.
21
+
22
+ If the command is not found at all, this machine has the skill but not the
23
+ command line. Either install it once with
24
+ `npm install --global @coderook/cli`, or put `npx -y @coderook/cli` where
25
+ `coderook` appears below — the commands are identical, npx is just slower
26
+ to start.
27
+
28
+ ## Reading
29
+
30
+ ```bash
31
+ cbx status # what has changed in this folder since its last version
32
+ cbx projects # every project on the account
33
+ cbx versions [project] # what has been saved, newest first
34
+ cbx tracks [project] # the lines a project has, and any waiting on a decision
35
+ ```
36
+
37
+ `status` is the one to reach for when somebody asks what is uncommitted, what
38
+ changed, or whether anything needs saving. It reads the folder and changes
39
+ nothing.
40
+
41
+ ## Saving
42
+
43
+ ```bash
44
+ cbx submit -m "What changed, in a sentence"
45
+ ```
46
+
47
+ Send only what changed; the version still names every file. Write the message
48
+ yourself from the actual diff rather than asking for one — a message like
49
+ "update" helps nobody reading the history later.
50
+
51
+ **Ask before running this.** Saving is the one thing here that leaves a mark on
52
+ somebody's account, and a version saved by mistake is a version they have to
53
+ explain. Propose it, say what it would send, and let them agree.
54
+
55
+ `cbx submit -n` shows exactly what would be sent without sending it. Use
56
+ that freely; it is safe and it is the honest way to answer "what would this
57
+ upload?".
58
+
59
+ ## Fetching
60
+
61
+ ```bash
62
+ cbx get # bring this folder up to date
63
+ cbx clone <project> [dir] # fetch a project into a new folder
64
+ ```
65
+
66
+ `get` protects local edits by default. `cbx get --replace` discards them
67
+ to make an exact copy — only run it when the person has said so in those terms.
68
+
69
+ ## Lines of work
70
+
71
+ A project can have more than one line, so two people can save without one
72
+ landing on top of the other.
73
+
74
+ ```bash
75
+ cbx track # which line this folder saves to
76
+ cbx track spike --new # start a line and switch to it
77
+ cbx track main # switch back
78
+ cbx submit --track spike -m "…"
79
+ ```
80
+
81
+ Switching says where the next save goes and nothing else — no files move.
82
+ Run `cbx get` afterwards to bring that line's files in.
83
+
84
+ ## Versions, and going back
85
+
86
+ Every save is a commit. A commit becomes a *version* — the thing the public
87
+ side of a project offers, and the thing a collaborator pulls — only when
88
+ somebody promotes it.
89
+
90
+ ```bash
91
+ cbx promote # pick from the last ten and name one
92
+ cbx mark 41 --pin # pin, hide, rename or label one save
93
+ cbx labels add shipped green # the labels a project can wear
94
+ cbx undo # put the project back on the save before this one
95
+ ```
96
+
97
+ `undo` moves where the project stands. Nothing is deleted and nothing is
98
+ renumbered — the saves passed over stay in the history, and the next save
99
+ carries on from wherever it now stands. It is the answer to "I pushed the
100
+ wrong thing"; hiding a version stops strangers reading it but leaves the
101
+ project standing on it, so the next person to pull still lands on the mistake.
102
+
103
+ **Ask before `undo`, `promote` and `take-down`.** They change what other
104
+ people see. `cbx versions` first, so the person can say which save they mean.
105
+
106
+ ## When two saves collide
107
+
108
+ If somebody saved while this folder was behind, the second save becomes a merge
109
+ waiting on a decision rather than overwriting anything.
110
+
111
+ ```bash
112
+ cbx merges # this folder's saves waiting on a decision
113
+ cbx merge <ref> # look at one, and decide
114
+ ```
115
+
116
+ Read the conflict out to the person and let them choose. Do not pick a side for
117
+ them: the whole reason it stopped is that the service could not tell which copy
118
+ was wanted.
119
+
120
+ ## What is worth leaving out
121
+
122
+ ```bash
123
+ cbx ignore --suggest # dependency directories, build output, virtual environments
124
+ cbx ignore --suggest --apply # add the confident ones to .gitignore
125
+ ```
126
+
127
+ Rules live in the project's own `.gitignore`, so CodeRook, git and the website
128
+ all read one file.
129
+
130
+ ## Bundles
131
+
132
+ ```bash
133
+ cbx bundle . project.cbx # the whole project, every version, as one file
134
+ cbx inspect project.cbx # what is inside, without unpacking
135
+ cbx unbundle project.cbx ./restored
136
+ ```
137
+
138
+ ## Reading a public project without signing in
139
+
140
+ Public projects can be read by anything, with no token, from
141
+ `https://api.coderook.com/v1/public`. There is no need to read the command
142
+ line's source to find these — this is the whole of it:
143
+
144
+ ```text
145
+ GET /projects?q= public projects, newest first
146
+ GET /projects/{owner}/{slug} one project
147
+ GET /projects/{owner}/{slug}/versions the versions it offers
148
+ GET /projects/{owner}/{slug}/releases/latest newest release and its files
149
+ GET /projects/{owner}/{slug}/updates/latest.yml electron-updater feed
150
+ GET /projects/{owner}/{slug}/versions/{v}/files every file, with digests
151
+ GET /projects/{owner}/{slug}/versions/{v}/file?path= one file
152
+ GET /projects/{owner}/{slug}/versions/{v}/archive the version as a zip
153
+ GET /projects/{owner}/{slug}/versions/{v}/attachments files attached to it
154
+ GET /projects/{owner}/{slug}/versions/{v}/attachments/{id} download one
155
+ ```
156
+
157
+ `{v}` is a version id or `latest`. A private project answers 404, exactly as
158
+ if it did not exist.
159
+
160
+ ### An app that updates itself from CodeRook
161
+
162
+ Release a version named like a version number and attach the installer:
163
+
164
+ ```bash
165
+ cbx release 1.4.0
166
+ cbx attach ./dist/MyApp-Setup-1.4.0.exe
167
+ ```
168
+
169
+ An Electron app then needs nothing custom — electron-builder's generic
170
+ provider pointed at the feed:
171
+
172
+ ```json
173
+ "publish": [{ "provider": "generic",
174
+ "url": "https://api.coderook.com/v1/public/projects/OWNER/SLUG/updates" }]
175
+ ```
176
+
177
+ The feed serves `latest.yml`, `latest-mac.yml` and `latest-linux.yml` with
178
+ each file's size and SHA-512, and redirects file names to their downloads.
179
+ Anything else can read `releases/latest` as JSON: version, notes, and every
180
+ file with its size, SHA-256 and `url`. Updaters have no account, so the
181
+ project they read must be public; keep private code in a separate project and
182
+ ship releases from a public one.
183
+
184
+ ## Things not to do
185
+
186
+ - Do not run `cbx delete` unless the person has asked for that project to
187
+ be deleted, by name, in this conversation.
188
+ - Do not run `cbx sign-out`. It costs them a token they have to fetch
189
+ again and gains nothing.
190
+ - Do not guess a project name. `cbx projects` lists them; a folder that is
191
+ already linked needs no name at all.
192
+ - Do not run `cbx take-down`. It destroys the files in a version and they do
193
+ not come back. Say it exists and let its owner run it.
194
+
195
+ ## If something refuses
196
+
197
+ The commands explain themselves — pass the message on rather than rewording it.
198
+ A refusal that says a token was not accepted means signing in; one that says a
199
+ project does not allow machines means its owner turned that off deliberately,
200
+ and the answer is to say so, not to find another route in.