@coderook/cli 0.6.0 → 0.7.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/dist/cli/src/api.js +2 -0
- package/dist/cli/src/cli.js +62 -0
- package/dist/desktop-app/src/main/download.js +21 -0
- package/dist/desktop-app/src/main/faults.js +39 -0
- package/dist/desktop-app/src/main/identify.js +40 -0
- package/dist/desktop-app/src/main/upload.js +2 -0
- package/package.json +5 -3
package/dist/cli/src/api.js
CHANGED
|
@@ -10,6 +10,7 @@ exports.resolveMergeConflict = resolveMergeConflict;
|
|
|
10
10
|
exports.applyMerge = applyMerge;
|
|
11
11
|
exports.cancelMerge = cancelMerge;
|
|
12
12
|
/** The small part of the API the command-line tool needs directly. */
|
|
13
|
+
const identify_js_1 = require("../../desktop-app/src/main/identify.js");
|
|
13
14
|
const config_js_1 = require("./config.js");
|
|
14
15
|
async function call(route, options = {}) {
|
|
15
16
|
const token = await (0, config_js_1.loadToken)();
|
|
@@ -22,6 +23,7 @@ async function call(route, options = {}) {
|
|
|
22
23
|
accept: "application/json",
|
|
23
24
|
authorization: `Bearer ${token}`,
|
|
24
25
|
"user-agent": "CodeRook-CLI/0.1",
|
|
26
|
+
...(0, identify_js_1.clientHeaders)(),
|
|
25
27
|
...(options.body ? { "content-type": "application/json" } : {}),
|
|
26
28
|
},
|
|
27
29
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -22,6 +22,8 @@ const api_js_1 = require("./api.js");
|
|
|
22
22
|
const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
|
|
23
23
|
const upload_js_1 = require("../../desktop-app/src/main/upload.js");
|
|
24
24
|
const download_js_1 = require("../../desktop-app/src/main/download.js");
|
|
25
|
+
const faults_js_1 = require("../../desktop-app/src/main/faults.js");
|
|
26
|
+
const identify_js_1 = require("../../desktop-app/src/main/identify.js");
|
|
25
27
|
const cbx_js_1 = require("../../desktop-app/src/main/cbx.js");
|
|
26
28
|
const api_js_2 = require("./api.js");
|
|
27
29
|
const config_js_1 = require("./config.js");
|
|
@@ -38,6 +40,12 @@ const VERSION = (() => {
|
|
|
38
40
|
return "0.0.0";
|
|
39
41
|
}
|
|
40
42
|
})();
|
|
43
|
+
/*
|
|
44
|
+
Said once, before anything reaches the service. The version comes from the
|
|
45
|
+
package rather than a constant, so it cannot drift from what was published
|
|
46
|
+
and claim a behaviour this build does not have.
|
|
47
|
+
*/
|
|
48
|
+
(0, identify_js_1.declareClient)("cli", VERSION);
|
|
41
49
|
// ── presentation ────────────────────────────────────────────────────────────
|
|
42
50
|
const colour = node_process_1.default.stdout.isTTY && !node_process_1.default.env.NO_COLOR;
|
|
43
51
|
const dim = (text) => (colour ? `[2m${text}[0m` : text);
|
|
@@ -474,6 +482,20 @@ async function commandGet(parsed) {
|
|
|
474
482
|
copy. Only the edits are worth stopping for, and only when the incoming
|
|
475
483
|
version would actually change those files.
|
|
476
484
|
*/
|
|
485
|
+
/*
|
|
486
|
+
A fetch that did not finish leaves the folder part-way to a version, with
|
|
487
|
+
the record still describing what was there before. Every difference it
|
|
488
|
+
shows is this operation's own work, not somebody's edit, so finishing the
|
|
489
|
+
job is the only sensible reading — and refusing would strand the folder in
|
|
490
|
+
the one state that ought to be easiest to leave.
|
|
491
|
+
|
|
492
|
+
It could only have started if there was nothing conflicting here, so
|
|
493
|
+
completing it cannot bury anybody's changes.
|
|
494
|
+
*/
|
|
495
|
+
const resuming = Boolean(link.fetching);
|
|
496
|
+
if (resuming) {
|
|
497
|
+
console.log(dim(`A fetch of v${link.fetching.sequence} did not finish; completing it.`));
|
|
498
|
+
}
|
|
477
499
|
const replacing = hasFlag(parsed, "replace", "f", "force");
|
|
478
500
|
/*
|
|
479
501
|
A folder connected by a client older than the materialisation record has
|
|
@@ -515,6 +537,31 @@ async function commandGet(parsed) {
|
|
|
515
537
|
file.sha256,
|
|
516
538
|
])
|
|
517
539
|
: []);
|
|
540
|
+
/*
|
|
541
|
+
Resuming needs a different question. Half these files differ from the
|
|
542
|
+
record because the interrupted run already replaced them, and those
|
|
543
|
+
are finished work rather than anybody's edit. What matters is a file
|
|
544
|
+
that matches neither what was here nor what is arriving — that one was
|
|
545
|
+
changed by hand while the fetch was stopped, and completing over it
|
|
546
|
+
would bury the only copy.
|
|
547
|
+
*/
|
|
548
|
+
if (resuming) {
|
|
549
|
+
const notYetArrived = await (0, worktree_js_1.changedFiles)(folder, rules, incoming);
|
|
550
|
+
const changedInTheGap = notYetArrived.filter((file) => !file.deleted &&
|
|
551
|
+
incoming.has(file.path) &&
|
|
552
|
+
edited.some((one) => one.path === file.path));
|
|
553
|
+
if (!changedInTheGap.length) {
|
|
554
|
+
return fetchInto(link.repositoryId, folder, link.slug, link.local ?? null, "replace");
|
|
555
|
+
}
|
|
556
|
+
console.error(red(`${changedInTheGap.length} file${changedInTheGap.length === 1 ? "" : "s"} ` +
|
|
557
|
+
`changed while the interrupted fetch was stopped:`));
|
|
558
|
+
for (const file of changedInTheGap.slice(0, 20))
|
|
559
|
+
console.error(` ${file.path}`);
|
|
560
|
+
console.error(`
|
|
561
|
+
Save them with ${accent("coderook submit")}, or finish the fetch and ` +
|
|
562
|
+
`discard them with ${accent("coderook get --replace")}.`);
|
|
563
|
+
return 1;
|
|
564
|
+
}
|
|
518
565
|
/*
|
|
519
566
|
Only a genuine collision stops the fetch: this folder changed the
|
|
520
567
|
file and so did the incoming version. An edit to a file the version
|
|
@@ -577,6 +624,18 @@ held, mode = "replace") {
|
|
|
577
624
|
console.error(red("That project has no saved version."));
|
|
578
625
|
return 1;
|
|
579
626
|
}
|
|
627
|
+
/*
|
|
628
|
+
Recorded before the folder is touched. If this run does not reach the
|
|
629
|
+
end, the next one finds this and knows the differences it sees are its
|
|
630
|
+
own unfinished work rather than somebody's edits.
|
|
631
|
+
*/
|
|
632
|
+
const before = await (0, config_js_1.readLink)(destination);
|
|
633
|
+
if (before) {
|
|
634
|
+
await (0, config_js_1.writeLink)(destination, {
|
|
635
|
+
...before,
|
|
636
|
+
fetching: { versionId: latest.id, sequence: latest.sequence },
|
|
637
|
+
});
|
|
638
|
+
}
|
|
580
639
|
const line = progressLine();
|
|
581
640
|
const result = await downloader.run(repositoryId, latest.id, destination, (progress) => {
|
|
582
641
|
line(` ${String(progress.percent).padStart(3)}% ${progress.files}/${progress.totalFiles}` +
|
|
@@ -596,7 +655,10 @@ held, mode = "replace") {
|
|
|
596
655
|
// version holds and the two agree again.
|
|
597
656
|
local: result.manifest,
|
|
598
657
|
manifest: result.manifest,
|
|
658
|
+
// Reached the end, so there is no longer a fetch in flight.
|
|
599
659
|
});
|
|
660
|
+
// The record is written; the run has not reported success yet.
|
|
661
|
+
(0, faults_js_1.maybeFail)("get:after-record");
|
|
600
662
|
console.log(`${accent(`v${latest.sequence}`)} · ${result.files} files · ${bytes(result.bytes)} into ${destination}`);
|
|
601
663
|
return 0;
|
|
602
664
|
}
|
|
@@ -15,6 +15,8 @@ exports.Downloader = exports.DownloadCancelled = void 0;
|
|
|
15
15
|
const node_crypto_1 = require("node:crypto");
|
|
16
16
|
const promises_1 = require("node:fs/promises");
|
|
17
17
|
const node_path_1 = __importDefault(require("node:path"));
|
|
18
|
+
const identify_js_1 = require("./identify.js");
|
|
19
|
+
const faults_js_1 = require("./faults.js");
|
|
18
20
|
async function exists(target) {
|
|
19
21
|
try {
|
|
20
22
|
await (0, promises_1.stat)(target);
|
|
@@ -83,11 +85,21 @@ class Downloader {
|
|
|
83
85
|
headers: {
|
|
84
86
|
authorization: `Bearer ${token}`,
|
|
85
87
|
"user-agent": "CodeRook/0.1",
|
|
88
|
+
...(0, identify_js_1.clientHeaders)(),
|
|
86
89
|
},
|
|
87
90
|
signal: this.controller.signal,
|
|
88
91
|
});
|
|
89
92
|
if (!response.ok) {
|
|
90
93
|
const text = await response.text().catch(() => "");
|
|
94
|
+
/*
|
|
95
|
+
A refusal for being too old is not a network failure and must not read
|
|
96
|
+
like one: the person can fix it, and only if they are told how.
|
|
97
|
+
*/
|
|
98
|
+
const tooOld = (0, identify_js_1.readRefusal)(response.status, text);
|
|
99
|
+
if (tooOld) {
|
|
100
|
+
throw new Error(`${tooOld.message} You are running ${tooOld.yourVersion ?? "an older build"}; ` +
|
|
101
|
+
`update from ${tooOld.upgradeUrl}.`);
|
|
102
|
+
}
|
|
91
103
|
let message = `${route} failed (${response.status})`;
|
|
92
104
|
try {
|
|
93
105
|
message = JSON.parse(text)?.error?.message ?? message;
|
|
@@ -187,7 +199,10 @@ class Downloader {
|
|
|
187
199
|
one volume is atomic, so no file is ever seen half-written, and
|
|
188
200
|
anything the version does not mention is left exactly as it is.
|
|
189
201
|
*/
|
|
202
|
+
// Everything has arrived and verified; the folder is still untouched.
|
|
203
|
+
(0, faults_js_1.maybeFail)("get:after-staging");
|
|
190
204
|
await (0, promises_1.mkdir)(destination, { recursive: true });
|
|
205
|
+
let placed = 0;
|
|
191
206
|
for (const file of files) {
|
|
192
207
|
const parts = file.path.split("/").filter(Boolean);
|
|
193
208
|
const target = node_path_1.default.join(destination, ...parts);
|
|
@@ -206,6 +221,9 @@ class Downloader {
|
|
|
206
221
|
await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
|
|
207
222
|
await (0, promises_1.rm)(target, { recursive: true, force: true });
|
|
208
223
|
await (0, promises_1.rename)(node_path_1.default.join(staging, ...parts), target);
|
|
224
|
+
placed += 1;
|
|
225
|
+
if (placed === 1)
|
|
226
|
+
(0, faults_js_1.maybeFail)("get:after-first-file");
|
|
209
227
|
}
|
|
210
228
|
/*
|
|
211
229
|
A file this folder received but the version no longer holds is gone
|
|
@@ -229,7 +247,10 @@ class Downloader {
|
|
|
229
247
|
continue;
|
|
230
248
|
await (0, promises_1.rm)(target, { force: true });
|
|
231
249
|
await pruneEmpty(destination, target);
|
|
250
|
+
(0, faults_js_1.maybeFail)("get:after-delete");
|
|
232
251
|
}
|
|
252
|
+
// The folder is now the new version; nothing has recorded that yet.
|
|
253
|
+
(0, faults_js_1.maybeFail)("get:before-record");
|
|
233
254
|
await (0, promises_1.rm)(staging, { recursive: true, force: true });
|
|
234
255
|
report({
|
|
235
256
|
files: written,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Deliberate failure points, for proving that interruption recovers.
|
|
4
|
+
*
|
|
5
|
+
* Every operation that touches a folder is a sequence of individually correct
|
|
6
|
+
* steps, and the dangerous state is between two of them: half the files
|
|
7
|
+
* replaced, a deletion applied but the record not yet written, a version
|
|
8
|
+
* committed but never learned about. Those states cannot be reached by
|
|
9
|
+
* ordinary testing, because ordinary testing runs the sequence to the end.
|
|
10
|
+
*
|
|
11
|
+
* So the sequence can be cut, at named points, by setting an environment
|
|
12
|
+
* variable. Nothing here does anything at all unless that variable names a
|
|
13
|
+
* point — there is no configuration, no flag and no code path that reaches it
|
|
14
|
+
* otherwise, which is what makes it safe to leave in the shipped build rather
|
|
15
|
+
* than maintaining a separate instrumented one that nobody ever runs.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.InjectedFault = void 0;
|
|
19
|
+
exports.maybeFail = maybeFail;
|
|
20
|
+
class InjectedFault extends Error {
|
|
21
|
+
point;
|
|
22
|
+
constructor(point) {
|
|
23
|
+
super(`Interrupted deliberately at ${point}`);
|
|
24
|
+
this.point = point;
|
|
25
|
+
this.name = "InjectedFault";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
exports.InjectedFault = InjectedFault;
|
|
29
|
+
/**
|
|
30
|
+
* Stop here, if this is the point somebody asked to be interrupted at.
|
|
31
|
+
*
|
|
32
|
+
* Reads the environment on every call rather than caching it, so a test can
|
|
33
|
+
* drive several runs in one process without the first one deciding for all
|
|
34
|
+
* the others.
|
|
35
|
+
*/
|
|
36
|
+
function maybeFail(point) {
|
|
37
|
+
if (process.env.CODEROOK_FAULT === point)
|
|
38
|
+
throw new InjectedFault(point);
|
|
39
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.declareClient = declareClient;
|
|
4
|
+
exports.clientHeaders = clientHeaders;
|
|
5
|
+
exports.readRefusal = readRefusal;
|
|
6
|
+
/** Set once at start-up, because the two clients learn their version differently. */
|
|
7
|
+
let identity = "";
|
|
8
|
+
function declareClient(kind, version) {
|
|
9
|
+
identity = `${kind}/${version}`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The identifying headers, or nothing when start-up has not declared yet.
|
|
13
|
+
*
|
|
14
|
+
* Returning nothing rather than a guess matters: an unrecognised client is
|
|
15
|
+
* allowed through, so a wrong guess would be worse than silence — it would
|
|
16
|
+
* claim a version whose behaviour this build does not have.
|
|
17
|
+
*/
|
|
18
|
+
function clientHeaders() {
|
|
19
|
+
return identity ? { "x-coderook-client": identity } : {};
|
|
20
|
+
}
|
|
21
|
+
function readRefusal(status, body) {
|
|
22
|
+
if (status !== 426)
|
|
23
|
+
return null;
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(body);
|
|
26
|
+
const error = parsed.error;
|
|
27
|
+
if (!error || error.code !== "client_too_old")
|
|
28
|
+
return null;
|
|
29
|
+
return {
|
|
30
|
+
capability: String(error.capability ?? "that"),
|
|
31
|
+
minimumSupported: String(error.minimumSupported ?? "a newer version"),
|
|
32
|
+
yourVersion: error.yourVersion ? String(error.yourVersion) : null,
|
|
33
|
+
upgradeUrl: String(error.upgradeUrl ?? "https://coderook.com/download"),
|
|
34
|
+
message: String(error.message ?? "This version is too old."),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -16,6 +16,7 @@ const node_crypto_1 = require("node:crypto");
|
|
|
16
16
|
const node_fs_1 = require("node:fs");
|
|
17
17
|
const promises_1 = require("node:fs/promises");
|
|
18
18
|
const node_path_1 = __importDefault(require("node:path"));
|
|
19
|
+
const identify_js_1 = require("./identify.js");
|
|
19
20
|
const worktree_js_1 = require("./worktree.js");
|
|
20
21
|
const publish_name_js_1 = require("./publish_name.js");
|
|
21
22
|
/** Above this the API insists on a multipart session. */
|
|
@@ -87,6 +88,7 @@ class Uploader {
|
|
|
87
88
|
accept: "application/json",
|
|
88
89
|
authorization: `Bearer ${token}`,
|
|
89
90
|
"user-agent": "CodeRook/0.1",
|
|
91
|
+
...(0, identify_js_1.clientHeaders)(),
|
|
90
92
|
...(init.contentType ? { "content-type": init.contentType } : {}),
|
|
91
93
|
},
|
|
92
94
|
body: init.body,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coderook/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "CodeRook from the command line, on any operating system",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
6
|
"homepage": "https://coderook.com",
|
|
@@ -32,9 +32,11 @@
|
|
|
32
32
|
"test:matrix": "node test/state-matrix.mjs",
|
|
33
33
|
"test:attempt": "node test/attempt-identity.mjs",
|
|
34
34
|
"test:get": "node test/get-safety.mjs",
|
|
35
|
-
"test:live": "node test/e2e.mjs --allow-production && node test/state-matrix.mjs --allow-production && node test/get-safety.mjs --allow-production && node test/get-protects-edits.mjs --allow-production && node test/upgrade-migration.mjs --allow-production && node test/attempt-identity.mjs --allow-production",
|
|
35
|
+
"test:live": "node test/e2e.mjs --allow-production && node test/state-matrix.mjs --allow-production && node test/get-safety.mjs --allow-production && node test/get-protects-edits.mjs --allow-production && node test/upgrade-migration.mjs --allow-production && node test/fault-get.mjs --allow-production && node test/version-floor.mjs --allow-production && node test/attempt-identity.mjs --allow-production",
|
|
36
36
|
"test:getedits": "node test/get-protects-edits.mjs",
|
|
37
|
-
"test:upgrade": "node test/upgrade-migration.mjs"
|
|
37
|
+
"test:upgrade": "node test/upgrade-migration.mjs",
|
|
38
|
+
"test:faultget": "node test/fault-get.mjs",
|
|
39
|
+
"test:floor": "node test/version-floor.mjs"
|
|
38
40
|
},
|
|
39
41
|
"devDependencies": {
|
|
40
42
|
"@types/node": "24.10.1",
|