@coderook/cli 0.32.1 → 0.33.1
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/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/cbx/src/core/download.js +103 -5
- package/dist/cbx/src/core/upload.js +32 -1
- package/dist/cbx/src/local/remote.js +8 -2
- package/dist/cli/src/api.js +41 -13
- package/dist/cli/src/cli.js +173 -59
- package/dist/cli/src/config.js +57 -4
- package/dist/cli/src/git_remote.js +8 -5
- package/dist/cli/src/licence_commands.js +18 -2
- package/dist/cli/src/network.js +77 -0
- package/dist/cli/src/publish.js +1 -0
- package/package.json +1 -1
|
@@ -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.
|
|
5
|
+
"version": "0.33.1",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ACCA Gaming Productions",
|
|
8
8
|
"url": "https://coderook.com"
|
package/README.md
CHANGED
|
@@ -331,7 +331,7 @@ does not already have.
|
|
|
331
331
|
| Tag kind | A lightweight tag comes back annotated. CodeRook records a name, not which sort of tag made it. |
|
|
332
332
|
| Force pushes, branch deletion | Refused — versions are immutable, so there is nothing to rewind to. |
|
|
333
333
|
| Submodules | Skipped on push, and it says which paths. |
|
|
334
|
-
| The executable bit |
|
|
334
|
+
| The executable bit | Kept: a script pushed as `100755` clones back executable, with `cbx clone` and `git clone` alike. Versions saved before 0.33 recorded every file as not executable. |
|
|
335
335
|
|
|
336
336
|
**On round trips.** Push then clone gives you back the same *files*, not the
|
|
337
337
|
same *commit ids*. A commit id covers its author, committer and timestamps,
|
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.Downloader = exports.DownloadCancelled = void 0;
|
|
7
7
|
exports.underway = underway;
|
|
8
|
+
exports.applyMode = applyMode;
|
|
8
9
|
/**
|
|
9
10
|
* Bringing a project down: the file list for a version, then its bytes.
|
|
10
11
|
*
|
|
@@ -18,6 +19,7 @@ const node_fs_1 = require("node:fs");
|
|
|
18
19
|
const promises_1 = require("node:fs/promises");
|
|
19
20
|
const promises_2 = require("node:stream/promises");
|
|
20
21
|
const node_stream_1 = require("node:stream");
|
|
22
|
+
const node_zlib_1 = require("node:zlib");
|
|
21
23
|
const node_path_1 = __importDefault(require("node:path"));
|
|
22
24
|
const safe_path_js_1 = require("../shared/safe_path.js");
|
|
23
25
|
const identify_js_1 = require("./identify.js");
|
|
@@ -290,6 +292,7 @@ class Downloader {
|
|
|
290
292
|
...(row.objectId ? { objectId: String(row.objectId) } : {}),
|
|
291
293
|
sha256: String(row.sha256 ?? ""),
|
|
292
294
|
sourceSize: Number(row.sourceSize ?? 0),
|
|
295
|
+
...(row.executable === true ? { executable: true } : {}),
|
|
293
296
|
...(row.sealed === true ? { sealed: true } : {}),
|
|
294
297
|
...(packed?.objectId
|
|
295
298
|
? {
|
|
@@ -344,7 +347,9 @@ class Downloader {
|
|
|
344
347
|
*/
|
|
345
348
|
async fileTo(repositoryId, versionId, file, target) {
|
|
346
349
|
await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
|
|
347
|
-
|
|
350
|
+
const digest = await this.fetchInto(repositoryId, versionId, file, target);
|
|
351
|
+
await applyMode(target, file.executable === true);
|
|
352
|
+
return digest;
|
|
348
353
|
}
|
|
349
354
|
/**
|
|
350
355
|
* Reconstruct and verify every file in a Version without materialising the
|
|
@@ -489,6 +494,18 @@ class Downloader {
|
|
|
489
494
|
await (0, promises_1.rm)(temporary, { force: true });
|
|
490
495
|
}
|
|
491
496
|
}
|
|
497
|
+
/*
|
|
498
|
+
Paths whose bytes arrived still gzip-compressed and were recovered.
|
|
499
|
+
|
|
500
|
+
A service that recorded an object's encoding wrongly served its stored
|
|
501
|
+
gzip as though it were the file (found on CodeRook in September 2026; the
|
|
502
|
+
service now judges the encoding from the stored size). The bytes were
|
|
503
|
+
never damaged, only still compressed, so a body that fails its digest,
|
|
504
|
+
starts with gzip's magic number and decompresses to exactly the expected
|
|
505
|
+
digest is accepted. Anything else still fails. Listed here so a caller
|
|
506
|
+
can say it happened.
|
|
507
|
+
*/
|
|
508
|
+
recoveredFromGzip = [];
|
|
492
509
|
async fetchInto(repositoryId, versionId, file, target) {
|
|
493
510
|
const whole = (0, node_crypto_1.createHash)("sha256");
|
|
494
511
|
/*
|
|
@@ -499,6 +516,7 @@ class Downloader {
|
|
|
499
516
|
that cannot match the digest this file was listed with.
|
|
500
517
|
*/
|
|
501
518
|
const pieces = file.sealed ? [] : (file.chunks ?? []);
|
|
519
|
+
let recovered = false;
|
|
502
520
|
// Captured so the generators below do not need `this` rebound.
|
|
503
521
|
const request = (route) => this.request(route);
|
|
504
522
|
const check = () => this.check();
|
|
@@ -508,7 +526,7 @@ class Downloader {
|
|
|
508
526
|
check();
|
|
509
527
|
const piece = pieces[at];
|
|
510
528
|
const reply = await request(`/v1/repositories/${repositoryId}/objects/${piece.objectId}`);
|
|
511
|
-
|
|
529
|
+
let body = Buffer.from(await reply.arrayBuffer());
|
|
512
530
|
/*
|
|
513
531
|
Checked against what the upload recorded for this piece, before
|
|
514
532
|
a byte of it reaches the file. Without the digest the only
|
|
@@ -517,8 +535,13 @@ class Downloader {
|
|
|
517
535
|
*/
|
|
518
536
|
const digest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
|
|
519
537
|
if (piece.sha256 && digest !== piece.sha256) {
|
|
520
|
-
|
|
521
|
-
|
|
538
|
+
const plain = gunzippedMatching(body, piece.sha256);
|
|
539
|
+
if (!plain) {
|
|
540
|
+
throw new Error(`${file.path}: piece ${at + 1} of ${pieces.length} ` +
|
|
541
|
+
`did not arrive intact`);
|
|
542
|
+
}
|
|
543
|
+
recovered = true;
|
|
544
|
+
body = plain;
|
|
522
545
|
}
|
|
523
546
|
whole.update(body);
|
|
524
547
|
yield body;
|
|
@@ -545,7 +568,16 @@ class Downloader {
|
|
|
545
568
|
up holding something that looks finished.
|
|
546
569
|
*/
|
|
547
570
|
await (0, promises_2.pipeline)(source, (0, node_fs_1.createWriteStream)(target));
|
|
548
|
-
|
|
571
|
+
const digest = whole.digest("hex");
|
|
572
|
+
if (recovered)
|
|
573
|
+
this.recoveredFromGzip.push(file.path);
|
|
574
|
+
if (!pieces.length && file.sha256 && digest !== file.sha256) {
|
|
575
|
+
if (await recoverGzipFile(target, file.sha256)) {
|
|
576
|
+
this.recoveredFromGzip.push(file.path);
|
|
577
|
+
return file.sha256;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return digest;
|
|
549
581
|
}
|
|
550
582
|
/**
|
|
551
583
|
* Write a version into `destination`.
|
|
@@ -801,6 +833,7 @@ class Downloader {
|
|
|
801
833
|
await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
|
|
802
834
|
await (0, promises_1.rm)(target, { recursive: true, force: true });
|
|
803
835
|
await (0, promises_1.rename)((0, safe_path_js_1.inside)(staging, file.path), target);
|
|
836
|
+
await applyMode(target, file.executable === true);
|
|
804
837
|
placed += 1;
|
|
805
838
|
if (placed === 1)
|
|
806
839
|
(0, faults_js_1.maybeFail)("get:after-first-file");
|
|
@@ -849,3 +882,68 @@ class Downloader {
|
|
|
849
882
|
}
|
|
850
883
|
}
|
|
851
884
|
exports.Downloader = Downloader;
|
|
885
|
+
/** The gunzipped bytes when they hash to `expected`, otherwise null. */
|
|
886
|
+
function gunzippedMatching(body, expected) {
|
|
887
|
+
if (body.length < 2 || body[0] !== 0x1f || body[1] !== 0x8b)
|
|
888
|
+
return null;
|
|
889
|
+
try {
|
|
890
|
+
const plain = (0, node_zlib_1.gunzipSync)(body);
|
|
891
|
+
return (0, node_crypto_1.createHash)("sha256").update(plain).digest("hex") === expected ? plain : null;
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Replace a file that arrived still gzip-compressed with its contents, when
|
|
899
|
+
* those contents hash to `expected`. Streamed, so a large file is never held.
|
|
900
|
+
*/
|
|
901
|
+
async function recoverGzipFile(target, expected) {
|
|
902
|
+
const handle = await (0, promises_1.open)(target, "r");
|
|
903
|
+
const magic = Buffer.alloc(2);
|
|
904
|
+
try {
|
|
905
|
+
await handle.read(magic, 0, 2, 0);
|
|
906
|
+
}
|
|
907
|
+
finally {
|
|
908
|
+
await handle.close();
|
|
909
|
+
}
|
|
910
|
+
if (magic[0] !== 0x1f || magic[1] !== 0x8b)
|
|
911
|
+
return false;
|
|
912
|
+
const temporary = `${target}.gunzip`;
|
|
913
|
+
const hash = (0, node_crypto_1.createHash)("sha256");
|
|
914
|
+
try {
|
|
915
|
+
await (0, promises_2.pipeline)((0, node_fs_1.createReadStream)(target), (0, node_zlib_1.createGunzip)(), new node_stream_1.Transform({
|
|
916
|
+
transform(chunk, _encoding, done) {
|
|
917
|
+
hash.update(chunk);
|
|
918
|
+
done(null, chunk);
|
|
919
|
+
},
|
|
920
|
+
}), (0, node_fs_1.createWriteStream)(temporary));
|
|
921
|
+
}
|
|
922
|
+
catch {
|
|
923
|
+
await (0, promises_1.rm)(temporary, { force: true });
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
if (hash.digest("hex") !== expected) {
|
|
927
|
+
await (0, promises_1.rm)(temporary, { force: true });
|
|
928
|
+
return false;
|
|
929
|
+
}
|
|
930
|
+
await (0, promises_1.rename)(temporary, target);
|
|
931
|
+
return true;
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Give a written file the execute bit its version records, or take it away.
|
|
935
|
+
*
|
|
936
|
+
* Windows has no execute bit, so nothing is done there. Elsewhere a script
|
|
937
|
+
* that came back as an ordinary file could not be run until somebody noticed
|
|
938
|
+
* and ran chmod, which on Linux was every shell script in every clone.
|
|
939
|
+
*/
|
|
940
|
+
async function applyMode(target, executable) {
|
|
941
|
+
if (process.platform === "win32")
|
|
942
|
+
return;
|
|
943
|
+
try {
|
|
944
|
+
await (0, promises_1.chmod)(target, executable ? 0o755 : 0o644);
|
|
945
|
+
}
|
|
946
|
+
catch {
|
|
947
|
+
/* A file that cannot take a mode is still the right file. */
|
|
948
|
+
}
|
|
949
|
+
}
|
|
@@ -805,9 +805,19 @@ class Uploader {
|
|
|
805
805
|
sourceSize: held.sourceSize,
|
|
806
806
|
storedSize: held.storedSize,
|
|
807
807
|
mediaType: held.mediaType,
|
|
808
|
+
executable: request.executable?.get(file.path) ?? held.executable,
|
|
808
809
|
};
|
|
809
810
|
}),
|
|
810
811
|
].sort((left, right) => left.path.localeCompare(right.path));
|
|
812
|
+
/*
|
|
813
|
+
Whether each sent file runs. Every file used to go up as not, so a
|
|
814
|
+
script cloned on Linux came back unusable until somebody ran chmod.
|
|
815
|
+
*/
|
|
816
|
+
for (const item of contents) {
|
|
817
|
+
if (item.executable !== undefined)
|
|
818
|
+
continue;
|
|
819
|
+
item.executable = await executableFor(request, item.path, prior.get(item.path)?.executable ?? false);
|
|
820
|
+
}
|
|
811
821
|
/*
|
|
812
822
|
Last check before the version is written: everything the person ticked
|
|
813
823
|
has to be in it. Reaching this with something missing would publish a
|
|
@@ -917,7 +927,7 @@ class Uploader {
|
|
|
917
927
|
sourceSize: item.sourceSize,
|
|
918
928
|
storedSize: item.storedSize,
|
|
919
929
|
mediaType: item.mediaType,
|
|
920
|
-
executable:
|
|
930
|
+
executable: item.executable === true,
|
|
921
931
|
})),
|
|
922
932
|
}),
|
|
923
933
|
}));
|
|
@@ -1719,6 +1729,7 @@ class Uploader {
|
|
|
1719
1729
|
sourceSize: Number(row.sourceSize ?? 0),
|
|
1720
1730
|
storedSize: Number(row.storedSize ?? 0),
|
|
1721
1731
|
mediaType: String(row.mediaType ?? "application/octet-stream"),
|
|
1732
|
+
executable: row.executable === true,
|
|
1722
1733
|
});
|
|
1723
1734
|
}
|
|
1724
1735
|
}
|
|
@@ -2629,3 +2640,23 @@ class Uploader {
|
|
|
2629
2640
|
}
|
|
2630
2641
|
}
|
|
2631
2642
|
exports.Uploader = Uploader;
|
|
2643
|
+
/**
|
|
2644
|
+
* Whether a file being sent is marked to run.
|
|
2645
|
+
*
|
|
2646
|
+
* The caller's word first, then the file's own mode. Windows has no execute
|
|
2647
|
+
* bit to read, so there a file keeps whatever the previous version said,
|
|
2648
|
+
* rather than every save from Windows quietly clearing it.
|
|
2649
|
+
*/
|
|
2650
|
+
async function executableFor(request, relative, before) {
|
|
2651
|
+
const told = request.executable?.get(relative);
|
|
2652
|
+
if (told !== undefined)
|
|
2653
|
+
return told;
|
|
2654
|
+
if (process.platform === "win32")
|
|
2655
|
+
return before;
|
|
2656
|
+
try {
|
|
2657
|
+
return ((await (0, promises_1.stat)(node_path_1.default.join(request.localPath, relative))).mode & 0o111) !== 0;
|
|
2658
|
+
}
|
|
2659
|
+
catch {
|
|
2660
|
+
return before;
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
@@ -226,6 +226,8 @@ async function push(repository, options) {
|
|
|
226
226
|
the wrong ones. The git push sends this for the same reason.
|
|
227
227
|
*/
|
|
228
228
|
allowIgnored: true,
|
|
229
|
+
/* The save's own record of what runs; on Windows the scratch tree cannot say. */
|
|
230
|
+
executable: new Map([...after].map(([file, entry]) => [file, entry.executable])),
|
|
229
231
|
...(options.allowSecrets ? { allowSecrets: true } : {}),
|
|
230
232
|
...(options.acknowledged ? { acknowledged: true } : {}),
|
|
231
233
|
...(known ? { known } : {}),
|
|
@@ -325,7 +327,11 @@ async function pull(repository, options) {
|
|
|
325
327
|
const was = before.get(file.path);
|
|
326
328
|
const reuse = was?.sha256 === file.sha256 ? was : byDigest.get(file.sha256);
|
|
327
329
|
if (reuse) {
|
|
328
|
-
entries.push({
|
|
330
|
+
entries.push({
|
|
331
|
+
...reuse,
|
|
332
|
+
path: file.path,
|
|
333
|
+
executable: file.executable ?? was?.executable ?? reuse.executable,
|
|
334
|
+
});
|
|
329
335
|
}
|
|
330
336
|
else {
|
|
331
337
|
fetch.push(file);
|
|
@@ -347,7 +353,7 @@ async function pull(repository, options) {
|
|
|
347
353
|
path: file.path,
|
|
348
354
|
size: file.sourceSize,
|
|
349
355
|
sha256: stored.sha256,
|
|
350
|
-
executable: before.get(file.path)?.executable ?? false,
|
|
356
|
+
executable: file.executable ?? before.get(file.path)?.executable ?? false,
|
|
351
357
|
chunks: stored.chunks,
|
|
352
358
|
};
|
|
353
359
|
entries.push(entry);
|
package/dist/cli/src/api.js
CHANGED
|
@@ -58,6 +58,7 @@ exports.addWebhook = addWebhook;
|
|
|
58
58
|
exports.removeWebhook = removeWebhook;
|
|
59
59
|
exports.collisionCheck = collisionCheck;
|
|
60
60
|
/** The small part of the API the command-line tool needs directly. */
|
|
61
|
+
const network_js_1 = require("./network.js");
|
|
61
62
|
const identify_js_1 = require("../../cbx/src/core/identify.js");
|
|
62
63
|
const config_js_1 = require("./config.js");
|
|
63
64
|
/**
|
|
@@ -99,18 +100,36 @@ async function call(route, options = {}) {
|
|
|
99
100
|
if (!token) {
|
|
100
101
|
throw new Error("Not signed in. Run: cbx sign-in");
|
|
101
102
|
}
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
103
|
+
const method = options.method ?? "GET";
|
|
104
|
+
/*
|
|
105
|
+
A read that failed before any answer is asked again, twice, with a pause.
|
|
106
|
+
Only reads: a write that may have landed is the caller's to retry, with
|
|
107
|
+
what it knows about idempotency.
|
|
108
|
+
*/
|
|
109
|
+
let response;
|
|
110
|
+
for (let attempt = 1;; attempt += 1) {
|
|
111
|
+
try {
|
|
112
|
+
response = await fetch(`${(0, config_js_1.apiOrigin)()}${route}`, {
|
|
113
|
+
method,
|
|
114
|
+
headers: {
|
|
115
|
+
accept: "application/json",
|
|
116
|
+
authorization: `Bearer ${token}`,
|
|
117
|
+
"user-agent": "CodeRook-CLI/0.1",
|
|
118
|
+
...(0, identify_js_1.clientHeaders)(),
|
|
119
|
+
...(options.body ? { "content-type": "application/json" } : {}),
|
|
120
|
+
},
|
|
121
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
122
|
+
signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
|
|
123
|
+
});
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (method !== "GET" || attempt >= 3 || !(0, network_js_1.isNetworkFailure)(error)) {
|
|
128
|
+
throw new Error((0, network_js_1.describeError)(error), { cause: error });
|
|
129
|
+
}
|
|
130
|
+
await (0, network_js_1.pause)(500 * attempt);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
114
133
|
const text = await response.text();
|
|
115
134
|
const body = text ? JSON.parse(text) : {};
|
|
116
135
|
if (!response.ok) {
|
|
@@ -299,7 +318,16 @@ async function findProject(reference) {
|
|
|
299
318
|
the authenticated listing knows about private projects the public one
|
|
300
319
|
cannot see.
|
|
301
320
|
*/
|
|
302
|
-
|
|
321
|
+
/*
|
|
322
|
+
Only the service saying no becomes "no projects". A network failure or a
|
|
323
|
+
missing sign-in is said as itself: swallowing it here turned a timeout
|
|
324
|
+
into "No project matching", which sent somebody looking for a typo.
|
|
325
|
+
*/
|
|
326
|
+
const all = await projects().catch((error) => {
|
|
327
|
+
if (error instanceof ServiceError)
|
|
328
|
+
return [];
|
|
329
|
+
throw error;
|
|
330
|
+
});
|
|
303
331
|
const ownersMatch = (project) => project.slug.toLowerCase() === bare || project.name.toLowerCase() === bare;
|
|
304
332
|
if (!owner) {
|
|
305
333
|
const mine = all.find(ownersMatch);
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
18
18
|
const promises_1 = require("node:readline/promises");
|
|
19
19
|
const node_os_1 = __importDefault(require("node:os"));
|
|
20
20
|
const promises_2 = require("node:fs/promises");
|
|
21
|
+
const node_crypto_1 = require("node:crypto");
|
|
21
22
|
const node_fs_1 = require("node:fs");
|
|
22
23
|
const node_path_1 = __importDefault(require("node:path"));
|
|
23
24
|
const node_process_1 = __importDefault(require("node:process"));
|
|
@@ -45,6 +46,7 @@ const faults_js_1 = require("../../cbx/src/core/faults.js");
|
|
|
45
46
|
const version_commands_js_1 = require("./version_commands.js");
|
|
46
47
|
const identify_js_1 = require("../../cbx/src/core/identify.js");
|
|
47
48
|
const diff_command_js_1 = require("./diff_command.js");
|
|
49
|
+
const network_js_1 = require("./network.js");
|
|
48
50
|
const local_commands_js_1 = require("./local_commands.js");
|
|
49
51
|
const profile_js_1 = require("../../cbx/src/core/profile.js");
|
|
50
52
|
const detect_js_1 = require("../../cbx/src/core/detect.js");
|
|
@@ -604,9 +606,51 @@ async function commandImport(parsed) {
|
|
|
604
606
|
}
|
|
605
607
|
return code;
|
|
606
608
|
}
|
|
609
|
+
/** A file's SHA-256, streamed; null when it cannot be read. */
|
|
610
|
+
async function digestOfFile(full) {
|
|
611
|
+
try {
|
|
612
|
+
const hash = (0, node_crypto_1.createHash)("sha256");
|
|
613
|
+
for await (const block of (0, node_fs_1.createReadStream)(full))
|
|
614
|
+
hash.update(block);
|
|
615
|
+
return hash.digest("hex");
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Say so when files deleted here are still in the saved version.
|
|
623
|
+
*
|
|
624
|
+
* A plain save adds and updates and never removes, so a deleted or moved file
|
|
625
|
+
* lives on in every save after it until somebody passes --sync. `status`
|
|
626
|
+
* already said this; `submit` did not, and from Linux a project turned out to
|
|
627
|
+
* carry a stale copy of a whole app at its root for weeks. Counted from the
|
|
628
|
+
* folder's record of what it holds, so it costs a stat per recorded file.
|
|
629
|
+
*/
|
|
630
|
+
function noteKeptDeletions(folder, baseline, syncing) {
|
|
631
|
+
if (syncing || !baseline?.size)
|
|
632
|
+
return;
|
|
633
|
+
const gone = [...baseline.keys()].filter((file) => !(0, node_fs_1.existsSync)(node_path_1.default.join(folder, ...file.split("/"))));
|
|
634
|
+
if (!gone.length)
|
|
635
|
+
return;
|
|
636
|
+
console.log(dim(`${gone.length} file${gone.length === 1 ? "" : "s"} deleted here ${gone.length === 1 ? "is" : "are"} still in the saved version` +
|
|
637
|
+
` (${gone.slice(0, 3).join(", ")}${gone.length > 3 ? ", …" : ""}). A save adds and updates;` +
|
|
638
|
+
` run cbx submit --sync to remove ${gone.length === 1 ? "it" : "them"}.`));
|
|
639
|
+
}
|
|
607
640
|
async function commandSubmit(parsed) {
|
|
608
641
|
const folder = folderFor(parsed);
|
|
609
642
|
const message = flagText(parsed, "m", "message") ?? "";
|
|
643
|
+
/*
|
|
644
|
+
Checked before anything is scanned or planned. The service's own refusal
|
|
645
|
+
arrived after the upload plan had been printed, as a schema error about
|
|
646
|
+
"<=240 characters", which reads like a fault rather than a limit.
|
|
647
|
+
*/
|
|
648
|
+
if (message.trim().length > 240) {
|
|
649
|
+
console.error(red(`The message is ${message.trim().length} characters; the limit is 240.`));
|
|
650
|
+
console.error(dim("Put the detail in release notes or a NOTES file, and keep the message to a line."));
|
|
651
|
+
return 1;
|
|
652
|
+
}
|
|
653
|
+
const dryRun = hasFlag(parsed, "dry-run", "n");
|
|
610
654
|
const { link, baseline, behind, member } = await reconcile(folder);
|
|
611
655
|
/*
|
|
612
656
|
Somebody else's project, cloned from its public page. Reading and
|
|
@@ -632,7 +676,12 @@ async function commandSubmit(parsed) {
|
|
|
632
676
|
Only on a project's first save. After that an absent LICENSE is somebody's
|
|
633
677
|
decision and putting one back would be arguing with them.
|
|
634
678
|
*/
|
|
635
|
-
|
|
679
|
+
/*
|
|
680
|
+
A dry run changes nothing, and that includes writing a LICENSE: it says
|
|
681
|
+
what would be added instead.
|
|
682
|
+
*/
|
|
683
|
+
const wouldLicence = dryRun && !link && !hasFlag(parsed, "no-licence") && !(0, node_fs_1.existsSync)(node_path_1.default.join(folder, "LICENSE"));
|
|
684
|
+
const added = link || dryRun
|
|
636
685
|
? null
|
|
637
686
|
: await (0, licence_commands_js_1.licenceNewProject)(folder,
|
|
638
687
|
/*
|
|
@@ -836,6 +885,10 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
|
|
|
836
885
|
}
|
|
837
886
|
console.log(dim("Sending them anyway, because --allow-secrets was given."));
|
|
838
887
|
}
|
|
888
|
+
if (wouldLicence) {
|
|
889
|
+
console.log(`${accent("Would add an MIT licence")} on a real save, so other people may use this.` +
|
|
890
|
+
dim(" Pass --no-licence to leave it out."));
|
|
891
|
+
}
|
|
839
892
|
if (added) {
|
|
840
893
|
console.log(`${accent("Added an MIT licence")}, so other people may use this.`);
|
|
841
894
|
console.log(dim(` Change it with `) +
|
|
@@ -846,7 +899,7 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
|
|
|
846
899
|
console.log("");
|
|
847
900
|
}
|
|
848
901
|
const line = progressLine();
|
|
849
|
-
|
|
902
|
+
let uploader = new upload_js_1.Uploader(config_js_1.credentials);
|
|
850
903
|
/*
|
|
851
904
|
Built by the shared description, so this and the git remote helper cannot
|
|
852
905
|
drift. They already had: the helper listed deletions in `deletions` alone
|
|
@@ -948,63 +1001,99 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
|
|
|
948
1001
|
}
|
|
949
1002
|
}
|
|
950
1003
|
let result;
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1004
|
+
/*
|
|
1005
|
+
How long to wait before picking a dropped upload up again by itself.
|
|
1006
|
+
|
|
1007
|
+
Each request is already retried for a few seconds, but a Wi-Fi drop or a
|
|
1008
|
+
router reconnecting outlasts that, and the upload ended with "run the same
|
|
1009
|
+
command again" for somebody to notice and act on. Running it again was
|
|
1010
|
+
always safe and cheap (pieces already stored are recognised and skipped,
|
|
1011
|
+
and the save itself is named so it can never be made twice), so the
|
|
1012
|
+
command now does it itself, a few times, before giving up.
|
|
1013
|
+
*/
|
|
1014
|
+
const resumeAfter = [10, 30, 60];
|
|
1015
|
+
for (let resumed = 0;; resumed += 1) {
|
|
1016
|
+
try {
|
|
1017
|
+
const plan = await uploader.plan(uploadRequest, (progress) => {
|
|
1018
|
+
line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
|
|
1019
|
+
`${"plan".padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
|
|
1020
|
+
dim(progress.path.slice(-40)));
|
|
1021
|
+
});
|
|
1022
|
+
done(line);
|
|
1023
|
+
console.log(`${accent("Upload plan")} ${bytes(plan.sourceBytes)} selected → ` +
|
|
1024
|
+
`${bytes(plan.compactedBytes)} compacted; ${bytes(plan.chargeableBytes)} new storage.`);
|
|
1025
|
+
console.log(plan.allowance.exempt
|
|
1026
|
+
? dim("Annual plan: the monthly staged upload allowance does not apply.")
|
|
1027
|
+
: dim(`Monthly stage ${plan.allowance.stage}: ${plan.allowance.unlockedPercent}% unlocked, ` +
|
|
1028
|
+
`${bytes(plan.allowance.remainingBytes)} remains after this reservation.`));
|
|
1029
|
+
if (dryRun) {
|
|
1030
|
+
await uploader.cancelPlan(plan, true);
|
|
1031
|
+
const going = files.filter((file) => !file.deleted);
|
|
1032
|
+
const removing = files.filter((file) => file.deleted);
|
|
1033
|
+
console.log(`${going.length} file${going.length === 1 ? "" : "s"} would be sent or reused:`);
|
|
1034
|
+
for (const file of going)
|
|
1035
|
+
console.log(` ${file.path}`);
|
|
1036
|
+
if (removing.length) {
|
|
1037
|
+
console.log(`${removing.length} file${removing.length === 1 ? "" : "s"} would be removed from the save:`);
|
|
1038
|
+
for (const file of removing)
|
|
1039
|
+
console.log(` ${red("deleted")} ${file.path}`);
|
|
1040
|
+
}
|
|
1041
|
+
noteKeptDeletions(folder, baseline, hasFlag(parsed, "sync"));
|
|
1042
|
+
console.log("Nothing was uploaded and no Version was created.");
|
|
1043
|
+
return 0;
|
|
1044
|
+
}
|
|
1045
|
+
result = await uploader.execute(uploadRequest, plan, (progress) => {
|
|
1046
|
+
line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
|
|
1047
|
+
`${progress.stage.padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
|
|
1048
|
+
dim(progress.path.slice(-40)));
|
|
1049
|
+
});
|
|
1050
|
+
break;
|
|
994
1051
|
}
|
|
995
|
-
|
|
1052
|
+
catch (error) {
|
|
1053
|
+
done(line);
|
|
996
1054
|
/*
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1055
|
+
Classified by the shared description rather than by a private regular
|
|
1056
|
+
expression, so a push and a submit disagree about nothing — including
|
|
1057
|
+
what counts as "the connection failed" and what that means for whether
|
|
1058
|
+
the work was saved.
|
|
1001
1059
|
*/
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1060
|
+
const failure = (0, publish_js_1.classifyPublishFailure)(error);
|
|
1061
|
+
const text = (0, network_js_1.describeError)(error);
|
|
1062
|
+
if (failure?.kind === "interrupted" && !dryRun && resumed < resumeAfter.length) {
|
|
1063
|
+
const wait = resumeAfter[resumed];
|
|
1064
|
+
console.error(dim(`The connection dropped (${text})`));
|
|
1065
|
+
console.error(dim(`Picking the upload up again in ${wait}s (try ${resumed + 2} of ${resumeAfter.length + 1}).` +
|
|
1066
|
+
` Pieces already sent are kept; only the rest goes.`));
|
|
1067
|
+
/*
|
|
1068
|
+
The failed attempt's other lanes are still retrying; stopped first, so
|
|
1069
|
+
they do not carry on sending beside the attempt that replaces them.
|
|
1070
|
+
*/
|
|
1071
|
+
uploader.cancel();
|
|
1072
|
+
await (0, network_js_1.pause)(wait * 1000);
|
|
1073
|
+
uploader = new upload_js_1.Uploader(config_js_1.credentials);
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
if (failure?.kind === "interrupted") {
|
|
1077
|
+
console.error(red(`
|
|
1078
|
+
The connection failed: ${text}`));
|
|
1079
|
+
console.error(`Your work may already have been saved. Run the same command again —` +
|
|
1080
|
+
` it will not create a second version.`);
|
|
1081
|
+
return 1;
|
|
1082
|
+
}
|
|
1083
|
+
if (failure?.kind === "conflict") {
|
|
1084
|
+
/*
|
|
1085
|
+
Somebody else saved to this project first. Everything that did not
|
|
1086
|
+
overlap has already been combined by the service; what is left is a
|
|
1087
|
+
genuine decision, so the answer is to bring their work down and look
|
|
1088
|
+
at it rather than to try again harder.
|
|
1089
|
+
*/
|
|
1090
|
+
console.error(red(error instanceof Error ? error.message : String(error)));
|
|
1091
|
+
console.error(`\nRun ${accent("cbx get")} to bring the latest version down, then` +
|
|
1092
|
+
` submit again. Nothing was changed on your account.`);
|
|
1093
|
+
return 1;
|
|
1094
|
+
}
|
|
1095
|
+
throw error;
|
|
1006
1096
|
}
|
|
1007
|
-
throw error;
|
|
1008
1097
|
}
|
|
1009
1098
|
done(line);
|
|
1010
1099
|
if (result.mergeTrack) {
|
|
@@ -1105,6 +1194,7 @@ The connection failed: ${text}`));
|
|
|
1105
1194
|
command does not do.
|
|
1106
1195
|
*/
|
|
1107
1196
|
` · the save holds ${bytes(result.sourceBytes)}`);
|
|
1197
|
+
noteKeptDeletions(folder, baseline, hasFlag(parsed, "sync"));
|
|
1108
1198
|
/*
|
|
1109
1199
|
Where the time went, when asked for.
|
|
1110
1200
|
|
|
@@ -1236,9 +1326,23 @@ Save them with ${accent("cbx submit")}, or finish the fetch and ` +
|
|
|
1236
1326
|
and stopping for those would make the warning something people learn
|
|
1237
1327
|
to click past.
|
|
1238
1328
|
*/
|
|
1239
|
-
const atRisk =
|
|
1240
|
-
|
|
1241
|
-
|
|
1329
|
+
const atRisk = [];
|
|
1330
|
+
for (const file of edited) {
|
|
1331
|
+
if (file.deleted || !incoming.has(file.path))
|
|
1332
|
+
continue;
|
|
1333
|
+
if (incoming.get(file.path) === link.local[file.path])
|
|
1334
|
+
continue;
|
|
1335
|
+
/*
|
|
1336
|
+
Already exactly what is arriving: nothing would be lost by writing
|
|
1337
|
+
it. This is what the same folder looks like after being saved from
|
|
1338
|
+
another machine or another operating system, and refusing it left
|
|
1339
|
+
a folder that could neither fetch nor save without a merge.
|
|
1340
|
+
*/
|
|
1341
|
+
const here = await digestOfFile(node_path_1.default.join(folder, ...file.path.split("/")));
|
|
1342
|
+
if (here === incoming.get(file.path))
|
|
1343
|
+
continue;
|
|
1344
|
+
atRisk.push(file);
|
|
1345
|
+
}
|
|
1242
1346
|
if (atRisk.length) {
|
|
1243
1347
|
console.error(red(`${atRisk.length} file${atRisk.length === 1 ? "" : "s"} here ` +
|
|
1244
1348
|
`${atRisk.length === 1 ? "has" : "have"} changes that fetching would overwrite:`));
|
|
@@ -1382,6 +1486,15 @@ trackName) {
|
|
|
1382
1486
|
// dropped is removed while everything untracked is left alone.
|
|
1383
1487
|
held, mode);
|
|
1384
1488
|
done(line);
|
|
1489
|
+
/*
|
|
1490
|
+
Said, not hidden. The files are right, but the service sent them still
|
|
1491
|
+
compressed, which means its record of how they are stored is wrong.
|
|
1492
|
+
*/
|
|
1493
|
+
if (downloader.recoveredFromGzip.length) {
|
|
1494
|
+
console.log(dim(`${downloader.recoveredFromGzip.length} file${downloader.recoveredFromGzip.length === 1 ? "" : "s"} ` +
|
|
1495
|
+
`arrived still compressed and were unpacked here; the copies are exact. ` +
|
|
1496
|
+
`Tell security@coderook.com if this keeps happening.`));
|
|
1497
|
+
}
|
|
1385
1498
|
// Fetching is how a folder catches up, so this is what moves its base.
|
|
1386
1499
|
await (0, config_js_1.writeLink)(destination, {
|
|
1387
1500
|
repositoryId,
|
|
@@ -3119,9 +3232,10 @@ async function main(argv) {
|
|
|
3119
3232
|
function leave(code) {
|
|
3120
3233
|
node_process_1.default.exitCode = code;
|
|
3121
3234
|
}
|
|
3235
|
+
(0, network_js_1.tuneNetwork)();
|
|
3122
3236
|
main(node_process_1.default.argv.slice(2))
|
|
3123
3237
|
.then(leave)
|
|
3124
3238
|
.catch((error) => {
|
|
3125
|
-
console.error(red(
|
|
3239
|
+
console.error(red((0, network_js_1.describeError)(error)));
|
|
3126
3240
|
leave(1);
|
|
3127
3241
|
});
|
package/dist/cli/src/config.js
CHANGED
|
@@ -319,18 +319,66 @@ async function lookUp(localPath) {
|
|
|
319
319
|
if (!stored)
|
|
320
320
|
continue;
|
|
321
321
|
if ("forgotten" in stored)
|
|
322
|
-
return
|
|
322
|
+
return "forgotten";
|
|
323
323
|
return unpack(stored);
|
|
324
324
|
}
|
|
325
325
|
return null;
|
|
326
326
|
}
|
|
327
|
+
/*
|
|
328
|
+
A copy of the link kept in the folder itself, beside the offline outbox.
|
|
329
|
+
|
|
330
|
+
The machine's own record is keyed by the folder's path, so the same folder
|
|
331
|
+
seen from another operating system (a drive letter from Windows, a mount
|
|
332
|
+
point from Linux, on the same disk) or from another machine was a stranger,
|
|
333
|
+
and every project had to be cloned again into a new folder. `.coderook` is
|
|
334
|
+
never scanned or uploaded, and the copy holds no token: only which project
|
|
335
|
+
and version the folder stands on, as `.git` records its remote.
|
|
336
|
+
*/
|
|
337
|
+
const folderLinkFile = (localPath) => node_path_1.default.join(node_path_1.default.resolve(localPath), ".coderook", "link.json");
|
|
338
|
+
async function readFolderLink(localPath) {
|
|
339
|
+
try {
|
|
340
|
+
const raw = JSON.parse(await (0, promises_1.readFile)(folderLinkFile(localPath), "utf8"));
|
|
341
|
+
if (typeof raw.repositoryId !== "string" || typeof raw.slug !== "string")
|
|
342
|
+
return null;
|
|
343
|
+
return {
|
|
344
|
+
...raw,
|
|
345
|
+
manifest: raw.manifest ?? {},
|
|
346
|
+
sequence: Number(raw.sequence ?? 0),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
async function writeFolderLink(localPath, link) {
|
|
354
|
+
try {
|
|
355
|
+
const directory = node_path_1.default.dirname(folderLinkFile(localPath));
|
|
356
|
+
await (0, promises_1.mkdir)(directory, { recursive: true });
|
|
357
|
+
/* Git leaves it alone too, without anybody editing their .gitignore. */
|
|
358
|
+
await (0, promises_1.writeFile)(node_path_1.default.join(directory, ".gitignore"), "*\n").catch(() => undefined);
|
|
359
|
+
await (0, promises_1.writeFile)(folderLinkFile(localPath), JSON.stringify(link));
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
/* A folder that cannot hold the copy still has the machine's record. */
|
|
363
|
+
}
|
|
364
|
+
}
|
|
327
365
|
async function readLink(localPath) {
|
|
328
|
-
let
|
|
329
|
-
if (!
|
|
366
|
+
let found = await lookUp(localPath);
|
|
367
|
+
if (!found &&
|
|
330
368
|
((await exists(legacyLinksFile())) ||
|
|
331
369
|
(await exists(`${legacyLinksFile()}.migrating`)))) {
|
|
332
370
|
await migrateLegacyLinks();
|
|
333
|
-
|
|
371
|
+
found = await lookUp(localPath);
|
|
372
|
+
}
|
|
373
|
+
/* Unlinked on this machine on purpose: the folder's copy does not overrule that. */
|
|
374
|
+
if (found === "forgotten")
|
|
375
|
+
return null;
|
|
376
|
+
let link = found;
|
|
377
|
+
if (!link) {
|
|
378
|
+
link = await readFolderLink(localPath);
|
|
379
|
+
/* Adopted, so this machine knows the folder from now on. */
|
|
380
|
+
if (link)
|
|
381
|
+
await writeLink(localPath, link);
|
|
334
382
|
}
|
|
335
383
|
if (!link)
|
|
336
384
|
return null;
|
|
@@ -353,6 +401,7 @@ async function forgetLink(localPath) {
|
|
|
353
401
|
const forgotten = { key, forgotten: true };
|
|
354
402
|
await writePrivate(storedFileFor(key), JSON.stringify(forgotten));
|
|
355
403
|
}
|
|
404
|
+
await (0, promises_1.rm)(folderLinkFile(localPath), { force: true });
|
|
356
405
|
return true;
|
|
357
406
|
}
|
|
358
407
|
async function writeLink(localPath, link) {
|
|
@@ -363,6 +412,10 @@ async function writeLink(localPath, link) {
|
|
|
363
412
|
...(baseVersionId ? { baseVersionId, versionId: baseVersionId } : {}),
|
|
364
413
|
});
|
|
365
414
|
await writePrivate(storedFileFor(key), JSON.stringify(stored));
|
|
415
|
+
await writeFolderLink(localPath, {
|
|
416
|
+
...link,
|
|
417
|
+
...(baseVersionId ? { baseVersionId, versionId: baseVersionId } : {}),
|
|
418
|
+
});
|
|
366
419
|
// A folder recorded under the old key moves to the new one rather than
|
|
367
420
|
// being left behind as a second connection to the same place.
|
|
368
421
|
const legacy = legacyKeyFor(localPath);
|
|
@@ -88,9 +88,9 @@ exports.main = main;
|
|
|
88
88
|
* is nothing to rewind to. Both are refused rather than silently ignored.
|
|
89
89
|
* - **Submodules.** A gitlink is a pointer into another repository and there
|
|
90
90
|
* are no bytes to publish. Skipped on push, and said out loud.
|
|
91
|
-
* - **File modes.**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
91
|
+
* - **File modes.** The executable bit travels both ways: a pushed `100755`
|
|
92
|
+
* is recorded as executable, and an executable file is fetched as `100755`.
|
|
93
|
+
* Versions saved before the bit was recorded come back `100644`.
|
|
94
94
|
*
|
|
95
95
|
* ## What a round trip does and does not preserve
|
|
96
96
|
*
|
|
@@ -284,6 +284,7 @@ async function applyCommit(catFile, scratch, fromCommit, toCommit) {
|
|
|
284
284
|
const written = [];
|
|
285
285
|
const deleted = [];
|
|
286
286
|
const skipped = [];
|
|
287
|
+
const executable = new Map();
|
|
287
288
|
for (let index = 0; index + 1 < fields.length; index += 2) {
|
|
288
289
|
const status = fields[index];
|
|
289
290
|
const filePath = fields[index + 1];
|
|
@@ -309,8 +310,9 @@ async function applyCommit(catFile, scratch, fromCommit, toCommit) {
|
|
|
309
310
|
await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
|
|
310
311
|
await (0, promises_1.writeFile)(target, bytes);
|
|
311
312
|
written.push(filePath);
|
|
313
|
+
executable.set(filePath, mode === "100755");
|
|
312
314
|
}
|
|
313
|
-
return { written, deleted, skipped };
|
|
315
|
+
return { written, deleted, skipped, executable };
|
|
314
316
|
}
|
|
315
317
|
/*
|
|
316
318
|
The service's answer when a line is already there.
|
|
@@ -838,7 +840,7 @@ depth = null) {
|
|
|
838
840
|
*/
|
|
839
841
|
if (file.path.split("/").some(safe_path_js_1.isGitDirectoryName))
|
|
840
842
|
continue;
|
|
841
|
-
send(`M 100644 :${blobMark.get(file.sha256)} ${(0, git_history_js_1.quotePath)(file.path)}`);
|
|
843
|
+
send(`M ${file.executable ? "100755" : "100644"} :${blobMark.get(file.sha256)} ${(0, git_history_js_1.quotePath)(file.path)}`);
|
|
842
844
|
}
|
|
843
845
|
send("");
|
|
844
846
|
done += 1;
|
|
@@ -1305,6 +1307,7 @@ async function doPush(requests, url) {
|
|
|
1305
1307
|
baseVersionId,
|
|
1306
1308
|
track: branch,
|
|
1307
1309
|
allowIgnored: true,
|
|
1310
|
+
executable: changes.executable,
|
|
1308
1311
|
...(Object.keys(manifest).length ? { known: local } : {}),
|
|
1309
1312
|
});
|
|
1310
1313
|
let result;
|
|
@@ -111,12 +111,28 @@ async function commandLicence(parsed) {
|
|
|
111
111
|
*/
|
|
112
112
|
let holder = flagValue(parsed, "holder") ?? "";
|
|
113
113
|
if (licence.personalised && !holder) {
|
|
114
|
+
/*
|
|
115
|
+
Why the name could not be had decides what to say. This used to answer
|
|
116
|
+
every failure with "sign in", including a network timeout for somebody
|
|
117
|
+
who was signed in, and sent them to fix the wrong thing.
|
|
118
|
+
*/
|
|
119
|
+
let failure = null;
|
|
114
120
|
holder = await (0, api_js_1.whoami)()
|
|
115
121
|
.then((account) => account.displayName || account.username || "")
|
|
116
|
-
.catch(() =>
|
|
122
|
+
.catch((error) => {
|
|
123
|
+
failure = error;
|
|
124
|
+
return "";
|
|
125
|
+
});
|
|
117
126
|
if (!holder) {
|
|
127
|
+
const reason = failure instanceof Error ? failure.message : "";
|
|
118
128
|
console.error(red("Could not work out whose name goes on the licence."));
|
|
119
|
-
|
|
129
|
+
if (/sign-in|not signed in|not accepted/i.test(reason) || !failure) {
|
|
130
|
+
console.error(`Sign in, or pass ${accent('--holder "Your Name"')} to say directly.`);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
console.error(dim(reason));
|
|
134
|
+
console.error(`Try again, or pass ${accent('--holder "Your Name"')} to say directly.`);
|
|
135
|
+
}
|
|
120
136
|
return 1;
|
|
121
137
|
}
|
|
122
138
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.tuneNetwork = tuneNetwork;
|
|
7
|
+
exports.isNetworkFailure = isNetworkFailure;
|
|
8
|
+
exports.describeError = describeError;
|
|
9
|
+
exports.pause = pause;
|
|
10
|
+
/**
|
|
11
|
+
* Reaching the service on networks that half-support IPv6.
|
|
12
|
+
*
|
|
13
|
+
* Found from Linux: a router hands out IPv6 addresses but has no IPv6 route.
|
|
14
|
+
* Node tries both families at once and gives each address 250 ms. The IPv6
|
|
15
|
+
* attempt fails at once, and an IPv4 handshake that occasionally takes longer
|
|
16
|
+
* than 250 ms is abandoned too, so a perfectly healthy connection came back as
|
|
17
|
+
* the two words "fetch failed". curl and browsers never showed it.
|
|
18
|
+
*/
|
|
19
|
+
const node_dns_1 = __importDefault(require("node:dns"));
|
|
20
|
+
const node_net_1 = __importDefault(require("node:net"));
|
|
21
|
+
/** Called once, before any request. Explicit settings in NODE_OPTIONS win. */
|
|
22
|
+
function tuneNetwork() {
|
|
23
|
+
const options = process.env.NODE_OPTIONS ?? "";
|
|
24
|
+
if (!options.includes("autoselection-attempt-timeout")) {
|
|
25
|
+
try {
|
|
26
|
+
node_net_1.default.setDefaultAutoSelectFamilyAttemptTimeout(1_000);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* Older Node: nothing to tune. */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!options.includes("dns-result-order")) {
|
|
33
|
+
try {
|
|
34
|
+
node_dns_1.default.setDefaultResultOrder("ipv4first");
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* Older Node: nothing to tune. */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Whether a failure happened before any answer came back. */
|
|
42
|
+
function isNetworkFailure(error) {
|
|
43
|
+
if (!(error instanceof Error))
|
|
44
|
+
return false;
|
|
45
|
+
if (error.name === "TimeoutError" || error.name === "AbortError")
|
|
46
|
+
return true;
|
|
47
|
+
return error.message === "fetch failed" || Boolean(error.cause);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The real reason, instead of "fetch failed".
|
|
51
|
+
*
|
|
52
|
+
* Node puts what went wrong in `cause` (and, for a failed happy-eyeballs
|
|
53
|
+
* connection, in `cause.errors`, one per address tried). Naming the code and
|
|
54
|
+
* the address is what lets somebody tell a dead network from a slow one.
|
|
55
|
+
*/
|
|
56
|
+
function describeError(error) {
|
|
57
|
+
if (!(error instanceof Error))
|
|
58
|
+
return String(error);
|
|
59
|
+
if (error.name === "TimeoutError") {
|
|
60
|
+
return "CodeRook did not answer in time. Check the connection, then try again.";
|
|
61
|
+
}
|
|
62
|
+
const cause = error.cause;
|
|
63
|
+
if (error.message === "fetch failed" && cause) {
|
|
64
|
+
const attempts = cause.errors?.length ? cause.errors : [cause];
|
|
65
|
+
const named = attempts
|
|
66
|
+
.map((one) => [one.code, one.address].filter(Boolean).join(" "))
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
if (named.length) {
|
|
69
|
+
return `Could not reach CodeRook (${[...new Set(named)].join(", ")}). Check the connection, then try again.`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return error.message;
|
|
73
|
+
}
|
|
74
|
+
/** Wait, for a retry. */
|
|
75
|
+
function pause(milliseconds) {
|
|
76
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
77
|
+
}
|
package/dist/cli/src/publish.js
CHANGED
|
@@ -19,6 +19,7 @@ function uploadRequestFor(input) {
|
|
|
19
19
|
...(input.allowSecrets ? { allowSecrets: true } : {}),
|
|
20
20
|
...(input.acknowledged ? { acknowledged: true } : {}),
|
|
21
21
|
...(input.acknowledgedNoLicence ? { acknowledgedNoLicence: true } : {}),
|
|
22
|
+
...(input.executable ? { executable: input.executable } : {}),
|
|
22
23
|
...(input.baseVersionId
|
|
23
24
|
? { expectedHeadVersionId: input.baseVersionId }
|
|
24
25
|
: {}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coderook/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.1",
|
|
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",
|