@coderook/cli 0.7.0 → 0.8.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.
@@ -430,6 +430,12 @@ The connection failed: ${text}`));
430
430
  ` or ${accent("coderook merges")} to see everything waiting.`);
431
431
  return 0;
432
432
  }
433
+ /*
434
+ The version exists on the account. Stopping before the line below is the
435
+ crash that leaves this machine not knowing that — the one the derived
436
+ attempt name exists to make survivable.
437
+ */
438
+ (0, faults_js_1.maybeFail)("submit:after-commit");
433
439
  await (0, config_js_1.writeLink)(folder, {
434
440
  repositoryId: result.repositoryId,
435
441
  slug: link?.slug ?? node_path_1.default.basename(folder),
@@ -446,7 +452,16 @@ The connection failed: ${text}`));
446
452
  }
447
453
  console.log(`Saved ${accent(`v${result.sequence}`)} · sent ${bytes(result.sentBytes)} in ` +
448
454
  `${result.sentFiles} file${result.sentFiles === 1 ? "" : "s"}` +
449
- (result.reusedFiles ? `, ${result.reusedFiles} already stored` : "") +
455
+ /*
456
+ Three different things, kept apart. Sent is what crossed the wire.
457
+ Already on the account is content the service turned out to have, which
458
+ is what makes finishing an interrupted save cheap. Unchanged is what
459
+ the version keeps without either.
460
+ */
461
+ (result.alreadyStoredFiles
462
+ ? `, ${result.alreadyStoredFiles} already on the account`
463
+ : "") +
464
+ (result.reusedFiles ? `, ${result.reusedFiles} unchanged` : "") +
450
465
  ` · version holds ${bytes(result.sourceBytes)}`);
451
466
  return 0;
452
467
  }
@@ -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 faults_js_1 = require("./faults.js");
19
20
  const identify_js_1 = require("./identify.js");
20
21
  const worktree_js_1 = require("./worktree.js");
21
22
  const publish_name_js_1 = require("./publish_name.js");
