@coderook/cli 0.25.4 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "name": "coderook",
3
3
  "displayName": "CodeRook",
4
4
  "description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, from Claude Code.",
5
- "version": "0.25.4",
5
+ "version": "0.26.0",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -28,6 +28,14 @@ exports.runLogs = runLogs;
28
28
  exports.tokens = tokens;
29
29
  exports.revokeToken = revokeToken;
30
30
  exports.deleteProject = deleteProject;
31
+ exports.projectLabels = projectLabels;
32
+ exports.createProjectLabel = createProjectLabel;
33
+ exports.deleteProjectLabel = deleteProjectLabel;
34
+ exports.changeVersion = changeVersion;
35
+ exports.reviewVersion = reviewVersion;
36
+ exports.removeVersionContent = removeVersionContent;
37
+ exports.versionAttachments = versionAttachments;
38
+ exports.undoTo = undoTo;
31
39
  /** The small part of the API the command-line tool needs directly. */
32
40
  const identify_js_1 = require("../../desktop-app/src/main/identify.js");
33
41
  const config_js_1 = require("./config.js");
@@ -242,6 +250,25 @@ async function versions(repositoryId) {
242
250
  ? row.parentVersionIds.map(String)
243
251
  : [],
244
252
  authorName: String(row.author?.displayName ?? ""),
253
+ name: row.name == null ? null : String(row.name),
254
+ /*
255
+ Falls back to the older field, so this keeps working against a service
256
+ that has not been updated yet rather than reporting everything private.
257
+ */
258
+ visibility: row.visibility ??
259
+ (row.public === false ? "private" : "public"),
260
+ state: String(row.state ?? "verified"),
261
+ pinned: row.pinned === true,
262
+ labels: Array.isArray(row.labels)
263
+ ? row.labels.map((one) => ({
264
+ id: String(one.id ?? ""),
265
+ name: String(one.name ?? ""),
266
+ colour: String(one.colour ?? "#888888"),
267
+ description: one.description == null ? null : String(one.description),
268
+ }))
269
+ : [],
270
+ removedAt: row.removedAt == null ? null : String(row.removedAt),
271
+ head: row.head === true,
245
272
  }));
246
273
  }
