@coderook/cli 0.24.0 → 0.25.1

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.24.0",
5
+ "version": "0.25.1",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
package/README.md CHANGED
@@ -126,6 +126,42 @@ and the desktop application all read one file. A `!` line puts something back.
126
126
  Personal exclusions that should not reach a collaborator belong in
127
127
  `.git/info/exclude`.
128
128
 
129
+ ## Moving a repository here
130
+
131
+ `cbx import` takes a snapshot — the files as they are now, one version, no
132
+ history. To move off a host entirely, with the history intact:
133
+
134
+ ```
135
+ cbx transfer https://github.com/owner/project
136
+ ```
137
+
138
+ GitHub, GitLab, Codeberg and any self-hosted Gitea or Forgejo. Every commit
139
+ becomes a version and every branch a line, so it takes a while on a long
140
+ history — the commit count and an estimate are printed before it starts.
141
+
142
+ **It goes through the public route on purpose.** The history is published by
143
+ running `git push` against the CodeRook remote, exactly as you would by hand.
144
+ A transfer that used a private path would prove only that a private path
145
+ exists.
146
+
147
+ | | |
148
+ | --- | --- |
149
+ | Comes across | commits, every branch, tags as releases, the description |
150
+ | With `--issues` and a read token | the open issues |
151
+ | Does not come across | pull requests and reviews, CI configuration, collaborators, stars |
152
+
153
+ The project is created **private** whatever the source was — publishing
154
+ somebody's code as a side effect of moving it is not recoverable. Pass
155
+ `--visibility same` to mirror the source, or `--public`.
156
+
157
+ A private repository needs a read token in the environment — `GITHUB_TOKEN`,
158
+ `GITLAB_TOKEN` or `FORGE_TOKEN`. It is never taken as a flag, never stored,
159
+ and there is no OAuth flow: holding your forge credentials to run a one-off
160
+ migration sits badly beside a promise that your work is not handed to anybody.
161
+
162
+ The report at the end lists what arrived and what did not, rather than leaving
163
+ the absence to be discovered later.
164
+
129
165
  ## Using git instead
130
166
 
131
167
  Installing the CLI also installs `git-remote-coderook`, so git talks to
@@ -246,12 +282,14 @@ a half-written tree.
246
282
 
247
283
  ## Claude Code
248
284
 
249
- ```
285
+ Run this **in a terminal**, not inside Claude Code:
286
+
287
+ ```bash
250
288
  cbx skill
251
289
  ```
252
290
 
253
- That is the whole setup. It writes a short guide into Claude Code's skills
254
- folder, and from then on you ask for what you want in ordinary words:
291
+ That is the whole setup. It writes `~/.claude/skills/coderook/SKILL.md`, and
292
+ from then on you ask for what you want in ordinary words:
255
293
 
256
294
  > save this to CodeRook
257
295
 
@@ -262,6 +300,12 @@ folder, and from then on you ask for what you want in ordinary words:
262
300
  Claude works out which commands to run. You can also call it by name with
263
301
  `/coderook`.
264
302
 
303
+ **The skill is called `coderook`, not `cbx`.** `cbx` is the command line, run
304
+ in a terminal; the skill is the file that teaches Claude how to use it. Typing
305
+ `cbx skill` *into* Claude Code asks it for a skill by that name and it will
306
+ tell you there is not one — which is correct, and is why the line above says
307
+ where to run it.
308
+
265
309
  `cbx skill --project` writes it into `./.claude` instead of your home
266
310
  folder, so it travels with the repository and everybody who clones it has it
267
311
  too.
@@ -11,6 +11,7 @@ exports.applyMerge = applyMerge;
11
11
  exports.cancelMerge = cancelMerge;
12
12
  exports.aiAccess = aiAccess;
13
13
  exports.setAiAccess = setAiAccess;
14
+ exports.updateProject = updateProject;
14
15
  exports.versions = versions;
15
16
  exports.markRelease = markRelease;
16
17
  exports.issues = issues;
@@ -68,6 +69,7 @@ async function projects() {
68
69
  slug: String(row.slug ?? ""),
69
70
  name: String(row.displayName || row.slug || "Untitled"),
70
71
  visibility: String(row.visibility ?? "private"),
72
+ defaultBranch: String(row.defaultBranch || "main"),
71
73
  versionCount: Number(row.versionCount ?? 0),
72
74
  fileCount: Number(row.fileCount ?? 0),
73
75
  storedBytes: Number(row.storedSize ?? 0),
@@ -149,6 +151,18 @@ async function setAiAccess(repositoryId, change) {
149
151
  body: change,
150
152
  });
151
153
  }
154
+ /**
155
+ * Change what a project says about itself.
156
+ *
157
+ * The same PATCH the AI switches use, which is where these live: a handful of
158
+ * columns on the repository rather than a resource of their own.
159
+ */
160
+ async function updateProject(repositoryId, change) {
161
+ await call(`/v1/repositories/${encodeURIComponent(repositoryId)}`, {
162
+ method: "PATCH",
163
+ body: change,
164
+ });
165
+ }
152
166
  /** Every saved version of a project, newest first. */
153
167
  async function versions(repositoryId) {
154
168
  const body = await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/versions`);
@@ -27,6 +27,8 @@ const progress_js_1 = require("./progress.js");
27
27
  const publish_js_1 = require("./publish.js");
28
28
  const project_commands_js_1 = require("./project_commands.js");
29
29
  const track_commands_js_1 = require("./track_commands.js");
30
+ const tracks_js_1 = require("../../desktop-app/src/main/tracks.js");
31
+ const transfer_command_js_1 = require("./transfer_command.js");
30
32
  const mcp_js_1 = require("./mcp.js");
31
33
  const skill_command_js_1 = require("./skill_command.js");
32
34
  const licence_commands_js_1 = require("./licence_commands.js");
@@ -42,25 +44,13 @@ const api_js_2 = require("./api.js");
42
44
  const import_command_js_1 = require("./import_command.js");
43
45
  const runner_js_1 = require("./runner.js");
44
46
  const config_js_1 = require("./config.js");
45
- /*
46
- Read from the package rather than written twice. A hardcoded copy had
47
- already drifted from the published version, which makes `cbx doctor`
48
- worse than useless when working out what somebody is actually running.
49
- */
50
- const VERSION = (() => {
51
- try {
52
- return (require("../../../package.json").version ?? "0.0.0");
53
- }
54
- catch {
55
- return "0.0.0";
56
- }
57
- })();
47
+ const version_js_1 = require("./version.js");
58
48
  /*
59
49
  Said once, before anything reaches the service. The version comes from the
60
50
  package rather than a constant, so it cannot drift from what was published
61
51
  and claim a behaviour this build does not have.
62
52
  */
63
- (0, identify_js_1.declareClient)("cli", VERSION);
53
+ (0, identify_js_1.declareClient)("cli", version_js_1.VERSION);
64
54
  // ── presentation ────────────────────────────────────────────────────────────
