@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.
@@ -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,
@@ -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 ? `${text}` : 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.6.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",