@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.
- package/.claude-plugin/plugin.json +2 -2
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +11 -1
- package/dist/cbx/src/core/download.js +103 -5
- package/dist/cbx/src/core/upload.js +32 -1
- package/dist/cbx/src/local/remote.js +8 -2
- package/dist/cli/src/api.js +41 -13
- package/dist/cli/src/cli.js +97 -11
- package/dist/cli/src/config.js +57 -4
- package/dist/cli/src/git_remote.js +8 -5
- package/dist/cli/src/licence_commands.js +18 -2
- package/dist/cli/src/network.js +77 -0
- package/dist/cli/src/publish.js +1 -0
- package/package.json +10 -4
- package/LICENSE.txt +0 -46
|
@@ -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.
|
|
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": "
|
|
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 |
|
|
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
|
-
|
|
350
|
+
const digest = await this.fetchInto(repositoryId, versionId, file, target);
|
|
351
|
+
await applyMode(target, file.executable === true);
|
|
352
|
+
return digest;
|
|
348
353
|
}
|
|
349
354
|
/**
|
|
350
355
|
* Reconstruct and verify every file in a Version without materialising the
|
|
@@ -489,6 +494,18 @@ class Downloader {
|
|
|
489
494
|
await (0, promises_1.rm)(temporary, { force: true });
|
|
490
495
|
}
|
|
491
496
|
}
|
|
497
|
+
/*
|
|
498
|
+
Paths whose bytes arrived still gzip-compressed and were recovered.
|
|
499
|
+
|
|
500
|
+
A service that recorded an object's encoding wrongly served its stored
|
|
501
|
+
gzip as though it were the file (found on CodeRook in September 2026; the
|
|
502
|
+
service now judges the encoding from the stored size). The bytes were
|
|
503
|
+
never damaged, only still compressed, so a body that fails its digest,
|
|
504
|
+
starts with gzip's magic number and decompresses to exactly the expected
|
|
505
|
+
digest is accepted. Anything else still fails. Listed here so a caller
|
|
506
|
+
can say it happened.
|
|
507
|
+
*/
|
|
508
|
+
recoveredFromGzip = [];
|
|
492
509
|
async fetchInto(repositoryId, versionId, file, target) {
|
|
493
510
|
const whole = (0, node_crypto_1.createHash)("sha256");
|
|
494
511
|
/*
|
|
@@ -499,6 +516,7 @@ class Downloader {
|
|
|
499
516
|
that cannot match the digest this file was listed with.
|
|
500
517
|
*/
|
|
501
518
|
const pieces = file.sealed ? [] : (file.chunks ?? []);
|
|
519
|
+
let recovered = false;
|
|
502
520
|
// Captured so the generators below do not need `this` rebound.
|
|
503
521
|
const request = (route) => this.request(route);
|
|
504
522
|
const check = () => this.check();
|
|
@@ -508,7 +526,7 @@ class Downloader {
|
|
|
508
526
|
check();
|
|
509
527
|
const piece = pieces[at];
|
|
510
528
|
const reply = await request(`/v1/repositories/${repositoryId}/objects/${piece.objectId}`);
|
|
511
|
-
|
|
529
|
+
let body = Buffer.from(await reply.arrayBuffer());
|
|
512
530
|
/*
|
|
513
531
|
Checked against what the upload recorded for this piece, before
|
|
514
532
|
a byte of it reaches the file. Without the digest the only
|
|
@@ -517,8 +535,13 @@ class Downloader {
|
|
|
517
535
|
*/
|
|
518
536
|
const digest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
|
|
519
537
|
if (piece.sha256 && digest !== piece.sha256) {
|
|
520
|
-
|
|
521
|
-
|
|
538
|
+
const plain = gunzippedMatching(body, piece.sha256);
|
|
539
|
+
if (!plain) {
|
|
540
|
+
throw new Error(`${file.path}: piece ${at + 1} of ${pieces.length} ` +
|
|
541
|
+
`did not arrive intact`);
|
|
542
|
+
}
|
|
543
|
+
recovered = true;
|
|
544
|
+
body = plain;
|
|
522
545
|
}
|
|
523
546
|
whole.update(body);
|
|
524
547
|
yield body;
|
|
@@ -545,7 +568,16 @@ class Downloader {
|
|
|
545
568
|
up holding something that looks finished.
|
|
546
569
|
*/
|
|
547
570
|
await (0, promises_2.pipeline)(source, (0, node_fs_1.createWriteStream)(target));
|
|
548
|
-
|
|
571
|
+
const digest = whole.digest("hex");
|
|
572
|
+
if (recovered)
|
|
573
|
+
this.recoveredFromGzip.push(file.path);
|
|
574
|
+
if (!pieces.length && file.sha256 && digest !== file.sha256) {
|
|
575
|
+
if (await recoverGzipFile(target, file.sha256)) {
|
|
576
|
+
this.recoveredFromGzip.push(file.path);
|
|
577
|
+
return file.sha256;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return digest;
|
|
549
581
|
}
|
|
550
582
|
/**
|
|
551
583
|
* Write a version into `destination`.
|
|
@@ -801,6 +833,7 @@ class Downloader {
|
|
|
801
833
|
await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
|
|
802
834
|
await (0, promises_1.rm)(target, { recursive: true, force: true });
|
|
803
835
|
await (0, promises_1.rename)((0, safe_path_js_1.inside)(staging, file.path), target);
|
|
836
|
+
await applyMode(target, file.executable === true);
|
|
804
837
|
placed += 1;
|
|
805
838
|
if (placed === 1)
|
|
806
839
|
(0, faults_js_1.maybeFail)("get:after-first-file");
|
|
@@ -849,3 +882,68 @@ class Downloader {
|
|
|
849
882
|
}
|
|
850
883
|
}
|
|
851
884
|
exports.Downloader = Downloader;
|
|
885
|
+
/** The gunzipped bytes when they hash to `expected`, otherwise null. */
|
|
886
|
+
function gunzippedMatching(body, expected) {
|
|
887
|
+
if (body.length < 2 || body[0] !== 0x1f || body[1] !== 0x8b)
|
|
888
|
+
return null;
|
|
889
|
+
try {
|
|
890
|
+
const plain = (0, node_zlib_1.gunzipSync)(body);
|
|
891
|
+
return (0, node_crypto_1.createHash)("sha256").update(plain).digest("hex") === expected ? plain : null;
|
|
892
|
+
}
|
|
893
|
+
catch {
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* Replace a file that arrived still gzip-compressed with its contents, when
|
|
899
|
+
* those contents hash to `expected`. Streamed, so a large file is never held.
|
|
900
|
+
*/
|
|
901
|
+
async function recoverGzipFile(target, expected) {
|
|
902
|
+
const handle = await (0, promises_1.open)(target, "r");
|
|
903
|
+
const magic = Buffer.alloc(2);
|
|
904
|
+
try {
|
|
905
|
+
await handle.read(magic, 0, 2, 0);
|
|
906
|
+
}
|
|
907
|
+
finally {
|
|
908
|
+
await handle.close();
|
|
909
|
+
}
|
|
910
|
+
if (magic[0] !== 0x1f || magic[1] !== 0x8b)
|
|
911
|
+
return false;
|
|
912
|
+
const temporary = `${target}.gunzip`;
|
|
913
|
+
const hash = (0, node_crypto_1.createHash)("sha256");
|
|
914
|
+
try {
|
|
915
|
+
await (0, promises_2.pipeline)((0, node_fs_1.createReadStream)(target), (0, node_zlib_1.createGunzip)(), new node_stream_1.Transform({
|
|
916
|
+
transform(chunk, _encoding, done) {
|
|
917
|
+
hash.update(chunk);
|
|
918
|
+
done(null, chunk);
|
|
919
|
+
},
|
|
920
|
+
}), (0, node_fs_1.createWriteStream)(temporary));
|
|
921
|
+
}
|
|
922
|
+
catch {
|
|
923
|
+
await (0, promises_1.rm)(temporary, { force: true });
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
if (hash.digest("hex") !== expected) {
|
|
927
|
+
await (0, promises_1.rm)(temporary, { force: true });
|
|
928
|
+
return false;
|
|
929
|
+
}
|
|
930
|
+
await (0, promises_1.rename)(temporary, target);
|
|
931
|
+
return true;
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Give a written file the execute bit its version records, or take it away.
|
|
935
|
+
*
|
|
936
|
+
* Windows has no execute bit, so nothing is done there. Elsewhere a script
|
|
937
|
+
* that came back as an ordinary file could not be run until somebody noticed
|
|
938
|
+
* and ran chmod, which on Linux was every shell script in every clone.
|
|
939
|
+
*/
|
|
940
|
+
async function applyMode(target, executable) {
|
|
941
|
+
if (process.platform === "win32")
|
|
942
|
+
return;
|
|
943
|
+
try {
|
|
944
|
+
await (0, promises_1.chmod)(target, executable ? 0o755 : 0o644);
|
|
945
|
+
}
|
|
946
|
+
catch {
|
|
947
|
+
/* A file that cannot take a mode is still the right file. */
|
|
948
|
+
}
|
|
949
|
+
}
|
|
@@ -805,9 +805,19 @@ class Uploader {
|
|
|
805
805
|
sourceSize: held.sourceSize,
|
|
806
806
|
storedSize: held.storedSize,
|
|
807
807
|
mediaType: held.mediaType,
|
|
808
|
+
executable: request.executable?.get(file.path) ?? held.executable,
|
|
808
809
|
};
|
|
809
810
|
}),
|
|
810
811
|
].sort((left, right) => left.path.localeCompare(right.path));
|
|
812
|
+
/*
|
|
813
|
+
Whether each sent file runs. Every file used to go up as not, so a
|
|
814
|
+
script cloned on Linux came back unusable until somebody ran chmod.
|
|
815
|
+
*/
|
|
816
|
+
for (const item of contents) {
|
|
817
|
+
if (item.executable !== undefined)
|
|
818
|
+
continue;
|
|
819
|
+
item.executable = await executableFor(request, item.path, prior.get(item.path)?.executable ?? false);
|
|
820
|
+
}
|
|
811
821
|
/*
|
|
812
822
|
Last check before the version is written: everything the person ticked
|
|
813
823
|
has to be in it. Reaching this with something missing would publish a
|
|
@@ -917,7 +927,7 @@ class Uploader {
|
|
|
917
927
|
sourceSize: item.sourceSize,
|
|
918
928
|
storedSize: item.storedSize,
|
|
919
929
|
mediaType: item.mediaType,
|
|
920
|
-
executable:
|
|
930
|
+
executable: item.executable === true,
|
|
921
931
|
})),
|
|
922
932
|
}),
|
|
923
933
|
}));
|
|
@@ -1719,6 +1729,7 @@ class Uploader {
|
|
|
1719
1729
|
sourceSize: Number(row.sourceSize ?? 0),
|
|
1720
1730
|
storedSize: Number(row.storedSize ?? 0),
|
|
1721
1731
|
mediaType: String(row.mediaType ?? "application/octet-stream"),
|
|
1732
|
+
executable: row.executable === true,
|
|
1722
1733
|
});
|
|
1723
1734
|
}
|
|
1724
1735
|
}
|
|
@@ -2629,3 +2640,23 @@ class Uploader {
|
|
|
2629
2640
|
}
|
|
2630
2641
|
}
|
|
2631
2642
|
exports.Uploader = Uploader;
|
|
2643
|
+
/**
|
|
2644
|
+
* Whether a file being sent is marked to run.
|
|
2645
|
+
*
|
|
2646
|
+
* The caller's word first, then the file's own mode. Windows has no execute
|
|
2647
|
+
* bit to read, so there a file keeps whatever the previous version said,
|
|
2648
|
+
* rather than every save from Windows quietly clearing it.
|
|
2649
|
+
*/
|
|
2650
|
+
async function executableFor(request, relative, before) {
|
|
2651
|
+
const told = request.executable?.get(relative);
|
|
2652
|
+
if (told !== undefined)
|
|
2653
|
+
return told;
|
|
2654
|
+
if (process.platform === "win32")
|
|
2655
|
+
return before;
|
|
2656
|
+
try {
|
|
2657
|
+
return ((await (0, promises_1.stat)(node_path_1.default.join(request.localPath, relative))).mode & 0o111) !== 0;
|
|
2658
|
+
}
|
|
2659
|
+
catch {
|
|
2660
|
+
return before;
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
@@ -226,6 +226,8 @@ async function push(repository, options) {
|
|
|
226
226
|
the wrong ones. The git push sends this for the same reason.
|
|
227
227
|
*/
|
|
228
228
|
allowIgnored: true,
|
|
229
|
+
/* The save's own record of what runs; on Windows the scratch tree cannot say. */
|
|
230
|
+
executable: new Map([...after].map(([file, entry]) => [file, entry.executable])),
|
|
229
231
|
...(options.allowSecrets ? { allowSecrets: true } : {}),
|
|
230
232
|
...(options.acknowledged ? { acknowledged: true } : {}),
|
|
231
233
|
...(known ? { known } : {}),
|
|
@@ -325,7 +327,11 @@ async function pull(repository, options) {
|
|
|
325
327
|
const was = before.get(file.path);
|
|
326
328
|
const reuse = was?.sha256 === file.sha256 ? was : byDigest.get(file.sha256);
|
|
327
329
|
if (reuse) {
|
|
328
|
-
entries.push({
|
|
330
|
+
entries.push({
|
|
331
|
+
...reuse,
|
|
332
|
+
path: file.path,
|
|
333
|
+
executable: file.executable ?? was?.executable ?? reuse.executable,
|
|
334
|
+
});
|
|
329
335
|
}
|
|
330
336
|
else {
|
|
331
337
|
fetch.push(file);
|
|
@@ -347,7 +353,7 @@ async function pull(repository, options) {
|
|
|
347
353
|
path: file.path,
|
|
348
354
|
size: file.sourceSize,
|
|
349
355
|
sha256: stored.sha256,
|
|
350
|
-
executable: before.get(file.path)?.executable ?? false,
|
|
356
|
+
executable: file.executable ?? before.get(file.path)?.executable ?? false,
|
|
351
357
|
chunks: stored.chunks,
|
|
352
358
|
};
|
|
353
359
|
entries.push(entry);
|
package/dist/cli/src/api.js
CHANGED
|
@@ -58,6 +58,7 @@ exports.addWebhook = addWebhook;
|
|
|
58
58
|
exports.removeWebhook = removeWebhook;
|
|
59
59
|
exports.collisionCheck = collisionCheck;
|
|
60
60
|
/** The small part of the API the command-line tool needs directly. */
|
|
61
|
+
const network_js_1 = require("./network.js");
|
|
61
62
|
const identify_js_1 = require("../../cbx/src/core/identify.js");
|
|
62
63
|
const config_js_1 = require("./config.js");
|
|
63
64
|
/**
|
|
@@ -99,18 +100,36 @@ async function call(route, options = {}) {
|
|
|
99
100
|
if (!token) {
|
|
100
101
|
throw new Error("Not signed in. Run: cbx sign-in");
|
|
101
102
|
}
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
103
|
+
const method = options.method ?? "GET";
|
|
104
|
+
/*
|
|
105
|
+
A read that failed before any answer is asked again, twice, with a pause.
|
|
106
|
+
Only reads: a write that may have landed is the caller's to retry, with
|
|
107
|
+
what it knows about idempotency.
|
|
108
|
+
*/
|
|
109
|
+
let response;
|
|
110
|
+
for (let attempt = 1;; attempt += 1) {
|
|
111
|
+
try {
|
|
112
|
+
response = await fetch(`${(0, config_js_1.apiOrigin)()}${route}`, {
|
|
113
|
+
method,
|
|
114
|
+
headers: {
|
|
115
|
+
accept: "application/json",
|
|
116
|
+
authorization: `Bearer ${token}`,
|
|
117
|
+
"user-agent": "CodeRook-CLI/0.1",
|
|
118
|
+
...(0, identify_js_1.clientHeaders)(),
|
|
119
|
+
...(options.body ? { "content-type": "application/json" } : {}),
|
|
120
|
+
},
|
|
121
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
122
|
+
signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
|
|
123
|
+
});
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (method !== "GET" || attempt >= 3 || !(0, network_js_1.isNetworkFailure)(error)) {
|
|
128
|
+
throw new Error((0, network_js_1.describeError)(error), { cause: error });
|
|
129
|
+
}
|
|
130
|
+
await (0, network_js_1.pause)(500 * attempt);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
114
133
|
const text = await response.text();
|
|
115
134
|
const body = text ? JSON.parse(text) : {};
|
|
116
135
|
if (!response.ok) {
|
|
@@ -299,7 +318,16 @@ async function findProject(reference) {
|
|
|
299
318
|
the authenticated listing knows about private projects the public one
|
|
300
319
|
cannot see.
|
|
301
320
|
*/
|
|
302
|
-
|
|
321
|
+
/*
|
|
322
|
+
Only the service saying no becomes "no projects". A network failure or a
|
|
323
|
+
missing sign-in is said as itself: swallowing it here turned a timeout
|
|
324
|
+
into "No project matching", which sent somebody looking for a typo.
|
|
325
|
+
*/
|
|
326
|
+
const all = await projects().catch((error) => {
|
|
327
|
+
if (error instanceof ServiceError)
|
|
328
|
+
return [];
|
|
329
|
+
throw error;
|
|
330
|
+
});
|
|
303
331
|
const ownersMatch = (project) => project.slug.toLowerCase() === bare || project.name.toLowerCase() === bare;
|
|
304
332
|
if (!owner) {
|
|
305
333
|
const mine = all.find(ownersMatch);
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
18
18
|
const promises_1 = require("node:readline/promises");
|
|
19
19
|
const node_os_1 = __importDefault(require("node:os"));
|
|
20
20
|
const promises_2 = require("node:fs/promises");
|
|
21
|
+
const node_crypto_1 = require("node:crypto");
|
|
21
22
|
const node_fs_1 = require("node:fs");
|
|
22
23
|
const node_path_1 = __importDefault(require("node:path"));
|
|
23
24
|
const node_process_1 = __importDefault(require("node:process"));
|
|
@@ -45,6 +46,7 @@ const faults_js_1 = require("../../cbx/src/core/faults.js");
|
|
|
45
46
|
const version_commands_js_1 = require("./version_commands.js");
|
|
46
47
|
const identify_js_1 = require("../../cbx/src/core/identify.js");
|
|
47
48
|
const diff_command_js_1 = require("./diff_command.js");
|
|
49
|
+
const network_js_1 = require("./network.js");
|
|
48
50
|
const local_commands_js_1 = require("./local_commands.js");
|
|
49
51
|
const profile_js_1 = require("../../cbx/src/core/profile.js");
|
|
50
52
|
const detect_js_1 = require("../../cbx/src/core/detect.js");
|
|
@@ -604,9 +606,51 @@ async function commandImport(parsed) {
|
|
|
604
606
|
}
|
|
605
607
|
return code;
|
|
606
608
|
}
|
|
609
|
+
/** A file's SHA-256, streamed; null when it cannot be read. */
|
|
610
|
+
async function digestOfFile(full) {
|
|
611
|
+
try {
|
|
612
|
+
const hash = (0, node_crypto_1.createHash)("sha256");
|
|
613
|
+
for await (const block of (0, node_fs_1.createReadStream)(full))
|
|
614
|
+
hash.update(block);
|
|
615
|
+
return hash.digest("hex");
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Say so when files deleted here are still in the saved version.
|
|
623
|
+
*
|
|
624
|
+
* A plain save adds and updates and never removes, so a deleted or moved file
|
|
625
|
+
* lives on in every save after it until somebody passes --sync. `status`
|
|
626
|
+
* already said this; `submit` did not, and from Linux a project turned out to
|
|
627
|
+
* carry a stale copy of a whole app at its root for weeks. Counted from the
|
|
628
|
+
* folder's record of what it holds, so it costs a stat per recorded file.
|
|
629
|
+
*/
|
|
630
|
+
function noteKeptDeletions(folder, baseline, syncing) {
|
|
631
|
+
if (syncing || !baseline?.size)
|
|
632
|
+
return;
|
|
633
|
+
const gone = [...baseline.keys()].filter((file) => !(0, node_fs_1.existsSync)(node_path_1.default.join(folder, ...file.split("/"))));
|
|
634
|
+
if (!gone.length)
|
|
635
|
+
return;
|
|
636
|
+
console.log(dim(`${gone.length} file${gone.length === 1 ? "" : "s"} deleted here ${gone.length === 1 ? "is" : "are"} still in the saved version` +
|
|
637
|
+
` (${gone.slice(0, 3).join(", ")}${gone.length > 3 ? ", …" : ""}). A save adds and updates;` +
|
|
638
|
+
` run cbx submit --sync to remove ${gone.length === 1 ? "it" : "them"}.`));
|
|
639
|
+
}
|
|
607
640
|
async function commandSubmit(parsed) {
|
|
608
641
|
const folder = folderFor(parsed);
|
|
609
642
|
const message = flagText(parsed, "m", "message") ?? "";
|
|
643
|
+
/*
|
|
644
|
+
Checked before anything is scanned or planned. The service's own refusal
|
|
645
|
+
arrived after the upload plan had been printed, as a schema error about
|
|
646
|
+
"<=240 characters", which reads like a fault rather than a limit.
|
|
647
|
+
*/
|
|
648
|
+
if (message.trim().length > 240) {
|
|
649
|
+
console.error(red(`The message is ${message.trim().length} characters; the limit is 240.`));
|
|
650
|
+
console.error(dim("Put the detail in release notes or a NOTES file, and keep the message to a line."));
|
|
651
|
+
return 1;
|
|
652
|
+
}
|
|
653
|
+
const dryRun = hasFlag(parsed, "dry-run", "n");
|
|
610
654
|
const { link, baseline, behind, member } = await reconcile(folder);
|
|
611
655
|
/*
|
|
612
656
|
Somebody else's project, cloned from its public page. Reading and
|
|
@@ -632,7 +676,12 @@ async function commandSubmit(parsed) {
|
|
|
632
676
|
Only on a project's first save. After that an absent LICENSE is somebody's
|
|
633
677
|
decision and putting one back would be arguing with them.
|
|
634
678
|
*/
|
|
635
|
-
|
|
679
|
+
/*
|
|
680
|
+
A dry run changes nothing, and that includes writing a LICENSE: it says
|
|
681
|
+
what would be added instead.
|
|
682
|
+
*/
|
|
683
|
+
const wouldLicence = dryRun && !link && !hasFlag(parsed, "no-licence") && !(0, node_fs_1.existsSync)(node_path_1.default.join(folder, "LICENSE"));
|
|
684
|
+
const added = link || dryRun
|
|
636
685
|
? null
|
|
637
686
|
: await (0, licence_commands_js_1.licenceNewProject)(folder,
|
|
638
687
|
/*
|
|
@@ -836,6 +885,10 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
|
|
|
836
885
|
}
|
|
837
886
|
console.log(dim("Sending them anyway, because --allow-secrets was given."));
|
|
838
887
|
}
|
|
888
|
+
if (wouldLicence) {
|
|
889
|
+
console.log(`${accent("Would add an MIT licence")} on a real save, so other people may use this.` +
|
|
890
|
+
dim(" Pass --no-licence to leave it out."));
|
|
891
|
+
}
|
|
839
892
|
if (added) {
|
|
840
893
|
console.log(`${accent("Added an MIT licence")}, so other people may use this.`);
|
|
841
894
|
console.log(dim(` Change it with `) +
|
|
@@ -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 (
|
|
1017
|
+
if (dryRun) {
|
|
965
1018
|
await uploader.cancelPlan(plan, true);
|
|
966
|
-
|
|
967
|
-
|
|
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 =
|
|
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 =
|
|
1240
|
-
|
|
1241
|
-
|
|
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: "
|
|
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(
|
|
3211
|
+
console.error(red((0, network_js_1.describeError)(error)));
|
|
3126
3212
|
leave(1);
|
|
3127
3213
|
});
|
package/dist/cli/src/config.js
CHANGED
|
@@ -319,18 +319,66 @@ async function lookUp(localPath) {
|
|
|
319
319
|
if (!stored)
|
|
320
320
|
continue;
|
|
321
321
|
if ("forgotten" in stored)
|
|
322
|
-
return
|
|
322
|
+
return "forgotten";
|
|
323
323
|
return unpack(stored);
|
|
324
324
|
}
|
|
325
325
|
return null;
|
|
326
326
|
}
|
|
327
|
+
/*
|
|
328
|
+
A copy of the link kept in the folder itself, beside the offline outbox.
|
|
329
|
+
|
|
330
|
+
The machine's own record is keyed by the folder's path, so the same folder
|
|
331
|
+
seen from another operating system (a drive letter from Windows, a mount
|
|
332
|
+
point from Linux, on the same disk) or from another machine was a stranger,
|
|
333
|
+
and every project had to be cloned again into a new folder. `.coderook` is
|
|
334
|
+
never scanned or uploaded, and the copy holds no token: only which project
|
|
335
|
+
and version the folder stands on, as `.git` records its remote.
|
|
336
|
+
*/
|
|
337
|
+
const folderLinkFile = (localPath) => node_path_1.default.join(node_path_1.default.resolve(localPath), ".coderook", "link.json");
|
|
338
|
+
async function readFolderLink(localPath) {
|
|
339
|
+
try {
|
|
340
|
+
const raw = JSON.parse(await (0, promises_1.readFile)(folderLinkFile(localPath), "utf8"));
|
|
341
|
+
if (typeof raw.repositoryId !== "string" || typeof raw.slug !== "string")
|
|
342
|
+
return null;
|
|
343
|
+
return {
|
|
344
|
+
...raw,
|
|
345
|
+
manifest: raw.manifest ?? {},
|
|
346
|
+
sequence: Number(raw.sequence ?? 0),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
async function writeFolderLink(localPath, link) {
|
|
354
|
+
try {
|
|
355
|
+
const directory = node_path_1.default.dirname(folderLinkFile(localPath));
|
|
356
|
+
await (0, promises_1.mkdir)(directory, { recursive: true });
|
|
357
|
+
/* Git leaves it alone too, without anybody editing their .gitignore. */
|
|
358
|
+
await (0, promises_1.writeFile)(node_path_1.default.join(directory, ".gitignore"), "*\n").catch(() => undefined);
|
|
359
|
+
await (0, promises_1.writeFile)(folderLinkFile(localPath), JSON.stringify(link));
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
/* A folder that cannot hold the copy still has the machine's record. */
|
|
363
|
+
}
|
|
364
|
+
}
|
|
327
365
|
async function readLink(localPath) {
|
|
328
|
-
let
|
|
329
|
-
if (!
|
|
366
|
+
let found = await lookUp(localPath);
|
|
367
|
+
if (!found &&
|
|
330
368
|
((await exists(legacyLinksFile())) ||
|
|
331
369
|
(await exists(`${legacyLinksFile()}.migrating`)))) {
|
|
332
370
|
await migrateLegacyLinks();
|
|
333
|
-
|
|
371
|
+
found = await lookUp(localPath);
|
|
372
|
+
}
|
|
373
|
+
/* Unlinked on this machine on purpose: the folder's copy does not overrule that. */
|
|
374
|
+
if (found === "forgotten")
|
|
375
|
+
return null;
|
|
376
|
+
let link = found;
|
|
377
|
+
if (!link) {
|
|
378
|
+
link = await readFolderLink(localPath);
|
|
379
|
+
/* Adopted, so this machine knows the folder from now on. */
|
|
380
|
+
if (link)
|
|
381
|
+
await writeLink(localPath, link);
|
|
334
382
|
}
|
|
335
383
|
if (!link)
|
|
336
384
|
return null;
|
|
@@ -353,6 +401,7 @@ async function forgetLink(localPath) {
|
|
|
353
401
|
const forgotten = { key, forgotten: true };
|
|
354
402
|
await writePrivate(storedFileFor(key), JSON.stringify(forgotten));
|
|
355
403
|
}
|
|
404
|
+
await (0, promises_1.rm)(folderLinkFile(localPath), { force: true });
|
|
356
405
|
return true;
|
|
357
406
|
}
|
|
358
407
|
async function writeLink(localPath, link) {
|
|
@@ -363,6 +412,10 @@ async function writeLink(localPath, link) {
|
|
|
363
412
|
...(baseVersionId ? { baseVersionId, versionId: baseVersionId } : {}),
|
|
364
413
|
});
|
|
365
414
|
await writePrivate(storedFileFor(key), JSON.stringify(stored));
|
|
415
|
+
await writeFolderLink(localPath, {
|
|
416
|
+
...link,
|
|
417
|
+
...(baseVersionId ? { baseVersionId, versionId: baseVersionId } : {}),
|
|
418
|
+
});
|
|
366
419
|
// A folder recorded under the old key moves to the new one rather than
|
|
367
420
|
// being left behind as a second connection to the same place.
|
|
368
421
|
const legacy = legacyKeyFor(localPath);
|
|
@@ -88,9 +88,9 @@ exports.main = main;
|
|
|
88
88
|
* is nothing to rewind to. Both are refused rather than silently ignored.
|
|
89
89
|
* - **Submodules.** A gitlink is a pointer into another repository and there
|
|
90
90
|
* are no bytes to publish. Skipped on push, and said out loud.
|
|
91
|
-
* - **File modes.**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
91
|
+
* - **File modes.** The executable bit travels both ways: a pushed `100755`
|
|
92
|
+
* is recorded as executable, and an executable file is fetched as `100755`.
|
|
93
|
+
* Versions saved before the bit was recorded come back `100644`.
|
|
94
94
|
*
|
|
95
95
|
* ## What a round trip does and does not preserve
|
|
96
96
|
*
|
|
@@ -284,6 +284,7 @@ async function applyCommit(catFile, scratch, fromCommit, toCommit) {
|
|
|
284
284
|
const written = [];
|
|
285
285
|
const deleted = [];
|
|
286
286
|
const skipped = [];
|
|
287
|
+
const executable = new Map();
|
|
287
288
|
for (let index = 0; index + 1 < fields.length; index += 2) {
|
|
288
289
|
const status = fields[index];
|
|
289
290
|
const filePath = fields[index + 1];
|
|
@@ -309,8 +310,9 @@ async function applyCommit(catFile, scratch, fromCommit, toCommit) {
|
|
|
309
310
|
await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
|
|
310
311
|
await (0, promises_1.writeFile)(target, bytes);
|
|
311
312
|
written.push(filePath);
|
|
313
|
+
executable.set(filePath, mode === "100755");
|
|
312
314
|
}
|
|
313
|
-
return { written, deleted, skipped };
|
|
315
|
+
return { written, deleted, skipped, executable };
|
|
314
316
|
}
|
|
315
317
|
/*
|
|
316
318
|
The service's answer when a line is already there.
|
|
@@ -838,7 +840,7 @@ depth = null) {
|
|
|
838
840
|
*/
|
|
839
841
|
if (file.path.split("/").some(safe_path_js_1.isGitDirectoryName))
|
|
840
842
|
continue;
|
|
841
|
-
send(`M 100644 :${blobMark.get(file.sha256)} ${(0, git_history_js_1.quotePath)(file.path)}`);
|
|
843
|
+
send(`M ${file.executable ? "100755" : "100644"} :${blobMark.get(file.sha256)} ${(0, git_history_js_1.quotePath)(file.path)}`);
|
|
842
844
|
}
|
|
843
845
|
send("");
|
|
844
846
|
done += 1;
|
|
@@ -1305,6 +1307,7 @@ async function doPush(requests, url) {
|
|
|
1305
1307
|
baseVersionId,
|
|
1306
1308
|
track: branch,
|
|
1307
1309
|
allowIgnored: true,
|
|
1310
|
+
executable: changes.executable,
|
|
1308
1311
|
...(Object.keys(manifest).length ? { known: local } : {}),
|
|
1309
1312
|
});
|
|
1310
1313
|
let result;
|
|
@@ -111,12 +111,28 @@ async function commandLicence(parsed) {
|
|
|
111
111
|
*/
|
|
112
112
|
let holder = flagValue(parsed, "holder") ?? "";
|
|
113
113
|
if (licence.personalised && !holder) {
|
|
114
|
+
/*
|
|
115
|
+
Why the name could not be had decides what to say. This used to answer
|
|
116
|
+
every failure with "sign in", including a network timeout for somebody
|
|
117
|
+
who was signed in, and sent them to fix the wrong thing.
|
|
118
|
+
*/
|
|
119
|
+
let failure = null;
|
|
114
120
|
holder = await (0, api_js_1.whoami)()
|
|
115
121
|
.then((account) => account.displayName || account.username || "")
|
|
116
|
-
.catch(() =>
|
|
122
|
+
.catch((error) => {
|
|
123
|
+
failure = error;
|
|
124
|
+
return "";
|
|
125
|
+
});
|
|
117
126
|
if (!holder) {
|
|
127
|
+
const reason = failure instanceof Error ? failure.message : "";
|
|
118
128
|
console.error(red("Could not work out whose name goes on the licence."));
|
|
119
|
-
|
|
129
|
+
if (/sign-in|not signed in|not accepted/i.test(reason) || !failure) {
|
|
130
|
+
console.error(`Sign in, or pass ${accent('--holder "Your Name"')} to say directly.`);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
console.error(dim(reason));
|
|
134
|
+
console.error(`Try again, or pass ${accent('--holder "Your Name"')} to say directly.`);
|
|
135
|
+
}
|
|
120
136
|
return 1;
|
|
121
137
|
}
|
|
122
138
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.tuneNetwork = tuneNetwork;
|
|
7
|
+
exports.isNetworkFailure = isNetworkFailure;
|
|
8
|
+
exports.describeError = describeError;
|
|
9
|
+
exports.pause = pause;
|
|
10
|
+
/**
|
|
11
|
+
* Reaching the service on networks that half-support IPv6.
|
|
12
|
+
*
|
|
13
|
+
* Found from Linux: a router hands out IPv6 addresses but has no IPv6 route.
|
|
14
|
+
* Node tries both families at once and gives each address 250 ms. The IPv6
|
|
15
|
+
* attempt fails at once, and an IPv4 handshake that occasionally takes longer
|
|
16
|
+
* than 250 ms is abandoned too, so a perfectly healthy connection came back as
|
|
17
|
+
* the two words "fetch failed". curl and browsers never showed it.
|
|
18
|
+
*/
|
|
19
|
+
const node_dns_1 = __importDefault(require("node:dns"));
|
|
20
|
+
const node_net_1 = __importDefault(require("node:net"));
|
|
21
|
+
/** Called once, before any request. Explicit settings in NODE_OPTIONS win. */
|
|
22
|
+
function tuneNetwork() {
|
|
23
|
+
const options = process.env.NODE_OPTIONS ?? "";
|
|
24
|
+
if (!options.includes("autoselection-attempt-timeout")) {
|
|
25
|
+
try {
|
|
26
|
+
node_net_1.default.setDefaultAutoSelectFamilyAttemptTimeout(1_000);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* Older Node: nothing to tune. */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!options.includes("dns-result-order")) {
|
|
33
|
+
try {
|
|
34
|
+
node_dns_1.default.setDefaultResultOrder("ipv4first");
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* Older Node: nothing to tune. */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Whether a failure happened before any answer came back. */
|
|
42
|
+
function isNetworkFailure(error) {
|
|
43
|
+
if (!(error instanceof Error))
|
|
44
|
+
return false;
|
|
45
|
+
if (error.name === "TimeoutError" || error.name === "AbortError")
|
|
46
|
+
return true;
|
|
47
|
+
return error.message === "fetch failed" || Boolean(error.cause);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The real reason, instead of "fetch failed".
|
|
51
|
+
*
|
|
52
|
+
* Node puts what went wrong in `cause` (and, for a failed happy-eyeballs
|
|
53
|
+
* connection, in `cause.errors`, one per address tried). Naming the code and
|
|
54
|
+
* the address is what lets somebody tell a dead network from a slow one.
|
|
55
|
+
*/
|
|
56
|
+
function describeError(error) {
|
|
57
|
+
if (!(error instanceof Error))
|
|
58
|
+
return String(error);
|
|
59
|
+
if (error.name === "TimeoutError") {
|
|
60
|
+
return "CodeRook did not answer in time. Check the connection, then try again.";
|
|
61
|
+
}
|
|
62
|
+
const cause = error.cause;
|
|
63
|
+
if (error.message === "fetch failed" && cause) {
|
|
64
|
+
const attempts = cause.errors?.length ? cause.errors : [cause];
|
|
65
|
+
const named = attempts
|
|
66
|
+
.map((one) => [one.code, one.address].filter(Boolean).join(" "))
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
if (named.length) {
|
|
69
|
+
return `Could not reach CodeRook (${[...new Set(named)].join(", ")}). Check the connection, then try again.`;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return error.message;
|
|
73
|
+
}
|
|
74
|
+
/** Wait, for a retry. */
|
|
75
|
+
function pause(milliseconds) {
|
|
76
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
77
|
+
}
|
package/dist/cli/src/publish.js
CHANGED
|
@@ -19,6 +19,7 @@ function uploadRequestFor(input) {
|
|
|
19
19
|
...(input.allowSecrets ? { allowSecrets: true } : {}),
|
|
20
20
|
...(input.acknowledged ? { acknowledged: true } : {}),
|
|
21
21
|
...(input.acknowledgedNoLicence ? { acknowledgedNoLicence: true } : {}),
|
|
22
|
+
...(input.executable ? { executable: input.executable } : {}),
|
|
22
23
|
...(input.baseVersionId
|
|
23
24
|
? { expectedHeadVersionId: input.baseVersionId }
|
|
24
25
|
: {}),
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coderook/cli",
|
|
3
|
-
"version": "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": "
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://coderook.com",
|
|
7
7
|
"bugs": {
|
|
8
|
-
"url": "https://
|
|
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.
|