@coderook/cli 0.7.0 → 0.9.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/cli.js +16 -1
- package/dist/cli/src/config.js +32 -3
- package/dist/desktop-app/src/main/upload.js +78 -20
- package/package.json +5 -3
package/dist/cli/src/cli.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/cli/src/config.js
CHANGED
|
@@ -42,15 +42,44 @@ function configDirectory() {
|
|
|
42
42
|
}
|
|
43
43
|
const tokenFile = () => node_path_1.default.join(configDirectory(), "token");
|
|
44
44
|
const linksFile = () => node_path_1.default.join(configDirectory(), "links.json");
|
|
45
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Write a file only its owner can read, and atomically.
|
|
47
|
+
*
|
|
48
|
+
* The temporary name carries this process's own identity. Sharing one — as
|
|
49
|
+
* `${target}.tmp` did — means two runs at once write the same scratch file
|
|
50
|
+
* and then race to move it, which on Windows fails outright and elsewhere
|
|
51
|
+
* quietly hands one run the other's bytes.
|
|
52
|
+
*
|
|
53
|
+
* The move is retried briefly because Windows refuses a rename while another
|
|
54
|
+
* process still has the destination open, which during a race is ordinary
|
|
55
|
+
* rather than exceptional.
|
|
56
|
+
*/
|
|
46
57
|
async function writePrivate(target, body) {
|
|
47
58
|
await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
|
|
48
|
-
const temporary = `${target}.tmp`;
|
|
59
|
+
const temporary = `${target}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
49
60
|
await (0, promises_1.writeFile)(temporary, body, "utf8");
|
|
50
61
|
// Windows ignores the mode; on everything else this is what keeps the
|
|
51
62
|
// token out of other accounts' reach.
|
|
52
63
|
await (0, promises_1.chmod)(temporary, 0o600).catch(() => undefined);
|
|
53
|
-
|
|
64
|
+
try {
|
|
65
|
+
for (let attempt = 0;; attempt += 1) {
|
|
66
|
+
try {
|
|
67
|
+
await (0, promises_1.rename)(temporary, target);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
const code = error.code;
|
|
72
|
+
if (attempt >= 20 || (code !== "EPERM" && code !== "EACCES" && code !== "EBUSY")) {
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
await new Promise((wake) => setTimeout(wake, 10 + attempt * 5));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
// A failed move must not leave scratch files accumulating beside it.
|
|
81
|
+
await (0, promises_1.rm)(temporary, { force: true }).catch(() => undefined);
|
|
82
|
+
}
|
|
54
83
|
}
|
|
55
84
|
async function storeToken(token) {
|
|
56
85
|
await writePrivate(tokenFile(), `${token.trim()}\n`);
|
|
@@ -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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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.
|
|
3
|
+
"version": "0.9.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,13 @@
|
|
|
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/race-attempts.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",
|
|
41
|
+
"test:race": "node test/race-attempts.mjs"
|
|
40
42
|
},
|
|
41
43
|
"devDependencies": {
|
|
42
44
|
"@types/node": "24.10.1",
|