@coderook/cli 0.27.0 → 0.29.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 +155 -1
- package/dist/cli/src/attach_command.js +217 -0
- package/dist/cli/src/cli.js +154 -8
- package/dist/cli/src/git_remote.js +11 -5
- package/dist/cli/src/service_commands.js +201 -1
- package/dist/desktop-app/src/main/detect.js +55 -4
- package/dist/desktop-app/src/main/tracks.js +153 -6
- package/dist/desktop-app/src/main/upload.js +9 -1
- package/dist/desktop-app/src/main/worktree.js +167 -99
- package/package.json +1 -1
|
@@ -1045,12 +1045,18 @@ async function doPush(requests, url) {
|
|
|
1045
1045
|
continue;
|
|
1046
1046
|
}
|
|
1047
1047
|
/*
|
|
1048
|
-
Said before the work starts, not after. Each commit
|
|
1049
|
-
|
|
1048
|
+
Said before the work starts, not after. Each commit becomes a save on
|
|
1049
|
+
the project and saves are not free to make, so somebody pushing years
|
|
1050
1050
|
of history deserves the chance to stop and import a snapshot instead.
|
|
1051
|
+
|
|
1052
|
+
"to publish as versions" is what this used to say, and it stopped
|
|
1053
|
+
being true when commits and versions separated: a pushed commit is a
|
|
1054
|
+
save that nobody outside the project can see. Publishing one is what
|
|
1055
|
+
pushing a *tag* does, because a tag is a name and a named save is a
|
|
1056
|
+
version.
|
|
1051
1057
|
*/
|
|
1052
1058
|
const estimate = Math.round((commits.length * 11) / 60);
|
|
1053
|
-
say(` ${commits.length} commit${commits.length === 1 ? "" : "s"} to
|
|
1059
|
+
say(` ${commits.length} commit${commits.length === 1 ? "" : "s"} to send as save${commits.length === 1 ? "" : "s"}` +
|
|
1054
1060
|
(commits.length > 20 ? ` — roughly ${estimate} minute${estimate === 1 ? "" : "s"}` : ""));
|
|
1055
1061
|
/*
|
|
1056
1062
|
A push that continues a branch has to start from where the branch
|
|
@@ -1114,8 +1120,8 @@ async function doPush(requests, url) {
|
|
|
1114
1120
|
.slice(1);
|
|
1115
1121
|
if (parents.length > 1 && !warnedAboutMerges) {
|
|
1116
1122
|
warnedAboutMerges = true;
|
|
1117
|
-
say(` note: merge
|
|
1118
|
-
`
|
|
1123
|
+
say(` note: a merge commit arrives as one save holding the merged ` +
|
|
1124
|
+
`tree.\n The contents are exact; the branch shape is not kept.`);
|
|
1119
1125
|
}
|
|
1120
1126
|
const changes = await applyCommit(catFile, scratch, previous, sha);
|
|
1121
1127
|
previous = sha;
|
|
@@ -21,6 +21,7 @@ exports.commandRuns = commandRuns;
|
|
|
21
21
|
exports.commandLogs = commandLogs;
|
|
22
22
|
exports.commandTokens = commandTokens;
|
|
23
23
|
exports.commandDelete = commandDelete;
|
|
24
|
+
exports.commandHooks = commandHooks;
|
|
24
25
|
const node_process_1 = __importDefault(require("node:process"));
|
|
25
26
|
const promises_1 = require("node:readline/promises");
|
|
26
27
|
const api_js_1 = require("./api.js");
|
|
@@ -51,11 +52,47 @@ async function commandIssues(parsed) {
|
|
|
51
52
|
}
|
|
52
53
|
if (typeof title === "string" && title.trim()) {
|
|
53
54
|
const body = parsed.flags.get("body");
|
|
55
|
+
/*
|
|
56
|
+
Labels at the moment it is opened, rather than in a second command
|
|
57
|
+
afterwards. The service has always taken them here; nothing had ever
|
|
58
|
+
sent any, so every issue arrived unlabelled and the label filter on the
|
|
59
|
+
website had nothing to filter by until somebody went back and tidied.
|
|
60
|
+
*/
|
|
61
|
+
const named = parsed.flags.get("labels");
|
|
62
|
+
const labels = typeof named === "string"
|
|
63
|
+
? named
|
|
64
|
+
.split(",")
|
|
65
|
+
.map((one) => one.trim())
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
: [];
|
|
68
|
+
/*
|
|
69
|
+
And which save it is about, when it is about one. "It broke" and "it
|
|
70
|
+
broke in v41" are different reports, and only the first could be
|
|
71
|
+
written down.
|
|
72
|
+
*/
|
|
73
|
+
const about = parsed.flags.get("version");
|
|
74
|
+
let versionId;
|
|
75
|
+
if (typeof about === "string" && about.trim()) {
|
|
76
|
+
const wanted = about.trim().replace(/^v/i, "");
|
|
77
|
+
const all = await (0, api_js_1.versions)(project.id);
|
|
78
|
+
const found = all.find((one) => String(one.sequence) === wanted);
|
|
79
|
+
if (!found) {
|
|
80
|
+
console.error(red(`${project.name} has no version ${about.trim()}.`));
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
versionId = found.id;
|
|
84
|
+
}
|
|
54
85
|
const created = await (0, api_js_1.createIssue)(project.id, {
|
|
55
86
|
title: title.trim(),
|
|
56
87
|
body: typeof body === "string" ? body : "",
|
|
88
|
+
labels,
|
|
89
|
+
...(versionId ? { versionId } : {}),
|
|
57
90
|
});
|
|
58
91
|
console.log(green(`Opened issue #${created.number} on ${project.name}.`));
|
|
92
|
+
if (labels.length)
|
|
93
|
+
console.log(dim(` labelled ${labels.join(", ")}`));
|
|
94
|
+
if (versionId)
|
|
95
|
+
console.log(dim(` about v${about}`));
|
|
59
96
|
return 0;
|
|
60
97
|
}
|
|
61
98
|
const found = await (0, api_js_1.issues)(project.id);
|
|
@@ -216,10 +253,67 @@ async function commandWatch(parsed) {
|
|
|
216
253
|
}
|
|
217
254
|
/** The automations a project has, and how they have been going. */
|
|
218
255
|
async function commandWorkflows(parsed) {
|
|
219
|
-
const project = await (0, project_commands_js_1.resolveProject)(parsed.
|
|
256
|
+
const project = await (0, project_commands_js_1.resolveProject)(typeof parsed.flags.get("project") === "string"
|
|
257
|
+
? String(parsed.flags.get("project"))
|
|
258
|
+
: parsed.positional[0]);
|
|
220
259
|
if (!project)
|
|
221
260
|
return 1;
|
|
222
261
|
const found = (await (0, api_js_1.workflows)(project.id)).filter((workflow) => !workflow.archivedAt);
|
|
262
|
+
/*
|
|
263
|
+
Setting, before printing. `cbx actions --ship Build` reads as a sentence
|
|
264
|
+
and then shows the list it has just changed, which is the same shape
|
|
265
|
+
`cbx notes` uses: one command, print by default, flags to write.
|
|
266
|
+
*/
|
|
267
|
+
const ship = parsed.flags.get("ship");
|
|
268
|
+
const stop = parsed.flags.get("no-ship");
|
|
269
|
+
if (ship !== undefined || stop !== undefined) {
|
|
270
|
+
const wanted = ship !== undefined;
|
|
271
|
+
const named = wanted ? ship : stop;
|
|
272
|
+
if (typeof named !== "string" || !named.trim()) {
|
|
273
|
+
console.error(red("Which action?") + dim(` cbx actions ${wanted ? "--ship" : "--no-ship"} "Build"`));
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
const target = found.find((workflow) => workflow.name.toLowerCase() === named.trim().toLowerCase());
|
|
277
|
+
if (!target) {
|
|
278
|
+
console.error(red(`${project.name} has no action called ${named.trim()}.`));
|
|
279
|
+
if (found.length) {
|
|
280
|
+
console.error(dim(` It has: ${found.map((one) => one.name).join(", ")}`));
|
|
281
|
+
}
|
|
282
|
+
return 1;
|
|
283
|
+
}
|
|
284
|
+
if (wanted && !target.artifactPaths.length) {
|
|
285
|
+
/*
|
|
286
|
+
Refused rather than set. Shipping what a run collected means nothing
|
|
287
|
+
when the run collects nothing, and a flag that reported success and
|
|
288
|
+
then never produced a download would be indistinguishable from the
|
|
289
|
+
feature being broken.
|
|
290
|
+
*/
|
|
291
|
+
console.error(red(`${target.name} keeps no files, so it has nothing to ship.`));
|
|
292
|
+
console.error(dim(" Give it something to keep on the Actions screen first, e.g. dist/*.exe"));
|
|
293
|
+
return 1;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
await (0, api_js_1.changeWorkflow)(project.id, target.id, { attachArtifacts: wanted });
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
console.error(red(error instanceof Error ? error.message : String(error)));
|
|
300
|
+
return 1;
|
|
301
|
+
}
|
|
302
|
+
target.attachArtifacts = wanted;
|
|
303
|
+
console.log(wanted
|
|
304
|
+
? green(`${target.name} will attach what it builds to the version it ran against.`)
|
|
305
|
+
: green(`${target.name} will keep its files without attaching them.`));
|
|
306
|
+
if (wanted) {
|
|
307
|
+
/*
|
|
308
|
+
Said plainly, because attaching is publishing. An attachment on a
|
|
309
|
+
public version is a download anybody can take, and somebody turning
|
|
310
|
+
this on for a workflow that builds an internal tool deserves to hear
|
|
311
|
+
that before the next run rather than after it.
|
|
312
|
+
*/
|
|
313
|
+
console.log(dim(" Anything it attaches to a public version is public the moment it lands."));
|
|
314
|
+
}
|
|
315
|
+
console.log("");
|
|
316
|
+
}
|
|
223
317
|
if (!found.length) {
|
|
224
318
|
console.log(dim("No actions on this project."));
|
|
225
319
|
return 0;
|
|
@@ -232,6 +326,12 @@ async function commandWorkflows(parsed) {
|
|
|
232
326
|
console.log(` ${workflow.name.padEnd(24)} ${dim(workflow.trigger).padEnd(20)} ` +
|
|
233
327
|
`${dim(workflow.runsOn).padEnd(18)} ${health}`);
|
|
234
328
|
console.log(` ${dim(workflow.command)}`);
|
|
329
|
+
if (workflow.artifactPaths.length) {
|
|
330
|
+
console.log(` ${dim("keeps")} ${dim(workflow.artifactPaths.join(", "))}` +
|
|
331
|
+
(workflow.attachArtifacts
|
|
332
|
+
? ` ${accent("→ downloads on the version")}`
|
|
333
|
+
: ""));
|
|
334
|
+
}
|
|
235
335
|
}
|
|
236
336
|
return 0;
|
|
237
337
|
}
|
|
@@ -399,3 +499,103 @@ async function commandDelete(parsed) {
|
|
|
399
499
|
console.log(green(`Deleted ${project.name}.`));
|
|
400
500
|
return 0;
|
|
401
501
|
}
|
|
502
|
+
/**
|
|
503
|
+
* Where a project tells something else that something happened.
|
|
504
|
+
*
|
|
505
|
+
* The service has been able to do this since Merge Tracks shipped — queue an
|
|
506
|
+
* event, sign it, deliver it after the request that caused it — and nothing
|
|
507
|
+
* could reach it. A project could not be told to notify a build box, a chat
|
|
508
|
+
* room or a status page, because no client asked.
|
|
509
|
+
*/
|
|
510
|
+
async function commandHooks(parsed) {
|
|
511
|
+
const project = await (0, project_commands_js_1.resolveProject)(typeof parsed.flags.get("project") === "string"
|
|
512
|
+
? String(parsed.flags.get("project"))
|
|
513
|
+
: parsed.positional[0]);
|
|
514
|
+
if (!project)
|
|
515
|
+
return 1;
|
|
516
|
+
const adding = parsed.flags.get("add");
|
|
517
|
+
if (adding !== undefined) {
|
|
518
|
+
if (typeof adding !== "string" || !adding.trim()) {
|
|
519
|
+
console.error(red("Which address?") + dim(" cbx hooks --add https://example.com/hook"));
|
|
520
|
+
return 1;
|
|
521
|
+
}
|
|
522
|
+
const url = adding.trim();
|
|
523
|
+
if (!/^https:\/\//i.test(url)) {
|
|
524
|
+
/*
|
|
525
|
+
Refused here as well as by the service. A webhook carries a signed
|
|
526
|
+
payload about a private project, and http would put it on the wire in
|
|
527
|
+
the clear — worth saying before the round trip rather than after.
|
|
528
|
+
*/
|
|
529
|
+
console.error(red("A webhook address has to be https."));
|
|
530
|
+
return 1;
|
|
531
|
+
}
|
|
532
|
+
const named = parsed.flags.get("events");
|
|
533
|
+
const events = typeof named === "string"
|
|
534
|
+
? named.split(",").map((one) => one.trim()).filter(Boolean)
|
|
535
|
+
: [];
|
|
536
|
+
try {
|
|
537
|
+
const made = await (0, api_js_1.addWebhook)(project.id, url, events);
|
|
538
|
+
console.log(green(`${project.name} will notify ${url}.`));
|
|
539
|
+
console.log("");
|
|
540
|
+
/*
|
|
541
|
+
Printed once because it exists once. The service generates it and no
|
|
542
|
+
route ever hands it back, so this is the only moment anybody can
|
|
543
|
+
write it down — and saying so is the difference between a person
|
|
544
|
+
copying it and a person losing it.
|
|
545
|
+
*/
|
|
546
|
+
console.log(` ${bold("Signing secret")} ${made.secret}`);
|
|
547
|
+
console.log(dim(" Written once and never again. Use it to check the signature on"));
|
|
548
|
+
console.log(dim(" what arrives, so nobody else can post to your endpoint."));
|
|
549
|
+
if (events.length)
|
|
550
|
+
console.log(dim(` Sending: ${events.join(", ")}`));
|
|
551
|
+
else
|
|
552
|
+
console.log(dim(" Sending: everything this project announces"));
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
console.error(red(error instanceof Error ? error.message : String(error)));
|
|
556
|
+
return 1;
|
|
557
|
+
}
|
|
558
|
+
return 0;
|
|
559
|
+
}
|
|
560
|
+
const removing = parsed.flags.get("remove");
|
|
561
|
+
if (removing !== undefined) {
|
|
562
|
+
if (typeof removing !== "string" || !removing.trim()) {
|
|
563
|
+
console.error(red("Which one?") + dim(" cbx hooks --remove <id>"));
|
|
564
|
+
return 1;
|
|
565
|
+
}
|
|
566
|
+
const wanted = removing.trim();
|
|
567
|
+
const found = (await (0, api_js_1.webhooks)(project.id)).find((one) => one.id === wanted || one.url === wanted);
|
|
568
|
+
if (!found) {
|
|
569
|
+
console.error(red(`${project.name} has no webhook called ${wanted}.`));
|
|
570
|
+
return 1;
|
|
571
|
+
}
|
|
572
|
+
await (0, api_js_1.removeWebhook)(project.id, found.id);
|
|
573
|
+
console.log(green(`${found.url} will not be notified any more.`));
|
|
574
|
+
return 0;
|
|
575
|
+
}
|
|
576
|
+
const found = await (0, api_js_1.webhooks)(project.id);
|
|
577
|
+
if (!found.length) {
|
|
578
|
+
console.log(dim("This project notifies nothing."));
|
|
579
|
+
console.log(dim(" cbx hooks --add https://example.com/coderook"));
|
|
580
|
+
return 0;
|
|
581
|
+
}
|
|
582
|
+
console.log(bold(project.name));
|
|
583
|
+
for (const hook of found) {
|
|
584
|
+
/*
|
|
585
|
+
A run of failures shown rather than hidden. A webhook that has been
|
|
586
|
+
failing for a week looks exactly like one that works until somebody
|
|
587
|
+
checks the receiving end, which is the wrong place to find out.
|
|
588
|
+
*/
|
|
589
|
+
const health = !hook.active
|
|
590
|
+
? red("off")
|
|
591
|
+
: hook.consecutiveFailures
|
|
592
|
+
? red(`${hook.consecutiveFailures} failed in a row`)
|
|
593
|
+
: hook.lastDeliveredAt
|
|
594
|
+
? green("delivering")
|
|
595
|
+
: dim("nothing sent yet");
|
|
596
|
+
console.log(` ${hook.url}`);
|
|
597
|
+
console.log(` ${dim(hook.id)} ${health}` +
|
|
598
|
+
(hook.events.length ? ` ${dim(hook.events.join(", "))}` : ` ${dim("everything")}`));
|
|
599
|
+
}
|
|
600
|
+
return 0;
|
|
601
|
+
}
|
|
@@ -39,6 +39,40 @@ const RULES = [
|
|
|
39
39
|
{ directory: ".next", reason: "Next.js build output", recommended: true },
|
|
40
40
|
{ directory: ".gradle", reason: "Gradle build state", recommended: true },
|
|
41
41
|
{ directory: ".terraform", reason: "Downloaded Terraform providers", recommended: true },
|
|
42
|
+
/*
|
|
43
|
+
Game engines, which is where the size actually is.
|
|
44
|
+
|
|
45
|
+
A Unity project is mostly not the project: `Library/` alone is routinely
|
|
46
|
+
thousands of times the size of `Assets/`, and every byte of it is rebuilt
|
|
47
|
+
from `Assets/` and `Packages/` the next time the editor opens. Until these
|
|
48
|
+
existed a Unity folder produced no suggestions at all — the one shape of
|
|
49
|
+
project where the question "what should I leave out?" has the largest
|
|
50
|
+
possible answer was the one the detector had nothing to say about.
|
|
51
|
+
*/
|
|
52
|
+
{ directory: "Library", reason: "Unity's import cache — rebuilt when the project next opens", recommended: true },
|
|
53
|
+
{ directory: "Temp", reason: "Unity's scratch folder for the running editor", recommended: true },
|
|
54
|
+
{ directory: "MemoryCaptures", reason: "Unity memory snapshots — large, and not the project", recommended: true },
|
|
55
|
+
{ directory: "Recordings", reason: "Unity recorder output", recommended: true },
|
|
56
|
+
{ directory: "UserSettings", reason: "Your own editor layout — not part of the project", recommended: true },
|
|
57
|
+
{ directory: "DerivedDataCache", reason: "Unreal's derived data cache — rebuilt on demand", recommended: true },
|
|
58
|
+
{ directory: "Intermediate", reason: "Unreal build intermediates — rebuilt when you build", recommended: true },
|
|
59
|
+
{ directory: "Binaries", reason: "Unreal compiled output — rebuilt when you build", recommended: true },
|
|
60
|
+
/*
|
|
61
|
+
Named like the work, and it is not: Unreal keeps logs, crash reports and
|
|
62
|
+
autosaves here. Offered rather than ticked, because a folder called
|
|
63
|
+
"Saved" is the one nobody should have excluded on our say-so.
|
|
64
|
+
*/
|
|
65
|
+
{ directory: "Saved", reason: "Unreal logs and autosaves — check before excluding", recommended: false },
|
|
66
|
+
{ directory: ".godot", reason: "Godot's import cache — rebuilt when the project next opens", recommended: true },
|
|
67
|
+
{ directory: ".import", reason: "Godot's import cache — rebuilt when the project next opens", recommended: true },
|
|
68
|
+
{ directory: "Logs", reason: "Editor logs", recommended: true },
|
|
69
|
+
{ directory: "obj", reason: "Compiler intermediates — rebuilt when you build", recommended: true },
|
|
70
|
+
{ directory: ".vs", reason: "Visual Studio's local cache", recommended: true },
|
|
71
|
+
/*
|
|
72
|
+
Offered, not ticked. JetBrains keeps run configurations in here that some
|
|
73
|
+
projects deliberately share, so this is a judgement rather than a fact.
|
|
74
|
+
*/
|
|
75
|
+
{ directory: ".idea", reason: "JetBrains editor state — check before excluding", recommended: false },
|
|
42
76
|
/*
|
|
43
77
|
A Chromium or Electron profile left in the project folder. Worth its own
|
|
44
78
|
reason because the consequence is not only size: these hold lock files
|
|
@@ -59,6 +93,7 @@ const RULES = [
|
|
|
59
93
|
{ directory: "dist", reason: "Usually build output — check before excluding", recommended: false },
|
|
60
94
|
{ directory: "build", reason: "Usually build output — check before excluding", recommended: false },
|
|
61
95
|
{ directory: "out", reason: "Usually build output — check before excluding", recommended: false },
|
|
96
|
+
{ directory: "bin", reason: "Usually build output — check before excluding", recommended: false },
|
|
62
97
|
{ extension: ".safetensors", reason: "Model weights — large, and usually downloadable", recommended: false },
|
|
63
98
|
{ extension: ".ckpt", reason: "Model weights — large, and usually downloadable", recommended: false },
|
|
64
99
|
{ extension: ".pt", reason: "Model weights — large, and usually downloadable", recommended: false },
|
|
@@ -86,13 +121,29 @@ function suggestExclusions(files) {
|
|
|
86
121
|
const extension = node_path_1.default.extname(name).toLowerCase();
|
|
87
122
|
for (const rule of RULES) {
|
|
88
123
|
let pattern = null;
|
|
89
|
-
if (rule.directory
|
|
90
|
-
|
|
124
|
+
if (rule.directory) {
|
|
125
|
+
/*
|
|
126
|
+
Matched without regard to case, and written back with the case the
|
|
127
|
+
folder actually has.
|
|
128
|
+
|
|
129
|
+
Every engine disagrees about capitals — Unity ships `Library` and
|
|
130
|
+
`obj` in the same project, Unreal `Binaries`, Godot `.godot` — and
|
|
131
|
+
an exact-name match meant the detector recognised whichever spelling
|
|
132
|
+
happened to be written here and silently missed the rest. Emitting
|
|
133
|
+
the observed name rather than the rule's own keeps the line that
|
|
134
|
+
gets written matching the folder that is there.
|
|
135
|
+
*/
|
|
136
|
+
const wanted = rule.directory.toLowerCase();
|
|
137
|
+
const found = segments
|
|
138
|
+
.slice(0, -1)
|
|
139
|
+
.find((segment) => segment.toLowerCase() === wanted);
|
|
140
|
+
if (found)
|
|
141
|
+
pattern = `${found}/`;
|
|
91
142
|
}
|
|
92
|
-
|
|
143
|
+
if (!pattern && rule.file && name === rule.file) {
|
|
93
144
|
pattern = rule.file;
|
|
94
145
|
}
|
|
95
|
-
|
|
146
|
+
if (!pattern && rule.extension && extension === rule.extension) {
|
|
96
147
|
pattern = `*${rule.extension}`;
|
|
97
148
|
}
|
|
98
149
|
if (!pattern)
|
|
@@ -1,8 +1,66 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.Tracks = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The lines a project is saved on, and the merges waiting on a person.
|
|
6
|
+
*
|
|
7
|
+
* The service has had all of this since Tracks existed: a project has one or
|
|
8
|
+
* more lines of versions, and a publish that diverges from what the account
|
|
9
|
+
* holds is put on a Merge Track of its own rather than laid over work the
|
|
10
|
+
* uploader had not seen. That is the right behaviour and it already happens.
|
|
11
|
+
*
|
|
12
|
+
* What did not exist was any way to know. The app never asked for a track, so
|
|
13
|
+
* a save that diverted reported "done" and said nothing about where the work
|
|
14
|
+
* went — the version was safe, on a track nobody could see, and the only way
|
|
15
|
+
* to find it was the website. This is the half that was missing.
|
|
16
|
+
*/
|
|
17
|
+
const node_crypto_1 = require("node:crypto");
|
|
4
18
|
const identify_js_1 = require("./identify.js");
|
|
5
19
|
const retry_js_1 = require("./retry.js");
|
|
20
|
+
/** A media type from the name, so an edited file is stored as what it is. */
|
|
21
|
+
function mediaTypeForPath(path) {
|
|
22
|
+
const known = {
|
|
23
|
+
css: "text/css",
|
|
24
|
+
html: "text/html",
|
|
25
|
+
js: "text/javascript",
|
|
26
|
+
json: "application/json",
|
|
27
|
+
md: "text/markdown",
|
|
28
|
+
py: "text/x-python",
|
|
29
|
+
ts: "text/typescript",
|
|
30
|
+
tsx: "text/typescript",
|
|
31
|
+
xml: "application/xml",
|
|
32
|
+
yaml: "application/yaml",
|
|
33
|
+
yml: "application/yaml",
|
|
34
|
+
};
|
|
35
|
+
return known[path.split(".").pop()?.toLowerCase() ?? ""] ?? "text/plain";
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The error to raise for a failed request, with a refusal said properly.
|
|
39
|
+
*
|
|
40
|
+
* A build refused for being too old is not a network failure and must not
|
|
41
|
+
* read like one: the person can fix it, and only if they are told how. The
|
|
42
|
+
* two requests below build their own fetches rather than going through
|
|
43
|
+
* `call`, so without this a refused desktop was told only "(426)".
|
|
44
|
+
*/
|
|
45
|
+
function refusalOrError(body, status, fallback) {
|
|
46
|
+
const tooOld = (0, identify_js_1.readRefusal)(status, body);
|
|
47
|
+
if (tooOld) {
|
|
48
|
+
return new Error(`${tooOld.message} You are running ${tooOld.yourVersion ?? "an older build"}; ` +
|
|
49
|
+
`update from ${tooOld.upgradeUrl}.`);
|
|
50
|
+
}
|
|
51
|
+
let message = `${fallback} (${status})`;
|
|
52
|
+
if (body.trimStart().startsWith("{")) {
|
|
53
|
+
try {
|
|
54
|
+
message =
|
|
55
|
+
JSON.parse(body).error?.message ??
|
|
56
|
+
message;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* keep the generic message */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return Object.assign(new Error(message), { status });
|
|
63
|
+
}
|
|
6
64
|
class Tracks {
|
|
7
65
|
credentials;
|
|
8
66
|
constructor(credentials) {
|
|
@@ -177,17 +235,106 @@ class Tracks {
|
|
|
177
235
|
return [];
|
|
178
236
|
}
|
|
179
237
|
}
|
|
180
|
-
/**
|
|
238
|
+
/**
|
|
239
|
+
* One merge and every path it is waiting on.
|
|
240
|
+
*
|
|
241
|
+
* The merge itself is nested under `mergeTrack`, which this used to spread
|
|
242
|
+
* flat — so `track.reference` was undefined and the window's title fell
|
|
243
|
+
* back to the word "Merge" on every merge there has ever been. The rest of
|
|
244
|
+
* the reply was thrown away with it, including the two version ids that
|
|
245
|
+
* make showing the conflict possible at all.
|
|
246
|
+
*/
|
|
181
247
|
async merge(mergeTrackId) {
|
|
182
248
|
const body = await this.call(`/v1/merge-tracks/${mergeTrackId}`);
|
|
183
|
-
|
|
184
|
-
|
|
249
|
+
return {
|
|
250
|
+
track: body.mergeTrack,
|
|
251
|
+
conflicts: body.conflicts ?? [],
|
|
252
|
+
repositoryId: body.mergeTrack.repositoryId,
|
|
253
|
+
candidateVersionId: body.mergeTrack.candidateVersionId ?? null,
|
|
254
|
+
currentTargetVersionId: body.currentTargetVersionId ?? null,
|
|
255
|
+
checks: body.checks ?? [],
|
|
256
|
+
ready: body.provisional?.ready ?? false,
|
|
257
|
+
unresolvedPaths: body.provisional?.unresolvedPaths ?? [],
|
|
258
|
+
blockedByChecks: body.provisional?.blockedByChecks ?? [],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* One side of a conflict, as text.
|
|
263
|
+
*
|
|
264
|
+
* Both sides of a merge are versions, so this is the ordinary file route.
|
|
265
|
+
* Null means there is genuinely no file on that side, which is half of
|
|
266
|
+
* these conflicts: one person edited what another deleted.
|
|
267
|
+
*/
|
|
268
|
+
async fileAt(repositoryId, versionId, path) {
|
|
269
|
+
if (!versionId)
|
|
270
|
+
return null;
|
|
271
|
+
const token = await this.credentials.token();
|
|
272
|
+
if (!token)
|
|
273
|
+
throw new Error("Sign in again");
|
|
274
|
+
const response = await fetch(`${this.credentials.origin()}/v1/repositories/${repositoryId}` +
|
|
275
|
+
`/versions/${versionId}/file?path=${encodeURIComponent(path)}`, {
|
|
276
|
+
headers: {
|
|
277
|
+
authorization: `Bearer ${token}`,
|
|
278
|
+
"user-agent": "CodeRook/0.1",
|
|
279
|
+
...(0, identify_js_1.clientHeaders)(),
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
if (response.status === 404)
|
|
283
|
+
return null;
|
|
284
|
+
if (!response.ok) {
|
|
285
|
+
throw refusalOrError(await response.text().catch(() => ""), response.status, `Could not read ${path}`);
|
|
286
|
+
}
|
|
287
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
288
|
+
/*
|
|
289
|
+
Decoded strictly, so a file that is not UTF-8 reports itself as binary
|
|
290
|
+
rather than arriving as a screenful of replacement characters somebody
|
|
291
|
+
might then save over the real thing.
|
|
292
|
+
*/
|
|
293
|
+
let text = null;
|
|
294
|
+
try {
|
|
295
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
296
|
+
if (text.includes("\u0000"))
|
|
297
|
+
text = null;
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
text = null;
|
|
301
|
+
}
|
|
302
|
+
return { text, bytes: bytes.byteLength };
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Send a file somebody wrote, and answer with the object it became.
|
|
306
|
+
*
|
|
307
|
+
* The other half of an edited resolution: the service takes an object id,
|
|
308
|
+
* and something has to put the bytes there first.
|
|
309
|
+
*/
|
|
310
|
+
async putText(repositoryId, path, text) {
|
|
311
|
+
const token = await this.credentials.token();
|
|
312
|
+
if (!token)
|
|
313
|
+
throw new Error("Sign in again");
|
|
314
|
+
const bytes = new TextEncoder().encode(text);
|
|
315
|
+
const digest = (0, node_crypto_1.createHash)("sha256").update(bytes).digest("hex");
|
|
316
|
+
const response = await fetch(`${this.credentials.origin()}/v1/repositories/${repositoryId}` +
|
|
317
|
+
`/objects/${digest}?kind=chunk&role=chunk&logicalSize=${bytes.byteLength}` +
|
|
318
|
+
`&mediaType=${encodeURIComponent(mediaTypeForPath(path))}`, {
|
|
319
|
+
method: "PUT",
|
|
320
|
+
headers: {
|
|
321
|
+
authorization: `Bearer ${token}`,
|
|
322
|
+
"content-type": "application/octet-stream",
|
|
323
|
+
"user-agent": "CodeRook/0.1",
|
|
324
|
+
...(0, identify_js_1.clientHeaders)(),
|
|
325
|
+
},
|
|
326
|
+
body: bytes,
|
|
327
|
+
});
|
|
328
|
+
const body = await response.text();
|
|
329
|
+
if (!response.ok)
|
|
330
|
+
throw refusalOrError(body, response.status, "Could not send the file");
|
|
331
|
+
return JSON.parse(body).objectId;
|
|
185
332
|
}
|
|
186
|
-
/** Settle one path. */
|
|
187
|
-
async resolve(mergeTrackId, conflictId, resolution) {
|
|
333
|
+
/** Settle one path. An edit also names the object it produced. */
|
|
334
|
+
async resolve(mergeTrackId, conflictId, resolution, objectId) {
|
|
188
335
|
await this.call(`/v1/merge-tracks/${mergeTrackId}/conflicts/${conflictId}`, {
|
|
189
336
|
method: "PUT",
|
|
190
|
-
body: JSON.stringify({ resolution }),
|
|
337
|
+
body: JSON.stringify(objectId ? { resolution, objectId } : { resolution }),
|
|
191
338
|
});
|
|
192
339
|
}
|
|
193
340
|
/**
|
|
@@ -354,7 +354,15 @@ class Uploader {
|
|
|
354
354
|
*/
|
|
355
355
|
const everything = await (0, profile_js_1.timed)("survey the project", () => (0, worktree_js_1.surveyFiles)(request.localPath, rules));
|
|
356
356
|
(0, profile_js_1.counted)("files surveyed", everything.length);
|
|
357
|
-
const
|
|
357
|
+
const unticked = new Set(request.excluded ?? []);
|
|
358
|
+
const ticked = request.excluded
|
|
359
|
+
? new Set([
|
|
360
|
+
...everything
|
|
361
|
+
.map((file) => file.path)
|
|
362
|
+
.filter((path) => !unticked.has(path)),
|
|
363
|
+
...(request.deletions ?? []),
|
|
364
|
+
])
|
|
365
|
+
: new Set(request.include);
|
|
358
366
|
if (!ticked.size)
|
|
359
367
|
throw new Error("Nothing is selected to upload");
|
|
360
368
|
const prior = request.repositoryId
|