65
55
  const colour = node_process_1.default.stdout.isTTY && !node_process_1.default.env.NO_COLOR;
66
56
  const dim = (text) => (colour ? `${text}` : text);
@@ -884,7 +874,7 @@ async function commandGet(parsed) {
884
874
  incoming.has(file.path) &&
885
875
  edited.some((one) => one.path === file.path));
886
876
  if (!changedInTheGap.length) {
887
- return fetchInto(link.repositoryId, folder, link.slug, link.local ?? null, "replace");
877
+ return fetchInto(link.repositoryId, folder, link.slug, link.local ?? null, "replace", await (0, track_commands_js_1.trackFor)(folder));
888
878
  }
889
879
  console.error(red(`${changedInTheGap.length} file${changedInTheGap.length === 1 ? "" : "s"} ` +
890
880
  `changed while the interrupted fetch was stopped:`));
@@ -923,7 +913,9 @@ Save them with ${accent("cbx submit")}, or finish the fetch and ` +
923
913
  what the version actually changed is written over — unless somebody asks
924
914
  for an exact copy, which is how a damaged folder is repaired.
925
915
  */
926
- return fetchInto(link.repositoryId, folder, link.slug, link.local ?? null, replacing ? "replace" : "reconcile");
916
+ return fetchInto(link.repositoryId, folder, link.slug, link.local ?? null, replacing ? "replace" : "reconcile",
917
+ // The line this folder saves to, so catching up brings that line down.
918
+ await (0, track_commands_js_1.trackFor)(folder));
927
919
  }
928
920
  async function commandClone(parsed) {
929
921
  const reference = parsed.positional[0];
@@ -941,7 +933,34 @@ async function commandClone(parsed) {
941
933
  return 1;
942
934
  }
943
935
  const destination = node_path_1.default.resolve(parsed.positional[1] ?? project.slug);
944
- return fetchInto(project.id, destination, project.slug);
936
+ /*
937
+ A clone opens on the project's default line, or on the one named.
938
+
939
+ It used to take whichever version was newest across every line, which is
940
+ only the same thing while a project has one. As soon as somebody pushed to
941
+ a branch, that branch held the newest version — so cloning the project
942
+ handed you their unfinished work under the impression it was the project.
943
+ Nothing said so: the fetch reported a version number and the files looked
944
+ like a project, just not the one anybody asked for.
945
+
946
+ Naming a line here also removes the clone-switch-fetch dance that was the
947
+ only way to get a branch before, which is what exposed the same fault in
948
+ `get`.
949
+ */
950
+ const wanted = flagText(parsed, "track") ?? project.defaultBranch;
951
+ /*
952
+ Falling back rather than failing when the default line does not exist.
953
+ `defaultBranch` is a property of the project and a line can be renamed out
954
+ from under it; refusing to clone at all in that case would be a worse
955
+ answer than the old behaviour. An explicitly named line is still refused,
956
+ because there the person said which one they meant.
957
+ */
958
+ const lines = await new tracks_js_1.Tracks(config_js_1.credentials).list(project.id);
959
+ const usable = lines.some((line) => line.kind === "line" && line.name === wanted && line.headVersionId);
960
+ if (!usable && !flagText(parsed, "track")) {
961
+ console.log(dim(`This project has no line called ${wanted}; taking its newest version.`));
962
+ }
963
+ return fetchInto(project.id, destination, project.slug, null, "replace", usable || flagText(parsed, "track") ? wanted : undefined);
945
964
  }
946
965
  async function fetchInto(repositoryId, destination, slug,
947
966
  /*
@@ -950,9 +969,45 @@ async function fetchInto(repositoryId, destination, slug,
950
969
  is removed, because "missing from the version" cannot then be told from
951
970
  "never came from the version at all".
952
971
  */
953
- held, mode = "replace") {
972
+ held, mode = "replace",
973
+ /*
974
+ The line to fetch. Omitted means the project's newest version, which is
975
+ right for a clone and wrong for a folder that has chosen a line.
976
+ */
977
+ trackName) {
954
978
  const downloader = new download_js_1.Downloader(config_js_1.credentials);
955
- const latest = (await downloader.versions(repositoryId))[0];
979
+ const all = await downloader.versions(repositoryId);
980
+ /*
981
+ The head of this folder's line, not the newest version in the project.
982
+
983
+ Those are the same thing only while a project has one line. With two, the
984
+ newest version usually belongs to the *other* one — so switching to a line
985
+ and fetching brought down the line you had just left, reported success,
986
+ and left the folder holding the wrong contents while still claiming to
987
+ save to the line you asked for. The next publish would then be computed
988
+ against a baseline from somewhere else.
989
+
990
+ Worse than a wrong answer: `track` ends by telling you to run `get`, so
991
+ the command that set this up was the one recommending it.
992
+ */
993
+ let latest = all[0];
994
+ if (trackName) {
995
+ const chosen = (await new tracks_js_1.Tracks(config_js_1.credentials).list(repositoryId)).find((candidate) => candidate.name === trackName && candidate.kind === "line");
996
+ if (!chosen) {
997
+ console.error(red(`This project has no line called ${trackName}.`));
998
+ return 1;
999
+ }
1000
+ if (!chosen.headVersionId) {
1001
+ console.error(red(`The line ${trackName} has nothing saved to it yet.`));
1002
+ return 1;
1003
+ }
1004
+ const head = all.find((version) => version.id === chosen.headVersionId);
1005
+ if (!head) {
1006
+ console.error(red(`Could not read the current version of ${trackName}.`));
1007
+ return 1;
1008
+ }
1009
+ latest = head;
1010
+ }
956
1011
  if (!latest) {
957
1012
  console.error(red("That project has no saved version."));
958
1013
  return 1;
@@ -991,6 +1046,16 @@ held, mode = "replace") {
991
1046
  // version holds and the two agree again.
992
1047
  local: result.manifest,
993
1048
  manifest: result.manifest,
1049
+ /*
1050
+ The line this folder is on survives being caught up.
1051
+
1052
+ This record is built fresh rather than merged, so anything not named
1053
+ here is dropped — and `track` was not named. Fetching therefore moved
1054
+ the folder back to `main` every time, silently: the files were right,
1055
+ the folder reported the wrong line, and the next publish went somewhere
1056
+ nobody chose.
1057
+ */
1058
+ ...(trackName ? { track: trackName } : {}),
994
1059
  // Reached the end, so there is no longer a fetch in flight.
995
1060
  });
996
1061
  // The record is written; the run has not reported success yet.
@@ -1128,7 +1193,7 @@ async function commandInspect(parsed) {
1128
1193
  }
1129
1194
  async function commandDoctor() {
1130
1195
  const service = await (0, api_js_2.health)().catch(() => null);
1131
- console.log(`cbx ${VERSION} · Node ${node_process_1.default.versions.node} · ${node_process_1.default.platform}`);
1196
+ console.log(`cbx ${version_js_1.VERSION} · Node ${node_process_1.default.versions.node} · ${node_process_1.default.platform}`);
1132
1197
  console.log(`Config: ${(0, config_js_1.configDirectory)()}`);
1133
1198
  console.log(service
1134
1199
  ? `Service: ${service.status} · schema ${service.schemaVersion}` +
@@ -1340,7 +1405,7 @@ async function commandRunner(parsed) {
1340
1405
  for (;;) {
1341
1406
  let job = null;
1342
1407
  try {
1343
- job = await (0, runner_js_1.claim)(project.id, name, VERSION, answersTo);
1408
+ job = await (0, runner_js_1.claim)(project.id, name, version_js_1.VERSION, answersTo);
1344
1409
  }
1345
1410
  catch (error) {
1346
1411
  /*
@@ -1500,6 +1565,43 @@ const SPECS = [
1500
1565
  ],
1501
1566
  run: commandImport,
1502
1567
  },
1568
+ {
1569
+ /*
1570
+ Distinct from `import`, which takes a snapshot. This moves the history,
1571
+ the branches, the tags and what the project says about itself — the
1572
+ things somebody leaving a host is actually worried about losing.
1573
+ */
1574
+ name: "transfer",
1575
+ aliases: ["migrate"],
1576
+ group: "Getting started",
1577
+ summary: "move a whole repository here from GitHub, GitLab or Codeberg",
1578
+ usage: "transfer <address>",
1579
+ detail: "Brings the commits, every branch, the tags and the description across.\n" +
1580
+ "Each commit becomes a version and each branch a line, so a long history\n" +
1581
+ "takes a while; the count and an estimate are printed before it starts.\n\n" +
1582
+ "The history is published by running `git push` against the CodeRook\n" +
1583
+ "remote — the same route anybody else uses, deliberately, because the\n" +
1584
+ "claim being made is that git repositories work here.\n\n" +
1585
+ "A private repository needs a read token in the environment:\n" +
1586
+ "GITHUB_TOKEN, GITLAB_TOKEN or FORGE_TOKEN. It is never asked for on the\n" +
1587
+ "command line and never stored.\n\n" +
1588
+ "The project is created private whatever the source was, unless you say\n" +
1589
+ "otherwise. Pull requests, CI configuration and collaborators do not come\n" +
1590
+ "across; the report at the end lists what did and what did not.",
1591
+ options: [
1592
+ { flags: "--name <name>", description: "call the project this, rather than the repository's name" },
1593
+ {
1594
+ flags: "--visibility <how>",
1595
+ description: "private (default), same as the source, or public",
1596
+ },
1597
+ { flags: "--issues", description: "also bring the open issues; needs a read token" },
1598
+ ],
1599
+ examples: [
1600
+ "cbx transfer https://github.com/owner/project",
1601
+ "cbx transfer https://codeberg.org/owner/project --visibility same",
1602
+ ],
1603
+ run: transfer_command_js_1.commandTransfer,
1604
+ },
1503
1605
  {
1504
1606
  /*
1505
1607
  Where the next save goes, which is a property of this folder rather
@@ -1555,6 +1657,13 @@ const SPECS = [
1555
1657
  group: "Working with a folder",
1556
1658
  summary: "fetch a project into a new folder",
1557
1659
  usage: "clone <project> [dir]",
1660
+ options: [
1661
+ {
1662
+ flags: "--track <name>",
1663
+ description: "fetch this line rather than the project's newest version",
1664
+ },
1665
+ ],
1666
+ examples: ["cbx clone my-project", "cbx clone my-project --track spike"],
1558
1667
  run: commandClone,
1559
1668
  },
1560
1669
  {
@@ -1919,7 +2028,7 @@ const SPECS = [
1919
2028
  "A project whose owner has turned off machine reading is refused, in\n" +
1920
2029
  "words rather than as a status code.\n",
1921
2030
  examples: ["claude mcp add coderook -- cbx mcp"],
1922
- run: () => (0, mcp_js_1.commandMcp)(VERSION),
2031
+ run: () => (0, mcp_js_1.commandMcp)(version_js_1.VERSION),
1923
2032
  },
1924
2033
  {
1925
2034
  name: "doctor",
@@ -1933,11 +2042,11 @@ const REGISTRY = (0, registry_js_1.buildRegistry)(SPECS);
1933
2042
  async function main(argv) {
1934
2043
  const [name, ...rest] = argv;
1935
2044
  if (!name || name === "--help" || name === "-h") {
1936
- console.log((0, help_js_1.renderHelp)(REGISTRY, VERSION));
2045
+ console.log((0, help_js_1.renderHelp)(REGISTRY, version_js_1.VERSION));
1937
2046
  return 0;
1938
2047
  }
1939
2048
  if (name === "--version" || name === "-v" || name === "version") {
1940
- console.log(VERSION);
2049
+ console.log(version_js_1.VERSION);
1941
2050
  return 0;
1942
2051
  }
1943
2052
  /*
@@ -1948,7 +2057,7 @@ async function main(argv) {
1948
2057
  if (name === "help") {
1949
2058
  const wanted = rest[0];
1950
2059
  if (!wanted) {
1951
- console.log((0, help_js_1.renderHelp)(REGISTRY, VERSION));
2060
+ console.log((0, help_js_1.renderHelp)(REGISTRY, version_js_1.VERSION));
1952
2061
  return 0;
1953
2062
  }
1954
2063
  const spec = REGISTRY.lookup.get(wanted);
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ /**
3
+ * Reading a repository address, and nothing else.
4
+ *
5
+ * Kept apart from the transfer itself so a test can exercise it directly:
6
+ * importing the command drags in the API client and the upload engine, and
7
+ * none of that is needed to decide that `gitlab.com/group/subgroup/project`
8
+ * belongs to `group/subgroup`. It is also the piece where a mistake is quiet —
9
+ * a URL parsed into the wrong owner still clones and still pushes, and only
10
+ * the project name afterwards says anything was wrong.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.readForgeUrl = readForgeUrl;
14
+ /**
15
+ * Work out which forge a URL points at.
16
+ *
17
+ * Gitea is the fallback rather than a failure: Codeberg, Forgejo and every
18
+ * self-hosted Gitea share one API shape, so an unknown host is far more likely
19
+ * to be one of those than to be nothing. Guessing wrong costs a failed
20
+ * metadata read, which is reported; refusing outright would cost the transfer.
21
+ */
22
+ function readForgeUrl(input) {
23
+ let raw = input.trim();
24
+ // `git@host:owner/repo.git` is not a URL; make it one.
25
+ const ssh = raw.match(/^git@([^:]+):(.+)$/);
26
+ if (ssh)
27
+ raw = `https://${ssh[1]}/${ssh[2]}`;
28
+ if (!/^[a-z]+:\/\//i.test(raw))
29
+ raw = `https://${raw}`;
30
+ let parsed;
31
+ try {
32
+ parsed = new URL(raw);
33
+ }
34
+ catch {
35
+ throw new Error(`That does not look like a repository address: ${input}`);
36
+ }
37
+ const parts = parsed.pathname.replace(/\.git$/i, "").split("/").filter(Boolean);
38
+ if (parts.length < 2) {
39
+ throw new Error(`That address names no repository: ${input}\n` +
40
+ ` Expected something like https://github.com/owner/project`);
41
+ }
42
+ /*
43
+ GitLab allows groups within groups, so the project is the last segment and
44
+ the owner is everything before it. Taking parts[0] would address the top
45
+ group and miss the project entirely.
46
+ */
47
+ const repo = parts[parts.length - 1];
48
+ const owner = parts.slice(0, -1).join("/");
49
+ const host = parsed.host;
50
+ const kind = /(^|\.)github\.com$/i.test(host)
51
+ ? "github"
52
+ : /(^|\.)gitlab\.com$/i.test(host)
53
+ ? "gitlab"
54
+ : "gitea";
55
+ return {
56
+ kind,
57
+ label: kind === "github" ? "GitHub" : kind === "gitlab" ? "GitLab" : `Gitea (${host})`,
58
+ host,
59
+ owner,
60
+ repo,
61
+ clone: `https://${host}/${owner}/${repo}.git`,
62
+ tokenNames: kind === "github"
63
+ ? ["GITHUB_TOKEN", "GH_TOKEN"]
64
+ : kind === "gitlab"
65
+ ? ["GITLAB_TOKEN"]
66
+ : ["FORGE_TOKEN", "GITEA_TOKEN"],
67
+ };
68
+ }
@@ -10,7 +10,7 @@
10
10
  * is why this file exists at all.
