@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +47 -3
- package/dist/cli/src/api.js +14 -0
- package/dist/cli/src/cli.js +134 -25
- package/dist/cli/src/forge_url.js +68 -0
- package/dist/cli/src/git_history.js +40 -3
- package/dist/cli/src/git_remote.js +123 -16
- package/dist/cli/src/transfer_command.js +331 -0
- package/dist/cli/src/version.js +59 -0
- package/package.json +4 -3
|
@@ -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.
|
|
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
|
|
254
|
-
|
|
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.
|
package/dist/cli/src/api.js
CHANGED
|
@@ -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`);
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -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 ? `[2m${text}[0m` : 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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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) => `[2m${value}[0m`;
|
|
52
|
+
const bold = (value) => `[1m${value}[0m`;
|
|
53
|
+
const red = (value) => `[31m${value}[0m`;
|
|
54
|
+
const green = (value) => `[32m${value}[0m`;
|
|
55
|
+
const accent = (value) => `[33m${value}[0m`;
|
|
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.
|
|
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",
|