@coderook/cli 0.31.2 → 0.31.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli/src/cli.js +14 -1
- package/dist/cli/src/git_history.js +58 -0
- package/dist/cli/src/git_remote.js +124 -47
- package/dist/cli/src/stored_zip.js +41 -1
- package/dist/desktop-app/src/main/cbx.js +6 -16
- package/dist/desktop-app/src/main/download.js +19 -17
- package/dist/desktop-app/src/main/upload.js +44 -1
- package/dist/desktop-app/src/shared/safe_path.js +88 -0
- package/package.json +1 -1
- package/skills/coderook/SKILL.md +200 -154
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "coderook",
|
|
3
3
|
"displayName": "CodeRook",
|
|
4
4
|
"description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, from Claude Code.",
|
|
5
|
-
"version": "0.31.
|
|
5
|
+
"version": "0.31.4",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ACCA Gaming Productions",
|
|
8
8
|
"url": "https://coderook.com"
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -39,6 +39,7 @@ const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
|
|
|
39
39
|
const rules_js_1 = require("../../desktop-app/src/main/rules.js");
|
|
40
40
|
const upload_js_1 = require("../../desktop-app/src/main/upload.js");
|
|
41
41
|
const download_js_1 = require("../../desktop-app/src/main/download.js");
|
|
42
|
+
const safe_path_js_1 = require("../../desktop-app/src/shared/safe_path.js");
|
|
42
43
|
const faults_js_1 = require("../../desktop-app/src/main/faults.js");
|
|
43
44
|
const version_commands_js_1 = require("./version_commands.js");
|
|
44
45
|
const identify_js_1 = require("../../desktop-app/src/main/identify.js");
|
|
@@ -1477,7 +1478,19 @@ async function commandUnbundle(parsed) {
|
|
|
1477
1478
|
return 1;
|
|
1478
1479
|
}
|
|
1479
1480
|
const manifest = await (0, cbx_js_1.readManifest)(node_path_1.default.resolve(source));
|
|
1480
|
-
|
|
1481
|
+
/*
|
|
1482
|
+
The bundle's own name, never its path. `source_name` comes from the file
|
|
1483
|
+
being opened, and taken whole it could be `/home/you/.ssh` or `C:\Users` —
|
|
1484
|
+
and the folder it names is deleted and replaced. Only the last name
|
|
1485
|
+
survives, into the current folder, and only if it is a plain name.
|
|
1486
|
+
*/
|
|
1487
|
+
const suggested = node_path_1.default.posix.basename((manifest.source_name ?? "").replace(/\\/g, "/"));
|
|
1488
|
+
const plainName = suggested && !(0, safe_path_js_1.whyUnsafe)(suggested) && !/^[.]+$/.test(suggested) && !/[:]/.test(suggested);
|
|
1489
|
+
if (!parsed.positional[1] && !plainName) {
|
|
1490
|
+
console.error(red("That bundle does not name a usable folder. Try: cbx unbundle <file.cbx> <folder>"));
|
|
1491
|
+
return 1;
|
|
1492
|
+
}
|
|
1493
|
+
const destination = node_path_1.default.resolve(parsed.positional[1] ?? suggested);
|
|
1481
1494
|
const line = progressLine();
|
|
1482
1495
|
const result = await (0, cbx_js_1.unpackBundle)(node_path_1.default.resolve(source), destination, (progress) => {
|
|
1483
1496
|
line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
|
|
@@ -22,6 +22,8 @@ exports.stamp = stamp;
|
|
|
22
22
|
exports.importRef = importRef;
|
|
23
23
|
exports.quotePath = quotePath;
|
|
24
24
|
exports.refPlacements = refPlacements;
|
|
25
|
+
exports.withinDepth = withinDepth;
|
|
26
|
+
exports.historyLeftOut = historyLeftOut;
|
|
25
27
|
/**
|
|
26
28
|
* Git's constant hash for the empty tree.
|
|
27
29
|
*
|
|
@@ -262,3 +264,59 @@ function refPlacements(wanted, markOfVersion) {
|
|
|
262
264
|
}
|
|
263
265
|
return { placed, unplaceable };
|
|
264
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* The versions within `depth` generations of these heads: 1 is the heads
|
|
269
|
+
* alone, 2 adds their parents, and so on.
|
|
270
|
+
*
|
|
271
|
+
* What `git clone --depth` means here. The oldest version taken is imported
|
|
272
|
+
* with no parent, so it becomes a root commit — which also means its commit id
|
|
273
|
+
* differs from the same version's id in a full clone, because a commit's id
|
|
274
|
+
* covers its parents. That is the same trade git's own shallow clones make.
|
|
275
|
+
*/
|
|
276
|
+
function withinDepth(heads, all, depth) {
|
|
277
|
+
const byId = new Map(all.map((version) => [version.id, version]));
|
|
278
|
+
const kept = new Set();
|
|
279
|
+
let level = heads.filter((id) => byId.has(id));
|
|
280
|
+
for (let generation = 0; generation < depth && level.length; generation += 1) {
|
|
281
|
+
const next = [];
|
|
282
|
+
for (const id of level) {
|
|
283
|
+
if (kept.has(id))
|
|
284
|
+
continue;
|
|
285
|
+
kept.add(id);
|
|
286
|
+
for (const parent of byId.get(id).parentVersionIds) {
|
|
287
|
+
if (byId.has(parent) && !kept.has(parent))
|
|
288
|
+
next.push(parent);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
level = next;
|
|
292
|
+
}
|
|
293
|
+
return kept;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* History an earlier shallow clone chose not to take.
|
|
297
|
+
*
|
|
298
|
+
* Every ancestor of something already imported that was not itself imported.
|
|
299
|
+
* A full clone imported every ancestor, so for it this is always empty; for a
|
|
300
|
+
* shallow one it is everything below the cut. Without it the first ordinary
|
|
301
|
+
* `git fetch` after a `--depth 1` clone would import the whole history anyway,
|
|
302
|
+
* as a second, disconnected line of commits nobody asked for.
|
|
303
|
+
*/
|
|
304
|
+
function historyLeftOut(all, imported) {
|
|
305
|
+
const byId = new Map(all.map((version) => [version.id, version]));
|
|
306
|
+
const have = new Set(imported);
|
|
307
|
+
const left = new Set();
|
|
308
|
+
const pending = [];
|
|
309
|
+
for (const id of have) {
|
|
310
|
+
for (const parent of byId.get(id)?.parentVersionIds ?? [])
|
|
311
|
+
pending.push(parent);
|
|
312
|
+
}
|
|
313
|
+
while (pending.length) {
|
|
314
|
+
const id = pending.pop();
|
|
315
|
+
if (have.has(id) || left.has(id) || !byId.has(id))
|
|
316
|
+
continue;
|
|
317
|
+
left.add(id);
|
|
318
|
+
for (const parent of byId.get(id).parentVersionIds)
|
|
319
|
+
pending.push(parent);
|
|
320
|
+
}
|
|
321
|
+
return left;
|
|
322
|
+
}
|
|
@@ -109,6 +109,7 @@ exports.main = main;
|
|
|
109
109
|
* where the original branched.
|
|
110
110
|
*/
|
|
111
111
|
const node_child_process_1 = require("node:child_process");
|
|
112
|
+
const safe_path_js_1 = require("../../desktop-app/src/shared/safe_path.js");
|
|
112
113
|
const promises_1 = require("node:fs/promises");
|
|
113
114
|
const node_os_1 = __importDefault(require("node:os"));
|
|
114
115
|
const node_path_1 = __importDefault(require("node:path"));
|
|
@@ -136,6 +137,17 @@ class ImportFailed extends Error {
|
|
|
136
137
|
class AlreadyReported extends Error {
|
|
137
138
|
}
|
|
138
139
|
exports.AlreadyReported = AlreadyReported;
|
|
140
|
+
/**
|
|
141
|
+
* A name as fast-import may carry it on an author, committer or tagger line.
|
|
142
|
+
*
|
|
143
|
+
* Every other line of the stream is built from values the helper made; these
|
|
144
|
+
* come from what people typed. A newline in a display name ended the line and
|
|
145
|
+
* began another — any command the stream accepts, a symlink or a ref update
|
|
146
|
+
* included — and `<` or `>` would end the name early. Each becomes a space.
|
|
147
|
+
*/
|
|
148
|
+
function identity(name) {
|
|
149
|
+
return name.replace(/[\x00-\x1f\x7f<>]/g, " ").replace(/\s+/g, " ").trim() || "CodeRook";
|
|
150
|
+
}
|
|
139
151
|
/** Everything the helper writes for a person goes to stderr; stdout is protocol. */
|
|
140
152
|
function say(text) {
|
|
141
153
|
node_process_1.default.stderr.write(`${text}\n`);
|
|
@@ -544,8 +556,14 @@ async function recordPushedCommits(marks) {
|
|
|
544
556
|
first and an empty window always accepts one more.
|
|
545
557
|
*/
|
|
546
558
|
const BLOB_LANES = 8;
|
|
559
|
+
/** Fewer new files than this, and one request for them each is quicker. */
|
|
560
|
+
const ARCHIVE_MIN_FILES = 64;
|
|
561
|
+
/** The share of a version's bytes that must be new before it is fetched whole. */
|
|
562
|
+
const ARCHIVE_MIN_SHARE = 0.5;
|
|
547
563
|
const BLOB_WINDOW_BYTES = 64 * 1024 * 1024;
|
|
548
|
-
async function doImport(refs, url
|
|
564
|
+
async function doImport(refs, url,
|
|
565
|
+
/** From `git clone --depth N`; only honoured on a first import. */
|
|
566
|
+
depth = null) {
|
|
549
567
|
const { owner, slug } = (0, git_history_js_1.parseRemoteUrl)(url);
|
|
550
568
|
const project = await (0, api_js_1.findProject)(owner ? `${owner}/${slug}` : slug);
|
|
551
569
|
if (!project) {
|
|
@@ -593,6 +611,26 @@ async function doImport(refs, url) {
|
|
|
593
611
|
let nextMark = 1;
|
|
594
612
|
for (const mark of commitMark.values())
|
|
595
613
|
nextMark = Math.max(nextMark, mark + 1);
|
|
614
|
+
/*
|
|
615
|
+
What this import takes, out of everything the heads can reach.
|
|
616
|
+
|
|
617
|
+
Never history an earlier shallow clone left out: its first ordinary fetch
|
|
618
|
+
would otherwise bring the lot in as a second line of commits. And on a
|
|
619
|
+
first import with `--depth`, only that many generations of each line.
|
|
620
|
+
*/
|
|
621
|
+
const leftOut = (0, git_history_js_1.historyLeftOut)(all, commitMark.keys());
|
|
622
|
+
let chosen = order.filter((version) => !leftOut.has(version.id));
|
|
623
|
+
if (depth && depth > 0) {
|
|
624
|
+
if (commitMark.size) {
|
|
625
|
+
say(` --depth applies to a new clone; fetching everything newer than what is here.`);
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
const keep = (0, git_history_js_1.withinDepth)([...wanted.values()], all, depth);
|
|
629
|
+
chosen = chosen.filter((version) => keep.has(version.id));
|
|
630
|
+
say(`Taking the newest ${depth} version${depth === 1 ? "" : "s"} of each line ` +
|
|
631
|
+
`(--depth ${depth}); ${order.length - chosen.length} older left out.`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
596
634
|
/*
|
|
597
635
|
Point every ref git asked for at the commit its line's head became.
|
|
598
636
|
|
|
@@ -610,7 +648,7 @@ async function doImport(refs, url) {
|
|
|
610
648
|
say(` could not place ${ref}: no commit for its head version`);
|
|
611
649
|
}
|
|
612
650
|
};
|
|
613
|
-
const fresh =
|
|
651
|
+
const fresh = chosen.filter((version) => !commitMark.has(version.id));
|
|
614
652
|
if (!fresh.length) {
|
|
615
653
|
say(`Already up to date with ${slug}.`);
|
|
616
654
|
/*
|
|
@@ -631,8 +669,8 @@ async function doImport(refs, url) {
|
|
|
631
669
|
return;
|
|
632
670
|
}
|
|
633
671
|
say(`Importing ${fresh.length} version${fresh.length === 1 ? "" : "s"} from ${slug}` +
|
|
634
|
-
(
|
|
635
|
-
? ` (${
|
|
672
|
+
(chosen.length > fresh.length
|
|
673
|
+
? ` (${chosen.length - fresh.length} already here)`
|
|
636
674
|
: "") +
|
|
637
675
|
".");
|
|
638
676
|
const { Downloader } = await Promise.resolve().then(() => __importStar(require("../../desktop-app/src/main/download.js")));
|
|
@@ -667,41 +705,7 @@ async function doImport(refs, url) {
|
|
|
667
705
|
bytes together, because sixteen source files and sixteen video files
|
|
668
706
|
are not the same amount to be holding at once.
|
|
669
707
|
*/
|
|
670
|
-
/*
|
|
671
|
-
The whole version in one request, where that works.
|
|
672
|
-
|
|
673
|
-
Fetching per file is one round trip each: 21,071 of them on a real
|
|
674
|
-
Unity project, which is four hours in series and about half an hour
|
|
675
|
-
even eight at a time. The archive is the same bytes in one request.
|
|
676
|
-
|
|
677
|
-
Anything unexpected about it — an entry this cannot read, a short
|
|
678
|
-
download, a service that does not offer the route — falls through to
|
|
679
|
-
the per-file path below, which is slow and known to work. A clone
|
|
680
|
-
that is slow is a nuisance; a clone that writes the wrong bytes is
|
|
681
|
-
not recoverable by trying again.
|
|
682
|
-
*/
|
|
683
|
-
const fromArchive = new Map();
|
|
684
|
-
try {
|
|
685
|
-
const packed = await downloader.archive(repositoryId, version.id);
|
|
686
|
-
for (const entry of (0, stored_zip_js_1.readStoredZip)(packed)) {
|
|
687
|
-
fromArchive.set(entry.path, entry.bytes);
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
catch (error) {
|
|
691
|
-
/*
|
|
692
|
-
Said out loud, not swallowed.
|
|
693
|
-
|
|
694
|
-
This fell back silently, and the silence cost an hour: a deadline
|
|
695
|
-
meant for ordinary requests was aborting the archive, the import
|
|
696
|
-
dropped to fetching every file on its own, and from the outside it
|
|
697
|
-
looked exactly like the slow clone the archive was added to fix.
|
|
698
|
-
A fallback nobody can see is a fallback nobody can diagnose.
|
|
699
|
-
*/
|
|
700
|
-
say(` the archive for v${version.sequence} could not be used ` +
|
|
701
|
-
`(${error instanceof Error ? error.message : String(error)}); ` +
|
|
702
|
-
`fetching its files one by one`);
|
|
703
|
-
fromArchive.clear();
|
|
704
|
-
}
|
|
708
|
+
/* Only content not already imported: a blob is written once, by digest. */
|
|
705
709
|
const seenBlob = new Set();
|
|
706
710
|
const wantedBlobs = files.filter((file) => {
|
|
707
711
|
if (blobMark.has(file.sha256) || seenBlob.has(file.sha256))
|
|
@@ -709,6 +713,57 @@ async function doImport(refs, url) {
|
|
|
709
713
|
seenBlob.add(file.sha256);
|
|
710
714
|
return true;
|
|
711
715
|
});
|
|
716
|
+
/*
|
|
717
|
+
The whole version in one request — but only when most of it is new.
|
|
718
|
+
|
|
719
|
+
The archive is every file the version holds, and this used to fetch it
|
|
720
|
+
for every version and then keep only the files not already imported.
|
|
721
|
+
After the first, that is nearly none of them: a five-version clone of
|
|
722
|
+
a large project downloaded its whole snapshot five times over to use
|
|
723
|
+
the changed files out of each, which is why an archive added to speed
|
|
724
|
+
clones up left one taking eighty-two minutes.
|
|
725
|
+
|
|
726
|
+
So it is weighed per version: where the files still wanted are at
|
|
727
|
+
least half of the version's bytes, one request for the lot wins; where
|
|
728
|
+
they are a handful, fetching just those does. Below a few dozen files
|
|
729
|
+
the round trips are not worth avoiding at all.
|
|
730
|
+
|
|
731
|
+
Where it is worth it, fetching per file would be one round trip each:
|
|
732
|
+
21,071 of them on a real Unity project, four hours in series and about
|
|
733
|
+
half an hour even eight at a time.
|
|
734
|
+
|
|
735
|
+
Anything unexpected about the archive — an entry this cannot read, a
|
|
736
|
+
short download, a service that does not offer the route — falls
|
|
737
|
+
through to the per-file path below, which is slow and known to work.
|
|
738
|
+
A clone that is slow is a nuisance; a clone that writes the wrong bytes
|
|
739
|
+
is not recoverable by trying again.
|
|
740
|
+
*/
|
|
741
|
+
const bytesOf = (list) => list.reduce((sum, file) => sum + Number(file.sourceSize ?? 0), 0);
|
|
742
|
+
const archiveWorthIt = wantedBlobs.length >= ARCHIVE_MIN_FILES &&
|
|
743
|
+
bytesOf(wantedBlobs) >= bytesOf(files) * ARCHIVE_MIN_SHARE;
|
|
744
|
+
const fromArchive = new Map();
|
|
745
|
+
if (archiveWorthIt)
|
|
746
|
+
try {
|
|
747
|
+
const packed = await downloader.archive(repositoryId, version.id);
|
|
748
|
+
for (const entry of (0, stored_zip_js_1.readStoredZip)(packed)) {
|
|
749
|
+
fromArchive.set(entry.path, entry.bytes);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
/*
|
|
754
|
+
Said out loud, not swallowed.
|
|
755
|
+
|
|
756
|
+
This fell back silently, and the silence cost an hour: a deadline
|
|
757
|
+
meant for ordinary requests was aborting the archive, the import
|
|
758
|
+
dropped to fetching every file on its own, and from the outside it
|
|
759
|
+
looked exactly like the slow clone the archive was added to fix.
|
|
760
|
+
A fallback nobody can see is a fallback nobody can diagnose.
|
|
761
|
+
*/
|
|
762
|
+
say(` the archive for v${version.sequence} could not be used ` +
|
|
763
|
+
`(${error instanceof Error ? error.message : String(error)}); ` +
|
|
764
|
+
`fetching its files one by one`);
|
|
765
|
+
fromArchive.clear();
|
|
766
|
+
}
|
|
712
767
|
for (let at = 0; at < wantedBlobs.length;) {
|
|
713
768
|
const window = [];
|
|
714
769
|
let held = 0;
|
|
@@ -765,8 +820,8 @@ async function doImport(refs, url) {
|
|
|
765
820
|
worse than an obviously synthetic one: it survives being copied into a
|
|
766
821
|
mailing list or a CONTRIBUTORS file.
|
|
767
822
|
*/
|
|
768
|
-
send(`author ${author} <noreply@coderook.com> ${when}`);
|
|
769
|
-
send(`committer ${author} <noreply@coderook.com> ${when}`);
|
|
823
|
+
send(`author ${identity(author)} <noreply@coderook.com> ${when}`);
|
|
824
|
+
send(`committer ${identity(author)} <noreply@coderook.com> ${when}`);
|
|
770
825
|
send(`data ${body.length}`);
|
|
771
826
|
node_process_1.default.stdout.write(body);
|
|
772
827
|
node_process_1.default.stdout.write("\n");
|
|
@@ -776,6 +831,13 @@ async function doImport(refs, url) {
|
|
|
776
831
|
send(`merge :${extra}`);
|
|
777
832
|
send("deleteall");
|
|
778
833
|
for (const file of files) {
|
|
834
|
+
/*
|
|
835
|
+
Never into git's own directory. Git would refuse to check one out,
|
|
836
|
+
but it would sit in the history of every clone; new versions cannot
|
|
837
|
+
hold one, and this keeps older ones from carrying it into git.
|
|
838
|
+
*/
|
|
839
|
+
if (file.path.split("/").some(safe_path_js_1.isGitDirectoryName))
|
|
840
|
+
continue;
|
|
779
841
|
send(`M 100644 :${blobMark.get(file.sha256)} ${(0, git_history_js_1.quotePath)(file.path)}`);
|
|
780
842
|
}
|
|
781
843
|
send("");
|
|
@@ -837,9 +899,12 @@ async function doImport(refs, url) {
|
|
|
837
899
|
if (mark === undefined)
|
|
838
900
|
continue;
|
|
839
901
|
const body = Buffer.from(release.notes || release.name, "utf8");
|
|
840
|
-
|
|
902
|
+
const tagName = release.name.replace(/[\s~^:?*[\\\x00-\x1f]/g, "-");
|
|
903
|
+
if (!tagName || tagName.startsWith("-") || tagName.includes(".."))
|
|
904
|
+
continue;
|
|
905
|
+
send(`tag ${tagName}`);
|
|
841
906
|
send(`from :${mark}`);
|
|
842
|
-
send(`tagger ${release.releasedBy || "CodeRook"} <noreply@coderook.com> ` +
|
|
907
|
+
send(`tagger ${identity(release.releasedBy || "CodeRook")} <noreply@coderook.com> ` +
|
|
843
908
|
`${(0, git_history_js_1.stamp)(release.releasedAt)} +0000`);
|
|
844
909
|
send(`data ${body.length}`);
|
|
845
910
|
node_process_1.default.stdout.write(body);
|
|
@@ -1393,6 +1458,8 @@ async function main(argv) {
|
|
|
1393
1458
|
clone of a project you cannot see left an empty folder and exited zero.
|
|
1394
1459
|
*/
|
|
1395
1460
|
let unservable = false;
|
|
1461
|
+
/** `git clone --depth N`, when git passed one. */
|
|
1462
|
+
let depth = null;
|
|
1396
1463
|
for await (const line of lines()) {
|
|
1397
1464
|
const command = line.trim();
|
|
1398
1465
|
if (command === "capabilities") {
|
|
@@ -1406,9 +1473,19 @@ async function main(argv) {
|
|
|
1406
1473
|
continue;
|
|
1407
1474
|
}
|
|
1408
1475
|
if (command.startsWith("option ")) {
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1476
|
+
/*
|
|
1477
|
+
Only what is actually acted on is accepted: answering "ok" to anything
|
|
1478
|
+
else would make git believe a setting took effect. Depth is honoured
|
|
1479
|
+
by importing fewer versions; git passes it for `clone --depth`.
|
|
1480
|
+
*/
|
|
1481
|
+
const [, name, value] = command.split(" ");
|
|
1482
|
+
if (name === "depth" && /^[1-9][0-9]*$/.test(value ?? "")) {
|
|
1483
|
+
depth = Number(value);
|
|
1484
|
+
send("ok");
|
|
1485
|
+
}
|
|
1486
|
+
else {
|
|
1487
|
+
send("unsupported");
|
|
1488
|
+
}
|
|
1412
1489
|
continue;
|
|
1413
1490
|
}
|
|
1414
1491
|
if (command === "list" || command === "list for-push") {
|
|
@@ -1521,7 +1598,7 @@ async function main(argv) {
|
|
|
1521
1598
|
if (importing.length) {
|
|
1522
1599
|
const batch = importing.splice(0, importing.length);
|
|
1523
1600
|
try {
|
|
1524
|
-
await doImport(batch, url);
|
|
1601
|
+
await doImport(batch, url, depth);
|
|
1525
1602
|
}
|
|
1526
1603
|
catch (error) {
|
|
1527
1604
|
/*
|
|
@@ -42,11 +42,14 @@ function readStoredZip(archive) {
|
|
|
42
42
|
const names = new TextDecoder("utf-8", { fatal: false });
|
|
43
43
|
const entries = [];
|
|
44
44
|
let at = 0;
|
|
45
|
+
let ended = false;
|
|
45
46
|
while (at + 4 <= archive.length) {
|
|
46
47
|
const signature = view.getUint32(at, true);
|
|
47
48
|
/* Either record marks the end of the entries. */
|
|
48
|
-
if (signature === CENTRAL_HEADER || signature === END_OF_DIRECTORY)
|
|
49
|
+
if (signature === CENTRAL_HEADER || signature === END_OF_DIRECTORY) {
|
|
50
|
+
ended = true;
|
|
49
51
|
break;
|
|
52
|
+
}
|
|
50
53
|
if (signature !== LOCAL_HEADER) {
|
|
51
54
|
throw new Error(`Not a stored archive: unexpected record at byte ${at}`);
|
|
52
55
|
}
|
|
@@ -81,5 +84,42 @@ function readStoredZip(archive) {
|
|
|
81
84
|
});
|
|
82
85
|
at = end;
|
|
83
86
|
}
|
|
87
|
+
/*
|
|
88
|
+
A zip is only whole once its directory arrives.
|
|
89
|
+
|
|
90
|
+
Running out of bytes exactly between two entries used to read as the end
|
|
91
|
+
of the archive. It is also exactly what a download cut off at a file
|
|
92
|
+
boundary looks like — and the service's archive of a 9,010-file version
|
|
93
|
+
was being cut off, by a Worker limit, after 4,978 of them. Every one of
|
|
94
|
+
those read back perfectly, so the clone took them and fetched the other
|
|
95
|
+
4,032 one at a time without a word, which is why it took half an hour.
|
|
96
|
+
Now the directory has to be there, and has to count what was read.
|
|
97
|
+
*/
|
|
98
|
+
if (!ended) {
|
|
99
|
+
throw new Error(`Archive was cut off after ${entries.length} entries: it has no directory`);
|
|
100
|
+
}
|
|
101
|
+
const counted = directoryCount(view);
|
|
102
|
+
if (counted === null) {
|
|
103
|
+
throw new Error("Archive has no end-of-directory record");
|
|
104
|
+
}
|
|
105
|
+
if (counted !== entries.length) {
|
|
106
|
+
throw new Error(`Archive says it holds ${counted} entries and ${entries.length} were read`);
|
|
107
|
+
}
|
|
84
108
|
return entries;
|
|
85
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* How many entries the archive's end-of-directory record says it holds.
|
|
112
|
+
*
|
|
113
|
+
* Searched for from the end, as the format intends: the record is the last
|
|
114
|
+
* thing in the file, followed only by a comment of up to 64 KB.
|
|
115
|
+
*/
|
|
116
|
+
function directoryCount(view) {
|
|
117
|
+
const last = view.byteLength - 22;
|
|
118
|
+
const first = Math.max(0, last - 0xffff);
|
|
119
|
+
for (let at = last; at >= first; at -= 1) {
|
|
120
|
+
if (view.getUint32(at, true) === END_OF_DIRECTORY) {
|
|
121
|
+
return view.getUint16(at + 10, true);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
@@ -29,6 +29,7 @@ const node_crypto_1 = require("node:crypto");
|
|
|
29
29
|
const node_fs_1 = require("node:fs");
|
|
30
30
|
const promises_1 = require("node:fs/promises");
|
|
31
31
|
const node_path_1 = __importDefault(require("node:path"));
|
|
32
|
+
const safe_path_js_1 = require("../shared/safe_path.js");
|
|
32
33
|
const node_zlib_1 = require("node:zlib");
|
|
33
34
|
const download_js_1 = require("./download.js");
|
|
34
35
|
const chunking_js_1 = require("../shared/chunking.js");
|
|
@@ -343,9 +344,9 @@ async function unpackBundle(source, destination, report) {
|
|
|
343
344
|
let written = 0;
|
|
344
345
|
let bytes = 0;
|
|
345
346
|
for (const directory of manifest.directories ?? []) {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
347
|
+
if (!directory || directory === ".")
|
|
348
|
+
continue;
|
|
349
|
+
await (0, promises_1.mkdir)((0, safe_path_js_1.inside)(staging, directory), { recursive: true });
|
|
349
350
|
}
|
|
350
351
|
for (const record of manifest.files) {
|
|
351
352
|
report?.({
|
|
@@ -356,10 +357,8 @@ async function unpackBundle(source, destination, report) {
|
|
|
356
357
|
path: record.path,
|
|
357
358
|
percent: (0, download_js_1.underway)(bytes, totalBytes),
|
|
358
359
|
});
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
throw new Error(`Unsafe path in bundle: ${record.path}`);
|
|
362
|
-
const full = node_path_1.default.join(staging, ...parts);
|
|
360
|
+
/* Refuses `..`, absolute paths and git's own directory; see safe_path.ts. */
|
|
361
|
+
const full = (0, safe_path_js_1.inside)(staging, record.path);
|
|
363
362
|
await (0, promises_1.mkdir)(node_path_1.default.dirname(full), { recursive: true });
|
|
364
363
|
const pieces = [];
|
|
365
364
|
if (record.segments) {
|
|
@@ -405,15 +404,6 @@ async function unpackBundle(source, destination, report) {
|
|
|
405
404
|
await handle.close();
|
|
406
405
|
}
|
|
407
406
|
}
|
|
408
|
-
/** Reject anything that would escape the destination before it is used. */
|
|
409
|
-
function safeParts(relative) {
|
|
410
|
-
const parts = relative.split(/[\\/]/).filter((part) => part && part !== ".");
|
|
411
|
-
if (parts.some((part) => part === ".." || part.includes("\0")))
|
|
412
|
-
return [];
|
|
413
|
-
if (node_path_1.default.isAbsolute(relative) || /^[a-zA-Z]:/.test(relative))
|
|
414
|
-
return [];
|
|
415
|
-
return parts;
|
|
416
|
-
}
|
|
417
407
|
/** Every file under `root`, relative and POSIX-separated, sorted. */
|
|
418
408
|
async function collectEntries(root) {
|
|
419
409
|
const found = [];
|
|
@@ -19,6 +19,7 @@ const promises_1 = require("node:fs/promises");
|
|
|
19
19
|
const promises_2 = require("node:stream/promises");
|
|
20
20
|
const node_stream_1 = require("node:stream");
|
|
21
21
|
const node_path_1 = __importDefault(require("node:path"));
|
|
22
|
+
const safe_path_js_1 = require("../shared/safe_path.js");
|
|
22
23
|
const identify_js_1 = require("./identify.js");
|
|
23
24
|
const faults_js_1 = require("./faults.js");
|
|
24
25
|
const retry_js_1 = require("./retry.js");
|
|
@@ -557,14 +558,18 @@ class Downloader {
|
|
|
557
558
|
jobs.push({ kind: "file", file });
|
|
558
559
|
}
|
|
559
560
|
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
561
|
+
/*
|
|
562
|
+
Every path checked before a byte is fetched, so a version carrying one
|
|
563
|
+
it must not write is refused whole rather than half-written. The check
|
|
564
|
+
used to refuse `..` alone, which let `.git/config` through to be
|
|
565
|
+
renamed over a checkout's real one — see shared/safe_path.ts.
|
|
566
|
+
*/
|
|
567
|
+
for (const file of files) {
|
|
568
|
+
const reason = (0, safe_path_js_1.whyUnsafe)(file.path);
|
|
569
|
+
if (reason) {
|
|
570
|
+
throw new Error(`That version contains an unsafe path (${reason}): ${file.path}`);
|
|
565
571
|
}
|
|
566
|
-
|
|
567
|
-
};
|
|
572
|
+
}
|
|
568
573
|
const told = { at: 0 };
|
|
569
574
|
const sayProgress = (where) => {
|
|
570
575
|
const now = Date.now();
|
|
@@ -599,7 +604,6 @@ class Downloader {
|
|
|
599
604
|
}
|
|
600
605
|
for (const member of packed.get(packId) ?? []) {
|
|
601
606
|
this.check();
|
|
602
|
-
const memberParts = safeParts(member.path);
|
|
603
607
|
const offset = member.pack.offset;
|
|
604
608
|
const length = member.pack.length;
|
|
605
609
|
if (offset < 0 || length < 0 || offset + length > body.byteLength) {
|
|
@@ -610,7 +614,7 @@ class Downloader {
|
|
|
610
614
|
if (member.sha256 && digest !== member.sha256) {
|
|
611
615
|
throw new Error(`${member.path} did not arrive intact`);
|
|
612
616
|
}
|
|
613
|
-
const full =
|
|
617
|
+
const full = (0, safe_path_js_1.inside)(staging, member.path);
|
|
614
618
|
await (0, promises_1.mkdir)(node_path_1.default.dirname(full), { recursive: true });
|
|
615
619
|
await (0, promises_1.writeFile)(full, contents);
|
|
616
620
|
manifest[member.path] = digest;
|
|
@@ -620,8 +624,7 @@ class Downloader {
|
|
|
620
624
|
}
|
|
621
625
|
};
|
|
622
626
|
const restoreFile = async (file) => {
|
|
623
|
-
const
|
|
624
|
-
const full = node_path_1.default.join(staging, ...parts);
|
|
627
|
+
const full = (0, safe_path_js_1.inside)(staging, file.path);
|
|
625
628
|
await (0, promises_1.mkdir)(node_path_1.default.dirname(full), { recursive: true });
|
|
626
629
|
const digest = await this.fetchInto(repositoryId, versionId, file, full);
|
|
627
630
|
/*
|
|
@@ -721,8 +724,7 @@ class Downloader {
|
|
|
721
724
|
await (0, promises_1.mkdir)(destination, { recursive: true });
|
|
722
725
|
let placed = 0;
|
|
723
726
|
for (const file of files) {
|
|
724
|
-
const
|
|
725
|
-
const target = node_path_1.default.join(destination, ...parts);
|
|
727
|
+
const target = (0, safe_path_js_1.inside)(destination, file.path);
|
|
726
728
|
/*
|
|
727
729
|
Reconciling leaves alone whatever the incoming version did not
|
|
728
730
|
change. Writing it anyway would replace an edit in progress with a
|
|
@@ -737,7 +739,7 @@ class Downloader {
|
|
|
737
739
|
}
|
|
738
740
|
await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
|
|
739
741
|
await (0, promises_1.rm)(target, { recursive: true, force: true });
|
|
740
|
-
await (0, promises_1.rename)(
|
|
742
|
+
await (0, promises_1.rename)((0, safe_path_js_1.inside)(staging, file.path), target);
|
|
741
743
|
placed += 1;
|
|
742
744
|
if (placed === 1)
|
|
743
745
|
(0, faults_js_1.maybeFail)("get:after-first-file");
|
|
@@ -751,10 +753,10 @@ class Downloader {
|
|
|
751
753
|
for (const [gone, digest] of Object.entries(held ?? {})) {
|
|
752
754
|
if (manifest[gone] !== undefined)
|
|
753
755
|
continue;
|
|
754
|
-
|
|
755
|
-
if (
|
|
756
|
+
/* A path it must not write is not one it may delete either. */
|
|
757
|
+
if ((0, safe_path_js_1.whyUnsafe)(gone))
|
|
756
758
|
continue;
|
|
757
|
-
const target =
|
|
759
|
+
const target = (0, safe_path_js_1.inside)(destination, gone);
|
|
758
760
|
/*
|
|
759
761
|
The version dropped it — but if the bytes here are no longer the
|
|
760
762
|
ones this folder received, somebody has been working on it, and
|
|
@@ -1478,7 +1478,50 @@ class Uploader {
|
|
|
1478
1478
|
Read once, here. Everything below works from the declarations rather
|
|
1479
1479
|
than from the disk.
|
|
1480
1480
|
*/
|
|
1481
|
-
const
|
|
1481
|
+
const hashed = await this.hashSelection(request, sending, deletions, readFailures, vanished, report);
|
|
1482
|
+
/*
|
|
1483
|
+
A file that is exactly what the last version held is kept, not sent.
|
|
1484
|
+
|
|
1485
|
+
Both callers select everything — the command line says so with an empty
|
|
1486
|
+
exclusion list, because its list of changes stops at twenty thousand —
|
|
1487
|
+
and a selected file used to be a file to send. Most of a project is
|
|
1488
|
+
small files, and small files are stored inside packs, so the service
|
|
1489
|
+
cannot recognise one as already held; the whole lot was packed again and
|
|
1490
|
+
sent again on every save. CodeBox's own saves sent 3.1 MB across 1,025
|
|
1491
|
+
files to record a change to eight.
|
|
1492
|
+
|
|
1493
|
+
The digest has just been taken, so the comparison is exact: same path,
|
|
1494
|
+
same bytes, and the previous version's object for them — a pack slice,
|
|
1495
|
+
its chunks, or a plain object — is named again instead.
|
|
1496
|
+
*/
|
|
1497
|
+
const keptAsItWas = (declaration) => {
|
|
1498
|
+
const was = prior.get(declaration.path);
|
|
1499
|
+
return (!!was &&
|
|
1500
|
+
was.sha256 === declaration.sha256 &&
|
|
1501
|
+
!deletions.has(declaration.path) &&
|
|
1502
|
+
!!(was.pack?.objectId || was.chunks?.length || was.objectId));
|
|
1503
|
+
};
|
|
1504
|
+
const declarations = hashed.declarations.filter((one) => !keptAsItWas(one));
|
|
1505
|
+
let totalBytes = hashed.totalBytes;
|
|
1506
|
+
for (const one of hashed.declarations) {
|
|
1507
|
+
if (!keptAsItWas(one))
|
|
1508
|
+
continue;
|
|
1509
|
+
totalBytes -= one.size;
|
|
1510
|
+
const held = prior.get(one.path);
|
|
1511
|
+
reused.push({
|
|
1512
|
+
path: one.path,
|
|
1513
|
+
sourceSize: held.sourceSize,
|
|
1514
|
+
storedSize: held.storedSize,
|
|
1515
|
+
mediaType: held.mediaType,
|
|
1516
|
+
added: 0,
|
|
1517
|
+
removed: 0,
|
|
1518
|
+
included: true,
|
|
1519
|
+
deleted: false,
|
|
1520
|
+
binary: false,
|
|
1521
|
+
lines: 0,
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
(0, profile_js_1.counted)("files kept as they were", hashed.declarations.length - declarations.length);
|
|
1482
1525
|
// 2. The project needs somewhere on the account to live.
|
|
1483
1526
|
const repositoryId = await this.ensureRepository(request);
|
|
1484
1527
|
// Learn the wire contract once, before the two section pipelines begin.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.isGitDirectoryName = isGitDirectoryName;
|
|
7
|
+
exports.whyUnsafe = whyUnsafe;
|
|
8
|
+
exports.safeParts = safeParts;
|
|
9
|
+
exports.inside = inside;
|
|
10
|
+
/**
|
|
11
|
+
* Whether a path from a version may be written into somebody's folder.
|
|
12
|
+
*
|
|
13
|
+
* Written after Gitea's CVE-2026-60004, where repository content landed in a
|
|
14
|
+
* hooks directory and git ran it. Nothing here runs git, but the folder a
|
|
15
|
+
* project is fetched into very often is a git checkout — and a version holding
|
|
16
|
+
* `.git/config` would have been written over the real one. A `core.fsmonitor`
|
|
17
|
+
* line in that file runs a command on the next `git status`, with no
|
|
18
|
+
* executable bit needed; a plain file named `.git` replaced the whole
|
|
19
|
+
* directory with a pointer to one the version could also supply.
|
|
20
|
+
*
|
|
21
|
+
* The service refuses these paths when a version is saved. They are refused
|
|
22
|
+
* here as well because this is the side that writes to disk: it must not rest
|
|
23
|
+
* on the server having been right, having been uncompromised, or having held
|
|
24
|
+
* the same rules when an older version was saved.
|
|
25
|
+
*
|
|
26
|
+
* Shared by every writer — fetching, restoring, unbundling — so there is one
|
|
27
|
+
* rule, not one per place that happened to think of it.
|
|
28
|
+
*/
|
|
29
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
30
|
+
/**
|
|
31
|
+
* A name git treats as its own directory.
|
|
32
|
+
*
|
|
33
|
+
* The same comparison git's own `verify_path` makes with its NTFS and HFS
|
|
34
|
+
* protections on: any case, trailing dots and spaces dropped, an alternate
|
|
35
|
+
* data stream dropped, and the 8.3 short name Windows gives `.git`.
|
|
36
|
+
*/
|
|
37
|
+
function isGitDirectoryName(part) {
|
|
38
|
+
const name = part.split(":")[0].replace(/[. ]+$/, "").toLowerCase();
|
|
39
|
+
return name === ".git" || /^git~\d+$/.test(name);
|
|
40
|
+
}
|
|
41
|
+
/** Why this path must not be written, or null when it may. */
|
|
42
|
+
function whyUnsafe(relative) {
|
|
43
|
+
if (!relative)
|
|
44
|
+
return "it is empty";
|
|
45
|
+
if (relative.includes("\0"))
|
|
46
|
+
return "it contains a NUL character";
|
|
47
|
+
if (/^[\\/]/.test(relative) || /^[a-zA-Z]:/.test(relative)) {
|
|
48
|
+
return "it is absolute";
|
|
49
|
+
}
|
|
50
|
+
for (const part of relative.split(/[\\/]/)) {
|
|
51
|
+
if (part === "..")
|
|
52
|
+
return "it climbs out of the folder";
|
|
53
|
+
if (isGitDirectoryName(part))
|
|
54
|
+
return "it writes into git's own directory";
|
|
55
|
+
/* A stream or a drive-relative name on Windows; refused by the service too. */
|
|
56
|
+
if (process.platform === "win32" && part.includes(":")) {
|
|
57
|
+
return "it names a Windows data stream";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The path's parts, or an error naming the path and why.
|
|
64
|
+
*
|
|
65
|
+
* Both separators split it, because on Windows either one is a separator:
|
|
66
|
+
* `a\..\..\x` is three climbs there, whatever a check splitting on `/` saw.
|
|
67
|
+
*/
|
|
68
|
+
function safeParts(relative) {
|
|
69
|
+
const reason = whyUnsafe(relative);
|
|
70
|
+
if (reason) {
|
|
71
|
+
throw new Error(`That version contains an unsafe path (${reason}): ${relative}`);
|
|
72
|
+
}
|
|
73
|
+
return relative.split(/[\\/]/).filter((part) => part && part !== ".");
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Where a checked path lands under `root`, confirmed to be inside it.
|
|
77
|
+
*
|
|
78
|
+
* The part checks above should make this impossible to fail. It is here so
|
|
79
|
+
* that the next check somebody forgets does not become the next escape.
|
|
80
|
+
*/
|
|
81
|
+
function inside(root, relative) {
|
|
82
|
+
const base = node_path_1.default.resolve(root);
|
|
83
|
+
const full = node_path_1.default.resolve(base, ...safeParts(relative));
|
|
84
|
+
if (full !== base && !full.startsWith(base + node_path_1.default.sep)) {
|
|
85
|
+
throw new Error(`That version contains a path outside the folder: ${relative}`);
|
|
86
|
+
}
|
|
87
|
+
return full;
|
|
88
|
+
}
|
package/package.json
CHANGED
package/skills/coderook/SKILL.md
CHANGED
|
@@ -1,154 +1,200 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: coderook
|
|
3
|
-
description: Save, browse and restore versions of a project on CodeRook — a version host where every version is a complete snapshot and there is no git to learn. Use when asked to save or submit work to CodeRook, check what has changed, look at a project's versions or files, fetch a project, start or switch a line of work, or resolve a save that landed at the same time as somebody else's.
|
|
4
|
-
allowed-tools: Bash Read
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
# CodeRook
|
|
8
|
-
|
|
9
|
-
CodeRook stores whole snapshots of a folder. A version names every file the
|
|
10
|
-
project had at that moment, so restoring one never depends on the versions
|
|
11
|
-
around it, and there is no staging area, no branches to rebase and no history
|
|
12
|
-
to rewrite.
|
|
13
|
-
|
|
14
|
-
Everything here is the `coderook` command line. Run it with Bash.
|
|
15
|
-
|
|
16
|
-
## Before anything else
|
|
17
|
-
|
|
18
|
-
`cbx whoami` says who this machine is signed in as. If it refuses, the
|
|
19
|
-
person needs to run `cbx sign-in` themselves — it takes a personal access
|
|
20
|
-
token, so do not attempt it on their behalf.
|
|
21
|
-
|
|
22
|
-
If the command is not found at all, this machine has the skill but not the
|
|
23
|
-
command line. Either install it once with
|
|
24
|
-
`npm install --global @coderook/cli`, or put `npx -y @coderook/cli` where
|
|
25
|
-
`coderook` appears below — the commands are identical, npx is just slower
|
|
26
|
-
to start.
|
|
27
|
-
|
|
28
|
-
## Reading
|
|
29
|
-
|
|
30
|
-
```bash
|
|
31
|
-
cbx status # what has changed in this folder since its last version
|
|
32
|
-
cbx projects # every project on the account
|
|
33
|
-
cbx versions [project] # what has been saved, newest first
|
|
34
|
-
cbx tracks [project] # the lines a project has, and any waiting on a decision
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
`status` is the one to reach for when somebody asks what is uncommitted, what
|
|
38
|
-
changed, or whether anything needs saving. It reads the folder and changes
|
|
39
|
-
nothing.
|
|
40
|
-
|
|
41
|
-
## Saving
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
cbx submit -m "What changed, in a sentence"
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
Send only what changed; the version still names every file. Write the message
|
|
48
|
-
yourself from the actual diff rather than asking for one — a message like
|
|
49
|
-
"update" helps nobody reading the history later.
|
|
50
|
-
|
|
51
|
-
**Ask before running this.** Saving is the one thing here that leaves a mark on
|
|
52
|
-
somebody's account, and a version saved by mistake is a version they have to
|
|
53
|
-
explain. Propose it, say what it would send, and let them agree.
|
|
54
|
-
|
|
55
|
-
`cbx submit -n` shows exactly what would be sent without sending it. Use
|
|
56
|
-
that freely; it is safe and it is the honest way to answer "what would this
|
|
57
|
-
upload?".
|
|
58
|
-
|
|
59
|
-
## Fetching
|
|
60
|
-
|
|
61
|
-
```bash
|
|
62
|
-
cbx get # bring this folder up to date
|
|
63
|
-
cbx clone <project> [dir] # fetch a project into a new folder
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
`get` protects local edits by default. `cbx get --replace` discards them
|
|
67
|
-
to make an exact copy — only run it when the person has said so in those terms.
|
|
68
|
-
|
|
69
|
-
## Lines of work
|
|
70
|
-
|
|
71
|
-
A project can have more than one line, so two people can save without one
|
|
72
|
-
landing on top of the other.
|
|
73
|
-
|
|
74
|
-
```bash
|
|
75
|
-
cbx track # which line this folder saves to
|
|
76
|
-
cbx track spike --new # start a line and switch to it
|
|
77
|
-
cbx track main # switch back
|
|
78
|
-
cbx submit --track spike -m "…"
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
Switching says where the next save goes and nothing else — no files move.
|
|
82
|
-
Run `cbx get` afterwards to bring that line's files in.
|
|
83
|
-
|
|
84
|
-
## Versions, and going back
|
|
85
|
-
|
|
86
|
-
Every save is a commit. A commit becomes a *version* — the thing the public
|
|
87
|
-
side of a project offers, and the thing a collaborator pulls — only when
|
|
88
|
-
somebody promotes it.
|
|
89
|
-
|
|
90
|
-
```bash
|
|
91
|
-
cbx promote # pick from the last ten and name one
|
|
92
|
-
cbx mark 41 --pin # pin, hide, rename or label one save
|
|
93
|
-
cbx labels add shipped green # the labels a project can wear
|
|
94
|
-
cbx undo # put the project back on the save before this one
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
`undo` moves where the project stands. Nothing is deleted and nothing is
|
|
98
|
-
renumbered — the saves passed over stay in the history, and the next save
|
|
99
|
-
carries on from wherever it now stands. It is the answer to "I pushed the
|
|
100
|
-
wrong thing"; hiding a version stops strangers reading it but leaves the
|
|
101
|
-
project standing on it, so the next person to pull still lands on the mistake.
|
|
102
|
-
|
|
103
|
-
**Ask before `undo`, `promote` and `take-down`.** They change what other
|
|
104
|
-
people see. `cbx versions` first, so the person can say which save they mean.
|
|
105
|
-
|
|
106
|
-
## When two saves collide
|
|
107
|
-
|
|
108
|
-
If somebody saved while this folder was behind, the second save becomes a merge
|
|
109
|
-
waiting on a decision rather than overwriting anything.
|
|
110
|
-
|
|
111
|
-
```bash
|
|
112
|
-
cbx merges # this folder's saves waiting on a decision
|
|
113
|
-
cbx merge <ref> # look at one, and decide
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
Read the conflict out to the person and let them choose. Do not pick a side for
|
|
117
|
-
them: the whole reason it stopped is that the service could not tell which copy
|
|
118
|
-
was wanted.
|
|
119
|
-
|
|
120
|
-
## What is worth leaving out
|
|
121
|
-
|
|
122
|
-
```bash
|
|
123
|
-
cbx ignore --suggest # dependency directories, build output, virtual environments
|
|
124
|
-
cbx ignore --suggest --apply # add the confident ones to .gitignore
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
Rules live in the project's own `.gitignore`, so CodeRook, git and the website
|
|
128
|
-
all read one file.
|
|
129
|
-
|
|
130
|
-
## Bundles
|
|
131
|
-
|
|
132
|
-
```bash
|
|
133
|
-
cbx bundle . project.cbx # the whole project, every version, as one file
|
|
134
|
-
cbx inspect project.cbx # what is inside, without unpacking
|
|
135
|
-
cbx unbundle project.cbx ./restored
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
##
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
1
|
+
---
|
|
2
|
+
name: coderook
|
|
3
|
+
description: Save, browse and restore versions of a project on CodeRook — a version host where every version is a complete snapshot and there is no git to learn. Use when asked to save or submit work to CodeRook, check what has changed, look at a project's versions or files, fetch a project, start or switch a line of work, or resolve a save that landed at the same time as somebody else's.
|
|
4
|
+
allowed-tools: Bash Read
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# CodeRook
|
|
8
|
+
|
|
9
|
+
CodeRook stores whole snapshots of a folder. A version names every file the
|
|
10
|
+
project had at that moment, so restoring one never depends on the versions
|
|
11
|
+
around it, and there is no staging area, no branches to rebase and no history
|
|
12
|
+
to rewrite.
|
|
13
|
+
|
|
14
|
+
Everything here is the `coderook` command line. Run it with Bash.
|
|
15
|
+
|
|
16
|
+
## Before anything else
|
|
17
|
+
|
|
18
|
+
`cbx whoami` says who this machine is signed in as. If it refuses, the
|
|
19
|
+
person needs to run `cbx sign-in` themselves — it takes a personal access
|
|
20
|
+
token, so do not attempt it on their behalf.
|
|
21
|
+
|
|
22
|
+
If the command is not found at all, this machine has the skill but not the
|
|
23
|
+
command line. Either install it once with
|
|
24
|
+
`npm install --global @coderook/cli`, or put `npx -y @coderook/cli` where
|
|
25
|
+
`coderook` appears below — the commands are identical, npx is just slower
|
|
26
|
+
to start.
|
|
27
|
+
|
|
28
|
+
## Reading
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
cbx status # what has changed in this folder since its last version
|
|
32
|
+
cbx projects # every project on the account
|
|
33
|
+
cbx versions [project] # what has been saved, newest first
|
|
34
|
+
cbx tracks [project] # the lines a project has, and any waiting on a decision
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`status` is the one to reach for when somebody asks what is uncommitted, what
|
|
38
|
+
changed, or whether anything needs saving. It reads the folder and changes
|
|
39
|
+
nothing.
|
|
40
|
+
|
|
41
|
+
## Saving
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
cbx submit -m "What changed, in a sentence"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Send only what changed; the version still names every file. Write the message
|
|
48
|
+
yourself from the actual diff rather than asking for one — a message like
|
|
49
|
+
"update" helps nobody reading the history later.
|
|
50
|
+
|
|
51
|
+
**Ask before running this.** Saving is the one thing here that leaves a mark on
|
|
52
|
+
somebody's account, and a version saved by mistake is a version they have to
|
|
53
|
+
explain. Propose it, say what it would send, and let them agree.
|
|
54
|
+
|
|
55
|
+
`cbx submit -n` shows exactly what would be sent without sending it. Use
|
|
56
|
+
that freely; it is safe and it is the honest way to answer "what would this
|
|
57
|
+
upload?".
|
|
58
|
+
|
|
59
|
+
## Fetching
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
cbx get # bring this folder up to date
|
|
63
|
+
cbx clone <project> [dir] # fetch a project into a new folder
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`get` protects local edits by default. `cbx get --replace` discards them
|
|
67
|
+
to make an exact copy — only run it when the person has said so in those terms.
|
|
68
|
+
|
|
69
|
+
## Lines of work
|
|
70
|
+
|
|
71
|
+
A project can have more than one line, so two people can save without one
|
|
72
|
+
landing on top of the other.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
cbx track # which line this folder saves to
|
|
76
|
+
cbx track spike --new # start a line and switch to it
|
|
77
|
+
cbx track main # switch back
|
|
78
|
+
cbx submit --track spike -m "…"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Switching says where the next save goes and nothing else — no files move.
|
|
82
|
+
Run `cbx get` afterwards to bring that line's files in.
|
|
83
|
+
|
|
84
|
+
## Versions, and going back
|
|
85
|
+
|
|
86
|
+
Every save is a commit. A commit becomes a *version* — the thing the public
|
|
87
|
+
side of a project offers, and the thing a collaborator pulls — only when
|
|
88
|
+
somebody promotes it.
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
cbx promote # pick from the last ten and name one
|
|
92
|
+
cbx mark 41 --pin # pin, hide, rename or label one save
|
|
93
|
+
cbx labels add shipped green # the labels a project can wear
|
|
94
|
+
cbx undo # put the project back on the save before this one
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`undo` moves where the project stands. Nothing is deleted and nothing is
|
|
98
|
+
renumbered — the saves passed over stay in the history, and the next save
|
|
99
|
+
carries on from wherever it now stands. It is the answer to "I pushed the
|
|
100
|
+
wrong thing"; hiding a version stops strangers reading it but leaves the
|
|
101
|
+
project standing on it, so the next person to pull still lands on the mistake.
|
|
102
|
+
|
|
103
|
+
**Ask before `undo`, `promote` and `take-down`.** They change what other
|
|
104
|
+
people see. `cbx versions` first, so the person can say which save they mean.
|
|
105
|
+
|
|
106
|
+
## When two saves collide
|
|
107
|
+
|
|
108
|
+
If somebody saved while this folder was behind, the second save becomes a merge
|
|
109
|
+
waiting on a decision rather than overwriting anything.
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
cbx merges # this folder's saves waiting on a decision
|
|
113
|
+
cbx merge <ref> # look at one, and decide
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Read the conflict out to the person and let them choose. Do not pick a side for
|
|
117
|
+
them: the whole reason it stopped is that the service could not tell which copy
|
|
118
|
+
was wanted.
|
|
119
|
+
|
|
120
|
+
## What is worth leaving out
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
cbx ignore --suggest # dependency directories, build output, virtual environments
|
|
124
|
+
cbx ignore --suggest --apply # add the confident ones to .gitignore
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Rules live in the project's own `.gitignore`, so CodeRook, git and the website
|
|
128
|
+
all read one file.
|
|
129
|
+
|
|
130
|
+
## Bundles
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
cbx bundle . project.cbx # the whole project, every version, as one file
|
|
134
|
+
cbx inspect project.cbx # what is inside, without unpacking
|
|
135
|
+
cbx unbundle project.cbx ./restored
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Reading a public project without signing in
|
|
139
|
+
|
|
140
|
+
Public projects can be read by anything, with no token, from
|
|
141
|
+
`https://api.coderook.com/v1/public`. There is no need to read the command
|
|
142
|
+
line's source to find these — this is the whole of it:
|
|
143
|
+
|
|
144
|
+
```text
|
|
145
|
+
GET /projects?q= public projects, newest first
|
|
146
|
+
GET /projects/{owner}/{slug} one project
|
|
147
|
+
GET /projects/{owner}/{slug}/versions the versions it offers
|
|
148
|
+
GET /projects/{owner}/{slug}/releases/latest newest release and its files
|
|
149
|
+
GET /projects/{owner}/{slug}/updates/latest.yml electron-updater feed
|
|
150
|
+
GET /projects/{owner}/{slug}/versions/{v}/files every file, with digests
|
|
151
|
+
GET /projects/{owner}/{slug}/versions/{v}/file?path= one file
|
|
152
|
+
GET /projects/{owner}/{slug}/versions/{v}/archive the version as a zip
|
|
153
|
+
GET /projects/{owner}/{slug}/versions/{v}/attachments files attached to it
|
|
154
|
+
GET /projects/{owner}/{slug}/versions/{v}/attachments/{id} download one
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`{v}` is a version id or `latest`. A private project answers 404, exactly as
|
|
158
|
+
if it did not exist.
|
|
159
|
+
|
|
160
|
+
### An app that updates itself from CodeRook
|
|
161
|
+
|
|
162
|
+
Release a version named like a version number and attach the installer:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
cbx release 1.4.0
|
|
166
|
+
cbx attach ./dist/MyApp-Setup-1.4.0.exe
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
An Electron app then needs nothing custom — electron-builder's generic
|
|
170
|
+
provider pointed at the feed:
|
|
171
|
+
|
|
172
|
+
```json
|
|
173
|
+
"publish": [{ "provider": "generic",
|
|
174
|
+
"url": "https://api.coderook.com/v1/public/projects/OWNER/SLUG/updates" }]
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The feed serves `latest.yml`, `latest-mac.yml` and `latest-linux.yml` with
|
|
178
|
+
each file's size and SHA-512, and redirects file names to their downloads.
|
|
179
|
+
Anything else can read `releases/latest` as JSON: version, notes, and every
|
|
180
|
+
file with its size, SHA-256 and `url`. Updaters have no account, so the
|
|
181
|
+
project they read must be public; keep private code in a separate project and
|
|
182
|
+
ship releases from a public one.
|
|
183
|
+
|
|
184
|
+
## Things not to do
|
|
185
|
+
|
|
186
|
+
- Do not run `cbx delete` unless the person has asked for that project to
|
|
187
|
+
be deleted, by name, in this conversation.
|
|
188
|
+
- Do not run `cbx sign-out`. It costs them a token they have to fetch
|
|
189
|
+
again and gains nothing.
|
|
190
|
+
- Do not guess a project name. `cbx projects` lists them; a folder that is
|
|
191
|
+
already linked needs no name at all.
|
|
192
|
+
- Do not run `cbx take-down`. It destroys the files in a version and they do
|
|
193
|
+
not come back. Say it exists and let its owner run it.
|
|
194
|
+
|
|
195
|
+
## If something refuses
|
|
196
|
+
|
|
197
|
+
The commands explain themselves — pass the message on rather than rewording it.
|
|
198
|
+
A refusal that says a token was not accepted means signing in; one that says a
|
|
199
|
+
project does not allow machines means its owner turned that off deliberately,
|
|
200
|
+
and the answer is to say so, not to find another route in.
|