11
11
  */
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
- exports.TRAILER = exports.EMPTY_TREE = void 0;
13
+ exports.MESSAGE_LIMIT = exports.TRAILER = exports.EMPTY_TREE = void 0;
14
14
  exports.parseRemoteUrl = parseRemoteUrl;
15
15
  exports.commitFromMessage = commitFromMessage;
16
16
  exports.messageWithCommit = messageWithCommit;
@@ -75,10 +75,47 @@ function commitFromMessage(message) {
75
75
  ];
76
76
  return all.length ? all[all.length - 1][1] : null;
77
77
  }
78
- /** Attach the marker to a commit message without disturbing what it says. */
78
+ /**
79
+ * What a version message may hold, matching the service.
80
+ *
81
+ * Not a guess and not a safety margin: the publish endpoint trims and then
82
+ * refuses anything longer, so exceeding it fails the whole push rather than
83
+ * being quietly shortened somewhere along the way.
84
+ */
85
+ exports.MESSAGE_LIMIT = 240;
86
+ /**
87
+ * Attach the marker to a commit message, within what the service will accept.
88
+ *
89
+ * The marker is not decoration — it is how a version is tied back to the
90
+ * commit it came from, and losing it means a later push republishes history
91
+ * the project already holds. So when the two together are too long, the
92
+ * message gives way and the marker survives.
93
+ *
94
+ * Git's own convention decides how: the first line is the summary and the rest
95
+ * is detail, so as much as fits is kept and the remainder is cut on a word
96
+ * boundary. A repository with a two-paragraph commit message would otherwise
97
+ * fail the push outright — which is what it did, on a real transfer, with an
98
+ * error naming a limit the person had never heard of.
99
+ */
79
100
  function messageWithCommit(message, sha) {
101
+ const trailer = `\n\n${exports.TRAILER} ${sha}`;
102
+ const room = exports.MESSAGE_LIMIT - trailer.length;
80
103
  const body = message.replace(/\s+$/, "");
81
- return `${body}\n\n${exports.TRAILER} ${sha}`;
104
+ if (body.length <= room)
105
+ return `${body}${trailer}`;
106
+ /*
107
+ Cut on a word boundary when one is reasonably near the end, so the result
108
+ reads as a shortened sentence rather than a severed one. The ellipsis is
109
+ what tells a reader there was more.
110
+ */
111
+ const kept = body.slice(0, room - 1);
112
+ const tidy = kept.replace(/\s+\S*$/, "");
113
+ const shortened = (tidy.length > room / 2 ? tidy : kept).trimEnd();
114
+ /*
115
+ A message of nothing but whitespace would leave an empty string, which the
116
+ service refuses for its own reason: it requires at least one character.
117
+ */
118
+ return `${shortened || sha.slice(0, 8)}…${trailer}`;
82
119
  }
