@coderook/cli 0.32.0 → 0.33.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.
@@ -2,13 +2,13 @@
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.0",
5
+ "version": "0.33.0",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
9
9
  },
10
10
  "homepage": "https://coderook.com/docs#assistants",
11
- "license": "SEE LICENSE IN LICENSE.txt",
11
+ "license": "Apache-2.0",
12
12
  "keywords": [
13
13
  "coderook",
14
14
  "versioning",
package/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,8 @@
1
+ cbx command line
2
+ Copyright 2026 ACCA Gaming Productions
3
+
4
+ This product includes software developed by ACCA Gaming Productions
5
+ for CodeRook (https://coderook.com), including the CBX engine.
6
+
7
+ "CodeRook" is a name of ACCA Gaming Productions. The Apache License
8
+ grants no rights to use it; see section 6 of the licence.
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,
@@ -525,3 +525,13 @@ is reachable, and who this machine is signed in as.
525
525
 
526
526
  `0` on success, `1` on failure. Every command prints why it failed on standard
527
527
  error, so a script can branch on the status and log the reason.
528
+
529
+ ## Source and licence
530
+
531
+ The `cbx` command line and the CBX engine it is built on are open source under
532
+ the Apache License 2.0: <https://github.com/AlexC1991/cbx>. The storage format
533
+ and the sync protocol are specified there too, so other tools can read and
534
+ write the same histories.
535
+
536
+ Copyright 2026 ACCA Gaming Productions. "CodeRook" is a name of ACCA Gaming
537
+ Productions, and the licence grants no right to use it.
@@ -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 `) +
@@ -961,11 +1014,19 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
961
1014
  ? dim("Annual plan: the monthly staged upload allowance does not apply.")
962
1015
  : dim(`Monthly stage ${plan.allowance.stage}: ${plan.allowance.unlockedPercent}% unlocked, ` +
963
1016
  `${bytes(plan.allowance.remainingBytes)} remains after this reservation.`));
964
- if (hasFlag(parsed, "dry-run", "n")) {
1017
+ if (dryRun) {
965
1018
  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)
1019
+ const going = files.filter((file) => !file.deleted);
1020
+ const removing = files.filter((file) => file.deleted);
1021
+ console.log(`${going.length} file${going.length === 1 ? "" : "s"} would be sent or reused:`);
1022
+ for (const file of going)
968
1023
  console.log(` ${file.path}`);
1024
+ if (removing.length) {
1025
+ console.log(`${removing.length} file${removing.length === 1 ? "" : "s"} would be removed from the save:`);
1026
+ for (const file of removing)
1027
+ console.log(` ${red("deleted")} ${file.path}`);
1028
+ }
1029
+ noteKeptDeletions(folder, baseline, hasFlag(parsed, "sync"));
969
1030
  console.log("Nothing was uploaded and no Version was created.");
970
1031
  return 0;
971
1032
  }
@@ -984,7 +1045,7 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
984
1045
  the work was saved.
985
1046
  */
986
1047
  const failure = (0, publish_js_1.classifyPublishFailure)(error);
987
- const text = error instanceof Error ? error.message : String(error);
1048
+ const text = (0, network_js_1.describeError)(error);
988
1049
  if (failure?.kind === "interrupted") {
989
1050
  console.error(red(`
990
1051
  The connection failed: ${text}`));
@@ -1105,6 +1166,7 @@ The connection failed: ${text}`));
1105
1166
  command does not do.
1106
1167
  */
1107
1168
  ` · the save holds ${bytes(result.sourceBytes)}`);
1169
+ noteKeptDeletions(folder, baseline, hasFlag(parsed, "sync"));
1108
1170
  /*
1109
1171
  Where the time went, when asked for.
1110
1172
 
@@ -1236,9 +1298,23 @@ Save them with ${accent("cbx submit")}, or finish the fetch and ` +
1236
1298
  and stopping for those would make the warning something people learn
1237
1299
  to click past.
1238
1300
  */
1239
- const atRisk = edited.filter((file) => !file.deleted &&
1240
- incoming.has(file.path) &&
1241
- incoming.get(file.path) !== link.local[file.path]);
1301
+ const atRisk = [];
1302
+ for (const file of edited) {
1303
+ if (file.deleted || !incoming.has(file.path))
1304
+ continue;
1305
+ if (incoming.get(file.path) === link.local[file.path])
1306
+ continue;
1307
+ /*
1308
+ Already exactly what is arriving: nothing would be lost by writing
1309
+ it. This is what the same folder looks like after being saved from
1310
+ another machine or another operating system, and refusing it left
1311
+ a folder that could neither fetch nor save without a merge.
1312
+ */
1313
+ const here = await digestOfFile(node_path_1.default.join(folder, ...file.path.split("/")));
1314
+ if (here === incoming.get(file.path))
1315
+ continue;
1316
+ atRisk.push(file);
1317
+ }
1242
1318
  if (atRisk.length) {
1243
1319
  console.error(red(`${atRisk.length} file${atRisk.length === 1 ? "" : "s"} here ` +
1244
1320
  `${atRisk.length === 1 ? "has" : "have"} changes that fetching would overwrite:`));
@@ -1382,6 +1458,15 @@ trackName) {
1382
1458
  // dropped is removed while everything untracked is left alone.
1383
1459
  held, mode);
