@coderook/cli 0.29.2 → 0.29.3

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.
@@ -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.29.2",
5
+ "version": "0.29.3",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -194,6 +194,23 @@ async function reconcile(folder, options = {}) {
194
194
  const project = link
195
195
  ? (await (0, api_js_2.projects)()).find((candidate) => candidate.id === link.repositoryId)
196
196
  : await (0, api_js_2.findProject)(reference);
197
+ /*
198
+ A link that has outlived the project it points at.
199
+
200
+ The list above is everything this account can reach — owned, shared with
201
+ them, and through their organisation — so a linked project missing from it
202
+ is gone or no longer theirs. Left alone, the next call that used the
203
+ remembered id failed with "Repository not found", which names neither the
204
+ folder nor anything to do about it; the soak suite hit exactly this and it
205
+ took a debugger to work out which folder was complaining.
206
+ */
207
+ if (link && !project) {
208
+ throw new Error(`This folder is linked to a project that is no longer on your account.\n` +
209
+ ` ${folder}\n` +
210
+ `It may have been deleted, or your access to it withdrawn.\n` +
211
+ `Detach the folder with ${accent("cbx unlink")} — the files here are ` +
212
+ `untouched, and submitting afterwards starts a new project from them.`);
213
+ }
197
214
  if (!project?.versionCount) {
198
215
  // Nothing saved on the account, so everything here is outstanding.
199
216
  return { link: link ?? null, baseline: null, behind: null };
@@ -303,6 +320,19 @@ async function commandProjects() {
303
320
  }
304
321
  return 0;
305
322
  }
323
+ async function commandUnlink(parsed) {
324
+ const folder = folderFor(parsed);
325
+ const link = await (0, config_js_1.readLink)(folder);
326
+ if (!(await (0, config_js_1.forgetLink)(folder))) {
327
+ console.log("This folder was not linked to a project.");
328
+ return 0;
329
+ }
330
+ console.log(`Detached ${bold(node_path_1.default.basename(folder))}` +
331
+ (link?.slug ? dim(` from ${link.slug}`) : "") +
332
+ ".");
333
+ console.log(dim("Nothing was deleted. Submitting from here now starts a new project."));
334
+ return 0;
335
+ }
306
336
  async function commandStatus(parsed) {
307
337
  const folder = folderFor(parsed);
308
338
  let link;
@@ -1678,6 +1708,20 @@ const SPECS = [
1678
1708
  ],
1679
1709
  run: commandSubmit,
1680
1710
  },
1711
+ {
1712
+ name: "unlink",
1713
+ group: "Working with a folder",
1714
+ summary: "detach this folder from its project",
1715
+ usage: "unlink [folder]",
1716
+ detail: "Forgets which project this folder belongs to. Nothing is deleted:\n" +
1717
+ "the files here are untouched and the project stays on your account.\n\n" +
1718
+ "Mostly useful when a folder points at a project that is gone — one\n" +
1719
+ "that was deleted, or shared with you and since withdrawn — because\n" +
1720
+ "until the folder forgets it, every command asks about a project that\n" +
1721
+ "is no longer there.",
1722
+ examples: ["cbx unlink", "cbx unlink ./old-copy"],
1723
+ run: commandUnlink,
1724
+ },
1681
1725
  {
1682
1726
  name: "import",
1683
1727
  group: "Getting started",
@@ -11,6 +11,7 @@ exports.loadToken = loadToken;
11
11
  exports.clearToken = clearToken;
12
12
  exports.keyFor = keyFor;
13
13
  exports.readLink = readLink;
14
+ exports.forgetLink = forgetLink;
14
15
  exports.writeLink = writeLink;
15
16
  /**
16
17
  * Where the command-line tool keeps its credential and its project links.
@@ -151,6 +152,24 @@ async function readLink(localPath) {
151
152
  baseVersionId: link.baseVersionId ?? link.versionId,
152
153
  };
153
154
  }
155
+ /**
156
+ * Forget which project a folder belongs to.
157
+ *
158
+ * Both keys, because a folder linked by an older build is recorded under the
159
+ * old one and leaving that behind would mean the folder was still linked
160
+ * afterwards — which is the one thing this is for.
161
+ */
162
+ async function forgetLink(localPath) {
163
+ const links = await readLinks();
164
+ const had = links[keyFor(localPath)] !== undefined ||
165
+ links[legacyKeyFor(localPath)] !== undefined;
166
+ if (!had)
167
+ return false;
168
+ delete links[keyFor(localPath)];
169
+ delete links[legacyKeyFor(localPath)];
170
+ await writePrivate(linksFile(), JSON.stringify(links, null, 2));
171
+ return true;
172
+ }
154
173
  async function writeLink(localPath, link) {
155
174
  const links = await readLinks();
156
175
  // A folder recorded under the old key moves to the new one rather than
@@ -585,43 +585,75 @@ class Downloader {
585
585
  sayProgress(file.path);
586
586
  };
587
587
  /*
588
- Wide for the small, narrow for the large.
588
+ Bounded by bytes in flight, not by how many files are open.
589
589
 
590
590
  Six lanes turned an hour-long restore of many small files into a
591
- minute, and made a restore of a few very large ones eight times
592
- slower: each large file is already fetched in pieces, so six at once
593
- is six times the outstanding requests against the same line, and they
594
- all finish later than they would have one after another.
591
+ minute, and made a restore of three very large ones eight times
592
+ slower — 93 seconds became 802. Splitting the queue by file size
593
+ fixed that shape and cost another: a project of forty-megabyte video
594
+ files had its six big ones running two at a time while the small ones
595
+ finished and left the line idle, and its fetch went from 54 seconds
596
+ to 151.
595
597
 
596
- Split by what the job is: a pack or a small file is one request and
597
- belongs in the wide lane; a large file is a stream of its own and
598
- belongs in the narrow one. Both run at once, so a project holding
599
- both kinds keeps the line busy without either starving the other.
600
- The sending side splits its work the same way — see the note beside
601
- BATCH_LANES in upload.ts.
598
+ Neither is really about file count. A file is fetched a piece at a
599
+ time, so each job holds exactly one request open whatever its size —
600
+ what six large files at once actually costs is six streams' worth of
601
+ bytes being buffered, hashed and written against one line. So that is
602
+ what is bounded: lanes take work while the bytes already in flight
603
+ leave room for it. Forty-megabyte files then run five or six at a
604
+ time and hundred-megabyte ones run two, without a threshold having to
605
+ guess which project this is.
602
606
  */
603
- const HEAVY_BYTES = 8 * 1024 * 1024;
604
- const light = jobs.filter((job) => job.kind === "pack" || job.file.sourceSize < HEAVY_BYTES);
605
- const heavy = jobs.filter((job) => job.kind === "file" && job.file.sourceSize >= HEAVY_BYTES);
606
- const drain = async (queue, lanes) => {
607
- let next = 0;
608
- const lane = async () => {
609
- for (;;) {
610
- const at = next;
611
- next += 1;
612
- if (at >= queue.length)
613
- return;
614
- this.check();
615
- const job = queue[at];
607
+ const LANES = 6;
608
+ const BYTES_IN_FLIGHT = 200 * 1024 * 1024;
609
+ const sizeOf = (job) => job.kind === "pack" ? 0 : job.file.sourceSize;
610
+ let next = 0;
611
+ let flying = 0;
612
+ /*
613
+ Every waiting lane, not the most recent one. A single slot would let a
614
+ second waiter overwrite the first, and the first would then never be
615
+ woken — a restore that stops with lanes idle and work left.
616
+ */
617
+ const waiting = [];
618
+ const wakeAll = () => {
619
+ while (waiting.length)
620
+ waiting.pop()();
621
+ };
622
+ const roomFor = async (size) => {
623
+ /*
624
+ One job always proceeds, however large. A file bigger than the whole
625
+ budget would otherwise wait for room that can never appear.
626
+ */
627
+ while (flying > 0 && flying + size > BYTES_IN_FLIGHT) {
628
+ await new Promise((resolve) => {
629
+ waiting.push(resolve);
630
+ });
631
+ }
632
+ };
633
+ const lane = async () => {
634
+ for (;;) {
635
+ const at = next;
636
+ next += 1;
637
+ if (at >= jobs.length)
638
+ return;
639
+ this.check();
640
+ const job = jobs[at];
641
+ const size = sizeOf(job);
642
+ await roomFor(size);
643
+ flying += size;
644
+ try {
616
645
  if (job.kind === "pack")
617
646
  await restorePack(job.id);
618
647
  else
619
648
  await restoreFile(job.file);
620
649
  }
621
- };
622
- await Promise.all(Array.from({ length: Math.min(lanes, queue.length) }, () => lane()));
650
+ finally {
651
+ flying -= size;
652
+ wakeAll();
653
+ }
654
+ }
623
655
  };
624
- await Promise.all([drain(light, 6), drain(heavy, 2)]);
656
+ await Promise.all(Array.from({ length: Math.min(LANES, jobs.length) }, () => lane()));
625
657
  /*
626
658
  Everything has arrived and verified, so it can go into place. Each
627
659
  file is renamed over its destination individually: a rename within
@@ -132,6 +132,8 @@ class Uploader {
132
132
  while somebody reads the plan is hashed again rather than published under
133
133
  the digest it used to have.
134
134
  */
135
+ /** Whether this save has already said that packing was refused. */
136
+ packRefusalSaid = false;
135
137
  plannedDigests = new Map();
136
138
  planningCreatedRepositoryId = null;
137
139
  /** Attached to every object write after the server admits the exact plan. */
@@ -272,6 +274,7 @@ class Uploader {
272
274
  */
273
275
  async plan(request, report) {
274
276
  this.planning = true;
277
+ this.packRefusalSaid = false;
275
278
  this.activeQuoteId = null;
276
279
  this.plannedQuote = null;
277
280
  this.planningCreatedRepositoryId = null;
@@ -968,12 +971,23 @@ class Uploader {
968
971
  batched.set(digest, one);
969
972
  }
970
973
  }
971
- catch {
974
+ catch (reason) {
972
975
  /*
973
976
  A refusal here is never fatal: everything in the pack is simply
974
977
  left for the ordinary path below, which sends it one at a time.
975
978
  An older deployment without this route lands here every time.
979
+
980
+ Said once per save, not once per pack. It used to be swallowed
981
+ outright, and when the service began refusing packs for a reason
982
+ that also broke the fallback, the only thing anybody saw was the
983
+ fallback's complaint about an arbitrary file — which described
984
+ neither what went wrong nor where. One line naming the first
985
+ refusal is what makes the next one an hour shorter to find.
976
986
  */
987
+ if (!this.packRefusalSaid) {
988
+ this.packRefusalSaid = true;
989
+ console.warn(`Packing was refused; sending these files individually. ${String(reason).slice(0, 300)}`);
990
+ }
977
991
  }
978
992
  /*
979
993
  Only what actually landed. Counting the whole pack meant a batch the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.29.2",
3
+ "version": "0.29.3",
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",