@coderook/cli 0.12.0 → 0.14.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/README.md +71 -2
- package/dist/cli/src/cli.js +167 -10
- package/dist/cli/src/mcp.js +349 -0
- package/dist/cli/src/track_commands.js +157 -0
- package/dist/desktop-app/src/main/compress.js +95 -0
- package/dist/desktop-app/src/main/detect.js +137 -0
- package/dist/desktop-app/src/main/download.js +162 -15
- package/dist/desktop-app/src/main/profile.js +73 -0
- package/dist/desktop-app/src/main/retry.js +96 -0
- package/dist/desktop-app/src/main/solid.js +103 -0
- package/dist/desktop-app/src/main/staging.js +140 -0
- package/dist/desktop-app/src/main/tracks.js +132 -0
- package/dist/desktop-app/src/main/upload.js +1039 -55
- package/dist/desktop-app/src/main/worktree.js +214 -25
- package/package.json +1 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Knowing the whole project before uploading any of it, then sending it in
|
|
4
|
+
* bounded stages.
|
|
5
|
+
*
|
|
6
|
+
* Two separate failures made this necessary.
|
|
7
|
+
*
|
|
8
|
+
* The first was silent. `changedFiles` stops after twenty thousand rows —
|
|
9
|
+
* correctly, because that is a limit on what a list can usefully show — and
|
|
10
|
+
* the upload used the same function to decide what to send. A project past
|
|
11
|
+
* that many files produced a version containing whatever the walk reached
|
|
12
|
+
* first, with nothing anywhere saying the rest existed. A limit meant for a
|
|
13
|
+
* scrollbar was silently deciding what got backed up.
|
|
14
|
+
*
|
|
15
|
+
* The second was unbounded work. Everything was sent in one pass, so memory,
|
|
16
|
+
* progress and the blast radius of an interruption all scaled with the size of
|
|
17
|
+
* the project. A ten gigabyte upload was one attempt that either finished or
|
|
18
|
+
* did not.
|
|
19
|
+
*
|
|
20
|
+
* So: survey everything first, cheaply and without a cap, then divide it into
|
|
21
|
+
* sections with a fixed byte budget. A small project is one section and behaves
|
|
22
|
+
* exactly as it did. A large one is many, each finishing before the next
|
|
23
|
+
* begins, which keeps the working set the same size whatever the project is.
|
|
24
|
+
*/
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.SECTION_FILES = exports.SECTION_BYTES = exports.DIRECT_LIMIT = exports.CHUNK_THRESHOLD = exports.PACKABLE_LIMIT = void 0;
|
|
27
|
+
exports.bandOf = bandOf;
|
|
28
|
+
exports.survey = survey;
|
|
29
|
+
exports.planSections = planSections;
|
|
30
|
+
exports.describePlan = describePlan;
|
|
31
|
+
/**
|
|
32
|
+
* How each file travels, decided by size alone.
|
|
33
|
+
*
|
|
34
|
+
* The bands are the existing routes named: small files compress together into
|
|
35
|
+
* a pack, ordinary ones go as their own object, large ones are cut into
|
|
36
|
+
* content-defined chunks, and anything past the direct limit needs a multipart
|
|
37
|
+
* session. Naming them here means the plan can be inspected before a byte
|
|
38
|
+
* moves.
|
|
39
|
+
*/
|
|
40
|
+
exports.PACKABLE_LIMIT = 64 * 1024;
|
|
41
|
+
exports.CHUNK_THRESHOLD = 16 * 1024 * 1024;
|
|
42
|
+
exports.DIRECT_LIMIT = 95 * 1024 * 1024;
|
|
43
|
+
function bandOf(size) {
|
|
44
|
+
if (size <= exports.PACKABLE_LIMIT)
|
|
45
|
+
return "packable";
|
|
46
|
+
if (size <= exports.CHUNK_THRESHOLD)
|
|
47
|
+
return "ordinary";
|
|
48
|
+
if (size <= exports.DIRECT_LIMIT)
|
|
49
|
+
return "chunked";
|
|
50
|
+
return "multipart";
|
|
51
|
+
}
|
|
52
|
+
function survey(files) {
|
|
53
|
+
const bands = { packable: 0, ordinary: 0, chunked: 0, multipart: 0 };
|
|
54
|
+
let totalBytes = 0;
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
bands[bandOf(file.size)] += 1;
|
|
57
|
+
totalBytes += file.size;
|
|
58
|
+
}
|
|
59
|
+
return { files, totalBytes, bands };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* How much source a single section may carry.
|
|
63
|
+
*
|
|
64
|
+
* Chosen so the working set stays the same whatever the project is: a section
|
|
65
|
+
* is read, packed, sent and finished before the next one starts, so a thirty
|
|
66
|
+
* gigabyte upload holds no more in memory than a two hundred megabyte one. Big
|
|
67
|
+
* enough that a small project is still one section and behaves exactly as it
|
|
68
|
+
* did before any of this existed.
|
|
69
|
+
*/
|
|
70
|
+
exports.SECTION_BYTES = 256 * 1024 * 1024;
|
|
71
|
+
/** How many files one section may carry, however small they are. */
|
|
72
|
+
exports.SECTION_FILES = 4000;
|
|
73
|
+
/**
|
|
74
|
+
* Divide a survey into sections.
|
|
75
|
+
*
|
|
76
|
+
* Files are grouped by band first, so a section is mostly one kind of work —
|
|
77
|
+
* a pack of small files, or a run of large ones — rather than a mixture that
|
|
78
|
+
* makes progress lurch. Within a band they keep their order, so a resumed
|
|
79
|
+
* upload repeats the same plan and skips the same completed sections.
|
|
80
|
+
*
|
|
81
|
+
* A file larger than the whole budget gets a section to itself. It cannot be
|
|
82
|
+
* split across sections, and pretending otherwise would either overflow the
|
|
83
|
+
* budget silently or strand the file.
|
|
84
|
+
*/
|
|
85
|
+
function planSections(files, budget = exports.SECTION_BYTES, fileLimit = exports.SECTION_FILES) {
|
|
86
|
+
const order = ["packable", "ordinary", "chunked", "multipart"];
|
|
87
|
+
const grouped = new Map();
|
|
88
|
+
for (const file of files) {
|
|
89
|
+
const band = bandOf(file.size);
|
|
90
|
+
const held = grouped.get(band) ?? [];
|
|
91
|
+
held.push(file);
|
|
92
|
+
grouped.set(band, held);
|
|
93
|
+
}
|
|
94
|
+
const sections = [];
|
|
95
|
+
let current = [];
|
|
96
|
+
let bytes = 0;
|
|
97
|
+
const close = () => {
|
|
98
|
+
if (!current.length)
|
|
99
|
+
return;
|
|
100
|
+
sections.push({ index: sections.length, files: current, bytes });
|
|
101
|
+
current = [];
|
|
102
|
+
bytes = 0;
|
|
103
|
+
};
|
|
104
|
+
for (const band of order) {
|
|
105
|
+
for (const file of grouped.get(band) ?? []) {
|
|
106
|
+
/*
|
|
107
|
+
One file bigger than the budget is its own section. Splitting it here
|
|
108
|
+
would mean a section that overflows or a file that never fits, and the
|
|
109
|
+
chunker below the plan already knows how to send it in pieces.
|
|
110
|
+
*/
|
|
111
|
+
if (file.size >= budget) {
|
|
112
|
+
close();
|
|
113
|
+
sections.push({ index: sections.length, files: [file], bytes: file.size });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (bytes + file.size > budget || current.length >= fileLimit)
|
|
117
|
+
close();
|
|
118
|
+
current.push(file);
|
|
119
|
+
bytes += file.size;
|
|
120
|
+
}
|
|
121
|
+
/*
|
|
122
|
+
Sections do not straddle bands. A section of small files and a section of
|
|
123
|
+
large ones are different shapes of work, and mixing them makes the time
|
|
124
|
+
one takes unpredictable from its size.
|
|
125
|
+
*/
|
|
126
|
+
close();
|
|
127
|
+
}
|
|
128
|
+
close();
|
|
129
|
+
return sections;
|
|
130
|
+
}
|
|
131
|
+
/** A short description of the plan, for somebody deciding whether to start. */
|
|
132
|
+
function describePlan(surveyed, sections) {
|
|
133
|
+
const mb = (bytes) => `${(bytes / 1048576).toFixed(1)} MB`;
|
|
134
|
+
return [
|
|
135
|
+
`${surveyed.files.length} files, ${mb(surveyed.totalBytes)}`,
|
|
136
|
+
`${sections.length} section${sections.length === 1 ? "" : "s"}`,
|
|
137
|
+
`${surveyed.bands.packable} packed, ${surveyed.bands.ordinary} ordinary, ` +
|
|
138
|
+
`${surveyed.bands.chunked} chunked, ${surveyed.bands.multipart} multipart`,
|
|
139
|
+
].join(" · ");
|
|
140
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Tracks = void 0;
|
|
4
|
+
const identify_js_1 = require("./identify.js");
|
|
5
|
+
const retry_js_1 = require("./retry.js");
|
|
6
|
+
class Tracks {
|
|
7
|
+
credentials;
|
|
8
|
+
constructor(credentials) {
|
|
9
|
+
this.credentials = credentials;
|
|
10
|
+
}
|
|
11
|
+
async call(route, init) {
|
|
12
|
+
let wait = retry_js_1.RETRY_FIRST_WAIT_MS;
|
|
13
|
+
for (let attempt = 1;; attempt += 1) {
|
|
14
|
+
try {
|
|
15
|
+
return await this.attempt(route, init);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (attempt >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error))
|
|
19
|
+
throw error;
|
|
20
|
+
await (0, retry_js_1.pauseFor)(AbortSignal.timeout(wait * 4), wait).catch(() => undefined);
|
|
21
|
+
wait *= 2;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async attempt(route, init) {
|
|
26
|
+
const token = await this.credentials.token();
|
|
27
|
+
if (!token)
|
|
28
|
+
throw new Error("Sign in again");
|
|
29
|
+
const response = await fetch(`${this.credentials.origin()}${route}`, {
|
|
30
|
+
method: init?.method ?? "GET",
|
|
31
|
+
headers: {
|
|
32
|
+
accept: "application/json",
|
|
33
|
+
authorization: `Bearer ${token}`,
|
|
34
|
+
"user-agent": "CodeRook/0.1",
|
|
35
|
+
...(0, identify_js_1.clientHeaders)(),
|
|
36
|
+
...(init?.body ? { "content-type": "application/json" } : {}),
|
|
37
|
+
},
|
|
38
|
+
...(init?.body ? { body: init.body } : {}),
|
|
39
|
+
});
|
|
40
|
+
const text = await response.text();
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
let message = `${route} failed (${response.status})`;
|
|
43
|
+
if (text.trimStart().startsWith("{")) {
|
|
44
|
+
try {
|
|
45
|
+
message =
|
|
46
|
+
JSON.parse(text).error
|
|
47
|
+
?.message ?? message;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* keep the generic message */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
throw Object.assign(new Error(message), { status: response.status });
|
|
54
|
+
}
|
|
55
|
+
return (text ? JSON.parse(text) : {});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Every line and merge this project has.
|
|
59
|
+
*
|
|
60
|
+
* Answers an empty list rather than throwing when the account cannot be
|
|
61
|
+
* reached: the tracks panel is context, and a project that cannot be
|
|
62
|
+
* examined offline should still open.
|
|
63
|
+
*/
|
|
64
|
+
async list(repositoryId) {
|
|
65
|
+
try {
|
|
66
|
+
const body = await this.call(`/v1/repositories/${repositoryId}/tracks`);
|
|
67
|
+
return body.tracks ?? [];
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Start a new line, from wherever the project currently is.
|
|
75
|
+
*
|
|
76
|
+
* The service decides the starting point and refuses a name it cannot
|
|
77
|
+
* accept, so this passes the name through rather than pre-judging it —
|
|
78
|
+
* a rule enforced in two places is a rule that will disagree with itself.
|
|
79
|
+
*/
|
|
80
|
+
async create(repositoryId, name) {
|
|
81
|
+
const body = await this.call(`/v1/repositories/${repositoryId}/tracks`, { method: "POST", body: JSON.stringify({ name }) });
|
|
82
|
+
return body.track;
|
|
83
|
+
}
|
|
84
|
+
/** Merges that have not been applied or abandoned. */
|
|
85
|
+
async merges(repositoryId) {
|
|
86
|
+
try {
|
|
87
|
+
const body = await this.call(`/v1/repositories/${repositoryId}/merge-tracks`);
|
|
88
|
+
return (body.mergeTracks ?? []).filter((one) => one.state === "open" || one.state === "applying");
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** One merge and every path it is waiting on. */
|
|
95
|
+
async merge(mergeTrackId) {
|
|
96
|
+
const body = await this.call(`/v1/merge-tracks/${mergeTrackId}`);
|
|
97
|
+
const { conflicts = [], ...track } = body;
|
|
98
|
+
return { track: track, conflicts };
|
|
99
|
+
}
|
|
100
|
+
/** Settle one path. */
|
|
101
|
+
async resolve(mergeTrackId, conflictId, resolution) {
|
|
102
|
+
await this.call(`/v1/merge-tracks/${mergeTrackId}/conflicts/${conflictId}`, {
|
|
103
|
+
method: "PUT",
|
|
104
|
+
body: JSON.stringify({ resolution }),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Publish the merge, once every path has been settled.
|
|
109
|
+
*
|
|
110
|
+
* The service refuses while anything is unresolved, which is the check that
|
|
111
|
+
* matters — this is only the request.
|
|
112
|
+
*/
|
|
113
|
+
async apply(mergeTrackId) {
|
|
114
|
+
return this.call(`/v1/merge-tracks/${mergeTrackId}/apply`, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
body: "{}",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Abandon it.
|
|
121
|
+
*
|
|
122
|
+
* The candidate's versions are not deleted — they stay on their own track,
|
|
123
|
+
* so nothing that was uploaded is lost by deciding not to merge it.
|
|
124
|
+
*/
|
|
125
|
+
async cancel(mergeTrackId) {
|
|
126
|
+
await this.call(`/v1/merge-tracks/${mergeTrackId}/cancel`, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
body: "{}",
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
exports.Tracks = Tracks;
|