@coderook/cli 0.34.4 → 0.34.5

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, with cbx or plain git, from Claude Code.",
5
- "version": "0.34.4",
5
+ "version": "0.34.5",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -88,10 +88,14 @@ function mediaTypeOf(file) {
88
88
  return MEDIA_TYPES[node_path_1.default.extname(file).toLowerCase()] ?? "application/octet-stream";
89
89
  }
90
90
  /** Hash without holding the file in memory; some of these are large. */
91
- async function digestOf(full) {
91
+ async function digestOf(full, onBytes) {
92
92
  const hash = (0, node_crypto_1.createHash)("sha256");
93
- for await (const chunk of (0, node_fs_1.createReadStream)(full))
93
+ let read = 0;
94
+ for await (const chunk of (0, node_fs_1.createReadStream)(full)) {
94
95
  hash.update(chunk);
96
+ read += chunk.byteLength;
97
+ onBytes?.(read);
98
+ }
95
99
  return hash.digest("hex");
96
100
  }
97
101
  /**
@@ -243,6 +247,38 @@ class Uploader {
243
247
  /** A single attempt. Everything about repeating it lives in `call`. */
244
248
  async attempt(route, init) {
245
249
  const started = performance.now();
250
+ /*
251
+ A body that says how much of itself has gone.
252
+
253
+ A batch of small files is one request of a few megabytes, and on an
254
+ ordinary home connection three of them in flight take ten seconds each.
255
+ Counted only when the answer came back, the bar stood still for those
256
+ ten seconds and then jumped — and the last few batches of a save looked
257
+ exactly like a save that had stopped. Handed over in slices, each slice
258
+ taken is counted as it is taken, which is what the network has accepted.
259
+ A fresh stream per attempt, so a retry counts from nothing again.
260
+ */
261
+ const streamed = init.onSent && init.body instanceof Uint8Array && init.body.byteLength > 0
262
+ ? (() => {
263
+ const whole = init.body;
264
+ const report = init.onSent;
265
+ const SLICE = 256 * 1024;
266
+ let at = 0;
267
+ report(0);
268
+ return new ReadableStream({
269
+ pull(controller) {
270
+ if (at >= whole.byteLength) {
271
+ controller.close();
272
+ return;
273
+ }
274
+ const next = whole.subarray(at, Math.min(at + SLICE, whole.byteLength));
275
+ at += next.byteLength;
276
+ controller.enqueue(next);
277
+ report(at / whole.byteLength);
278
+ },
279
+ });
280
+ })()
281
+ : null;
246
282
  const token = await this.credentials.token();
247
283
  if (!token)
248
284
  throw new Error("Sign in again before uploading");
@@ -258,7 +294,8 @@ class Uploader {
258
294
  : {}),
259
295
  ...(init.contentType ? { "content-type": init.contentType } : {}),
260
296
  },
261
- body: init.body,
297
+ body: streamed ?? init.body,
298
+ ...(streamed ? { duplex: "half" } : {}),
262
299
  /*
263
300
  The upload's own cancellation, and a deadline.
264
301
 
@@ -575,6 +612,42 @@ class Uploader {
575
612
  let cursor = 0;
576
613
  /** Throttled: twenty thousand sends is its own cost, and nobody reads them. */
577
614
  let told = 0;
615
+ /*
616
+ Bytes, not just files.
617
+
618
+ This said how many files were done and nothing else, so a project whose
619
+ weight is four 1.6 GB builds sat on "1 of 11" for three minutes on a
620
+ hard disk and then leapt: every report came after a whole file. The
621
+ sizes are known from the survey, so how much has been read is a share of
622
+ a known total, and the lanes say how far into a large file they are.
623
+ */
624
+ const readTotal = sending.reduce((sum, file) => sum + file.size, 0);
625
+ let readDone = 0;
626
+ const readingNow = new Map();
627
+ const tell = (path, force = false) => {
628
+ const now = Date.now();
629
+ if (!force && now - told <= 250)
630
+ return;
631
+ told = now;
632
+ let bytes = readDone;
633
+ for (const partial of readingNow.values())
634
+ bytes += partial;
635
+ report({
636
+ stage: "hash",
637
+ files: hashed,
638
+ totalFiles: sending.length,
639
+ bytes: Math.min(bytes, readTotal),
640
+ totalBytes: readTotal,
641
+ path,
642
+ /*
643
+ Reading is the stretch from just past the walk to a fifth of
644
+ the way through. Starting it at nought put the bar behind where
645
+ it had already been and it went backwards between stages.
646
+ */
647
+ percent: 8 + Math.round((hashed / sending.length) * 12),
648
+ bytesPerSecond: 0,
649
+ });
650
+ };
578
651
  const lane = async () => {
579
652
  for (;;) {
580
653
  const index = cursor;
@@ -597,6 +670,7 @@ class Uploader {
597
670
  vanished.push(file.path);
598
671
  readFailures.set(file.path, error instanceof Error ? error.message : String(error));
599
672
  hashed += 1;
673
+ readDone += file.size;
600
674
  continue;
601
675
  }
602
676
  /*
@@ -630,13 +704,19 @@ class Uploader {
630
704
  mediaType: mediaTypeOf(file.path),
631
705
  };
632
706
  hashed += 1;
707
+ readDone += file.size;
633
708
  continue;
634
709
  }
635
710
  let digest;
636
711
  try {
637
- digest = await (0, profile_js_1.timed)("hash files", () => digestOf(full));
712
+ digest = await (0, profile_js_1.timed)("hash files", () => digestOf(full, (read) => {
713
+ readingNow.set(index, read);
714
+ tell(file.path);
715
+ }));
638
716
  }
639
717
  catch (error) {
718
+ readingNow.delete(index);
719
+ readDone += file.size;
640
720
  vanished.push(file.path);
641
721
  readFailures.set(file.path, error instanceof Error ? error.message : String(error));
642
722
  hashed += 1;
@@ -651,25 +731,9 @@ class Uploader {
651
731
  mediaType: mediaTypeOf(file.path),
652
732
  };
653
733
  hashed += 1;
654
- const now = Date.now();
655
- if (now - told > 80 || hashed === sending.length) {
656
- told = now;
657
- report({
658
- stage: "hash",
659
- files: hashed,
660
- totalFiles: sending.length,
661
- bytes: 0,
662
- totalBytes: 0,
663
- path: file.path,
664
- /*
665
- Reading is the stretch from just past the walk to a fifth of
666
- the way through. Starting it at nought put the bar behind where
667
- it had already been and it went backwards between stages.
668
- */
669
- percent: 8 + Math.round((hashed / sending.length) * 12),
670
- bytesPerSecond: 0,
671
- });
672
- }
734
+ readingNow.delete(index);
735
+ readDone += file.size;
736
+ tell(file.path, hashed === sending.length);
673
737
  }
