@coderook/cli 0.31.3 → 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.3",
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)}% ` +
@@ -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.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.