@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.
@@ -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.32.1",
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 | Everything arrives as a normal file. A script cloned back needs `chmod +x`. |
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
- return this.fetchInto(repositoryId, versionId, file, target);
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
- const body = Buffer.from(await reply.arrayBuffer());
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
- throw new Error(`${file.path}: piece ${at + 1} of ${pieces.length} ` +
521
- `did not arrive intact`);
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
- return whole.digest("hex");
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: false,
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({ ...reuse, path: file.path, executable: was?.executable ?? reuse.executable });
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);
@@ -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 response = await fetch(`${(0, config_js_1.apiOrigin)()}${route}`, {
103
- method: options.method ?? "GET",
104
- headers: {
105
- accept: "application/json",
106
- authorization: `Bearer ${token}`,
107
- "user-agent": "CodeRook-CLI/0.1",
108
- ...(0, identify_js_1.clientHeaders)(),
109
- ...(options.body ? { "content-type": "application/json" } : {}),
110
- },
111
- body: options.body ? JSON.stringify(options.body) : undefined,
112
- signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
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
- const all = await projects().catch(() => []);
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);
@@ -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
- const added = link
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
- const uploader = new upload_js_1.Uploader(config_js_1.credentials);
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
- try {
952
- const plan = await uploader.plan(uploadRequest, (progress) => {
953
- line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
954
- `${"plan".padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
955
- dim(progress.path.slice(-40)));
956
- });
957
- done(line);
958
- console.log(`${accent("Upload plan")} ${bytes(plan.sourceBytes)} selected → ` +
959
- `${bytes(plan.compactedBytes)} compacted; ${bytes(plan.chargeableBytes)} new storage.`);
960
- console.log(plan.allowance.exempt
961
- ? dim("Annual plan: the monthly staged upload allowance does not apply.")
962
- : dim(`Monthly stage ${plan.allowance.stage}: ${plan.allowance.unlockedPercent}% unlocked, ` +
963
- `${bytes(plan.allowance.remainingBytes)} remains after this reservation.`));
964
- if (hasFlag(parsed, "dry-run", "n")) {
965
- await uploader.cancelPlan(plan, true);
966
- console.log(`${files.length} file${files.length === 1 ? "" : "s"} would be sent or reused:`);
967
- for (const file of files)
968
- console.log(` ${file.path}`);
969
- console.log("Nothing was uploaded and no Version was created.");
970
- return 0;
971
- }
972
- result = await uploader.execute(uploadRequest, plan, (progress) => {
973
- line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
974
- `${progress.stage.padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
975
- dim(progress.path.slice(-40)));
976
- });
977
- }
978
- catch (error) {
979
- done(line);
980
- /*
981
- Classified by the shared description rather than by a private regular
982
- expression, so a push and a submit disagree about nothing — including
983
- what counts as "the connection failed" and what that means for whether
984
- the work was saved.
985
- */
986
- const failure = (0, publish_js_1.classifyPublishFailure)(error);
987
- const text = error instanceof Error ? error.message : String(error);
988
- if (failure?.kind === "interrupted") {
989
- console.error(red(`
990
- The connection failed: ${text}`));
991
- console.error(`Your work may already have been saved. Run the same command again —` +
992
- ` it will not create a second version.`);
993
- return 1;
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
- if (failure?.kind === "conflict") {
1052
+ catch (error) {
1053
+ done(line);
996
1054
  /*
997
- Somebody else saved to this project first. Everything that did not
998
- overlap has already been combined by the service; what is left is a
999
- genuine decision, so the answer is to bring their work down and look
1000
- at it rather than to try again harder.
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
- console.error(red(error instanceof Error ? error.message : String(error)));
1003
- console.error(`\nRun ${accent("cbx get")} to bring the latest version down, then` +
1004
- ` submit again. Nothing was changed on your account.`);
1005
- return 1;
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 = edited.filter((file) => !file.deleted &&
1240
- incoming.has(file.path) &&
1241
- incoming.get(file.path) !== link.local[file.path]);
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(error instanceof Error ? error.message : String(error)));
3239
+ console.error(red((0, network_js_1.describeError)(error)));
3126
3240
  leave(1);
3127
3241
  });
@@ -319,18 +319,66 @@ async function lookUp(localPath) {
319
319
  if (!stored)
320
320
  continue;
321
321
  if ("forgotten" in stored)
322
- return null;
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 link = await lookUp(localPath);
329
- if (!link &&
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
- link = await lookUp(localPath);
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.** Everything is imported as `100644`. CodeRook records an
92
- * executable bit but git's mode is not round-tripped yet, so a script
93
- * cloned back needs `chmod +x`.
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
- console.error(`Sign in, or pass ${accent('--holder "Your Name"')} to say directly.`);
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
+ }
@@ -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.32.1",
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",