@coderook/cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/src/cli.js +114 -14
- package/dist/desktop-app/src/main/upload.js +13 -1
- package/package.json +36 -36
package/dist/cli/src/cli.js
CHANGED
|
@@ -24,7 +24,19 @@ const download_js_1 = require("../../desktop-app/src/main/download.js");
|
|
|
24
24
|
const cbx_js_1 = require("../../desktop-app/src/main/cbx.js");
|
|
25
25
|
const api_js_1 = require("./api.js");
|
|
26
26
|
const config_js_1 = require("./config.js");
|
|
27
|
-
|
|
27
|
+
/*
|
|
28
|
+
Read from the package rather than written twice. A hardcoded copy had
|
|
29
|
+
already drifted from the published version, which makes `coderook doctor`
|
|
30
|
+
worse than useless when working out what somebody is actually running.
|
|
31
|
+
*/
|
|
32
|
+
const VERSION = (() => {
|
|
33
|
+
try {
|
|
34
|
+
return (require("../../../package.json").version ?? "0.0.0");
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return "0.0.0";
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
28
40
|
// ── presentation ────────────────────────────────────────────────────────────
|
|
29
41
|
const colour = node_process_1.default.stdout.isTTY && !node_process_1.default.env.NO_COLOR;
|
|
30
42
|
const dim = (text) => (colour ? `[2m${text}[0m` : text);
|
|
@@ -94,6 +106,18 @@ const folderFor = (parsed, index = 0) => node_path_1.default.resolve(parsed.posi
|
|
|
94
106
|
* what that repository already holds. The account is the authority, exactly
|
|
95
107
|
* as it is in the desktop application.
|
|
96
108
|
*/
|
|
109
|
+
/**
|
|
110
|
+
* Work out what this folder is based on, and whether the account has moved.
|
|
111
|
+
*
|
|
112
|
+
* The base is the version this folder was last brought level with — not
|
|
113
|
+
* whatever happens to be newest. Silently re-basing on the newest is what
|
|
114
|
+
* makes a folder look up to date when somebody else has published: the
|
|
115
|
+
* changes are then measured against their work, and saving quietly lays
|
|
116
|
+
* this machine's copy over theirs.
|
|
117
|
+
*
|
|
118
|
+
* So a folder that has fallen behind keeps its own base and is told, which
|
|
119
|
+
* is what lets the service merge the two rather than pick a winner.
|
|
120
|
+
*/
|
|
97
121
|
async function reconcile(folder, options = {}) {
|
|
98
122
|
const link = await (0, config_js_1.readLink)(folder);
|
|
99
123
|
const reference = link?.slug ?? node_path_1.default.basename(folder);
|
|
@@ -102,12 +126,31 @@ async function reconcile(folder, options = {}) {
|
|
|
102
126
|
: await (0, api_js_1.findProject)(reference);
|
|
103
127
|
if (!project?.versionCount) {
|
|
104
128
|
// Nothing saved on the account, so everything here is outstanding.
|
|
105
|
-
return { link: link ?? null, baseline: null };
|
|
129
|
+
return { link: link ?? null, baseline: null, behind: null };
|
|
106
130
|
}
|
|
107
131
|
const downloader = new download_js_1.Downloader(config_js_1.credentials);
|
|
108
132
|
const latest = (await downloader.versions(project.id))[0];
|
|
109
133
|
if (!latest)
|
|
110
|
-
return { link: link ?? null, baseline: null };
|
|
134
|
+
return { link: link ?? null, baseline: null, behind: null };
|
|
135
|
+
/*
|
|
136
|
+
A folder that already knows which version it stands on keeps it. Only a
|
|
137
|
+
folder that has never been reconciled — or was linked before versions
|
|
138
|
+
were recorded — adopts the newest as its starting point.
|
|
139
|
+
*/
|
|
140
|
+
const known = link?.versionId && link.manifest ? link : null;
|
|
141
|
+
const behind = known && known.versionId !== latest.id
|
|
142
|
+
? { sequence: latest.sequence, id: latest.id }
|
|
143
|
+
: null;
|
|
144
|
+
if (known) {
|
|
145
|
+
if (behind && !options.quiet) {
|
|
146
|
+
console.log(dim(`The account is on v${behind.sequence}; this folder is based on v${known.sequence}.`));
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
link: known,
|
|
150
|
+
baseline: new Map(Object.entries(known.manifest)),
|
|
151
|
+
behind,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
111
154
|
const baseline = new Map();
|
|
112
155
|
for (const file of await downloader.files(project.id, latest.id)) {
|
|
113
156
|
baseline.set(file.path, file.sha256);
|
|
@@ -116,13 +159,14 @@ async function reconcile(folder, options = {}) {
|
|
|
116
159
|
repositoryId: project.id,
|
|
117
160
|
slug: project.slug,
|
|
118
161
|
sequence: latest.sequence,
|
|
162
|
+
versionId: latest.id,
|
|
119
163
|
manifest: Object.fromEntries(baseline),
|
|
120
164
|
};
|
|
121
165
|
await (0, config_js_1.writeLink)(folder, fresh);
|
|
122
166
|
if (!options.quiet && !link) {
|
|
123
167
|
console.log(dim(`Linked this folder to ${project.slug}.`));
|
|
124
168
|
}
|
|
125
|
-
return { link: fresh, baseline };
|
|
169
|
+
return { link: fresh, baseline, behind: null };
|
|
126
170
|
}
|
|
127
171
|
// ── commands ────────────────────────────────────────────────────────────────
|
|
128
172
|
async function commandSignIn(parsed) {
|
|
@@ -206,6 +250,31 @@ async function commandSubmit(parsed) {
|
|
|
206
250
|
console.log("Nothing to submit; this folder matches the saved version.");
|
|
207
251
|
return 0;
|
|
208
252
|
}
|
|
253
|
+
/*
|
|
254
|
+
The policy is that credentials are never quietly dropped and never
|
|
255
|
+
quietly sent — they are asked about. The desktop application has always
|
|
256
|
+
put this question in front of the first upload; the command line was
|
|
257
|
+
sending them without a word, which is the worse half of the two
|
|
258
|
+
behaviours the policy exists to prevent.
|
|
259
|
+
*/
|
|
260
|
+
const secrets = await (0, worktree_js_1.detectSecrets)(folder);
|
|
261
|
+
const sending = new Set(files.map((file) => file.path));
|
|
262
|
+
const exposed = secrets.filter((secret) => sending.has(secret));
|
|
263
|
+
if (exposed.length) {
|
|
264
|
+
console.log(red(`\n${exposed.length} file${exposed.length === 1 ? " looks like a credential" : "s look like credentials"}:`));
|
|
265
|
+
for (const secret of exposed.slice(0, 20))
|
|
266
|
+
console.log(` ${secret}`);
|
|
267
|
+
if (exposed.length > 20) {
|
|
268
|
+
console.log(dim(` …and ${exposed.length - 20} more`));
|
|
269
|
+
}
|
|
270
|
+
if (!hasFlag(parsed, "allow-secrets")) {
|
|
271
|
+
console.error(`\nNothing was sent. Add them to ${accent(".gitignore")} to leave them` +
|
|
272
|
+
` behind, or pass ${accent("--allow-secrets")} if they genuinely belong` +
|
|
273
|
+
` in the project.`);
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
console.log(dim("Sending them anyway, because --allow-secrets was given."));
|
|
277
|
+
}
|
|
209
278
|
if (hasFlag(parsed, "dry-run", "n")) {
|
|
210
279
|
console.log(`${files.length} file${files.length === 1 ? "" : "s"} would be sent:`);
|
|
211
280
|
for (const file of files)
|
|
@@ -214,21 +283,49 @@ async function commandSubmit(parsed) {
|
|
|
214
283
|
}
|
|
215
284
|
const line = progressLine();
|
|
216
285
|
const uploader = new upload_js_1.Uploader(config_js_1.credentials);
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
286
|
+
let result;
|
|
287
|
+
try {
|
|
288
|
+
result = await uploader.run({
|
|
289
|
+
localPath: folder,
|
|
290
|
+
include: files.map((file) => file.path),
|
|
291
|
+
message,
|
|
292
|
+
projectName: link?.slug ?? node_path_1.default.basename(folder),
|
|
293
|
+
repositoryId: link?.repositoryId ?? null,
|
|
294
|
+
/*
|
|
295
|
+
Only claimed when this folder has actually been reconciled with a
|
|
296
|
+
known version. A folder linked before versions were recorded says
|
|
297
|
+
nothing rather than guessing, and publishes as it always did.
|
|
298
|
+
*/
|
|
299
|
+
...(link?.versionId ? { expectedHeadVersionId: link.versionId } : {}),
|
|
300
|
+
}, (progress) => {
|
|
301
|
+
line(` ${String(progress.percent).padStart(3)}% ${progress.stage.padEnd(7)} ` +
|
|
302
|
+
`${progress.files}/${progress.totalFiles} ${progress.path.slice(-48)}`);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
done(line);
|
|
307
|
+
const code = error.code;
|
|
308
|
+
if (code === "merge_required" || code === "track_moved") {
|
|
309
|
+
/*
|
|
310
|
+
Somebody else saved to this project first. Everything that did not
|
|
311
|
+
overlap has already been combined by the service; what is left is a
|
|
312
|
+
genuine decision, so the answer is to bring their work down and look
|
|
313
|
+
at it rather than to try again harder.
|
|
314
|
+
*/
|
|
315
|
+
console.error(red(error instanceof Error ? error.message : String(error)));
|
|
316
|
+
console.error(`\nRun ${accent("coderook get")} to bring the latest version down, then` +
|
|
317
|
+
` submit again. Nothing was changed on your account.`);
|
|
318
|
+
return 1;
|
|
319
|
+
}
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
227
322
|
done(line);
|
|
228
323
|
await (0, config_js_1.writeLink)(folder, {
|
|
229
324
|
repositoryId: result.repositoryId,
|
|
230
325
|
slug: link?.slug ?? node_path_1.default.basename(folder),
|
|
231
326
|
sequence: result.sequence,
|
|
327
|
+
// What this folder is now working from, so the next submit can say so.
|
|
328
|
+
versionId: result.versionId,
|
|
232
329
|
manifest: result.manifest,
|
|
233
330
|
});
|
|
234
331
|
console.log(`Saved ${accent(`v${result.sequence}`)} · sent ${bytes(result.sentBytes)} in ` +
|
|
@@ -277,10 +374,12 @@ async function fetchInto(repositoryId, destination, slug) {
|
|
|
277
374
|
` ${progress.path.slice(-48)}`);
|
|
278
375
|
});
|
|
279
376
|
done(line);
|
|
377
|
+
// Fetching is how a folder catches up, so this is what moves its base.
|
|
280
378
|
await (0, config_js_1.writeLink)(destination, {
|
|
281
379
|
repositoryId,
|
|
282
380
|
slug,
|
|
283
381
|
sequence: latest.sequence,
|
|
382
|
+
versionId: latest.id,
|
|
284
383
|
manifest: result.manifest,
|
|
285
384
|
});
|
|
286
385
|
console.log(`${accent(`v${latest.sequence}`)} · ${result.files} files · ${bytes(result.bytes)} into ${destination}`);
|
|
@@ -402,6 +501,7 @@ ${bold("Other")}
|
|
|
402
501
|
${bold("Options")}
|
|
403
502
|
-m, --message The version message for submit
|
|
404
503
|
-n, --dry-run Show what submit would send, without sending
|
|
504
|
+
--allow-secrets Send files that look like credentials anyway
|
|
405
505
|
--token Supply the token to sign-in instead of being asked
|
|
406
506
|
|
|
407
507
|
${dim("The environment variable CODEROOK_TOKEN is used when set, so automated")}
|
|
@@ -95,7 +95,16 @@ class Uploader {
|
|
|
95
95
|
const body = text ? JSON.parse(text) : {};
|
|
96
96
|
if (!response.ok) {
|
|
97
97
|
const message = body?.error?.message ?? `${route} failed (${response.status})`;
|
|
98
|
-
|
|
98
|
+
/*
|
|
99
|
+
The service says what kind of failure this is, and callers need that
|
|
100
|
+
to say anything useful — "someone else saved first" deserves a
|
|
101
|
+
different suggestion from "the disk is full". Carrying the code on
|
|
102
|
+
the error keeps them from having to match on wording.
|
|
103
|
+
*/
|
|
104
|
+
throw Object.assign(new Error(message), {
|
|
105
|
+
code: typeof body?.error?.code === "string" ? body.error.code : "",
|
|
106
|
+
status: response.status,
|
|
107
|
+
});
|
|
99
108
|
}
|
|
100
109
|
return body;
|
|
101
110
|
}
|
|
@@ -288,6 +297,9 @@ class Uploader {
|
|
|
288
297
|
contentType: "application/json",
|
|
289
298
|
body: JSON.stringify({
|
|
290
299
|
message: request.message.trim() || "Saved from the CodeRook desktop app",
|
|
300
|
+
...(request.expectedHeadVersionId === undefined
|
|
301
|
+
? {}
|
|
302
|
+
: { expectedHeadVersionId: request.expectedHeadVersionId }),
|
|
291
303
|
sourceSize: sourceBytes,
|
|
292
304
|
storedSize: storedBytes,
|
|
293
305
|
files: contents.map((item) => ({
|
package/package.json
CHANGED
|
@@ -1,36 +1,36 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@coderook/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CodeRook from the command line, on any operating system",
|
|
5
|
-
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
|
-
"homepage": "https://coderook.com",
|
|
7
|
-
"bugs": {
|
|
8
|
-
"url": "https://coderook.com/contact"
|
|
9
|
-
},
|
|
10
|
-
"keywords": [
|
|
11
|
-
"coderook",
|
|
12
|
-
"versioning",
|
|
13
|
-
"backup",
|
|
14
|
-
"cbx"
|
|
15
|
-
],
|
|
16
|
-
"engines": {
|
|
17
|
-
"node": ">=20.11.0"
|
|
18
|
-
},
|
|
19
|
-
"bin": {
|
|
20
|
-
"coderook": "dist/cli/src/cli.js"
|
|
21
|
-
},
|
|
22
|
-
"files": [
|
|
23
|
-
"dist"
|
|
24
|
-
],
|
|
25
|
-
"scripts": {
|
|
26
|
-
"build": "tsc -p tsconfig.json",
|
|
27
|
-
"check": "tsc -p tsconfig.json --noEmit",
|
|
28
|
-
"test": "tsc -p tsconfig.json && node --test --experimental-strip-types test/*.test.ts",
|
|
29
|
-
"start": "node dist/cli/src/cli.js",
|
|
30
|
-
"prepublishOnly": "npm run build"
|
|
31
|
-
},
|
|
32
|
-
"devDependencies": {
|
|
33
|
-
"@types/node": "24.10.1",
|
|
34
|
-
"typescript": "5.9.3"
|
|
35
|
-
}
|
|
36
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@coderook/cli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "CodeRook from the command line, on any operating system",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
|
+
"homepage": "https://coderook.com",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://coderook.com/contact"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"coderook",
|
|
12
|
+
"versioning",
|
|
13
|
+
"backup",
|
|
14
|
+
"cbx"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20.11.0"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"coderook": "dist/cli/src/cli.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.json",
|
|
27
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
28
|
+
"test": "tsc -p tsconfig.json && node --test --experimental-strip-types test/*.test.ts",
|
|
29
|
+
"start": "node dist/cli/src/cli.js",
|
|
30
|
+
"prepublishOnly": "npm run build"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "24.10.1",
|
|
34
|
+
"typescript": "5.9.3"
|
|
35
|
+
}
|
|
36
|
+
}
|