674
738
  };
675
739
  await Promise.all(Array.from({ length: Math.min(HASH_LANES, sending.length) }, () => lane()));
@@ -1090,7 +1154,7 @@ class Uploader {
1090
1154
  after which the service already holds the bytes and the copy costs a
1091
1155
  question rather than a transfer.
1092
1156
  */
1093
- const sending = new Map();
1157
+ const inFlightByDigest = new Map();
1094
1158
  const runSection = async (section) => {
1095
1159
  const declarations = section.files.map((file) => declarationByPath.get(file.path));
1096
1160
  /*
@@ -1202,6 +1266,19 @@ class Uploader {
1202
1266
  if (smallForPacking.length > 1) {
1203
1267
  let pack = [];
1204
1268
  let packBytes = 0;
1269
+ /* How many batches have had a lane of their own on the bar. */
1270
+ let batchLanes = 0;
1271
+ /*
1272
+ How long a source byte takes to land in a batch on this connection,
1273
+ learned from the batches that have landed.
1274
+
1275
+ Counting the body as it is handed over is not enough on its own: the
1276
+ operating system takes a few megabytes into its send buffer at once,
1277
+ so a whole batch reads as sent within a moment and then waits seconds
1278
+ for the wire. A batch in flight is shown no further along than its
1279
+ time so far at the pace earlier batches actually managed.
1280
+ */
1281
+ let secondsPerByte = 0;
1205
1282
  /*
1206
1283
  Batches overlap rather than queue.
1207
1284
 
@@ -1216,6 +1293,15 @@ class Uploader {
1216
1293
  that is reasonable to have outstanding.
1217
1294
  */
1218
1295
  const BATCH_LANES = 3;
1296
+ /*
1297
+ Time against the expected time, as a share of a batch: in step until
1298
+ nine tenths, then closing on the end without reaching it, so a batch
1299
+ that runs late keeps moving and never reads as landed before its
1300
+ answer says so.
1301
+ */
1302
+ const creep = (expectedShare) => expectedShare <= 0.9
1303
+ ? Math.max(0, expectedShare)
1304
+ : 0.9 + 0.09 * (1 - Math.exp(-(expectedShare - 0.9) / 0.3));
1219
1305
  const flying = new Set();
1220
1306
  const flush = async () => {
1221
1307
  if (!pack.length)
@@ -1223,6 +1309,52 @@ class Uploader {
1223
1309
  const taking = pack;
1224
1310
  pack = [];
1225
1311
  packBytes = 0;
1312
+ /*
1313
+ This batch's own lane on the bar, keyed below zero so it can never
1314
+ collide with a file's lane. Its source bytes are shared out as its
1315
+ body goes, and taken back off when the batch lands and is counted
1316
+ for real.
1317
+ */
1318
+ batchLanes += 1;
1319
+ const lane = -batchLanes;
1320
+ const batchSource = taking.reduce((sum, item) => sum + item.declaration.size, 0);
1321
+ let toldAt = 0;
1322
+ let handed = 0;
1323
+ const sentAt = Date.now();
1324
+ const show = () => {
1325
+ /*
1326
+ Until a batch has landed there is no pace of this connection to go
1327
+ by, and the first batches read as sent within a moment and then
1328
+ stood still for seconds. A cautious guess stands in until the first
1329
+ answer: a megabyte and a half a second, shared by the batches in
1330
+ flight. Too slow a guess costs only a jump forward when the batch
1331
+ lands; too fast a one would be the stall again.
1332
+ */
1333
+ const pace = secondsPerByte > 0 ? secondsPerByte : BATCH_LANES / 1_500_000;
1334
+ const elapsed = (Date.now() - sentAt) / 1000;
1335
+ const share = batchSource > 0 ? Math.min(handed, creep(elapsed / (pace * batchSource))) : handed;
1336
+ progress.lane(lane, Math.round(batchSource * share));
1337
+ const now = Date.now();
1338
+ if (now - toldAt < 250)
1339
+ return;
1340
+ toldAt = now;
1341
+ report({
1342
+ stage: "upload",
1343
+ files: advance(0),
1344
+ totalFiles: everyDeclaration.length,
1345
+ bytes: progress.bytes(),
1346
+ totalBytes,
1347
+ path: taking[taking.length - 1].declaration.path,
1348
+ percent: 20 + Math.round((progress.bytes() / Math.max(totalBytes, 1)) * 72),
1349
+ bytesPerSecond: progress.rate(),
1350
+ });
1351
+ };
1352
+ const onSent = (fraction) => {
1353
+ handed = fraction;
1354
+ show();
1355
+ };
1356
+ /* Keeps the estimate moving while the batch waits on the wire. */
1357
+ const pacing = setInterval(show, 300);
1226
1358
  const task = (async () => {
1227
1359
  let exactWireBytes = null;
1228
1360
  try {
@@ -1254,7 +1386,7 @@ class Uploader {
1254
1386
  batched.set(digest, one);
1255
1387
  }
1256
1388
  const placed = !delta && built
1257
- ? await (0, profile_js_1.timed)("send packs", () => this.putPack(repositoryId, packItems, built))
1389
+ ? await (0, profile_js_1.timed)("send packs", () => this.putPack(repositoryId, packItems, built, onSent))
1258
1390
  : null;
1259
1391
  if (placed) {
1260
1392
  (0, profile_js_1.counted)("files sent in packs", taking.length);
@@ -1267,7 +1399,7 @@ class Uploader {
1267
1399
  Not worth packing — already-compressed content, or too few files.
1268
1400
  They still travel together, just as separate objects.
1269
1401
  */
1270
- const answer = await (0, profile_js_1.timed)("send batches", () => this.putBatch(repositoryId, taking));
1402
+ const answer = await (0, profile_js_1.timed)("send batches", () => this.putBatch(repositoryId, taking, onSent));
1271
1403
  (0, profile_js_1.counted)("files sent in batches", taking.length);
1272
1404
  for (const [digest, one] of answer)
1273
1405
  batched.set(digest, one);
@@ -1297,6 +1429,13 @@ class Uploader {
1297
1429
  below and counted a second time — which is how the progress bar came
1298
1430
  to report a hundred and twenty percent of a forty megabyte upload.
1299
1431
  */
1432
+ /* Landed or refused, its estimate comes off: what landed is counted below. */
1433
+ clearInterval(pacing);
1434
+ progress.laneDone(lane);
1435
+ if (batchSource > 0 && taking.some((item) => batched.has(item.declaration.sha256) || packedInto.has(item.declaration.sha256))) {
1436
+ const took = (Date.now() - sentAt) / 1000 / batchSource;
1437
+ secondsPerByte = secondsPerByte > 0 ? secondsPerByte * 0.6 + took * 0.4 : took;
1438
+ }
1300
1439
  for (const item of taking) {
1301
1440
  const digest = item.declaration.sha256;
1302
1441
  if (!batched.has(digest) && !packedInto.has(digest))
@@ -1317,7 +1456,10 @@ class Uploader {
1317
1456
  percent: 20 + Math.round((progress.bytes() / Math.max(totalBytes, 1)) * 72),
1318
1457
  bytesPerSecond: progress.rate(),
1319
1458
  });
1320
- })().finally(() => flying.delete(task));
1459
+ })().finally(() => {
1460
+ clearInterval(pacing);
1461
+ flying.delete(task);
1462
+ });
1321
1463
  flying.add(task);
1322
1464
  if (flying.size >= BATCH_LANES)
1323
1465
  await Promise.race(flying);
@@ -1359,12 +1501,12 @@ class Uploader {
1359
1501
  const queue = declarations.entries();
1360
1502
  const runWorker = async () => {
1361
1503
  for (const [index, declaration] of queue) {
1362
- const earlier = sending.get(declaration.sha256);
1504
+ const earlier = inFlightByDigest.get(declaration.sha256);
1363
1505
  if (earlier)
1364
1506
  await earlier;
1365
1507
  let release = () => { };
1366
1508
  if (!earlier) {
1367
- sending.set(declaration.sha256, new Promise((done) => {
1509
+ inFlightByDigest.set(declaration.sha256, new Promise((done) => {
1368
1510
  release = done;
1369
1511
  }));
1370
1512
  }
@@ -1831,7 +1973,7 @@ class Uploader {
1831
1973
  * service would not take it — in which case the caller falls back to sending
1832
1974
  * them separately, which is slower and larger but always works.
1833
1975
  */
1834
- async putPack(repositoryId, items, alreadyBuilt) {
1976
+ async putPack(repositoryId, items, alreadyBuilt, onSent) {
1835
1977
  const built = alreadyBuilt ?? await (0, solid_js_1.buildSolidPack)(items.map((item) => ({
1836
1978
  sha256: item.declaration.sha256,
1837
1979
  body: item.body,
@@ -1867,6 +2009,7 @@ class Uploader {
1867
2009
  method: "POST",
1868
2010
  contentType: "application/octet-stream",
1869
2011
  body: packed,
2012
+ ...(onSent ? { onSent } : {}),
1870
2013
  });
1871
2014
  const placed = new Map();
1872
2015
  /*
@@ -2128,7 +2271,7 @@ class Uploader {
2128
2271
  * content-addressed, so a retry is free and a partial batch costs nothing
2129
2272
  * but the objects it missed.
2130
2273
  */
2131
- async putBatch(repositoryId, items) {
2274
+ async putBatch(repositoryId, items, onSent) {
2132
2275
  const landed = new Map();
2133
2276
  if (!items.length)
2134
2277
  return landed;
@@ -2170,6 +2313,7 @@ class Uploader {
2170
2313
  method: "POST",
2171
2314
  contentType: "application/octet-stream",
2172
2315
  body: packed,
2316
+ ...(onSent ? { onSent } : {}),
2173
2317
  });
2174
2318
  for (const one of answer.objects ?? []) {
2175
2319
  landed.set(one.sha256, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.34.4",
3
+ "version": "0.34.5",
4
4
  "description": "The command line for CodeRook (coderook.com): version control and project hosting for projects of any size. Every save is a compressed snapshot, private or public, never used to train AI.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://coderook.com",