@@ -239,6 +240,12 @@ class Uploader {
239
240
  // 3. Send the objects.
240
241
  const uploaded = [];
241
242
  let sentBytes = 0;
243
+ /*
244
+ Files whose content the service already had. Counted apart from what was
245
+ sent, because reporting them as sent would overstate the work and hide
246
+ the thing worth knowing: an interrupted save costs nothing to finish.
247
+ */
248
+ let alreadyOnAccount = 0;
242
249
  const startedAt = Date.now();
243
250
  const rate = () => {
244
251
  const seconds = (Date.now() - startedAt) / 1000;
@@ -256,25 +263,35 @@ class Uploader {
256
263
  percent: 20 + Math.round((sentBytes / Math.max(totalBytes, 1)) * 72),
257
264
  bytesPerSecond: rate(),
258
265
  });
259
- const result = declaration.size <= DIRECT_LIMIT
260
- ? await this.putDirect(repositoryId, declaration).catch((error) => {
261
- // Naming the file turns "it failed" into something actionable.
262
- const reason = error instanceof Error ? error.message : String(error);
263
- throw new Error(`${declaration.path}: ${reason}`);
264
- })
265
- : await this.putMultipart(repositoryId, declaration, (offset) => {
266
- report({
267
- stage: "upload",
268
- files: index,
269
- totalFiles: declarations.length,
270
- bytes: sentBytes + offset,
271
- totalBytes,
272
- path: declaration.path,
273
- percent: 20 +
274
- Math.round(((sentBytes + offset) / Math.max(totalBytes, 1)) * 72),
275
- bytesPerSecond: rate(),
276
- });
277
- });
266
+ /*
267
+ Content the service already holds does not need sending. Asking costs
268
+ one round trip and saves the whole file which after an interrupted
269
+ save is the difference between re-uploading a project and re-uploading
270
+ nothing.
271
+ */
272
+ const alreadyStored = await this.findStored(repositoryId, declaration);
273
+ if (alreadyStored)
274
+ alreadyOnAccount += 1;
275
+ const result = alreadyStored ??
276
+ (declaration.size <= DIRECT_LIMIT
277
+ ? await this.putDirect(repositoryId, declaration).catch((error) => {
278
+ // Naming the file turns "it failed" into something actionable.
279
+ const reason = error instanceof Error ? error.message : String(error);
280
+ throw new Error(`${declaration.path}: ${reason}`);
281
+ })
282
+ : await this.putMultipart(repositoryId, declaration, (offset) => {
283
+ report({
284
+ stage: "upload",
285
+ files: index,
286
+ totalFiles: declarations.length,
287
+ bytes: sentBytes + offset,
288
+ totalBytes,
289
+ path: declaration.path,
290
+ percent: 20 +
291
+ Math.round(((sentBytes + offset) / Math.max(totalBytes, 1)) * 72),
292
+ bytesPerSecond: rate(),
293
+ });
294
+ }));
278
295
  uploaded.push({
279
296
  path: declaration.path,
280
297
  objectId: result.objectId,
@@ -327,6 +344,12 @@ class Uploader {
327
344
  }
328
345
  const sourceBytes = contents.reduce((total, item) => total + item.sourceSize, 0);
329
346
  const storedBytes = contents.reduce((total, item) => total + item.storedSize, 0);
347
+ /*
348
+ Every object is uploaded and verified; nothing on the account points at
349
+ them yet. Stopping here has to leave no version and no partial one — the
350
+ objects are immutable and content-addressed, so a retry reuses them.
351
+ */
352
+ (0, faults_js_1.maybeFail)("submit:after-objects");
330
353
  const completed = await this.call(`/v1/repositories/${repositoryId}/versions`, {
331
354
  method: "POST",
332
355
  contentType: "application/json",
@@ -376,8 +399,10 @@ class Uploader {
376
399
  sourceBytes,
377
400
  storedBytes,
378
401
  sentBytes,
379
- sentFiles: uploaded.length,
402
+ sentFiles: uploaded.length - alreadyOnAccount,
380
403
  reusedFiles: reused.length,
404
+ /** Selected, but the service already held the content. */
405
+ alreadyStoredFiles: alreadyOnAccount,
381
406
  // A file that kept its old object records the digest of *that* copy,
382
407
  // not of the file on disk, so an unticked edit is still pending next
383
408
  // time rather than looking as though it had been saved.
@@ -440,6 +465,39 @@ class Uploader {
440
465
  return null;
441
466
  }
442
467
  }
468
+ /**
469
+ * The stored object for this content, when the service already has it.
470
+ *
471
+ * A miss is the ordinary case and must be cheap, so it is one request that
472
+ * carries no body either way. Any failure answers "not stored" rather than
473
+ * throwing: this is an optimisation, and it must never be the reason a save
474
+ * does not happen.
475
+ */
476
+ async findStored(repositoryId, declaration) {
477
+ try {
478
+ const token = await this.credentials.token();
479
+ if (!token)
480
+ return null;
481
+ const response = await fetch(`${this.credentials.origin()}/v1/repositories/${repositoryId}` +
482
+ `/stored?sha256=${declaration.sha256}`, {
483
+ headers: {
484
+ accept: "application/json",
485
+ authorization: `Bearer ${token}`,
486
+ "user-agent": "CodeRook/0.1",
487
+ ...(0, identify_js_1.clientHeaders)(),
488
+ },
489
+ });
490
+ if (!response.ok)
491
+ return null;
492
+ const body = (await response.json());
493
+ if (!body.stored || !body.objectId)
494
+ return null;
495
+ return { objectId: body.objectId, size: Number(body.storedSize ?? 0) };
496
+ }
497
+ catch {
498
+ return null;
499
+ }
500
+ }
443
501
  async putDirect(repositoryId, declaration) {
444
502
  const body = await (0, promises_1.readFile)(declaration.full);
445
503
  // The file was hashed earlier, and a live file — a database, a log — can
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.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,11 +32,12 @@
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/fault-get.mjs --allow-production && node test/version-floor.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/fault-submit.mjs --allow-production && node test/attempt-identity.mjs --allow-production",
36
36
  "test:getedits": "node test/get-protects-edits.mjs",
37
37
  "test:upgrade": "node test/upgrade-migration.mjs",
38
38
  "test:faultget": "node test/fault-get.mjs",
39
- "test:floor": "node test/version-floor.mjs"
39
+ "test:floor": "node test/version-floor.mjs",
40
+ "test:faultsubmit": "node test/fault-submit.mjs"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@types/node": "24.10.1",