@coderook/cli 0.31.3 → 0.31.5

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.3",
5
+ "version": "0.31.5",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -10,6 +10,8 @@ exports.projects = projects;
10
10
  exports.findPublicProject = findPublicProject;
11
11
  exports.findProject = findProject;
12
12
  exports.health = health;
13
+ exports.openProposal = openProposal;
14
+ exports.proposals = proposals;
13
15
  exports.mergeTracks = mergeTracks;
14
16
  exports.mergeTrack = mergeTrack;
15
17
  exports.resolveMergeConflict = resolveMergeConflict;
@@ -265,6 +267,17 @@ async function health() {
265
267
  });
266
268
  return (await response.json());
267
269
  }
270
+ /** Offer a line of work to another, usually main. */
271
+ async function openProposal(repositoryId, input) {
272
+ return call(`/v1/repositories/${repositoryId}/proposals`, {
273
+ method: "POST",
274
+ body: input,
275
+ });
276
+ }
277
+ async function proposals(repositoryId, all = false) {
278
+ const body = await call(`/v1/repositories/${repositoryId}/proposals${all ? "?state=all" : ""}`);
279
+ return body.proposals;
280
+ }
268
281
  async function mergeTracks(repositoryId) {
269
282
  const body = await call(`/v1/repositories/${repositoryId}/merge-tracks`);
270
283
  return body.mergeTracks;
@@ -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");
@@ -1025,6 +1026,17 @@ The connection failed: ${text}`));
1025
1026
  observedHeadVersionId: result.versionId,
1026
1027
  local: result.local,
1027
1028
  manifest: result.manifest,
1029
+ /*
1030
+ The line this folder is on survives a save onto it.
1031
+
1032
+ The same fault `get` had and lost: this record is built fresh, and
1033
+ without naming `track` every save onto a line moved the folder back to
1034
+ main — so the second save on a line went to main, and `cbx propose`
1035
+ found the folder "on main" straight after `cbx track spike --new` and a
1036
+ save. A one-off `--track` does not move it either: that flag means
1037
+ "just this once", so the folder keeps the line it had.
1038
+ */
1039
+ ...(link?.track ? { track: link.track } : {}),
1028
1040
  });
1029
1041
  /*
1030
1042
  What this save hashed, kept for the next one.
@@ -1477,7 +1489,19 @@ async function commandUnbundle(parsed) {
1477
1489
  return 1;
1478
1490
  }
1479
1491
  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);
1492
+ /*
1493
+ The bundle's own name, never its path. `source_name` comes from the file
1494
+ being opened, and taken whole it could be `/home/you/.ssh` or `C:\Users` —
1495
+ and the folder it names is deleted and replaced. Only the last name
1496
+ survives, into the current folder, and only if it is a plain name.
1497
+ */
1498
+ const suggested = node_path_1.default.posix.basename((manifest.source_name ?? "").replace(/\\/g, "/"));
1499
+ const plainName = suggested && !(0, safe_path_js_1.whyUnsafe)(suggested) && !/^[.]+$/.test(suggested) && !/[:]/.test(suggested);
1500
+ if (!parsed.positional[1] && !plainName) {
1501
+ console.error(red("That bundle does not name a usable folder. Try: cbx unbundle <file.cbx> <folder>"));
1502
+ return 1;
1503
+ }
1504
+ const destination = node_path_1.default.resolve(parsed.positional[1] ?? suggested);
1481
1505
  const line = progressLine();
1482
1506
  const result = await (0, cbx_js_1.unpackBundle)(node_path_1.default.resolve(source), destination, (progress) => {
1483
1507
  line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
@@ -1534,6 +1558,69 @@ async function commandDoctor() {
1534
1558
  * project looks untouched — so this is the way to find out that work of
1535
1559
  * yours is sitting somewhere, still complete, waiting.
1536
1560
  */
1561
+ /**
1562
+ * Offer this folder's line of work to another line, usually main.
1563
+ *
1564
+ * The folder's line is the one proposed, because it is the one being worked
1565
+ * on; proposing main is refused rather than guessed at, since the answer to
1566
+ * "propose what?" is then a line nobody named.
1567
+ */
1568
+ async function commandPropose(parsed) {
1569
+ const folder = folderFor(parsed);
1570
+ const link = await (0, config_js_1.readLink)(folder);
1571
+ if (!link) {
1572
+ console.error(red("This folder is not linked to a project on your account."));
1573
+ return 1;
1574
+ }
1575
+ const from = flagText(parsed, "from") ?? (await (0, track_commands_js_1.trackFor)(folder));
1576
+ const into = flagText(parsed, "into") ?? "main";
1577
+ if (from === into) {
1578
+ console.error(red(`This folder is on ${from}. Start a line with cbx track <name> --new, save onto it, then propose it.`));
1579
+ return 1;
1580
+ }
1581
+ const title = flagText(parsed, "message", "m", "title");
1582
+ if (!title) {
1583
+ console.error(red('Give it a title: cbx propose -m "What this changes"'));
1584
+ return 1;
1585
+ }
1586
+ const opened = await (0, api_js_1.openProposal)(link.repositoryId, {
1587
+ from,
1588
+ into,
1589
+ title,
1590
+ ...(flagText(parsed, "body") ? { body: flagText(parsed, "body") } : {}),
1591
+ });
1592
+ const reference = opened.mergeTrack.reference;
1593
+ console.log(`${accent(reference)} ${from} → ${into} ${title}`);
1594
+ if (opened.conflicts.settled) {
1595
+ console.log(dim(` ${opened.conflicts.settled} file(s) both sides changed were combined line by line.`));
1596
+ }
1597
+ console.log(opened.conflicts.unresolved
1598
+ ? ` ${opened.conflicts.unresolved} file(s) need a decision. Run cbx merge ${reference} to look.`
1599
+ : dim(` Nothing to decide. Run cbx merge ${reference} --apply when it has been reviewed.`));
1600
+ return 0;
1601
+ }
1602
+ async function commandProposals(parsed) {
1603
+ const folder = folderFor(parsed);
1604
+ const link = await (0, config_js_1.readLink)(folder);
1605
+ if (!link) {
1606
+ console.error(red("This folder is not linked to a project on your account."));
1607
+ return 1;
1608
+ }
1609
+ const listed = await (0, api_js_1.proposals)(link.repositoryId, hasFlag(parsed, "all"));
1610
+ if (!listed.length) {
1611
+ console.log(hasFlag(parsed, "all") ? "No proposals yet." : "No open proposals.");
1612
+ return 0;
1613
+ }
1614
+ for (const proposal of listed) {
1615
+ const counts = proposal.conflicts;
1616
+ const waiting = counts && counts.unresolved ? red(`${counts.unresolved} to decide`) : "";
1617
+ console.log(`${accent(proposal.reference)} ${proposal.proposal?.from ?? "?"} → ${proposal.proposal?.into ?? "?"} ` +
1618
+ `${proposal.proposal?.title ?? ""} ${waiting}` +
1619
+ (proposal.state === "open" ? "" : dim(` ${proposal.state}`)));
1620
+ }
1621
+ console.log(dim("\nRun cbx merge <reference> to look at one."));
1622
+ return 0;
1623
+ }
1537
1624
  async function commandMerges(parsed) {
1538
1625
  const folder = folderFor(parsed);
1539
1626
  const link = await (0, config_js_1.readLink)(folder);
@@ -1616,6 +1703,18 @@ async function commandMerge(parsed) {
1616
1703
  }
1617
1704
  }
1618
1705
  const now = await (0, api_js_1.mergeTrack)(summary.id);
1706
+ if (now.proposal) {
1707
+ console.log(`\n${accent(now.mergeTrack.reference)} ${now.proposal.from} → ${now.proposal.into}` +
1708
+ (now.changeRequest ? ` ${now.changeRequest.title}` : ""));
1709
+ const approvals = now.changeRequest?.approvals;
1710
+ if (approvals && approvals.required) {
1711
+ console.log(` ${approvals.satisfied ? accent("approved") : red("waiting for review")}` +
1712
+ dim(` · ${approvals.approvals} of ${approvals.required} approvals`));
1713
+ }
1714
+ for (const blocked of now.provisional.blockedByChecks ?? []) {
1715
+ console.log(` ${red("check")} ${blocked}`);
1716
+ }
1717
+ }
1619
1718
  console.log(`
1620
1719
  ${accent(now.mergeTrack.reference)} · ${now.provisional.fileCount} files · ` +
1621
1720
  (now.provisional.ready
@@ -1633,10 +1732,14 @@ ${accent(now.mergeTrack.reference)} · ${now.provisional.fileCount} files · ` +
1633
1732
  const applied = await (0, api_js_1.applyMerge)(summary.id);
1634
1733
  console.log(`
1635
1734
  Applied as ${accent(`v${applied.version.sequence}`)}.`);
1735
+ if (applied.closedIssues?.length) {
1736
+ console.log(`Closed ${applied.closedIssues.map((number) => `#${number}`).join(", ")}.`);
1737
+ }
1636
1738
  console.log(dim("Run cbx get to bring it down to this folder."));
1637
1739
  return 0;
1638
1740
  }
1639
- if (!decision) {
1741
+ const stillOpen = now.conflicts.some((conflict) => !conflict.resolvedAt);
1742
+ if (!decision && stillOpen) {
1640
1743
  console.log(dim(`
1641
1744
  --mine keeps yours, --theirs keeps what was already saved,` +
1642
1745
  ` --drop removes the file.
@@ -2184,7 +2287,7 @@ const SPECS = [
2184
2287
  detail: "A signed POST to an address of yours when something happens here.\n" +
2185
2288
  "The signing secret is generated by the service and shown once, when\n" +
2186
2289
  "the hook is added — no route hands it back afterwards.\n\n" +
2187
- "Events: version.published, merge.opened, merge.applied,\n" +
2290
+ "Events: version.published, merge.opened, merge.applied, merge.cancelled,\n" +
2188
2291
  "check.reported, change_request.opened, change_request.reviewed.\n" +
2189
2292
  "Naming none of them sends all of them.",
2190
2293
  options: [
@@ -2539,6 +2642,31 @@ const SPECS = [
2539
2642
  options: [{ flags: "--revoke <id>", description: "stop one working now" }],
2540
2643
  run: service_commands_js_1.commandTokens,
2541
2644
  },
2645
+ {
2646
+ name: "propose",
2647
+ group: "Working with a folder",
2648
+ summary: "offer this folder's line to main",
2649
+ usage: 'propose -m "title" [--into main] [--body text] [folder]',
2650
+ options: [
2651
+ { flags: "-m, --message <text>", description: "the proposal's title" },
2652
+ { flags: "--into <line>", description: "the line to propose into (default main)" },
2653
+ { flags: "--from <line>", description: "propose this line instead of the folder's" },
2654
+ { flags: "--body <text>", description: "a longer description" },
2655
+ ],
2656
+ detail: "Opens a proposal from the line this folder is on into main (or --into).\n" +
2657
+ "Files both sides changed are combined line by line where they can be;\n" +
2658
+ "the rest wait for a decision in cbx merge. \"Fixes #12\" in the title or\n" +
2659
+ "body closes issue 12 when the proposal lands.",
2660
+ run: commandPropose,
2661
+ },
2662
+ {
2663
+ name: "proposals",
2664
+ group: "Working with a folder",
2665
+ summary: "open proposals on this project",
2666
+ usage: "proposals [--all] [folder]",
2667
+ options: [{ flags: "--all", description: "include merged and cancelled ones" }],
2668
+ run: commandProposals,
2669
+ },
2542
2670
  {
2543
2671
  name: "merges",
2544
2672
  group: "When somebody saved first",
@@ -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`);
@@ -808,8 +820,8 @@ depth = null) {
808
820
  worse than an obviously synthetic one: it survives being copied into a
809
821
  mailing list or a CONTRIBUTORS file.
810
822
  */
811
- send(`author ${author} <noreply@coderook.com> ${when}`);
812
- 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}`);
813
825
  send(`data ${body.length}`);
814
826
  node_process_1.default.stdout.write(body);
815
827
  node_process_1.default.stdout.write("\n");
@@ -819,6 +831,13 @@ depth = null) {
819
831
  send(`merge :${extra}`);
820
832
  send("deleteall");
821
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;
822
841
  send(`M 100644 :${blobMark.get(file.sha256)} ${(0, git_history_js_1.quotePath)(file.path)}`);
823
842
  }
824
843
  send("");
@@ -880,9 +899,12 @@ depth = null) {
880
899
  if (mark === undefined)
881
900
  continue;
882
901
  const body = Buffer.from(release.notes || release.name, "utf8");
883
- 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}`);
884
906
  send(`from :${mark}`);
885
- send(`tagger ${release.releasedBy || "CodeRook"} <noreply@coderook.com> ` +
907
+ send(`tagger ${identity(release.releasedBy || "CodeRook")} <noreply@coderook.com> ` +
886
908
  `${(0, git_history_js_1.stamp)(release.releasedAt)} +0000`);
887
909
  send(`data ${body.length}`);
888
910
  node_process_1.default.stdout.write(body);
@@ -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
@@ -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.3",
3
+ "version": "0.31.5",
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.