1384
1460
  done(line);
1461
+ /*
1462
+ Said, not hidden. The files are right, but the service sent them still
1463
+ compressed, which means its record of how they are stored is wrong.
1464
+ */
1465
+ if (downloader.recoveredFromGzip.length) {
1466
+ console.log(dim(`${downloader.recoveredFromGzip.length} file${downloader.recoveredFromGzip.length === 1 ? "" : "s"} ` +
1467
+ `arrived still compressed and were unpacked here; the copies are exact. ` +
1468
+ `Tell security@coderook.com if this keeps happening.`));
1469
+ }
1385
1470
  // Fetching is how a folder catches up, so this is what moves its base.
1386
1471
  await (0, config_js_1.writeLink)(destination, {
1387
1472
  repositoryId,
@@ -2892,8 +2977,8 @@ const SPECS = [
2892
2977
  {
2893
2978
  name: "merge",
2894
2979
  group: "When somebody saved first",
2895
- summary: "look at one, and decide",
2896
- usage: "merge <ref>",
2980
+ summary: "merge a line here, or decide one waiting on CodeRook",
2981
+ usage: "merge <line | ref>",
2897
2982
  detail: "In a folder with a history of its own, merges a line or a save into\n" +
2898
2983
  "this one. What only one side changed is taken; a text file both\n" +
2899
2984
  "changed is merged line by line; only what is left is a conflict,\n" +
@@ -3119,9 +3204,10 @@ async function main(argv) {
3119
3204
  function leave(code) {
3120
3205
  node_process_1.default.exitCode = code;
3121
3206
  }
3207
+ (0, network_js_1.tuneNetwork)();
3122
3208
  main(node_process_1.default.argv.slice(2))
3123
3209
  .then(leave)
3124
3210
  .catch((error) => {
3125
- console.error(red(error instanceof Error ? error.message : String(error)));
3211
+ console.error(red((0, network_js_1.describeError)(error)));
3126
3212
  leave(1);
3127
3213
  });
@@ -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,11 +1,11 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
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
- "license": "SEE LICENSE IN LICENSE.txt",
5
+ "license": "Apache-2.0",
6
6
  "homepage": "https://coderook.com",
7
7
  "bugs": {
8
- "url": "https://coderook.com/contact"
8
+ "url": "https://github.com/AlexC1991/cbx/issues"
9
9
  },
10
10
  "keywords": [
11
11
  "coderook",
@@ -30,7 +30,8 @@
30
30
  "files": [
31
31
  ".claude-plugin",
32
32
  "dist",
33
- "skills"
33
+ "skills",
34
+ "NOTICE"
34
35
  ],
35
36
  "scripts": {
36
37
  "build": "tsc -p tsconfig.json && node scripts/copy-cbx-licence.mjs",
@@ -56,5 +57,10 @@
56
57
  "devDependencies": {
57
58
  "@types/node": "24.10.1",
58
59
  "typescript": "5.9.3"
60
+ },
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "git+https://github.com/AlexC1991/cbx.git",
64
+ "directory": "cli"
59
65
  }
60
66
  }
package/LICENSE.txt DELETED
@@ -1,46 +0,0 @@
1
- CodeRook Desktop - Terms of Use
2
-
3
- Copyright (c) 2026 CodeRook. All rights reserved.
4
-
5
- 1. Licence
6
-
7
- CodeRook grants you a personal, non-exclusive, non-transferable licence to
8
- install and use CodeRook Desktop on computers you own or control, for the
9
- purpose of working with your own CodeRook projects.
10
-
11
- 2. What this software does
12
-
13
- CodeRook Desktop reads folders you choose on this computer and uploads the
14
- files you select to your CodeRook account. It shows you what will be sent
15
- before anything leaves the machine, and it never uploads a folder you have
16
- not opened.
17
-
18
- 3. Your content
19
-
20
- Your files remain yours. CodeRook stores them so you can retrieve them, and
21
- does not claim ownership of anything you upload.
22
-
23
- 4. Credentials
24
-
25
- Your access token is stored using the operating system's credential vault. It
26
- is not written to disk in plain text. Signing out removes it from this
27
- computer.
28
-
29
- 5. No warranty
30
-
31
- This software is provided "as is", without warranty of any kind, express or
32
- implied, including but not limited to the warranties of merchantability,
33
- fitness for a particular purpose and non-infringement. Keep your own backups
34
- of anything you cannot afford to lose.
35
-
36
- 6. Limitation of liability
37
-
38
- In no event shall CodeRook be liable for any claim, damages or other
39
- liability, whether in an action of contract, tort or otherwise, arising from,
40
- out of or in connection with the software or the use or other dealings in the
41
- software.
42
-
43
- 7. Privacy and terms
44
-
45
- Use of your CodeRook account is additionally governed by the terms and privacy
46
- policy published at https://coderook.com/terms and https://coderook.com/privacy.