83
120
  /**
84
121
  * The message as a person wrote it, with our bookkeeping taken back out.
@@ -106,6 +106,7 @@ const upload_js_1 = require("../../desktop-app/src/main/upload.js");
106
106
  const tracks_js_1 = require("../../desktop-app/src/main/tracks.js");
107
107
  const identify_js_1 = require("../../desktop-app/src/main/identify.js");
108
108
  const config_js_1 = require("./config.js");
109
+ const version_js_1 = require("./version.js");
109
110
  const publish_js_1 = require("./publish.js");
110
111
  const git_history_js_1 = require("./git_history.js");
111
112
  const api_js_1 = require("./api.js");
@@ -253,6 +254,54 @@ async function applyCommit(catFile, scratch, fromCommit, toCommit) {
253
254
  }
254
255
  return { written, deleted, skipped };
255
256
  }
257
+ /*
258
+ The service's answer when a line is already there.
259
+
260
+ Matched on the wording as well as the shape because the message is the only
261
+ thing that reaches here: `This project already has a track called main`. The
262
+ previous test looked for "exist", which that sentence does not contain — so a
263
+ branch arriving after the project had been created was reported as a hard
264
+ failure rather than the ordinary "it is already there".
265
+ */
266
+ const TRACK_EXISTS = /already has a track|track_exists|exists/i;
267
+ /**
268
+ * The order refs are published in, which is not the order git sends them.
269
+ *
270
+ * `git push --all` hands over every branch at once, and on a project that does
271
+ * not exist yet the first one creates it — along with an empty `main`. If a
272
+ * side branch happens to go first it takes the whole history, and `main`
273
+ * arrives to find its own line already made and nothing it can do with it:
274
+ * there is no way to move an existing line's head, only to start one at a
275
+ * version. The result was a project whose default line was permanently empty.
276
+ *
277
+ * Publishing the branch the others fork from first makes every later branch an
278
+ * ordinary fork, which is the case that already worked. Local HEAD wins,
279
+ * because that is what the person is standing on; `main` and `master` are the
280
+ * conventional fallbacks; everything else keeps a stable order so two runs of
281
+ * the same push behave the same way.
282
+ */
283
+ function ordered(requests) {
284
+ let head = "";
285
+ try {
286
+ head = git(["symbolic-ref", "--short", "HEAD"]).trim();
287
+ }
288
+ catch {
289
+ // A detached HEAD has no branch to prefer; the fallbacks still apply.
290
+ }
291
+ const rank = (request) => {
292
+ const branch = (0, git_history_js_1.branchOf)(request.dst);
293
+ if (!branch)
294
+ return 4; // tags last: they name versions that must exist
295
+ if (branch === head)
296
+ return 0;
297
+ if (branch === "main")
298
+ return 1;
299
+ if (branch === "master")
300
+ return 2;
301
+ return 3;
302
+ };
303
+ return [...requests].sort((left, right) => rank(left) - rank(right) || left.dst.localeCompare(right.dst));
304
+ }
256
305
  /**
257
306
  * The refs the remote already holds, as far as we can tell.
258
307
  *
@@ -612,18 +661,44 @@ async function doImport(refs, url) {
612
661
  }
613
662
  async function doPush(requests, url) {
614
663
  const { slug } = (0, git_history_js_1.parseRemoteUrl)(url);
615
- const project = await (0, api_js_1.findProject)(slug);
616
- const repositoryId = project?.id ?? null;
617
- const state = repositoryId ? await remoteState(repositoryId) : null;
618
664
  const marks = await readMarks();
619
- const known = new Map();
620
- for (const track of state?.tracks ?? []) {
621
- if (track.kind !== "line" || !track.headVersionId)
622
- continue;
623
- const sha = headCommit(track.headVersionId, state, marks);
624
- if (sha)
625
- known.set(`refs/heads/${track.name}`, sha);
626
- }
665
+ /*
666
+ Re-read the project before every ref, not once before the batch.
667
+
668
+ `git push --all` sends every branch in one batch, and the first of them
669
+ routinely creates the project. Reading the state once meant every later
670
+ ref in that same push still believed the project did not exist: it
671
+ published against a null repository, started its history from nothing, and
672
+ left its line without a head. Pushing two branches to a new project
673
+ produced one working line and one empty one, and reported success for
674
+ both — which is the shape a whole-repository transfer takes, so it failed
675
+ exactly where it mattered most.
676
+
677
+ One extra listing per ref. A push carries a handful of refs and each one
678
+ publishes versions costing seconds, so the cost is not worth the class of
679
+ bug avoiding it creates.
680
+ */
681
+ let repositoryId = (await (0, api_js_1.findProject)(slug))?.id ?? null;
682
+ let state = repositoryId
683
+ ? await remoteState(repositoryId)
684
+ : null;
685
+ const refreshProject = async () => {
686
+ if (!repositoryId)
687
+ repositoryId = (await (0, api_js_1.findProject)(slug))?.id ?? null;
688
+ state = repositoryId ? await remoteState(repositoryId) : null;
689
+ };
690
+ const knownRefs = () => {
691
+ const found = new Map();
692
+ for (const track of state?.tracks ?? []) {
693
+ if (track.kind !== "line" || !track.headVersionId)
694
+ continue;
695
+ const sha = headCommit(track.headVersionId, state, marks);
696
+ if (sha)
697
+ found.set(`refs/heads/${track.name}`, sha);
698
+ }
699
+ return found;
700
+ };
701
+ let known = knownRefs();
627
702
  let nextPushMark = 1;
