@coderook/cli 0.24.0 → 0.25.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.24.0",
5
+ "version": "0.25.0",
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
@@ -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");
@@ -884,7 +886,7 @@ async function commandGet(parsed) {
884
886
  incoming.has(file.path) &&
885
887
  edited.some((one) => one.path === file.path));
886
888
  if (!changedInTheGap.length) {
887
- return fetchInto(link.repositoryId, folder, link.slug, link.local ?? null, "replace");
889
+ return fetchInto(link.repositoryId, folder, link.slug, link.local ?? null, "replace", await (0, track_commands_js_1.trackFor)(folder));
888
890
  }
889
891
  console.error(red(`${changedInTheGap.length} file${changedInTheGap.length === 1 ? "" : "s"} ` +
890
892
  `changed while the interrupted fetch was stopped:`));
@@ -923,7 +925,9 @@ Save them with ${accent("cbx submit")}, or finish the fetch and ` +
923
925
  what the version actually changed is written over — unless somebody asks
924
926
  for an exact copy, which is how a damaged folder is repaired.
925
927
  */
926
- return fetchInto(link.repositoryId, folder, link.slug, link.local ?? null, replacing ? "replace" : "reconcile");
928
+ return fetchInto(link.repositoryId, folder, link.slug, link.local ?? null, replacing ? "replace" : "reconcile",
929
+ // The line this folder saves to, so catching up brings that line down.
930
+ await (0, track_commands_js_1.trackFor)(folder));
927
931
  }
928
932
  async function commandClone(parsed) {
929
933
  const reference = parsed.positional[0];
@@ -941,7 +945,34 @@ async function commandClone(parsed) {
941
945
  return 1;
942
946
  }
943
947
  const destination = node_path_1.default.resolve(parsed.positional[1] ?? project.slug);
944
- return fetchInto(project.id, destination, project.slug);
948
+ /*
949
+ A clone opens on the project's default line, or on the one named.
950
+
951
+ It used to take whichever version was newest across every line, which is
952
+ only the same thing while a project has one. As soon as somebody pushed to
953
+ a branch, that branch held the newest version — so cloning the project
954
+ handed you their unfinished work under the impression it was the project.
955
+ Nothing said so: the fetch reported a version number and the files looked
956
+ like a project, just not the one anybody asked for.
957
+
958
+ Naming a line here also removes the clone-switch-fetch dance that was the
959
+ only way to get a branch before, which is what exposed the same fault in
960
+ `get`.
961
+ */
962
+ const wanted = flagText(parsed, "track") ?? project.defaultBranch;
963
+ /*
964
+ Falling back rather than failing when the default line does not exist.
965
+ `defaultBranch` is a property of the project and a line can be renamed out
966
+ from under it; refusing to clone at all in that case would be a worse
967
+ answer than the old behaviour. An explicitly named line is still refused,
968
+ because there the person said which one they meant.
969
+ */
970
+ const lines = await new tracks_js_1.Tracks(config_js_1.credentials).list(project.id);
971
+ const usable = lines.some((line) => line.kind === "line" && line.name === wanted && line.headVersionId);
972
+ if (!usable && !flagText(parsed, "track")) {
973
+ console.log(dim(`This project has no line called ${wanted}; taking its newest version.`));
974
+ }
975
+ return fetchInto(project.id, destination, project.slug, null, "replace", usable || flagText(parsed, "track") ? wanted : undefined);
945
976
  }
946
977
  async function fetchInto(repositoryId, destination, slug,
947
978
  /*
@@ -950,9 +981,45 @@ async function fetchInto(repositoryId, destination, slug,
950
981
  is removed, because "missing from the version" cannot then be told from
951
982
  "never came from the version at all".
952
983
  */
953
- held, mode = "replace") {
984
+ held, mode = "replace",
985
+ /*
986
+ The line to fetch. Omitted means the project's newest version, which is
987
+ right for a clone and wrong for a folder that has chosen a line.
988
+ */
989
+ trackName) {
954
990
  const downloader = new download_js_1.Downloader(config_js_1.credentials);
955
- const latest = (await downloader.versions(repositoryId))[0];
991
+ const all = await downloader.versions(repositoryId);
992
+ /*
993
+ The head of this folder's line, not the newest version in the project.
994
+
995
+ Those are the same thing only while a project has one line. With two, the
996
+ newest version usually belongs to the *other* one — so switching to a line
997
+ and fetching brought down the line you had just left, reported success,
998
+ and left the folder holding the wrong contents while still claiming to
999
+ save to the line you asked for. The next publish would then be computed
1000
+ against a baseline from somewhere else.
1001
+
1002
+ Worse than a wrong answer: `track` ends by telling you to run `get`, so
1003
+ the command that set this up was the one recommending it.
1004
+ */
1005
+ let latest = all[0];
1006
+ if (trackName) {
1007
+ const chosen = (await new tracks_js_1.Tracks(config_js_1.credentials).list(repositoryId)).find((candidate) => candidate.name === trackName && candidate.kind === "line");
1008
+ if (!chosen) {
1009
+ console.error(red(`This project has no line called ${trackName}.`));
1010
+ return 1;
1011
+ }
1012
+ if (!chosen.headVersionId) {
1013
+ console.error(red(`The line ${trackName} has nothing saved to it yet.`));
1014
+ return 1;
1015
+ }
1016
+ const head = all.find((version) => version.id === chosen.headVersionId);
1017
+ if (!head) {
1018
+ console.error(red(`Could not read the current version of ${trackName}.`));
1019
+ return 1;
1020
+ }
1021
+ latest = head;
1022
+ }
956
1023
  if (!latest) {
957
1024
  console.error(red("That project has no saved version."));
958
1025
  return 1;
@@ -991,6 +1058,16 @@ held, mode = "replace") {
991
1058
  // version holds and the two agree again.
992
1059
  local: result.manifest,
993
1060
  manifest: result.manifest,
1061
+ /*
1062
+ The line this folder is on survives being caught up.
1063
+
1064
+ This record is built fresh rather than merged, so anything not named
1065
+ here is dropped — and `track` was not named. Fetching therefore moved
1066
+ the folder back to `main` every time, silently: the files were right,
1067
+ the folder reported the wrong line, and the next publish went somewhere
1068
+ nobody chose.
1069
+ */
1070
+ ...(trackName ? { track: trackName } : {}),
994
1071
  // Reached the end, so there is no longer a fetch in flight.
995
1072
  });
996
1073
  // The record is written; the run has not reported success yet.
@@ -1500,6 +1577,43 @@ const SPECS = [
1500
1577
  ],
1501
1578
  run: commandImport,
1502
1579
  },
1580
+ {
1581
+ /*
1582
+ Distinct from `import`, which takes a snapshot. This moves the history,
1583
+ the branches, the tags and what the project says about itself — the
1584
+ things somebody leaving a host is actually worried about losing.
1585
+ */
1586
+ name: "transfer",
1587
+ aliases: ["migrate"],
1588
+ group: "Getting started",
1589
+ summary: "move a whole repository here from GitHub, GitLab or Codeberg",
1590
+ usage: "transfer <address>",
1591
+ detail: "Brings the commits, every branch, the tags and the description across.\n" +
1592
+ "Each commit becomes a version and each branch a line, so a long history\n" +
1593
+ "takes a while; the count and an estimate are printed before it starts.\n\n" +
1594
+ "The history is published by running `git push` against the CodeRook\n" +
1595
+ "remote — the same route anybody else uses, deliberately, because the\n" +
1596
+ "claim being made is that git repositories work here.\n\n" +
1597
+ "A private repository needs a read token in the environment:\n" +
1598
+ "GITHUB_TOKEN, GITLAB_TOKEN or FORGE_TOKEN. It is never asked for on the\n" +
1599
+ "command line and never stored.\n\n" +
1600
+ "The project is created private whatever the source was, unless you say\n" +
1601
+ "otherwise. Pull requests, CI configuration and collaborators do not come\n" +
1602
+ "across; the report at the end lists what did and what did not.",
1603
+ options: [
1604
+ { flags: "--name <name>", description: "call the project this, rather than the repository's name" },
1605
+ {
1606
+ flags: "--visibility <how>",
1607
+ description: "private (default), same as the source, or public",
1608
+ },
1609
+ { flags: "--issues", description: "also bring the open issues; needs a read token" },
1610
+ ],
1611
+ examples: [
1612
+ "cbx transfer https://github.com/owner/project",
1613
+ "cbx transfer https://codeberg.org/owner/project --visibility same",
1614
+ ],
1615
+ run: transfer_command_js_1.commandTransfer,
1616
+ },
1503
1617
  {
1504
1618
  /*
1505
1619
  Where the next save goes, which is a property of this folder rather
@@ -1555,6 +1669,13 @@ const SPECS = [
1555
1669
  group: "Working with a folder",
1556
1670
  summary: "fetch a project into a new folder",
1557
1671
  usage: "clone <project> [dir]",
1672
+ options: [
1673
+ {
1674
+ flags: "--track <name>",
1675
+ description: "fetch this line rather than the project's newest version",
1676
+ },
1677
+ ],
1678
+ examples: ["cbx clone my-project", "cbx clone my-project --track spike"],
1558
1679
  run: commandClone,
1559
1680
  },
1560
1681
  {
@@ -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
+ }
@@ -253,6 +253,54 @@ async function applyCommit(catFile, scratch, fromCommit, toCommit) {
253
253
  }
254
254
  return { written, deleted, skipped };
255
255
  }
256
+ /*
257
+ The service's answer when a line is already there.
258
+
259
+ Matched on the wording as well as the shape because the message is the only
260
+ thing that reaches here: `This project already has a track called main`. The
261
+ previous test looked for "exist", which that sentence does not contain — so a
262
+ branch arriving after the project had been created was reported as a hard
263
+ failure rather than the ordinary "it is already there".
264
+ */
265
+ const TRACK_EXISTS = /already has a track|track_exists|exists/i;
266
+ /**
267
+ * The order refs are published in, which is not the order git sends them.
268
+ *
269
+ * `git push --all` hands over every branch at once, and on a project that does
270
+ * not exist yet the first one creates it — along with an empty `main`. If a
271
+ * side branch happens to go first it takes the whole history, and `main`
272
+ * arrives to find its own line already made and nothing it can do with it:
273
+ * there is no way to move an existing line's head, only to start one at a
274
+ * version. The result was a project whose default line was permanently empty.
275
+ *
276
+ * Publishing the branch the others fork from first makes every later branch an
277
+ * ordinary fork, which is the case that already worked. Local HEAD wins,
278
+ * because that is what the person is standing on; `main` and `master` are the
279
+ * conventional fallbacks; everything else keeps a stable order so two runs of
280
+ * the same push behave the same way.
281
+ */
282
+ function ordered(requests) {
283
+ let head = "";
284
+ try {
285
+ head = git(["symbolic-ref", "--short", "HEAD"]).trim();
286
+ }
287
+ catch {
288
+ // A detached HEAD has no branch to prefer; the fallbacks still apply.
289
+ }
290
+ const rank = (request) => {
291
+ const branch = (0, git_history_js_1.branchOf)(request.dst);
292
+ if (!branch)
293
+ return 4; // tags last: they name versions that must exist
294
+ if (branch === head)
295
+ return 0;
296
+ if (branch === "main")
297
+ return 1;
298
+ if (branch === "master")
299
+ return 2;
300
+ return 3;
301
+ };
302
+ return [...requests].sort((left, right) => rank(left) - rank(right) || left.dst.localeCompare(right.dst));
303
+ }
256
304
  /**
257
305
  * The refs the remote already holds, as far as we can tell.
258
306
  *
@@ -612,18 +660,44 @@ async function doImport(refs, url) {
612
660
  }
613
661
  async function doPush(requests, url) {
614
662
  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
663
  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
- }
664
+ /*
665
+ Re-read the project before every ref, not once before the batch.
666
+
667
+ `git push --all` sends every branch in one batch, and the first of them
668
+ routinely creates the project. Reading the state once meant every later
669
+ ref in that same push still believed the project did not exist: it
670
+ published against a null repository, started its history from nothing, and
671
+ left its line without a head. Pushing two branches to a new project
672
+ produced one working line and one empty one, and reported success for
673
+ both — which is the shape a whole-repository transfer takes, so it failed
674
+ exactly where it mattered most.
675
+
676
+ One extra listing per ref. A push carries a handful of refs and each one
677
+ publishes versions costing seconds, so the cost is not worth the class of
678
+ bug avoiding it creates.
679
+ */
680
+ let repositoryId = (await (0, api_js_1.findProject)(slug))?.id ?? null;
681
+ let state = repositoryId
682
+ ? await remoteState(repositoryId)
683
+ : null;
684
+ const refreshProject = async () => {
685
+ if (!repositoryId)
686
+ repositoryId = (await (0, api_js_1.findProject)(slug))?.id ?? null;
687
+ state = repositoryId ? await remoteState(repositoryId) : null;
688
+ };
689
+ const knownRefs = () => {
690
+ const found = new Map();
691
+ for (const track of state?.tracks ?? []) {
692
+ if (track.kind !== "line" || !track.headVersionId)
693
+ continue;
694
+ const sha = headCommit(track.headVersionId, state, marks);
695
+ if (sha)
696
+ found.set(`refs/heads/${track.name}`, sha);
697
+ }
698
+ return found;
699
+ };
700
+ let known = knownRefs();
627
701
  let nextPushMark = 1;
628
702
  for (const mark of marks?.sha.keys() ?? []) {
629
703
  nextPushMark = Math.max(nextPushMark, mark + 1);
@@ -633,7 +707,13 @@ async function doPush(requests, url) {
633
707
  const scratch = await (0, promises_1.mkdtemp)(node_path_1.default.join(node_os_1.default.tmpdir(), "coderook-push-"));
634
708
  let warnedAboutMerges = false;
635
709
  try {
636
- for (const request of requests) {
710
+ for (const request of ordered(requests)) {
711
+ /*
712
+ Whatever the previous ref did, this one starts from what the project
713
+ actually holds now — including a project the previous ref created.
714
+ */
715
+ await refreshProject();
716
+ known = knownRefs();
637
717
  /*
638
718
  A tag names a version, which is exactly what a CodeRook release is:
639
719
  "a Version with a name on it, not a different kind of object". So a tag
@@ -808,7 +888,7 @@ async function doPush(requests, url) {
808
888
  }
809
889
  catch (error) {
810
890
  const text = error instanceof Error ? error.message : String(error);
811
- if (!/exist/i.test(text)) {
891
+ if (!TRACK_EXISTS.test(text)) {
812
892
  send(`error ${request.dst} ${text}`);
813
893
  continue;
814
894
  }
@@ -871,7 +951,7 @@ async function doPush(requests, url) {
871
951
  }
872
952
  catch (error) {
873
953
  const text = error instanceof Error ? error.message : String(error);
874
- if (!/exist/i.test(text))
954
+ if (!TRACK_EXISTS.test(text))
875
955
  throw error;
876
956
  }
877
957
  }
@@ -1052,7 +1132,26 @@ async function main(argv) {
1052
1132
  const sha = mark === undefined ? undefined : marks?.sha.get(mark);
1053
1133
  send(`${sha ?? "?"} refs/heads/${track.name}`);
1054
1134
  }
1055
- const head = lines.find((track) => track.name === "main") ?? lines[0];
1135
+ /*
1136
+ Which line a clone opens on.
1137
+
1138
+ The project's own default first, then the conventional names, then
1139
+ whatever has content. Looking only for `main` was wrong for a
1140
+ transferred repository: creating a project always makes an empty
1141
+ `main`, so a repository whose default is `master` ends up with a
1142
+ `main` that holds nothing — and `main` is filtered out of `lines`
1143
+ for exactly that reason, leaving HEAD to fall on whichever branch
1144
+ happened to sort first. It landed on `master` by luck rather than
1145
+ because anything chose it.
1146
+ */
1147
+ const preferred = [
1148
+ project?.defaultBranch,
1149
+ "main",
1150
+ "master",
1151
+ ].filter((name) => Boolean(name));
1152
+ const head = preferred
1153
+ .map((name) => lines.find((track) => track.name === name))
1154
+ .find(Boolean) ?? lines[0];
1056
1155
  if (head)
1057
1156
  send(`@refs/heads/${head.name} HEAD`);
1058
1157
  }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.24.0",
3
+ "version": "0.25.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",
@@ -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",