247
274
  /**
@@ -410,3 +437,61 @@ async function deleteProject(repositoryId) {
410
437
  method: "DELETE",
411
438
  });
412
439
  }
440
+ async function projectLabels(repositoryId) {
441
+ const body = await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/labels`);
442
+ return (body.labels ?? []).map((row) => ({
443
+ id: String(row.id ?? ""),
444
+ name: String(row.name ?? ""),
445
+ colour: String(row.colour ?? "#888888"),
446
+ description: row.description == null ? null : String(row.description),
447
+ }));
448
+ }
449
+ async function createProjectLabel(repositoryId, name, colour, description) {
450
+ const body = await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/labels`, { method: "POST", body: { name, colour, ...(description ? { description } : {}) } });
451
+ return {
452
+ id: String(body.label?.id ?? ""),
453
+ name: String(body.label?.name ?? name),
454
+ colour: String(body.label?.colour ?? colour),
455
+ description: body.label?.description == null ? null : String(body.label.description),
456
+ };
457
+ }
458
+ async function deleteProjectLabel(repositoryId, labelId) {
459
+ await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/labels/${encodeURIComponent(labelId)}`, { method: "DELETE" });
460
+ }
461
+ /**
462
+ * Everything about a version, changed in one call.
463
+ *
464
+ * Deliberately one request rather than one per field. The service takes a
465
+ * patch, so a command that sets a name and a colour at once is one round trip
466
+ * and, more to the point, one thing that either happened or did not.
467
+ */
468
+ async function changeVersion(repositoryId, versionId, patch) {
469
+ await call(`/v1/repositories/${encodeURIComponent(repositoryId)}` +
470
+ `/versions/${encodeURIComponent(versionId)}`, { method: "PATCH", body: patch });
471
+ }
472
+ /** Accept a held version, or turn it down with a reason. */
473
+ async function reviewVersion(repositoryId, versionId, decision, note) {
474
+ const body = await call(`/v1/repositories/${encodeURIComponent(repositoryId)}` +
475
+ `/versions/${encodeURIComponent(versionId)}/review`, { method: "POST", body: { decision, ...(note ? { note } : {}) } });
476
+ return { state: String(body.state ?? decision) };
477
+ }
478
+ /** Take a version's content down, leaving the version itself as a record. */
479
+ async function removeVersionContent(repositoryId, versionId, reason) {
480
+ const query = reason ? `?reason=${encodeURIComponent(reason)}` : "";
481
+ await call(`/v1/repositories/${encodeURIComponent(repositoryId)}` +
482
+ `/versions/${encodeURIComponent(versionId)}/content${query}`, { method: "DELETE" });
483
+ }
484
+ async function versionAttachments(repositoryId, versionId) {
485
+ const body = await call(`/v1/repositories/${encodeURIComponent(repositoryId)}` +
486
+ `/versions/${encodeURIComponent(versionId)}/attachments`);
487
+ return (body.attachments ?? []).map((row) => ({
488
+ id: String(row.id ?? ""),
489
+ name: String(row.name ?? ""),
490
+ sizeBytes: Number(row.sizeBytes ?? 0),
491
+ mediaType: String(row.mediaType ?? "application/octet-stream"),
492
+ }));
493
+ }
494
+ /** Put the project back on an earlier commit. */
495
+ async function undoTo(repositoryId, to) {
496
+ return await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/undo`, { method: "POST", body: to ? { to } : {} });
497
+ }
@@ -37,6 +37,7 @@ const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
37
37
  const upload_js_1 = require("../../desktop-app/src/main/upload.js");
38
38
  const download_js_1 = require("../../desktop-app/src/main/download.js");
39
39
  const faults_js_1 = require("../../desktop-app/src/main/faults.js");
40
+ const version_commands_js_1 = require("./version_commands.js");
40
41
  const identify_js_1 = require("../../desktop-app/src/main/identify.js");
41
42
  const detect_js_1 = require("../../desktop-app/src/main/detect.js");
42
43
  const cbx_js_1 = require("../../desktop-app/src/main/cbx.js");
@@ -1805,6 +1806,161 @@ const SPECS = [
1805
1806
  usage: "releases [project]",
1806
1807
  run: service_commands_js_1.commandReleases,
1807
1808
  },
1809
+ {
1810
+ /*
1811
+ The half that hiding never covered.
1812
+
1813
+ Hiding a bad push stops strangers reading it and leaves the project
1814
+ standing on it, so the next person to pull still lands on the mistake.
1815
+ This moves the project.
1816
+ */
1817
+ name: "undo",
1818
+ group: "Your projects",
1819
+ summary: "put the project back on an earlier save",
1820
+ usage: "undo [n] [project]",
1821
+ detail: "Goes back one save unless you name another to go back to.\n\n" +
1822
+ "Nothing is deleted and nothing is renumbered — the saves that get\n" +
1823
+ "passed over stay in the history, and the next save carries on from\n" +
1824
+ "wherever the project now stands.",
1825
+ examples: ["cbx undo", "cbx undo 41"],
1826
+ run: version_commands_js_1.commandUndo,
1827
+ },
1828
+ {
1829
+ /*
1830
+ The decision that separates a working history from a published one.
1831
+
1832
+ Every save is a commit and most of them are nobody else's business. This
1833
+ is where one becomes a version — the thing the public side of a project
1834
+ actually offers.
1835
+ */
1836
+ name: "promote",
1837
+ group: "Your projects",
1838
+ summary: "turn a commit into a version people can fetch",
1839
+ usage: "promote [n] [project]",
1840
+ detail: "Shows the last ten commits and asks which one. Give a number to skip\n" +
1841
+ "the list.\n\n" +
1842
+ "A version is a commit with a name on it. Until a project promotes its\n" +
1843
+ "first one its public page shows everything, as it always did.",
1844
+ options: [
1845
+ { flags: "--name <text>", description: "what to call it" },
1846
+ { flags: "--notes <text>", description: "what changed" },
1847
+ ],
1848
+ examples: [
1849
+ "cbx promote",
1850
+ "cbx promote 41 --name v2.1",
1851
+ 'cbx promote --name "Client build" --notes "Fixes the export dialog"',
1852
+ ],
1853
+ run: version_commands_js_1.commandPromote,
1854
+ },
1855
+ {
1856
+ /*
1857
+ One command for everything a version can be, because the service takes
1858
+ one patch. Three separate commands would be three requests, and three
1859
+ chances for a person to end up with a version that is named but not
1860
+ pinned because the second one failed.
1861
+ */
1862
+ /*
1863
+ Not called "version".
1864
+
1865
+ `cbx version` has printed the version number since the first release,
1866
+ and quietly changing that when arguments follow is the kind of thing
1867
+ that works until somebody scripts it. This marks a save — pins it,
1868
+ hides it, labels it — which is what it does anyway.
1869
+ */
1870
+ name: "mark",
1871
+ aliases: ["set"],
1872
+ group: "Your projects",
1873
+ summary: "name, hide, pin or label one save",
1874
+ usage: "mark [n] [project]",
1875
+ detail: "Changes the newest version unless you name another.\n" +
1876
+ "Everything you ask for happens in one request, so it either all\n" +
1877
+ "lands or none of it does.",
1878
+ options: [
1879
+ { flags: "--name <text>", description: "give it a code name" },
1880
+ { flags: "--unname", description: "take the name off again" },
1881
+ { flags: "--notes <text>", description: "what changed" },
1882
+ { flags: "--hide", description: "nobody outside the project can read it" },
1883
+ { flags: "--show", description: "anybody can read it" },
1884
+ {
1885
+ flags: "--visibility <who>",
1886
+ description: "private, unlisted (link only) or public",
1887
+ },
1888
+ { flags: "--pin", description: "keep it at the top of the list" },
1889
+ { flags: "--unpin", description: "put it back in order" },
1890
+ { flags: "--labels <a,b>", description: "the labels it should wear" },
1891
+ { flags: "--clear-labels", description: "take them all off" },
1892
+ ],
1893
+ examples: [
1894
+ "cbx mark --name v2.1 --pin",
1895
+ "cbx mark 41 --visibility unlisted",
1896
+ 'cbx mark 41 --labels "shipped,client work"',
1897
+ ],
1898
+ run: version_commands_js_1.commandVersion,
1899
+ },
1900
+ {
1901
+ name: "labels",
1902
+ group: "Your projects",
1903
+ summary: "the labels this project puts on versions",
1904
+ usage: "labels [list|add|remove] [name] [colour]",
1905
+ detail: "A label is a name and a colour. Put as many on a version as you like —\n" +
1906
+ "how you group and colour your own history is yours to decide.",
1907
+ examples: [
1908
+ "cbx labels",
1909
+ "cbx labels add shipped green",
1910
+ "cbx labels add urgent #e0574a",
1911
+ "cbx labels remove shipped",
1912
+ ],
1913
+ run: version_commands_js_1.commandLabels,
1914
+ },
1915
+ {
1916
+ name: "held",
1917
+ aliases: ["waiting"],
1918
+ group: "Your projects",
1919
+ summary: "versions waiting for somebody to approve them",
1920
+ usage: "held [project]",
1921
+ detail: "A project can be set to hold pushes from anyone below admin until an\n" +
1922
+ "admin accepts them. Those versions are real and stored — they are just\n" +
1923
+ "not the current version, and not on the public page.",
1924
+ run: version_commands_js_1.commandHeld,
1925
+ },
1926
+ {
1927
+ name: "review",
1928
+ group: "Your projects",
1929
+ summary: "accept a held version, or turn it down",
1930
+ usage: "review <n> --approve | --decline",
1931
+ options: [
1932
+ {
1933
+ flags: "--approve",
1934
+ description: "accept it; it becomes the current version",
1935
+ },
1936
+ { flags: "--decline", description: "turn it down" },
1937
+ { flags: "--note <text>", description: "why" },
1938
+ ],
1939
+ examples: [
1940
+ "cbx review 41 --approve",
1941
+ 'cbx review 41 --decline --note "wrong branch"',
1942
+ ],
1943
+ run: version_commands_js_1.commandReview,
1944
+ },
1945
+ {
1946
+ /*
1947
+ Named for what it does rather than "delete", because it does not delete
1948
+ the version. The files go; the version stays in the history as a gap
1949
+ saying what was removed and by whom.
1950
+ */
1951
+ name: "take-down",
1952
+ group: "Your projects",
1953
+ summary: "remove a version's files, for something published by mistake",
1954
+ usage: "take-down <n> --yes",
1955
+ detail: "The files go and do not come back. The version stays in the history as\n" +
1956
+ "a gap saying what was removed and by whom, so nothing is quietly\n" +
1957
+ "rewritten.",
1958
+ options: [
1959
+ { flags: "--yes", description: "confirm; without it nothing happens" },
1960
+ { flags: "--reason <text>", description: "recorded against the gap" },
1961
+ ],
1962
+ run: version_commands_js_1.commandTakeDown,
1963
+ },
1808
1964
  {
1809
1965
  /*
1810
1966
  A release is a version with a name on it, so this names one rather than
@@ -138,8 +138,30 @@ async function commandVersions(parsed) {
138
138
  const when = version.createdAt
139
139
  ? new Date(version.createdAt).toLocaleString()
140
140
  : "";
141
+ /*
142
+ What the project has made of this version, said before the numbers.
143
+
144
+ A name, a pin and a set of labels are how somebody finds the version
145
+ they meant among forty; the file count is how they check it once they
146
+ have. Putting the arrangement first is the difference between a log and
147
+ a list they organised.
148
+ */
149
+ const marks = [
150
+ /* Which one the project is standing on, which is not the same as which
151
+ one is at the top once somebody has pinned something. */
152
+ version.head ? green("current") : "",
153
+ version.pinned ? accent("pinned") : "",
154
+ version.name ? bold(version.name) : "",
155
+ version.state === "held" ? red("held") : "",
156
+ version.state === "declined" ? dim("declined") : "",
157
+ version.removedAt ? dim("taken down") : "",
158
+ version.visibility === "private" ? dim("private") : "",
159
+ version.visibility === "unlisted" ? dim("link only") : "",
160
+ ...version.labels.map((label) => accent(label.name)),
161
+ ].filter(Boolean);
141
162
  console.log(` ${accent(`v${version.sequence}`).padEnd(16)} ${when.padEnd(22)}` +
142
- `${String(version.fileCount).padStart(5)} files ${bytes(version.storedSize)}`);
163
+ `${String(version.fileCount).padStart(5)} files ${bytes(version.storedSize)}` +
164
+ (marks.length ? ` ${marks.join(" ")}` : ""));
143
165
  if (version.message)
144
166
  console.log(` ${dim(version.message)}`);
145
167
  }
@@ -105,7 +105,8 @@ async function commandRelease(parsed) {
105
105
  const wanted = parsed.flags.get("version");
106
106
  const target = typeof wanted === "string"
107
107
  ? all.find((version) => String(version.sequence) === wanted.replace(/^v/i, ""))
108
- : all[0];
108
+ /* The newest by number rather than by position in the list. */
109
+ : all.reduce((newest, one) => (one.sequence > newest.sequence ? one : newest));
109
110
  if (!target) {
110
111
  console.error(red(`This project has no version ${String(wanted)}.`));
111
112
  return 1;
@@ -0,0 +1,493 @@
1
+ "use strict";
2
+ /**
3
+ * Arranging a project's versions from the terminal.
4
+ *
5
+ * Every one of these things could already be done — from the website, and only
6
+ * from the website. That is the gap this closes: naming worked here, hiding
7
+ * did not, and colouring and pinning existed nowhere. A person who works in a
8
+ * terminal should not have to open a browser to say which version matters.
9
+ *
10
+ * One command does the changing, because the service takes one patch: `cbx
11
+ * version 12 --name v2.1 --hide --pin` is a single request that either happened
12
+ * or did not, rather than three that can half-happen.
13
+ */
14
+ var __importDefault = (this && this.__importDefault) || function (mod) {
15
+ return (mod && mod.__esModule) ? mod : { "default": mod };
16
+ };
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.split = split;
19
+ exports.commandVersion = commandVersion;
20
+ exports.commandLabels = commandLabels;
21
+ exports.commandHeld = commandHeld;
22
+ exports.commandReview = commandReview;
23
+ exports.commandTakeDown = commandTakeDown;
24
+ exports.commandPromote = commandPromote;
25
+ exports.commandUndo = commandUndo;
26
+ const api_js_1 = require("./api.js");
27
+ const project_commands_js_1 = require("./project_commands.js");
28
+ const promises_1 = require("node:readline/promises");
29
+ const node_process_1 = __importDefault(require("node:process"));
30
+ /* Written out rather than imported: the same four escapes every other command
31
+ file in here declares for itself, and a shared module for four one-line
32
+ functions would be the only thing they all depend on. */
33
+ const dim = (value) => `${value}`;
34
+ const bold = (value) => `${value}`;
35
+ const red = (value) => `${value}`;
36
+ const accent = (value) => `${value}`;
37
+ /**
38
+ * Which of `[n] [project]` the arguments actually were.
39
+ *
40
+ * Both are optional and both are positional, so `cbx undo blog` has to be read
41
+ * as a project and `cbx undo 41` as a save — the shape of the word is the only
42
+ * thing that separates them. A save is digits, optionally with a leading v;
43
+ * anything else is a project name.
44
+ *
45
+ * That leaves a project whose whole name is digits unreachable this way. It
46
+ * still works as `--project 41`, and reading a bare number as a save is the
47
+ * one that comes up.
48
+ */
49
+ function split(parsed) {
50
+ const named = parsed.flags.get("project") ?? parsed.flags.get("p");
51
+ const flagged = typeof named === "string" && named ? named : undefined;
52
+ const [first, second] = parsed.positional;
53
+ if (second !== undefined)
54
+ return { n: first, project: flagged ?? second };
55
+ if (first === undefined)
56
+ return { project: flagged };
57
+ if (flagged)
58
+ return { n: first, project: flagged };
59
+ return /^v?\d+$/i.test(first) ? { n: first } : { project: first };
60
+ }
61
+ /** The version somebody meant, which is usually the newest one. */
62
+ async function pick(repositoryId, wanted) {
63
+ const all = await (0, api_js_1.versions)(repositoryId);
64
+ if (!all.length) {
65
+ console.error(red("This project has no versions yet."));
66
+ return null;
67
+ }
68
+ /*
69
+ The newest by number, not by position.
70
+
71
+ The list arrives newest first today, and relying on that is how three
72
+ callers started reporting a pinned old save as the current one when the
73
+ order changed for a while. Asking for the largest number cannot be broken
74
+ by how the list happens to be sorted.
75
+ */
76
+ if (!wanted) {
77
+ return all.reduce((newest, one) => (one.sequence > newest.sequence ? one : newest));
78
+ }
79
+ const sequence = wanted.replace(/^v/i, "");
80
+ const found = all.find((one) => String(one.sequence) === sequence);
81
+ if (!found) {
82
+ console.error(red(`This project has no version ${wanted}.`));
83
+ return null;
84
+ }
85
+ return found;
86
+ }
87
+ const SWATCH = {
88
+ red: "#e0574a",
89
+ amber: "#e8a13f",
90
+ yellow: "#e8d13f",
91
+ green: "#5fcf8d",
92
+ blue: "#5aa9e6",
93
+ purple: "#a98ae0",
94
+ pink: "#e68ab8",
95
+ grey: "#8c968f",
96
+ gray: "#8c968f",
97
+ };
98
+ /**
99
+ * A colour, given either as a name or as hex.
100
+ *
101
+ * Names because nobody remembers hex, hex because somebody will want their own
102
+ * exact one and being told "pick from these eight" is the kind of small refusal
103
+ * that makes a tool feel like it is arguing.
104
+ */
105
+ function colourOf(given) {
106
+ const value = given.trim().toLowerCase();
107
+ if (SWATCH[value])
108
+ return SWATCH[value];
109
+ const hex = value.startsWith("#") ? value : `#${value}`;
110
+ return /^#[0-9a-f]{6}$/.test(hex) ? hex : null;
111
+ }
112
+ function paint(label) {
113
+ return accent(label.name);
114
+ }
115
+ async function commandVersion(parsed) {
116
+ const which = split(parsed);
117
+ const project = await (0, project_commands_js_1.resolveProject)(which.project);
118
+ if (!project)
119
+ return 1;
120
+ const target = await pick(project.id, which.n);
121
+ if (!target)
122
+ return 1;
123
+ const patch = {};
124
+ const name = parsed.flags.get("name");
125
+ if (typeof name === "string")
126
+ patch.name = name;
127
+ if (parsed.flags.get("unname") === true)
128
+ patch.name = null;
129
+ const notes = parsed.flags.get("notes");
130
+ if (typeof notes === "string")
131
+ patch.notes = notes;
132
+ if (parsed.flags.get("hide") === true)
133
+ patch.visibility = "private";
134
+ if (parsed.flags.get("show") === true)
135
+ patch.visibility = "public";
136
+ const visibility = parsed.flags.get("visibility");
137
+ if (typeof visibility === "string") {
138
+ if (!["private", "unlisted", "public"].includes(visibility)) {
139
+ console.error(red("Visibility is private, unlisted or public."));
140
+ return 1;
141
+ }
142
+ patch.visibility = visibility;
143
+ }
144
+ if (parsed.flags.get("pin") === true)
145
+ patch.pinned = true;
146
+ if (parsed.flags.get("unpin") === true)
147
+ patch.pinned = false;
148
+ const labelNames = parsed.flags.get("labels");
149
+ if (typeof labelNames === "string") {
150
+ const known = await (0, api_js_1.projectLabels)(project.id);
151
+ const wanted = labelNames
152
+ .split(",")
153
+ .map((one) => one.trim())
154
+ .filter(Boolean);
155
+ const ids = [];
156
+ for (const one of wanted) {
157
+ const found = known.find((label) => label.name.toLowerCase() === one.toLowerCase());
158
+ if (!found) {
159
+ console.error(red(`This project has no "${one}" label.`) +
160
+ dim(` Make one with cbx labels add ${one} green`));
161
+ return 1;
162
+ }
163
+ ids.push(found.id);
164
+ }
165
+ patch.labelIds = ids;
166
+ }
167
+ if (parsed.flags.get("clear-labels") === true)
168
+ patch.labelIds = [];
169
+ if (!Object.keys(patch).length) {
170
+ console.error(red("Say what to change.") +
171
+ dim(" --name, --notes, --hide, --show, --visibility, --pin, --labels"));
172
+ return 1;
173
+ }
174
+ try {
175
+ await (0, api_js_1.changeVersion)(project.id, target.id, patch);
176
+ }
177
+ catch (error) {
178
+ console.error(red(error instanceof Error ? error.message : String(error)));
179
+ return 1;
180
+ }
181
+ const said = [];
182
+ if (patch.name !== undefined) {
183
+ said.push(patch.name ? `named ${accent(patch.name)}` : "name removed");
184
+ }
185
+ if (patch.visibility)
186
+ said.push(patch.visibility);
187
+ if (patch.pinned !== undefined)
188
+ said.push(patch.pinned ? "pinned" : "unpinned");
189
+ if (patch.labelIds) {
190
+ said.push(patch.labelIds.length ? "labelled" : "labels cleared");
191
+ }
192
+ console.log(`${bold(`v${target.sequence}`)} ${said.join(", ")}.`);
193
+ return 0;
194
+ }
195
+ async function commandLabels(parsed) {
196
+ const action = parsed.positional[0] ?? "list";
197
+ if (action === "list") {
198
+ const project = await (0, project_commands_js_1.resolveProject)(parsed.positional[1]);
199
+ if (!project)
200
+ return 1;
201
+ const labels = await (0, api_js_1.projectLabels)(project.id);
202
+ if (!labels.length) {
203
+ console.log(dim("No labels yet. Make one with cbx labels add shipped green"));
204
+ return 0;
205
+ }
206
+ console.log(bold(project.name));
207
+ for (const label of labels) {
208
+ console.log(` ${paint(label).padEnd(28)} ${dim(label.colour)}` +
209
+ (label.description ? ` ${dim(label.description)}` : ""));
210
+ }
211
+ return 0;
212
+ }
213
+ if (action === "add") {
214
+ const name = parsed.positional[1];
215
+ const colour = parsed.positional[2] ?? "grey";
216
+ if (!name) {
217
+ console.error(red("Name the label: cbx labels add shipped green"));
218
+ return 1;
219
+ }
220
+ const project = await (0, project_commands_js_1.resolveProject)(parsed.positional[3]);
221
+ if (!project)
222
+ return 1;
223
+ const hex = colourOf(colour);
224
+ if (!hex) {
225
+ console.error(red(`"${colour}" is not a colour.`) +
226
+ dim(` Try one of ${Object.keys(SWATCH).slice(0, 8).join(", ")}, or #rrggbb`));
227
+ return 1;
228
+ }
229
+ try {
230
+ const made = await (0, api_js_1.createProjectLabel)(project.id, name, hex);
231
+ console.log(`Added ${paint(made)} ${dim(made.colour)}.`);
232
+ }
233
+ catch (error) {
234
+ console.error(red(error instanceof Error ? error.message : String(error)));
235
+ return 1;
236
+ }
237
+ return 0;
238
+ }
239
+ if (action === "remove" || action === "rm") {
240
+ const name = parsed.positional[1];
241
+ if (!name) {
242
+ console.error(red("Name the label to remove: cbx labels remove shipped"));
243
+ return 1;
244
+ }
245
+ const project = await (0, project_commands_js_1.resolveProject)(parsed.positional[2]);
246
+ if (!project)
247
+ return 1;
248
+ const labels = await (0, api_js_1.projectLabels)(project.id);
249
+ const found = labels.find((one) => one.name.toLowerCase() === name.toLowerCase());
250
+ if (!found) {
251
+ console.error(red(`This project has no "${name}" label.`));
252
+ return 1;
253
+ }
254
+ await (0, api_js_1.deleteProjectLabel)(project.id, found.id);
255
+ console.log(`Removed ${found.name}. ` +
256
+ dim("Versions that wore it keep everything else about them."));
257
+ return 0;
258
+ }
259
+ console.error(red(`Unknown: cbx labels ${action}`) + dim(" Try list, add or remove"));
260
+ return 1;
261
+ }
262
+ /**
263
+ * The versions a project is holding until somebody says yes.
264
+ *
265
+ * Listed first rather than requiring a version number, because the person
266
+ * running this is usually asking "is there anything waiting for me" rather
267
+ * than acting on one they already know about.
268
+ */
269
+ async function commandHeld(parsed) {
270
+ const project = await (0, project_commands_js_1.resolveProject)(parsed.positional[0]);
271
+ if (!project)
272
+ return 1;
273
+ const waiting = (await (0, api_js_1.versions)(project.id)).filter((one) => one.state === "held");
274
+ if (!waiting.length) {
275
+ console.log(dim("Nothing is waiting for a decision."));
276
+ return 0;
277
+ }
278
+ console.log(bold(`${waiting.length} waiting on ${project.name}`));
279
+ for (const one of waiting) {
280
+ console.log(` ${accent(`v${one.sequence}`).padEnd(16)} ${one.authorName.padEnd(20)} ` +
281
+ dim(one.message));
282
+ }
283
+ console.log(dim(`\n cbx review v${waiting[0].sequence} --approve`));
284
+ return 0;
285
+ }
286
+ async function commandReview(parsed) {
287
+ const which = split(parsed);
288
+ const approve = parsed.flags.get("approve") === true;
289
+ const decline = parsed.flags.get("decline") === true;
290
+ if (approve === decline) {
291
+ console.error(red("Say which: --approve or --decline."));
292
+ return 1;
293
+ }
294
+ const project = await (0, project_commands_js_1.resolveProject)(which.project);
295
+ if (!project)
296
+ return 1;
297
+ const target = await pick(project.id, which.n);
298
+ if (!target)
299
+ return 1;
300
+ if (target.state !== "held") {
301
+ console.error(red(`v${target.sequence} is not waiting for a decision.`));
302
+ return 1;
303
+ }
304
+ const note = parsed.flags.get("note");
305
+ try {
306
+ await (0, api_js_1.reviewVersion)(project.id, target.id, approve ? "approve" : "decline", typeof note === "string" ? note : null);
307
+ }
308
+ catch (error) {
309
+ console.error(red(error instanceof Error ? error.message : String(error)));
310
+ return 1;
311
+ }
312
+ console.log(approve
313
+ ? `${bold(`v${target.sequence}`)} approved. It is now the current version.`
314
+ : `${bold(`v${target.sequence}`)} declined. It stays in the history as a version that was not accepted.`);
315
+ return 0;
316
+ }
317
+ /**
318
+ * Take a version's content down.
319
+ *
320
+ * Asks first, and says exactly what will survive, because this is the one
321
+ * command here that destroys something. The version itself stays — the list
322
+ * keeps a gap saying what went and who removed it — and that distinction is
323
+ * the difference between this and rewriting history.
324
+ */
325
+ async function commandTakeDown(parsed) {
326
+ const which = split(parsed);
327
+ const project = await (0, project_commands_js_1.resolveProject)(which.project);
328
+ if (!project)
329
+ return 1;
330
+ const target = await pick(project.id, which.n);
331
+ if (!target)
332
+ return 1;
333
+ const reason = parsed.flags.get("reason");
334
+ if (parsed.flags.get("yes") !== true) {
335
+ console.log(red(`This removes the files in v${target.sequence}. They do not come back.`));
336
+ console.log(dim(` The version stays in the history as a gap saying it was removed.\n` +
337
+ ` Add --yes when you are sure.`));
338
+ return 1;
339
+ }
340
+ try {
341
+ await (0, api_js_1.removeVersionContent)(project.id, target.id, typeof reason === "string" ? reason : null);
342
+ }
343
+ catch (error) {
344
+ console.error(red(error instanceof Error ? error.message : String(error)));
345
+ return 1;
346
+ }
347
+ console.log(`${bold(`v${target.sequence}`)} taken down.`);
348
+ return 0;
349
+ }
350
+ /**
351
+ * Turning a commit into a version.
352
+ *
353
+ * A project's history is its working history — most of it says "fix that
354
+ * file", and none of that is anybody else's business. A version is a commit
355
+ * somebody decided was worth showing, and this is where that decision gets
356
+ * made: the last ten, pick one, name it.
357
+ *
358
+ * Named rather than numbered on purpose. A version people fetch is "v2.1" or
359
+ * "the one for the client", and inventing a second counter beside the commit
360
+ * numbers would give everybody two numbers to hold and tell them nothing.
361
+ */
362
+ async function commandPromote(parsed) {
363
+ const which = split(parsed);
364
+ const project = await (0, project_commands_js_1.resolveProject)(which.project);
365
+ if (!project)
366
+ return 1;
367
+ const all = await (0, api_js_1.versions)(project.id);
368
+ if (!all.length) {
369
+ console.error(red("This project has nothing saved yet."));
370
+ return 1;
371
+ }
372
+ const named = parsed.flags.get("name");
373
+ const notes = parsed.flags.get("notes");
374
+ /* A number given outright skips the list, for anybody scripting this. */
375
+ const asked = which.n;
376
+ let target = asked
377
+ ? all.find((one) => String(one.sequence) === asked.replace(/^v/i, ""))
378
+ : null;
379
+ if (asked && !target) {
380
+ console.error(red(`This project has no ${asked}.`));
381
+ return 1;
382
+ }
383
+ if (!target) {
384
+ const recent = all.filter((one) => one.state === "verified").slice(0, 10);
385
+ if (!recent.length) {
386
+ console.error(red("Nothing here can be promoted yet."));
387
+ return 1;
388
+ }
389
+ console.log(bold(`Recent commits on ${project.name}`));
390
+ recent.forEach((one, at) => {
391
+ const when = one.createdAt
392
+ ? new Date(one.createdAt).toLocaleString()
393
+ : "";
394
+ const already = one.name ? accent(` → ${one.name}`) : "";
395
+ console.log(` ${String(at + 1).padStart(2)}. ${accent(`v${one.sequence}`).padEnd(16)}` +
396
+ `${when.padEnd(22)}${dim(one.message)}${already}`);
397
+ });
398
+ const prompt = (0, promises_1.createInterface)({
399
+ input: node_process_1.default.stdin,
400
+ output: node_process_1.default.stdout,
401
+ });
402
+ const typed = await prompt.question("\nWhich one? (1-" + recent.length + ", or blank to stop) ");
403
+ prompt.close();
404
+ const choice = Number(typed.trim());
405
+ if (!typed.trim()) {
406
+ console.log(dim("Nothing promoted."));
407
+ return 0;
408
+ }
409
+ if (!Number.isInteger(choice) || choice < 1 || choice > recent.length) {
410
+ console.error(red("That was not one of the numbers listed."));
411
+ return 1;
412
+ }
413
+ target = recent[choice - 1];
414
+ }
415
+ let versionName = typeof named === "string" ? named.trim() : "";
416
+ if (!versionName) {
417
+ const prompt = (0, promises_1.createInterface)({
418
+ input: node_process_1.default.stdin,
419
+ output: node_process_1.default.stdout,
420
+ });
421
+ const typed = await prompt.question(`Call it what? (blank for v${target.sequence}.0) `);
422
+ prompt.close();
423
+ versionName = typed.trim() || `v${target.sequence}.0`;
424
+ }
425
+ try {
426
+ await (0, api_js_1.changeVersion)(project.id, target.id, {
427
+ name: versionName,
428
+ ...(typeof notes === "string" ? { notes } : {}),
429
+ });
430
+ }
431
+ catch (error) {
432
+ console.error(red(error instanceof Error ? error.message : String(error)));
433
+ return 1;
434
+ }
435
+ console.log(`${bold(versionName)} is now a version. ` +
436
+ dim("It is what the public side of this project offers."));
437
+ return 0;
438
+ }
439
+ /**
440
+ * Putting the project back where it was.
441
+ *
442
+ * Hiding a bad push stops strangers reading it and leaves everybody on the
443
+ * team standing on it. This moves the project itself, which is the half that
444
+ * was missing — and it destroys nothing: the commits that get passed over stay
445
+ * in the history with their numbers, and a later save carries on from here.
446
+ */
447
+ async function commandUndo(parsed) {
448
+ const which = split(parsed);
449
+ const project = await (0, project_commands_js_1.resolveProject)(which.project);
450
+ if (!project)
451
+ return 1;
452
+ const all = await (0, api_js_1.versions)(project.id);
453
+ if (all.length < 2) {
454
+ console.error(red("There is nothing before this to go back to."));
455
+ return 1;
456
+ }
457
+ let to = null;
458
+ const asked = which.n;
459
+ if (asked) {
460
+ const wanted = all.find((one) => String(one.sequence) === asked.replace(/^v/i, ""));
461
+ if (!wanted) {
462
+ console.error(red(`This project has no ${asked}.`));
463
+ return 1;
464
+ }
465
+ to = wanted.id;
466
+ }
467
+ try {
468
+ const undone = await (0, api_js_1.undoTo)(project.id, to);
469
+ console.log(`${bold(project.name)} is back on ${accent(`v${undone.to.sequence}`)}.`);
470
+ if (undone.skipped.length) {
471
+ /*
472
+ Named rather than counted. "3 commits passed over" tells somebody
473
+ nothing they can act on; the numbers let them look at one, or promote
474
+ one, or put the head back where it was.
475
+ */
476
+ console.log(dim(` Passed over: ${undone.skipped
477
+ .map((one) => `v${one.sequence}${one.name ? ` (${one.name})` : ""}`)
478
+ .join(", ")}`));
479
+ console.log(dim(" They are still here. Nothing was deleted and nothing renumbered."));
480
+ const stillPublic = undone.skipped.filter((one) => one.name);
481
+ if (stillPublic.length) {
482
+ console.log(dim(` ${stillPublic.map((one) => one.name).join(", ")} ` +
483
+ `${stillPublic.length === 1 ? "is" : "are"} still a published version — ` +
484
+ `use cbx mark to take ${stillPublic.length === 1 ? "it" : "them"} down.`));
485
+ }
486
+ }
487
+ }
488
+ catch (error) {
489
+ console.error(red(error instanceof Error ? error.message : String(error)));
490
+ return 1;
491
+ }
492
+ return 0;
493
+ }
@@ -367,6 +367,7 @@ const READABLE = new Set([
367
367
  ".groovy",
368
368
  ".h",
369
369
  ".hpp",
370
+ ".hcl",
370
371
  ".hs",
371
372
  ".htm",
372
373
  ".html",
@@ -54,6 +54,64 @@ class Tracks {
54
54
  }
55
55
  return (text ? JSON.parse(text) : {});
56
56
  }
57
+ /**
58
+ * A project's saves, as the owner sees them.
59
+ *
60
+ * Every one of them, including the ones held for review and the ones whose
61
+ * files were taken down — this is the working history, not the published
62
+ * one, and the whole point of showing it here is deciding what becomes
63
+ * published.
64
+ */
65
+ async versions(repositoryId) {
66
+ const body = await this.call(`/v1/repositories/${repositoryId}/versions`);
67
+ return body.versions ?? [];
68
+ }
69
+ async labels(repositoryId) {
70
+ const body = await this.call(`/v1/repositories/${repositoryId}/labels`);
71
+ return body.labels ?? [];
72
+ }
73
+ async createLabel(repositoryId, name, colour) {
74
+ const body = await this.call(`/v1/repositories/${repositoryId}/labels`, { method: "POST", body: JSON.stringify({ name, colour }) });
75
+ return body.label;
76
+ }
77
+ async reviewVersion(repositoryId, versionId, decision, note) {
78
+ await this.call(`/v1/repositories/${repositoryId}/versions/${versionId}/review`, {
79
+ method: "POST",
80
+ body: JSON.stringify({ decision, ...(note ? { note } : {}) }),
81
+ });
82
+ }
83
+ async takeVersionDown(repositoryId, versionId, reason) {
84
+ const query = reason ? `?reason=${encodeURIComponent(reason)}` : "";
85
+ await this.call(`/v1/repositories/${repositoryId}/versions/${versionId}/content${query}`, { method: "DELETE" });
86
+ }
87
+ /** Put the project back on an earlier save. Nothing is deleted. */
88
+ async undo(repositoryId, to) {
89
+ return await this.call(`/v1/repositories/${repositoryId}/undo`, {
90
+ method: "POST",
91
+ body: JSON.stringify(to ? { to } : {}),
92
+ });
93
+ }
94
+ /** Publish an older save's content again, as a new save. */
95
+ async restoreVersion(repositoryId, versionId, message) {
96
+ await this.call(`/v1/repositories/${repositoryId}/versions/${versionId}/restore`, {
97
+ method: "POST",
98
+ body: JSON.stringify({ message }),
99
+ });
100
+ }
101
+ /**
102
+ * Everything about a version, changed in one call.
103
+ *
104
+ * Named, hidden, pinned or labelled from the desktop app for the first time.
105
+ * All three clients call the same route with the same patch, which is what
106
+ * stops the next capability being reachable from one of them and not the
107
+ * others.
108
+ */
109
+ async changeVersion(repositoryId, versionId, patch) {
110
+ await this.call(`/v1/repositories/${repositoryId}/versions/${versionId}`, {
111
+ method: "PATCH",
112
+ body: JSON.stringify(patch),
113
+ });
114
+ }
57
115
  /**
58
116
  * Every line and merge this project has.
59
117
  *
@@ -1233,6 +1233,7 @@ class Uploader {
1233
1233
  repositoryId,
1234
1234
  versionId: completed.version.id,
1235
1235
  sequence: completed.version.sequence,
1236
+ ...(completed.version.state === "held" ? { held: true } : {}),
1236
1237
  ...(completed.mergeTrack ? { mergeTrack: completed.mergeTrack } : {}),
1237
1238
  ...(completed.repeated ? { repeated: true } : {}),
1238
1239
  sourceBytes: completed.version.sourceSize ?? sourceBytes,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.25.4",
3
+ "version": "0.26.0",
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",
@@ -81,6 +81,28 @@ cbx submit --track spike -m "…"
81
81
  Switching says where the next save goes and nothing else — no files move.
82
82
  Run `cbx get` afterwards to bring that line's files in.
83
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
+
84
106
  ## When two saves collide
85
107
 
86
108
  If somebody saved while this folder was behind, the second save becomes a merge
@@ -121,6 +143,8 @@ cbx unbundle project.cbx ./restored
121
143
  again and gains nothing.
122
144
  - Do not guess a project name. `cbx projects` lists them; a folder that is
123
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.
124
148
 
125
149
  ## If something refuses
126
150