628
703
  for (const mark of marks?.sha.keys() ?? []) {
629
704
  nextPushMark = Math.max(nextPushMark, mark + 1);
@@ -633,7 +708,13 @@ async function doPush(requests, url) {
633
708
  const scratch = await (0, promises_1.mkdtemp)(node_path_1.default.join(node_os_1.default.tmpdir(), "coderook-push-"));
634
709
  let warnedAboutMerges = false;
635
710
  try {
636
- for (const request of requests) {
711
+ for (const request of ordered(requests)) {
712
+ /*
713
+ Whatever the previous ref did, this one starts from what the project
714
+ actually holds now — including a project the previous ref created.
715
+ */
716
+ await refreshProject();
717
+ known = knownRefs();
637
718
  /*
638
719
  A tag names a version, which is exactly what a CodeRook release is:
639
720
  "a Version with a name on it, not a different kind of object". So a tag
@@ -808,7 +889,7 @@ async function doPush(requests, url) {
808
889
  }
809
890
  catch (error) {
810
891
  const text = error instanceof Error ? error.message : String(error);
811
- if (!/exist/i.test(text)) {
892
+ if (!TRACK_EXISTS.test(text)) {
812
893
  send(`error ${request.dst} ${text}`);
813
894
  continue;
814
895
  }
@@ -871,7 +952,7 @@ async function doPush(requests, url) {
871
952
  }
872
953
  catch (error) {
873
954
  const text = error instanceof Error ? error.message : String(error);
874
- if (!/exist/i.test(text))
955
+ if (!TRACK_EXISTS.test(text))
875
956
  throw error;
876
957
  }
877
958
  }
@@ -1000,7 +1081,14 @@ async function* lines() {
1000
1081
  }
1001
1082
  async function main(argv) {
1002
1083
  const url = argv[1] ?? argv[0] ?? "";
1003
- (0, identify_js_1.declareClient)("cli", "git-remote");
1084
+ /*
1085
+ The real version, not the word "git-remote". This passed its own name
1086
+ where the version belongs, so the service applied its version floor and
1087
+ refused to serve files — `git clone` failed with a demand to update to a
1088
+ release older than the one running. Push was unaffected, which is why it
1089
+ went unnoticed until a clone.
1090
+ */
1091
+ (0, identify_js_1.declareClient)("cli", version_js_1.VERSION);
1004
1092
  const pending = [];
1005
1093
  const importing = [];
1006
1094
  for await (const line of lines()) {
@@ -1052,7 +1140,26 @@ async function main(argv) {
1052
1140
  const sha = mark === undefined ? undefined : marks?.sha.get(mark);
1053
1141
  send(`${sha ?? "?"} refs/heads/${track.name}`);
1054
1142
  }
1055
- const head = lines.find((track) => track.name === "main") ?? lines[0];
1143
+ /*
1144
+ Which line a clone opens on.
1145
+
1146
+ The project's own default first, then the conventional names, then
1147
+ whatever has content. Looking only for `main` was wrong for a
1148
+ transferred repository: creating a project always makes an empty
1149
+ `main`, so a repository whose default is `master` ends up with a
1150
+ `main` that holds nothing — and `main` is filtered out of `lines`
1151
+ for exactly that reason, leaving HEAD to fall on whichever branch
1152
+ happened to sort first. It landed on `master` by luck rather than
1153
+ because anything chose it.
1154
+ */
1155
+ const preferred = [
1156
+ project?.defaultBranch,
1157
+ "main",
1158
+ "master",
1159
+ ].filter((name) => Boolean(name));
1160
+ const head = preferred
1161
+ .map((name) => lines.find((track) => track.name === name))
1162
+ .find(Boolean) ?? lines[0];
1056
1163
  if (head)
1057
1164
  send(`@refs/heads/${head.name} HEAD`);
1058
1165
  }
@@ -0,0 +1,331 @@
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.commandTransfer = commandTransfer;
7
+ /**
8
+ * Moving a whole repository off a git host and onto CodeRook.
9
+ *
10
+ * `cbx import` takes a snapshot: the files as they are now, one version, no
11
+ * history. That is the right answer for "get this onto CodeRook quickly" and
12
+ * the wrong one for "move off GitHub", where the history, the branches, the
13
+ * tags and the issues are most of what somebody is worried about losing.
14
+ *
15
+ * ## It uses the public route on purpose
16
+ *
17
+ * The history goes across by running `git push` against the CodeRook remote —
18
+ * the same command anybody else would type, through the same helper. Nothing
19
+ * here reaches past it into a private path.
20
+ *
21
+ * That is deliberate, because the claim being made is interoperability. A
22
+ * transfer that worked through a back door would prove only that a back door
23
+ * exists. This one works because `git push coderook --all` works; if that ever
24
+ * breaks, this breaks with it, loudly, rather than quietly diverging from what
25
+ * users are told to do.
26
+ *
27
+ * ## What crosses, and what cannot
28
+ *
29
+ * Commits, branches, tags and the project's own description come across.
30
+ * Issues come across when a read token is supplied. Pull requests, reviews,
31
+ * CI configuration, stars and collaborator lists do not — some because
32
+ * CodeRook has no such object, and one because it would be wrong: adding
33
+ * somebody to a project is an invitation they have to accept, not a field to
34
+ * copy. The report at the end says which is which rather than leaving the
35
+ * absence to be discovered.
36
+ *
37
+ * ## Tokens
38
+ *
39
+ * Read from the environment, never from a flag and never stored. A read-only
40
+ * token is enough, and no OAuth flow is offered: holding somebody's forge
41
+ * credentials to run a one-off migration sits badly beside a front page that
42
+ * promises their work is not handed to anybody.
43
+ */
44
+ const node_child_process_1 = require("node:child_process");
45
+ const promises_1 = require("node:fs/promises");
46
+ const node_os_1 = __importDefault(require("node:os"));
47
+ const node_path_1 = __importDefault(require("node:path"));
48
+ const node_process_1 = __importDefault(require("node:process"));
49
+ const api_js_1 = require("./api.js");
50
+ const forge_url_js_1 = require("./forge_url.js");
51
+ const dim = (value) => `${value}`;
52
+ const bold = (value) => `${value}`;
53
+ const red = (value) => `${value}`;
54
+ const green = (value) => `${value}`;
55
+ const accent = (value) => `${value}`;
56
+ /** A read token from the environment, or nothing. Never a flag. */
57
+ function forgeToken(forge) {
58
+ for (const name of forge.tokenNames) {
59
+ const value = node_process_1.default.env[name]?.trim();
60
+ if (value)
61
+ return value;
62
+ }
63
+ return null;
64
+ }
65
+ async function readJson(url, token, forge) {
66
+ const headers = {
67
+ accept: "application/json",
68
+ "user-agent": "cbx-transfer",
69
+ };
70
+ if (token) {
71
+ headers.authorization =
72
+ forge.kind === "gitlab" ? `Bearer ${token}` : `token ${token}`;
73
+ }
74
+ const response = await fetch(url, { headers });
75
+ if (!response.ok) {
76
+ throw new Error(`${response.status} from ${new URL(url).pathname}`);
77
+ }
78
+ return response.json();
79
+ }
80
+ /** What the forge says about the repository itself. */
81
+ async function describeSource(forge, token) {
82
+ const encoded = encodeURIComponent(`${forge.owner}/${forge.repo}`);
83
+ const url = forge.kind === "github"
84
+ ? `https://api.${forge.host}/repos/${forge.owner}/${forge.repo}`
85
+ : forge.kind === "gitlab"
86
+ ? `https://${forge.host}/api/v4/projects/${encoded}`
87
+ : `https://${forge.host}/api/v1/repos/${forge.owner}/${forge.repo}`;
88
+ try {
89
+ const body = (await readJson(url, token, forge));
90
+ const isPrivate = body.private === true || body.visibility === "private" || body.internal === true;
91
+ return {
92
+ description: String(body.description ?? "").slice(0, 1000),
93
+ visibility: isPrivate ? "private" : "public",
94
+ defaultBranch: body.default_branch ?? null,
95
+ };
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ /** Open issues on the source, oldest first so numbering reads sensibly. */
102
+ async function readIssues(forge, token) {
103
+ const encoded = encodeURIComponent(`${forge.owner}/${forge.repo}`);
104
+ const url = forge.kind === "github"
105
+ ? `https://api.${forge.host}/repos/${forge.owner}/${forge.repo}/issues?state=open&per_page=100`
106
+ : forge.kind === "gitlab"
107
+ ? `https://${forge.host}/api/v4/projects/${encoded}/issues?state=opened&per_page=100`
108
+ : `https://${forge.host}/api/v1/repos/${forge.owner}/${forge.repo}/issues?state=open&limit=100`;
109
+ const body = (await readJson(url, token, forge));
110
+ return body
111
+ /*
112
+ GitHub returns pull requests through the issues endpoint. They are not
113
+ issues, and CodeRook has nothing that a pull request becomes, so copying
114
+ them across as issues would be inventing content rather than moving it.
115
+ */
116
+ .filter((one) => !one.pull_request)
117
+ .map((one) => ({
118
+ title: String(one.title ?? "").slice(0, 200),
119
+ body: String(one.body ?? one.description ?? "").slice(0, 20_000),
120
+ number: Number(one.number ?? one.iid ?? 0),
121
+ closed: false,
122
+ }))
123
+ .filter((one) => one.title)
124
+ .reverse();
125
+ }
126
+ function run(command, args, cwd) {
127
+ const result = (0, node_child_process_1.spawnSync)(command, args, {
128
+ cwd,
129
+ encoding: "utf8",
130
+ maxBuffer: 1024 * 1024 * 64,
131
+ });
132
+ return {
133
+ ok: result.status === 0,
134
+ out: `${result.stdout ?? ""}${result.stderr ?? ""}`,
135
+ };
136
+ }
137
+ async function commandTransfer(parsed) {
138
+ const address = parsed.positional[0];
139
+ if (!address) {
140
+ console.error(red("Which repository? Try: cbx transfer https://github.com/owner/project"));
141
+ return 1;
142
+ }
143
+ let forge;
144
+ try {
145
+ forge = (0, forge_url_js_1.readForgeUrl)(address);
146
+ }
147
+ catch (error) {
148
+ console.error(red(error instanceof Error ? error.message : String(error)));
149
+ return 1;
150
+ }
151
+ if (!run("git", ["--version"]).ok) {
152
+ console.error(red("Transferring needs git on this machine."));
153
+ return 1;
154
+ }
155
+ /*
156
+ The push goes through the remote helper, which git finds on PATH by name.
157
+ Checking now turns a confusing failure halfway through a clone into one
158
+ sentence before anything is downloaded.
159
+ */
160
+ if (!run("git", ["remote-coderook", "--probe"]).ok) {
161
+ const help = run("git", ["help", "-a"]);
162
+ if (!/remote-coderook/.test(help.out)) {
163
+ console.error(red("git cannot find `git-remote-coderook` on this machine."));
164
+ console.error("It ships with this tool, so this usually means cbx was run from a\n" +
165
+ "checkout rather than installed. Install it globally and try again:\n" +
166
+ ` ${accent("npm install --global @coderook/cli")}`);
167
+ return 1;
168
+ }
169
+ }
170
+ const wanted = (typeof parsed.flags.get("name") === "string"
171
+ ? String(parsed.flags.get("name"))
172
+ : forge.repo).toLowerCase();
173
+ const existing = await (0, api_js_1.findProject)(wanted);
174
+ if (existing) {
175
+ console.error(red(`You already have a project called ${existing.slug}.`));
176
+ console.error(`Transfer creates a project; it does not merge into one. Choose another\n` +
177
+ `name with ${accent("--name")}, or delete that project first.`);
178
+ return 1;
179
+ }
180
+ const token = forgeToken(forge);
181
+ console.log(`${bold("Transferring")} ${forge.owner}/${forge.repo} ${dim(`from ${forge.label}`)}`);
182
+ console.log(dim(token
183
+ ? ` Using the read token in ${forge.tokenNames.find((name) => node_process_1.default.env[name]?.trim())}.`
184
+ : ` No token set. Public repositories work; set ${forge.tokenNames[0]} for a private one or for issues.`));
185
+ const scratch = await (0, promises_1.mkdtemp)(node_path_1.default.join(node_os_1.default.tmpdir(), "cbx-transfer-"));
186
+ const checkout = node_path_1.default.join(scratch, forge.repo);
187
+ const report = [];
188
+ try {
189
+ /*
190
+ A full clone, not the shallow one `import` uses. The whole point here is
191
+ the history, and `--depth 1` would silently deliver a single commit under
192
+ a command whose name promises everything.
193
+ */
194
+ console.log(`\n${dim("Fetching the repository with its full history…")}`);
195
+ const cloned = run("git", ["clone", "--no-single-branch", forge.clone, checkout]);
196
+ if (!cloned.ok) {
197
+ console.error(red(`\nCould not clone ${forge.clone}`));
198
+ console.error(cloned.out.trim().split("\n").slice(-4).join("\n"));
199
+ if (!token) {
200
+ console.error(dim(`\nIf it is private, set ${forge.tokenNames[0]} and try again.`));
201
+ }
202
+ return 1;
203
+ }
204
+ /*
205
+ Every branch, not only the one checked out. A fresh clone has remote
206
+ tracking refs for the rest, and nothing local pointing at them, so
207
+ `--all` would push exactly one branch.
208
+ */
209
+ const remoteBranches = run("git", [
210
+ "for-each-ref", "--format=%(refname:short)", "refs/remotes/origin",
211
+ ], checkout).out
212
+ .split("\n").map((one) => one.trim()).filter(Boolean)
213
+ /*
214
+ The remote's HEAD symref comes back as plain `origin`, not
215
+ `origin/HEAD`, so filtering on the latter leaves a phantom entry that
216
+ inflates the branch count and sends git looking for `origin/origin`.
217
+ */
218
+ .filter((one) => one.startsWith("origin/"))
219
+ .map((one) => one.slice("origin/".length))
220
+ .filter((one) => one && one !== "HEAD");
221
+ for (const branch of remoteBranches) {
222
+ run("git", ["branch", "--force", branch, `origin/${branch}`], checkout);
223
+ }
224
+ const commits = Number(run("git", ["rev-list", "--all", "--count"], checkout).out.trim() || "0");
225
+ const tags = run("git", ["tag", "-l"], checkout).out.split("\n").filter(Boolean);
226
+ console.log(` ${commits} commit${commits === 1 ? "" : "s"} · ` +
227
+ `${remoteBranches.length} branch${remoteBranches.length === 1 ? "" : "es"} · ` +
228
+ `${tags.length} tag${tags.length === 1 ? "" : "s"}`);
229
+ if (commits > 200) {
230
+ console.log(dim(` Each commit becomes a version, so this will take a while —\n` +
231
+ ` roughly ${Math.round((commits * 11) / 60)} minutes. It resumes if interrupted.`));
232
+ }
233
+ run("git", ["remote", "add", "coderook", `coderook://${wanted}`], checkout);
234
+ console.log(`\n${dim("Publishing the history…")}`);
235
+ const pushed = run("git", ["push", "coderook", "--all"], checkout);
236
+ node_process_1.default.stderr.write(pushed.out);
237
+ if (!pushed.ok) {
238
+ console.error(red("\nThe history did not transfer completely."));
239
+ return 1;
240
+ }
241
+ report.push(`${commits} commits across ${remoteBranches.length} branch(es)`);
242
+ if (tags.length) {
243
+ const pushedTags = run("git", ["push", "coderook", "--tags"], checkout);
244
+ node_process_1.default.stderr.write(pushedTags.out);
245
+ report.push(pushedTags.ok
246
+ ? `${tags.length} tag(s) as releases`
247
+ : `tags were refused — see above`);
248
+ }
249
+ const project = await (0, api_js_1.findProject)(wanted);
250
+ if (!project) {
251
+ console.error(red("\nThe project was not created. Nothing else was changed."));
252
+ return 1;
253
+ }
254
+ // What the repository says about itself.
255
+ const facts = await describeSource(forge, token);
256
+ if (facts) {
257
+ const asked = parsed.flags.get("visibility");
258
+ /*
259
+ Private unless asked otherwise, even when the source is public.
260
+ Mirroring visibility is the faithful thing and publishing somebody's
261
+ code by side effect is the unrecoverable thing, so the safe reading
262
+ wins and the report says what was chosen.
263
+ */
264
+ const visibility = asked === "same"
265
+ ? facts.visibility
266
+ : asked === "public"
267
+ ? "public"
268
+ : "private";
269
+ await (0, api_js_1.updateProject)(project.id, {
270
+ ...(facts.description ? { description: facts.description } : {}),
271
+ visibility,
272
+ });
273
+ report.push(`description and visibility (${visibility}` +
274
+ `${visibility !== facts.visibility ? `, source was ${facts.visibility}` : ""})`);
275
+ /*
276
+ Said rather than left to be noticed. Creating a project always makes a
277
+ `main` line, so a repository whose default is anything else arrives
278
+ with an empty one beside its real branches — and `cbx tracks` marks it
279
+ as the current line, which reads as though the transfer lost the work.
280
+ */
281
+ if (facts.defaultBranch && facts.defaultBranch !== "main") {
282
+ report.push(`default line is ${facts.defaultBranch}; the empty "main" beside it ` +
283
+ `was made when the project was created`);
284
+ }
285
+ }
286
+ else {
287
+ report.push("description could not be read from the source");
288
+ }
289
+ // Issues, only when asked and only with a token.
290
+ if (parsed.flags.has("issues")) {
291
+ if (!token) {
292
+ report.push(`issues skipped — set ${forge.tokenNames[0]} and run with --issues again`);
293
+ }
294
+ else {
295
+ try {
296
+ const issues = await readIssues(forge, token);
297
+ let made = 0;
298
+ for (const issue of issues) {
299
+ await (0, api_js_1.createIssue)(project.id, {
300
+ title: issue.title,
301
+ body: `${issue.body}\n\n` +
302
+ `— transferred from ${forge.label} ${forge.owner}/${forge.repo}#${issue.number}`,
303
+ });
304
+ made += 1;
305
+ }
306
+ report.push(`${made} open issue(s)`);
307
+ }
308
+ catch (error) {
309
+ report.push(`issues failed: ${error instanceof Error ? error.message : String(error)}`);
310
+ }
311
+ }
312
+ }
313
+ console.log(`\n${green("Transferred")} ${bold(project.slug)}`);
314
+ for (const line of report)
315
+ console.log(` ${green("·")} ${line}`);
316
+ console.log(`\n${dim("Not carried across:")}`);
317
+ console.log(dim(" · pull requests and reviews — CodeRook has no equivalent object"));
318
+ console.log(dim(" · CI configuration — runs are started deliberately, not by a push"));
319
+ console.log(dim(" · collaborators — access is an invitation to accept, not a field to copy"));
320
+ console.log(dim(" · stars, forks and watchers"));
321
+ if (!parsed.flags.has("issues")) {
322
+ console.log(dim(" · issues — pass --issues to bring the open ones"));
323
+ }
324
+ console.log(`\nFetch it anywhere with ${accent(`cbx clone ${project.slug}`)}` +
325
+ `${dim(", or ")}${accent(`git clone coderook://${project.slug}`)}${dim(".")}`);
326
+ return 0;
327
+ }
328
+ finally {
329
+ await (0, promises_1.rm)(scratch, { recursive: true, force: true });
330
+ }
331
+ }
@@ -0,0 +1,59 @@
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.VERSION = void 0;
7
+ /**
8
+ * What this build calls itself to the service.
9
+ *
10
+ * Read from the package rather than written down, because a hardcoded copy had
11
+ * already drifted from the published version once — and the version is not
12
+ * cosmetic. The service refuses operations it knows an older client cannot do
13
+ * safely, so a build that misstates its version is refused as though it were
14
+ * ancient.
15
+ *
16
+ * Which is exactly what happened: the git remote helper announced itself as
17
+ * `cli/git-remote`, having passed its own name where the version belongs. The
18
+ * service could make nothing of it, applied the floor, and told people to
19
+ * update to a version far older than the one they were running. Pushing was
20
+ * unaffected and cloning was refused, so it surfaced only on the way back.
21
+ */
22
+ const node_fs_1 = require("node:fs");
23
+ const node_path_1 = __importDefault(require("node:path"));
24
+ /**
25
+ * Search upwards for this package's own manifest.
26
+ *
27
+ * A fixed number of `..` segments only holds for one directory layout. The
28
+ * compiled file sits three deep under `dist`, the source two deep under `src`,
29
+ * and a bundler may put it somewhere else again — so a fixed path silently
30
+ * resolved to a different package's manifest, or to none, and fell back to
31
+ * `0.0.0`. Walking up and checking the name finds the right file wherever
32
+ * this ends up.
33
+ */
34
+ function readVersion() {
35
+ /*
36
+ `__dirname` in the build, which compiles to CommonJS and is what ships.
37
+ Running the source directly for a test loads it as a module where that name
38
+ does not exist, so the check is on `typeof` — which is safe for a name that
39
+ was never declared — and the working directory stands in.
40
+ */
41
+ let here = typeof __dirname === "string" ? __dirname : process.cwd();
42
+ for (let up = 0; up < 6; up += 1) {
43
+ try {
44
+ const manifest = JSON.parse((0, node_fs_1.readFileSync)(node_path_1.default.join(here, "package.json"), "utf8"));
45
+ if (manifest.name === "@coderook/cli" && manifest.version) {
46
+ return manifest.version;
47
+ }
48
+ }
49
+ catch {
50
+ // Not here, or not readable. Keep walking.
51
+ }
52
+ const parent = node_path_1.default.dirname(here);
53
+ if (parent === here)
54
+ break;
55
+ here = parent;
56
+ }
57
+ return "0.0.0";
58
+ }
59
+ exports.VERSION = readVersion();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
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",
@@ -36,7 +36,7 @@
36
36
  "test:matrix": "node test/state-matrix.mjs",
37
37
  "test:attempt": "node test/attempt-identity.mjs",
38
38
  "test:get": "node test/get-safety.mjs",
39
- "test:live": "node test/e2e.mjs --allow-production && node test/state-matrix.mjs --allow-production && node test/get-safety.mjs --allow-production && node test/get-protects-edits.mjs --allow-production && node test/upgrade-migration.mjs --allow-production && node test/fault-get.mjs --allow-production && node test/version-floor.mjs --allow-production && node test/fault-submit.mjs --allow-production && node test/race-attempts.mjs --allow-production && node test/attempt-identity.mjs --allow-production",
39
+ "test:live": "node test/e2e.mjs --allow-production && node test/state-matrix.mjs --allow-production && node test/get-safety.mjs --allow-production && node test/get-track.mjs --allow-production && node test/get-protects-edits.mjs --allow-production && node test/upgrade-migration.mjs --allow-production && node test/fault-get.mjs --allow-production && node test/version-floor.mjs --allow-production && node test/fault-submit.mjs --allow-production && node test/race-attempts.mjs --allow-production && node test/attempt-identity.mjs --allow-production",
40
40
  "test:getedits": "node test/get-protects-edits.mjs",
41
41
  "test:upgrade": "node test/upgrade-migration.mjs",
42
42
  "test:faultget": "node test/fault-get.mjs",
@@ -44,7 +44,8 @@
44
44
  "test:faultsubmit": "node test/fault-submit.mjs",
45
45
  "test:race": "node test/race-attempts.mjs",
46
46
  "test:runner": "node test/runner-live.mjs",
47
- "sync:plugin": "node scripts/sync-plugin-version.mjs"
47
+ "sync:plugin": "node scripts/sync-plugin-version.mjs",
48
+ "test:track": "node test/get-track.mjs"
48
49
  },
49
50
  "devDependencies": {
50
51
  "@types/node": "24.10.1",