@coderook/cli 0.30.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli/src/api.js +24 -1
- package/dist/cli/src/cli.js +25 -1
- package/dist/cli/src/config.js +23 -0
- package/dist/cli/src/git_remote.js +105 -16
- package/dist/cli/src/publish.js +5 -0
- package/dist/cli/src/runner.js +7 -0
- package/dist/cli/src/stored_zip.js +85 -0
- package/dist/cli/src/transfer_command.js +5 -1
- package/dist/desktop-app/src/main/detect.js +9 -2
- package/dist/desktop-app/src/main/download.js +58 -2
- package/dist/desktop-app/src/main/rules.js +48 -35
- package/dist/desktop-app/src/main/upload.js +41 -1
- package/dist/desktop-app/src/main/worktree.js +330 -206
- package/package.json +1 -1
|
@@ -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.31.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ACCA Gaming Productions",
|
|
8
8
|
"url": "https://coderook.com"
|
package/dist/cli/src/api.js
CHANGED
|
@@ -51,6 +51,21 @@ exports.collisionCheck = collisionCheck;
|
|
|
51
51
|
/** The small part of the API the command-line tool needs directly. */
|
|
52
52
|
const identify_js_1 = require("../../desktop-app/src/main/identify.js");
|
|
53
53
|
const config_js_1 = require("./config.js");
|
|
54
|
+
/**
|
|
55
|
+
* How long one request may go unanswered before it is treated as lost.
|
|
56
|
+
*
|
|
57
|
+
* Nothing here had a deadline, so a request the service never answered
|
|
58
|
+
* waited for ever. A real save of a 38,913-file project stopped dead on one
|
|
59
|
+
* open connection with no bytes moving in either direction and no CPU, and
|
|
60
|
+
* said nothing at all — there is no output to watch, because the client is
|
|
61
|
+
* inside `await fetch`.
|
|
62
|
+
*
|
|
63
|
+
* Three minutes is far longer than any of these calls takes and short enough
|
|
64
|
+
* that a save cannot sit silent all afternoon. A deadline that fires is an
|
|
65
|
+
* ordinary error, so it reaches whatever the caller does about errors rather
|
|
66
|
+
* than ending the run.
|
|
67
|
+
*/
|
|
68
|
+
const REQUEST_DEADLINE_MS = 180_000;
|
|
54
69
|
async function call(route, options = {}) {
|
|
55
70
|
const token = await (0, config_js_1.loadToken)();
|
|
56
71
|
if (!token) {
|
|
@@ -66,6 +81,7 @@ async function call(route, options = {}) {
|
|
|
66
81
|
...(options.body ? { "content-type": "application/json" } : {}),
|
|
67
82
|
},
|
|
68
83
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
84
|
+
signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
|
|
69
85
|
});
|
|
70
86
|
const text = await response.text();
|
|
71
87
|
const body = text ? JSON.parse(text) : {};
|
|
@@ -99,6 +115,7 @@ async function send(route, method, bytes, contentType) {
|
|
|
99
115
|
"content-length": String(bytes.byteLength),
|
|
100
116
|
},
|
|
101
117
|
body: bytes,
|
|
118
|
+
signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
|
|
102
119
|
});
|
|
103
120
|
const text = await response.text();
|
|
104
121
|
const body = text ? JSON.parse(text) : {};
|
|
@@ -174,7 +191,10 @@ async function projects() {
|
|
|
174
191
|
*/
|
|
175
192
|
async function findPublicProject(owner, slug) {
|
|
176
193
|
try {
|
|
177
|
-
const response = await fetch(`${(0, config_js_1.apiOrigin)()}/v1/public/projects/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}`, {
|
|
194
|
+
const response = await fetch(`${(0, config_js_1.apiOrigin)()}/v1/public/projects/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}`, {
|
|
195
|
+
headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
|
|
196
|
+
signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
|
|
197
|
+
});
|
|
178
198
|
if (!response.ok)
|
|
179
199
|
return null;
|
|
180
200
|
const row = (await response.json());
|
|
@@ -240,6 +260,8 @@ async function findProject(reference) {
|
|
|
240
260
|
async function health() {
|
|
241
261
|
const response = await fetch(`${(0, config_js_1.apiOrigin)()}/health?t=${Date.now()}`, {
|
|
242
262
|
headers: { "cache-control": "no-cache" },
|
|
263
|
+
/* A health check that hangs is the one call where waiting is absurd. */
|
|
264
|
+
signal: AbortSignal.timeout(15_000),
|
|
243
265
|
});
|
|
244
266
|
return (await response.json());
|
|
245
267
|
}
|
|
@@ -350,6 +372,7 @@ async function fileTextAt(repositoryId, versionId, filePath, mostBytes = 2 * 102
|
|
|
350
372
|
...(0, identify_js_1.clientHeaders)(),
|
|
351
373
|
range: `bytes=0-${mostBytes - 1}`,
|
|
352
374
|
},
|
|
375
|
+
signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
|
|
353
376
|
});
|
|
354
377
|
if (!response.ok)
|
|
355
378
|
return null;
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -623,7 +623,21 @@ async function commandSubmit(parsed) {
|
|
|
623
623
|
which is the wrong way round — so this is that capability, asked for
|
|
624
624
|
plainly.
|
|
625
625
|
*/
|
|
626
|
-
|
|
626
|
+
/*
|
|
627
|
+
Digests this machine has taken before, read before the scan rather than
|
|
628
|
+
before the upload.
|
|
629
|
+
|
|
630
|
+
The uploader has always accepted these and the desktop has always passed
|
|
631
|
+
them, but on the command line the expensive part happens earlier: the
|
|
632
|
+
scan below reads and hashes the whole folder to decide what changed, and
|
|
633
|
+
that is where a 21,071-file project spent 169 of its 299 seconds. The
|
|
634
|
+
same map serves both — `changedFiles` fills it in place, so what the scan
|
|
635
|
+
learns is what the upload is handed.
|
|
636
|
+
*/
|
|
637
|
+
const knownDigests = link?.repositoryId
|
|
638
|
+
? await (0, config_js_1.readDigests)(link.repositoryId)
|
|
639
|
+
: new Map();
|
|
640
|
+
const files = await (0, worktree_js_1.changedFiles)(folder, rules, baseline, hasFlag(parsed, "sync") ? "synchronize" : "add-and-update", knownDigests);
|
|
627
641
|
/*
|
|
628
642
|
An upgraded folder with no materialisation record and something that
|
|
629
643
|
differs from its recorded version is ambiguous in the one way that
|
|
@@ -788,6 +802,7 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
|
|
|
788
802
|
*/
|
|
789
803
|
const uploadRequest = (0, publish_js_1.uploadRequestFor)({
|
|
790
804
|
localPath: folder,
|
|
805
|
+
...(knownDigests.size ? { knownDigests } : {}),
|
|
791
806
|
changed: files.filter((file) => !file.deleted).map((file) => file.path),
|
|
792
807
|
deleted: files.filter((file) => file.deleted).map((file) => file.path),
|
|
793
808
|
/*
|
|
@@ -994,6 +1009,15 @@ The connection failed: ${text}`));
|
|
|
994
1009
|
local: result.local,
|
|
995
1010
|
manifest: result.manifest,
|
|
996
1011
|
});
|
|
1012
|
+
/*
|
|
1013
|
+
What this save hashed, kept for the next one.
|
|
1014
|
+
|
|
1015
|
+
Merged rather than replaced: `digestsTaken` holds only the files actually
|
|
1016
|
+
read this time, and everything served from the existing record has to
|
|
1017
|
+
survive or the next save reads it all again — which is precisely the cost
|
|
1018
|
+
this exists to avoid.
|
|
1019
|
+
*/
|
|
1020
|
+
await (0, config_js_1.writeDigests)(result.repositoryId, new Map([...knownDigests, ...uploader.digestsTaken()]));
|
|
997
1021
|
if (result.repeated) {
|
|
998
1022
|
console.log(`Already saved as ${accent(`v${result.sequence}`)} by an earlier attempt;` +
|
|
999
1023
|
` nothing was sent again.`);
|
package/dist/cli/src/config.js
CHANGED
|
@@ -13,6 +13,8 @@ exports.keyFor = keyFor;
|
|
|
13
13
|
exports.readLink = readLink;
|
|
14
14
|
exports.forgetLink = forgetLink;
|
|
15
15
|
exports.writeLink = writeLink;
|
|
16
|
+
exports.readDigests = readDigests;
|
|
17
|
+
exports.writeDigests = writeDigests;
|
|
16
18
|
/**
|
|
17
19
|
* Where the command-line tool keeps its credential and its project links.
|
|
18
20
|
*
|
|
@@ -182,3 +184,24 @@ async function writeLink(localPath, link) {
|
|
|
182
184
|
};
|
|
183
185
|
await writePrivate(linksFile(), JSON.stringify(links, null, 2));
|
|
184
186
|
}
|
|
187
|
+
const digestsFile = (projectId) => node_path_1.default.join(configDirectory(), "digests", `${projectId}.json`);
|
|
188
|
+
async function readDigests(projectId) {
|
|
189
|
+
try {
|
|
190
|
+
const raw = await (0, promises_1.readFile)(digestsFile(projectId), "utf8");
|
|
191
|
+
return new Map(Object.entries(JSON.parse(raw)));
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
/* No record yet, or one this build cannot read: hash everything. */
|
|
195
|
+
return new Map();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function writeDigests(projectId, digests) {
|
|
199
|
+
if (!digests.size)
|
|
200
|
+
return;
|
|
201
|
+
try {
|
|
202
|
+
await writePrivate(digestsFile(projectId), JSON.stringify(Object.fromEntries(digests)));
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
/* The next save hashes again, which is where this started. */
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -114,6 +114,7 @@ const node_os_1 = __importDefault(require("node:os"));
|
|
|
114
114
|
const node_path_1 = __importDefault(require("node:path"));
|
|
115
115
|
const node_process_1 = __importDefault(require("node:process"));
|
|
116
116
|
const upload_js_1 = require("../../desktop-app/src/main/upload.js");
|
|
117
|
+
const stored_zip_js_1 = require("./stored_zip.js");
|
|
117
118
|
const tracks_js_1 = require("../../desktop-app/src/main/tracks.js");
|
|
118
119
|
const identify_js_1 = require("../../desktop-app/src/main/identify.js");
|
|
119
120
|
const config_js_1 = require("./config.js");
|
|
@@ -533,6 +534,17 @@ async function recordPushedCommits(marks) {
|
|
|
533
534
|
* Contents are deduplicated by SHA-256 across the whole history, so a file
|
|
534
535
|
* unchanged across a thousand versions is downloaded and sent to git once.
|
|
535
536
|
*/
|
|
537
|
+
/*
|
|
538
|
+
How many blobs a clone fetches at once, and how much it will hold doing it.
|
|
539
|
+
|
|
540
|
+
Eight lanes for the same reason the folder walks use eight: these are
|
|
541
|
+
latency, not work, and the measured curve flattens there. The byte ceiling
|
|
542
|
+
is what stops a window of large files becoming a window of memory — one
|
|
543
|
+
oversized file still goes through on its own, because the count check comes
|
|
544
|
+
first and an empty window always accepts one more.
|
|
545
|
+
*/
|
|
546
|
+
const BLOB_LANES = 8;
|
|
547
|
+
const BLOB_WINDOW_BYTES = 64 * 1024 * 1024;
|
|
536
548
|
async function doImport(refs, url) {
|
|
537
549
|
const { owner, slug } = (0, git_history_js_1.parseRemoteUrl)(url);
|
|
538
550
|
const project = await (0, api_js_1.findProject)(owner ? `${owner}/${slug}` : slug);
|
|
@@ -639,22 +651,99 @@ async function doImport(refs, url) {
|
|
|
639
651
|
let done = 0;
|
|
640
652
|
for (const version of fresh) {
|
|
641
653
|
const files = await downloader.files(repositoryId, version.id);
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
654
|
+
/*
|
|
655
|
+
Blobs first: fast-import requires a mark to exist before a commit
|
|
656
|
+
names it. Fetched a window at a time and written out in order.
|
|
657
|
+
|
|
658
|
+
This asked for one file, waited for it, wrote it to a scratch path,
|
|
659
|
+
read it back, and only then asked for the next — so a clone cost one
|
|
660
|
+
network round trip per file, in series. Against a 21,071-file project
|
|
661
|
+
that is 21,071 round trips at about seven tenths of a second each,
|
|
662
|
+
and a clone that never finished: measured over twenty seconds it was
|
|
663
|
+
using 0.17s of processor and moving two kilobytes.
|
|
664
|
+
|
|
665
|
+
The stream fast-import reads has to stay ordered, so the fetching is
|
|
666
|
+
what overlaps, not the writing. The window is bounded by count and by
|
|
667
|
+
bytes together, because sixteen source files and sixteen video files
|
|
668
|
+
are not the same amount to be holding at once.
|
|
669
|
+
*/
|
|
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
|
+
}
|
|
705
|
+
const seenBlob = new Set();
|
|
706
|
+
const wantedBlobs = files.filter((file) => {
|
|
707
|
+
if (blobMark.has(file.sha256) || seenBlob.has(file.sha256))
|
|
708
|
+
return false;
|
|
709
|
+
seenBlob.add(file.sha256);
|
|
710
|
+
return true;
|
|
711
|
+
});
|
|
712
|
+
for (let at = 0; at < wantedBlobs.length;) {
|
|
713
|
+
const window = [];
|
|
714
|
+
let held = 0;
|
|
715
|
+
while (at < wantedBlobs.length && window.length < BLOB_LANES) {
|
|
716
|
+
const size = Number(wantedBlobs[at].sourceSize ?? 0);
|
|
717
|
+
if (window.length > 0 && held + size > BLOB_WINDOW_BYTES)
|
|
718
|
+
break;
|
|
719
|
+
held += size;
|
|
720
|
+
window.push(wantedBlobs[at]);
|
|
721
|
+
at += 1;
|
|
722
|
+
}
|
|
723
|
+
const fetched = await Promise.all(window.map(async (file, lane) => {
|
|
724
|
+
/* Already in hand, if the archive carried it. */
|
|
725
|
+
const packed = fromArchive.get(file.path);
|
|
726
|
+
if (packed)
|
|
727
|
+
return { file, bytes: packed };
|
|
728
|
+
/*
|
|
729
|
+
A scratch path per lane. They shared one name, which is fine
|
|
730
|
+
while only one download is ever in flight and silently wrong
|
|
731
|
+
the moment two are.
|
|
732
|
+
*/
|
|
733
|
+
const target = node_path_1.default.join(scratch, `blob-${lane}.bin`);
|
|
734
|
+
await (0, promises_1.rm)(target, { force: true });
|
|
735
|
+
await downloader.fileTo(repositoryId, version.id, file, target);
|
|
736
|
+
return { file, bytes: await (0, promises_1.readFile)(target) };
|
|
737
|
+
}));
|
|
738
|
+
for (const { file, bytes } of fetched) {
|
|
739
|
+
const mark = nextMark++;
|
|
740
|
+
blobMark.set(file.sha256, mark);
|
|
741
|
+
send("blob");
|
|
742
|
+
send(`mark :${mark}`);
|
|
743
|
+
send(`data ${bytes.length}`);
|
|
744
|
+
node_process_1.default.stdout.write(bytes);
|
|
745
|
+
node_process_1.default.stdout.write("\n");
|
|
746
|
+
}
|
|
658
747
|
}
|
|
659
748
|
const mark = nextMark++;
|
|
660
749
|
commitMark.set(version.id, mark);
|
package/dist/cli/src/publish.js
CHANGED
|
@@ -23,6 +23,11 @@ function uploadRequestFor(input) {
|
|
|
23
23
|
? { expectedHeadVersionId: input.baseVersionId }
|
|
24
24
|
: {}),
|
|
25
25
|
...(input.known ? { known: input.known } : {}),
|
|
26
|
+
/*
|
|
27
|
+
Digests this machine has taken before, so an unchanged file is not read
|
|
28
|
+
again. The desktop has always passed these; the command line never did.
|
|
29
|
+
*/
|
|
30
|
+
...(input.knownDigests ? { knownDigests: input.knownDigests } : {}),
|
|
26
31
|
};
|
|
27
32
|
}
|
|
28
33
|
function classifyPublishFailure(error) {
|
package/dist/cli/src/runner.js
CHANGED
|
@@ -42,6 +42,11 @@ const progress_js_1 = require("./progress.js");
|
|
|
42
42
|
const HEARTBEAT_MS = 30_000;
|
|
43
43
|
/** How often log lines are sent up while a command is running. */
|
|
44
44
|
const FLUSH_MS = 2_000;
|
|
45
|
+
/**
|
|
46
|
+
* The same deadline the rest of the client uses, so nothing here can wait for
|
|
47
|
+
* ever on a request the service never answers.
|
|
48
|
+
*/
|
|
49
|
+
const REQUEST_DEADLINE_MS = 180_000;
|
|
45
50
|
async function call(route, options = {}) {
|
|
46
51
|
const token = await (0, config_js_1.loadToken)();
|
|
47
52
|
if (!token)
|
|
@@ -55,6 +60,7 @@ async function call(route, options = {}) {
|
|
|
55
60
|
...(options.body ? { "content-type": "application/json" } : {}),
|
|
56
61
|
},
|
|
57
62
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
63
|
+
signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
|
|
58
64
|
});
|
|
59
65
|
const text = await response.text();
|
|
60
66
|
const body = text ? JSON.parse(text) : {};
|
|
@@ -221,6 +227,7 @@ async function uploadArtifacts(repositoryId, runId, workspace, patterns, say) {
|
|
|
221
227
|
const stored = await fetch(`${(0, config_js_1.apiOrigin)()}/v1/repositories/${repositoryId}` +
|
|
222
228
|
`/objects/${digest}?kind=chunk&role=chunk`, {
|
|
223
229
|
method: "PUT",
|
|
230
|
+
signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
|
|
224
231
|
headers: {
|
|
225
232
|
authorization: `Bearer ${token}`,
|
|
226
233
|
"content-type": "application/octet-stream",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Reading the archive CodeRook builds, without a compression library.
|
|
4
|
+
*
|
|
5
|
+
* A clone fetched every file individually: one HTTP round trip per blob, in
|
|
6
|
+
* series. Against a 21,071-file project that is 21,071 round trips, which at
|
|
7
|
+
* seven tenths of a second each is four hours — and the clone that exposed
|
|
8
|
+
* this simply never finished. Fetching eight at a time cut it to about half
|
|
9
|
+
* an hour; asking for the version as one archive cuts it to one request.
|
|
10
|
+
*
|
|
11
|
+
* Written by hand for the same reason `backend/src/archive.ts` writes it by
|
|
12
|
+
* hand: the entries are *stored*, never deflated, so the whole job is a
|
|
13
|
+
* header, a body, and skipping the directory at the end. Pulling in a zip
|
|
14
|
+
* library to not decompress anything would be a poor trade.
|
|
15
|
+
*
|
|
16
|
+
* Only the format that writer produces is accepted. Anything else — a
|
|
17
|
+
* deflated entry, a size deferred to a data descriptor, a Zip64 field — is
|
|
18
|
+
* refused rather than guessed at, and the caller falls back to fetching the
|
|
19
|
+
* files one by one. A reader that quietly mangles a project is worse than a
|
|
20
|
+
* slow one.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.readStoredZip = readStoredZip;
|
|
24
|
+
const LOCAL_HEADER = 0x04034b50;
|
|
25
|
+
const CENTRAL_HEADER = 0x02014b50;
|
|
26
|
+
/* An archive of nothing has no entries and no directory records — it opens
|
|
27
|
+
with this, and a reader that only knew the other two would refuse it. */
|
|
28
|
+
const END_OF_DIRECTORY = 0x06054b50;
|
|
29
|
+
/** Set in the general-purpose flags when sizes come after the body. */
|
|
30
|
+
const SIZES_DEFERRED = 0x0008;
|
|
31
|
+
const STORED = 0;
|
|
32
|
+
/**
|
|
33
|
+
* Every entry in a stored archive, in the order it was written.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately whole-buffer rather than streaming: the caller is holding one
|
|
36
|
+
* version's files to hand to fast-import, which needs them in order anyway,
|
|
37
|
+
* and a version large enough for this to matter is one the archive path
|
|
38
|
+
* should not be taking.
|
|
39
|
+
*/
|
|
40
|
+
function readStoredZip(archive) {
|
|
41
|
+
const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength);
|
|
42
|
+
const names = new TextDecoder("utf-8", { fatal: false });
|
|
43
|
+
const entries = [];
|
|
44
|
+
let at = 0;
|
|
45
|
+
while (at + 4 <= archive.length) {
|
|
46
|
+
const signature = view.getUint32(at, true);
|
|
47
|
+
/* Either record marks the end of the entries. */
|
|
48
|
+
if (signature === CENTRAL_HEADER || signature === END_OF_DIRECTORY)
|
|
49
|
+
break;
|
|
50
|
+
if (signature !== LOCAL_HEADER) {
|
|
51
|
+
throw new Error(`Not a stored archive: unexpected record at byte ${at}`);
|
|
52
|
+
}
|
|
53
|
+
if (at + 30 > archive.length)
|
|
54
|
+
throw new Error("Archive ends inside a header");
|
|
55
|
+
const flags = view.getUint16(at + 6, true);
|
|
56
|
+
const method = view.getUint16(at + 8, true);
|
|
57
|
+
const compressed = view.getUint32(at + 18, true);
|
|
58
|
+
const nameLength = view.getUint16(at + 26, true);
|
|
59
|
+
const extraLength = view.getUint16(at + 28, true);
|
|
60
|
+
if (method !== STORED) {
|
|
61
|
+
throw new Error(`Archive entry is compressed (method ${method})`);
|
|
62
|
+
}
|
|
63
|
+
if (flags & SIZES_DEFERRED) {
|
|
64
|
+
throw new Error("Archive entry defers its size to a data descriptor");
|
|
65
|
+
}
|
|
66
|
+
/*
|
|
67
|
+
0xffffffff is Zip64's marker for "the real size is in an extra field".
|
|
68
|
+
This writer never emits one, so seeing it means the archive did not come
|
|
69
|
+
from where it is assumed to have come from.
|
|
70
|
+
*/
|
|
71
|
+
if (compressed === 0xffffffff)
|
|
72
|
+
throw new Error("Archive entry needs Zip64");
|
|
73
|
+
const nameAt = at + 30;
|
|
74
|
+
const bodyAt = nameAt + nameLength + extraLength;
|
|
75
|
+
const end = bodyAt + compressed;
|
|
76
|
+
if (end > archive.length)
|
|
77
|
+
throw new Error("Archive ends inside an entry");
|
|
78
|
+
entries.push({
|
|
79
|
+
path: names.decode(archive.subarray(nameAt, nameAt + nameLength)),
|
|
80
|
+
bytes: archive.subarray(bodyAt, end),
|
|
81
|
+
});
|
|
82
|
+
at = end;
|
|
83
|
+
}
|
|
84
|
+
return entries;
|
|
85
|
+
}
|
|
@@ -71,7 +71,11 @@ async function readJson(url, token, forge) {
|
|
|
71
71
|
headers.authorization =
|
|
72
72
|
forge.kind === "gitlab" ? `Bearer ${token}` : `token ${token}`;
|
|
73
73
|
}
|
|
74
|
-
|
|
74
|
+
/* A forge that stops answering must not hold the import open for ever. */
|
|
75
|
+
const response = await fetch(url, {
|
|
76
|
+
headers,
|
|
77
|
+
signal: AbortSignal.timeout(60_000),
|
|
78
|
+
});
|
|
75
79
|
if (!response.ok) {
|
|
76
80
|
throw new Error(`${response.status} from ${new URL(url).pathname}`);
|
|
77
81
|
}
|
|
@@ -265,9 +265,16 @@ function suggestExclusions(files) {
|
|
|
265
265
|
Merged in here rather than offered through a second channel, so everything
|
|
266
266
|
that shows a suggestion keeps working unchanged. A folder already named by
|
|
267
267
|
a rule above is dropped: it has a better reason than "no source in it".
|
|
268
|
+
|
|
269
|
+
Matched on the folder, not on how the line is written. The rules emit an
|
|
270
|
+
anchored `/Library/` and this emits `Library/`, so comparing the patterns
|
|
271
|
+
as text found no overlap and a Unity project was offered its own import
|
|
272
|
+
cache twice over — once as "the editor rebuilds this" and once, below it,
|
|
273
|
+
as "37,000 files and no source in it".
|
|
268
274
|
*/
|
|
269
|
-
const
|
|
270
|
-
const
|
|
275
|
+
const asFolder = (pattern) => pattern.replace(/^\/+|\/+$/g, "").toLowerCase();
|
|
276
|
+
const already = new Set(named.map((one) => asFolder(one.pattern)));
|
|
277
|
+
const bulk = suggestBulkDirectories(files).filter((one) => !already.has(asFolder(one.pattern)));
|
|
271
278
|
return [...named, ...bulk].sort((left, right) => right.bytes - left.bytes);
|
|
272
279
|
}
|
|
273
280
|
/*
|
|
@@ -61,6 +61,23 @@ async function pruneEmpty(root, removed) {
|
|
|
61
61
|
directory = node_path_1.default.dirname(directory);
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* How long one request may go unanswered before it is treated as lost.
|
|
66
|
+
*
|
|
67
|
+
* The same three minutes the uploader uses, and for the same reason: what
|
|
68
|
+
* follows a deadline here is a retry, so the cost of it firing early on a
|
|
69
|
+
* genuinely slow call is one repeated request.
|
|
70
|
+
*/
|
|
71
|
+
const REQUEST_DEADLINE_MS = 180_000;
|
|
72
|
+
/**
|
|
73
|
+
* And how long a whole-version archive may take to arrive.
|
|
74
|
+
*
|
|
75
|
+
* The service reads every file in the version out of object storage to build
|
|
76
|
+
* it, so the wait is proportional to the project rather than to the network.
|
|
77
|
+
* Fifteen minutes is far beyond any archive measured and still an answer
|
|
78
|
+
* rather than an indefinite wait.
|
|
79
|
+
*/
|
|
80
|
+
const ARCHIVE_DEADLINE_MS = 900_000;
|
|
64
81
|
/**
|
|
65
82
|
* A percentage for work that is still going.
|
|
66
83
|
*
|
|
@@ -127,7 +144,18 @@ class Downloader {
|
|
|
127
144
|
}
|
|
128
145
|
}
|
|
129
146
|
}
|
|
130
|
-
async attempt(route
|
|
147
|
+
async attempt(route,
|
|
148
|
+
/*
|
|
149
|
+
Longer where the service has to build the answer before it can send it.
|
|
150
|
+
|
|
151
|
+
A deadline sized for an ordinary request aborts a large archive halfway
|
|
152
|
+
through making it, and the git import quietly falls back to fetching
|
|
153
|
+
every file on its own — so the fix for a slow clone became the cause of
|
|
154
|
+
one. The archive for a 21,071-file version takes about ninety seconds
|
|
155
|
+
to assemble and longer under load; three minutes is a request that has
|
|
156
|
+
failed, not one that is still working.
|
|
157
|
+
*/
|
|
158
|
+
deadline = REQUEST_DEADLINE_MS) {
|
|
131
159
|
const token = await this.credentials.token();
|
|
132
160
|
if (!token)
|
|
133
161
|
throw new Error("Sign in again before downloading");
|
|
@@ -137,7 +165,19 @@ class Downloader {
|
|
|
137
165
|
"user-agent": "CodeRook/0.1",
|
|
138
166
|
...(0, identify_js_1.clientHeaders)(),
|
|
139
167
|
},
|
|
140
|
-
|
|
168
|
+
/*
|
|
169
|
+
The download's own cancellation, and a deadline.
|
|
170
|
+
|
|
171
|
+
This carried only the cancel signal, so a request the service never
|
|
172
|
+
answered waited for ever — the same defect that was fixed in the
|
|
173
|
+
uploader and in the command line's own client, and missed here. A
|
|
174
|
+
restore that stops with no bytes moving and no message is the worse
|
|
175
|
+
half of it: the person is left without the thing they were restoring.
|
|
176
|
+
*/
|
|
177
|
+
signal: AbortSignal.any([
|
|
178
|
+
this.controller.signal,
|
|
179
|
+
AbortSignal.timeout(deadline),
|
|
180
|
+
]),
|
|
141
181
|
});
|
|
142
182
|
if (!response.ok) {
|
|
143
183
|
const text = await response.text().catch(() => "");
|
|
@@ -217,6 +257,22 @@ class Downloader {
|
|
|
217
257
|
authorName: String(row.authorName ?? ""),
|
|
218
258
|
}));
|
|
219
259
|
}
|
|
260
|
+
/**
|
|
261
|
+
* A whole version as one archive, for a caller that wants all of it.
|
|
262
|
+
*
|
|
263
|
+
* The git import asked for each file in its own request, which on a
|
|
264
|
+
* 21,071-file project is 21,071 round trips — four hours in series, and
|
|
265
|
+
* about half an hour even eight at a time. The service already builds the
|
|
266
|
+
* whole version as a single stored archive for the download button; this
|
|
267
|
+
* is that, for a caller that would otherwise ask file by file.
|
|
268
|
+
*
|
|
269
|
+
* Returned whole rather than streamed because the caller is handing the
|
|
270
|
+
* files to `git fast-import`, which wants them one after another anyway.
|
|
271
|
+
*/
|
|
272
|
+
async archive(repositoryId, versionId) {
|
|
273
|
+
const response = await this.attempt(`/v1/repositories/${repositoryId}/versions/${versionId}/archive`, ARCHIVE_DEADLINE_MS);
|
|
274
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
275
|
+
}
|
|
220
276
|
async files(repositoryId, versionId) {
|
|
221
277
|
const body = await this.json(`/v1/repositories/${repositoryId}/versions/${versionId}/files`);
|
|
222
278
|
return (body.files ?? []).map((row) => {
|
|
@@ -200,39 +200,7 @@ function decide(relativePath, isDirectory, layers) {
|
|
|
200
200
|
}
|
|
201
201
|
return found;
|
|
202
202
|
}
|
|
203
|
-
|
|
204
|
-
* Whether these layers exclude the path, negations included.
|
|
205
|
-
*
|
|
206
|
-
* An excluded directory takes everything under it, whatever a later negation
|
|
207
|
-
* says. This is git's rule — "it is not possible to re-include a file if a
|
|
208
|
-
* parent directory of that file is excluded" — and it is followed here for a
|
|
209
|
-
* plain reason: the file is called .gitignore, git reads it too, and a rule
|
|
210
|
-
* that means one thing to one reader and something else to the other is worse
|
|
211
|
-
* than either behaviour on its own.
|
|
212
|
-
*
|
|
213
|
-
* It was the other way once, deliberately, on the reasoning that somebody
|
|
214
|
-
* writing `!` meant it. What that produced was a project excluding a folder
|
|
215
|
-
* and publishing two files out of it anyway, because an unrelated
|
|
216
|
-
* `!.env.example` further down reached inside — git had ignored them, so
|
|
217
|
-
* nothing else in the toolchain gave any sign. Being more permissive than git
|
|
218
|
-
* is the dangerous direction to differ in: it uploads what somebody thought
|
|
219
|
-
* they had excluded.
|
|
220
|
-
*/
|
|
221
|
-
/**
|
|
222
|
-
* The rule that settles this path, counting the directories above it.
|
|
223
|
-
*
|
|
224
|
-
* A directory rule is written about the directory — `/[Ll]ibrary/` matches
|
|
225
|
-
* `Library`, not `Library/Artifacts/f1.dat` — so anything asking only about
|
|
226
|
-
* the file finds no rule and calls it included. The upload has always walked
|
|
227
|
-
* the ancestors; the filter screen asked about the file alone, and so showed
|
|
228
|
-
* a Unity project's whole import cache as going up when the save would have
|
|
229
|
-
* left every byte of it behind.
|
|
230
|
-
*
|
|
231
|
-
* Returned as the rule rather than a yes or no, because the screen names the
|
|
232
|
-
* line responsible — "Library/ — 400 files" is the useful form, and a boolean
|
|
233
|
-
* cannot say it.
|
|
234
|
-
*/
|
|
235
|
-
function decidingRule(relativePath, isDirectory, layers) {
|
|
203
|
+
function decidingRule(relativePath, isDirectory, layers, remembered) {
|
|
236
204
|
/*
|
|
237
205
|
Ancestors first, outermost in. The moment one is excluded the answer is
|
|
238
206
|
settled and nothing below it can appeal.
|
|
@@ -240,7 +208,14 @@ function decidingRule(relativePath, isDirectory, layers) {
|
|
|
240
208
|
const parts = relativePath.split("/");
|
|
241
209
|
for (let depth = 1; depth < parts.length; depth += 1) {
|
|
242
210
|
const ancestor = parts.slice(0, depth).join("/");
|
|
243
|
-
|
|
211
|
+
let above;
|
|
212
|
+
if (remembered && remembered.has(ancestor)) {
|
|
213
|
+
above = remembered.get(ancestor) ?? null;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
above = decide(ancestor, true, layers);
|
|
217
|
+
remembered?.set(ancestor, above);
|
|
218
|
+
}
|
|
244
219
|
if (above && !above.negated)
|
|
245
220
|
return above;
|
|
246
221
|
}
|
|
@@ -250,6 +225,29 @@ function excludes(relativePath, isDirectory, layers) {
|
|
|
250
225
|
const rule = decidingRule(relativePath, isDirectory, layers);
|
|
251
226
|
return Boolean(rule && !rule.negated);
|
|
252
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* Whether an anchored pattern could name anything inside this directory.
|
|
230
|
+
*
|
|
231
|
+
* Walked a segment at a time against the directory's own segments. A `**`
|
|
232
|
+
* makes everything below it possible and settles the question; a segment that
|
|
233
|
+
* simply does not match settles it the other way. Where the pattern runs out
|
|
234
|
+
* first it is left as possible, because this only ever needs to be certain
|
|
235
|
+
* when it says no.
|
|
236
|
+
*/
|
|
237
|
+
function couldDescendTo(pattern, directory) {
|
|
238
|
+
const wanted = pattern.replace(/^\/+/, "").replace(/\/+$/, "").split("/");
|
|
239
|
+
const here = directory.split("/").filter(Boolean);
|
|
240
|
+
for (let at = 0; at < here.length; at += 1) {
|
|
241
|
+
const part = wanted[at];
|
|
242
|
+
if (part === undefined)
|
|
243
|
+
return true;
|
|
244
|
+
if (part === "**")
|
|
245
|
+
return true;
|
|
246
|
+
if (!matcher(part).test(here[at]))
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
253
251
|
/**
|
|
254
252
|
* Whether a negation could rescue something inside this directory.
|
|
255
253
|
*
|
|
@@ -269,7 +267,22 @@ function negationReachesInto(relativePath, layers) {
|
|
|
269
267
|
// A bare glob is matched against every segment, so it reaches anywhere.
|
|
270
268
|
if (!rule.anchored && !rule.pattern.includes("/"))
|
|
271
269
|
return true;
|
|
272
|
-
// A
|
|
270
|
+
// A ** used to end the question here, and that one line was the
|
|
271
|
+
// reason a scan read every directory in the project.
|
|
272
|
+
//
|
|
273
|
+
// The starter carries the negation that keeps Unity's meta files, and
|
|
274
|
+
// it is written with a ** in the middle, so `includes` said yes to
|
|
275
|
+
// it for every directory on disk. `Library/`, excluded and 45,000
|
|
276
|
+
// files deep, was descended into on every scan of every Unity
|
|
277
|
+
// project, because a rule about `Assets` might conceivably have
|
|
278
|
+
// reached in.
|
|
279
|
+
//
|
|
280
|
+
// It cannot: the pattern is anchored and its first segment is
|
|
281
|
+
// `[Aa]ssets`, which does not match `Library`. Comparing segment by
|
|
282
|
+
// segment answers that exactly, and stops only where a **
|
|
283
|
+
// genuinely makes the rest unknowable.
|
|
284
|
+
if (!couldDescendTo(`${prefix}${rule.pattern}`, relativePath))
|
|
285
|
+
continue;
|
|
273
286
|
if (rule.pattern.includes("**"))
|
|
274
287
|
return true;
|
|
275
288
|
const target = `${prefix}${rule.pattern.replace(/\/+$/, "")}`;
|