@coderook/cli 0.4.0 → 0.6.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 +127 -6
- package/dist/desktop-app/src/main/download.js +52 -6
- package/package.json +43 -40
package/dist/cli/src/cli.js
CHANGED
|
@@ -270,9 +270,36 @@ async function commandStatus(parsed) {
|
|
|
270
270
|
async function commandSubmit(parsed) {
|
|
271
271
|
const folder = folderFor(parsed);
|
|
272
272
|
const message = flagText(parsed, "m", "message") ?? "";
|
|
273
|
-
const { link, baseline } = await reconcile(folder);
|
|
273
|
+
const { link, baseline, behind } = await reconcile(folder);
|
|
274
274
|
const rules = await (0, worktree_js_1.readRules)(folder);
|
|
275
275
|
const files = await (0, worktree_js_1.changedFiles)(folder, rules, baseline);
|
|
276
|
+
/*
|
|
277
|
+
An upgraded folder with no materialisation record and something that
|
|
278
|
+
differs from its recorded version is ambiguous in the one way that
|
|
279
|
+
matters: the difference is either work to send, or a copy left behind by
|
|
280
|
+
somebody's merge. Sending the second reverts their change.
|
|
281
|
+
|
|
282
|
+
While the folder is level with the project the question does not arise —
|
|
283
|
+
nothing can have merged underneath it — so only a folder that is behind
|
|
284
|
+
is stopped, and only until somebody says which it is.
|
|
285
|
+
*/
|
|
286
|
+
if (link && !link.local && files.length && behind) {
|
|
287
|
+
if (await establishMaterialisation(folder, link)) {
|
|
288
|
+
link.local = { ...link.manifest };
|
|
289
|
+
await (0, config_js_1.writeLink)(folder, link);
|
|
290
|
+
}
|
|
291
|
+
else if (!hasFlag(parsed, "f", "force")) {
|
|
292
|
+
console.error(red("This folder was connected by an older version of CodeRook,"));
|
|
293
|
+
console.error("which did not record what it had received — and somebody");
|
|
294
|
+
console.error(`has saved since (the project is on v${behind.sequence}).\n`);
|
|
295
|
+
console.error(`${files.length} file${files.length === 1 ? " differs" : "s differ"} here:`);
|
|
296
|
+
for (const file of files.slice(0, 20))
|
|
297
|
+
console.error(` ${file.path}`);
|
|
298
|
+
console.error(`\nIf that is your work, send it with ${accent("coderook submit --force")}.` +
|
|
299
|
+
`\nIf it is an old copy, take theirs with ${accent("coderook get --replace")}.`);
|
|
300
|
+
return 1;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
276
303
|
if (!files.length) {
|
|
277
304
|
// Having nothing to send is not the same as holding the whole version:
|
|
278
305
|
// a merge can leave this folder with an older copy of somebody else's
|
|
@@ -415,6 +442,20 @@ The connection failed: ${text}`));
|
|
|
415
442
|
` · version holds ${bytes(result.sourceBytes)}`);
|
|
416
443
|
return 0;
|
|
417
444
|
}
|
|
445
|
+
/**
|
|
446
|
+
* Work out what an upgraded folder actually holds, or admit that it cannot.
|
|
447
|
+
*
|
|
448
|
+
* Returns the materialisation when the folder matches its recorded version
|
|
449
|
+
* exactly — which proves the manifest describes what is here — and null when
|
|
450
|
+
* anything differs, because a difference is either somebody's unsaved edit or
|
|
451
|
+
* a copy their merge left stale, and nothing on disk says which.
|
|
452
|
+
*/
|
|
453
|
+
async function establishMaterialisation(folder, link) {
|
|
454
|
+
const rules = await (0, worktree_js_1.readRules)(folder);
|
|
455
|
+
const against = new Map(Object.entries(link.manifest));
|
|
456
|
+
const differing = await (0, worktree_js_1.changedFiles)(folder, rules, against);
|
|
457
|
+
return differing.length ? null : { ...link.manifest };
|
|
458
|
+
}
|
|
418
459
|
async function commandGet(parsed) {
|
|
419
460
|
const folder = folderFor(parsed);
|
|
420
461
|
const { link } = await reconcile(folder);
|
|
@@ -422,8 +463,87 @@ async function commandGet(parsed) {
|
|
|
422
463
|
console.error(red("This folder is not linked to a project on your account."));
|
|
423
464
|
return 1;
|
|
424
465
|
}
|
|
425
|
-
|
|
426
|
-
|
|
466
|
+
/*
|
|
467
|
+
Fetching writes over what is here. That is the point of it — but a file
|
|
468
|
+
somebody has edited and not yet saved is not the same as a file sitting
|
|
469
|
+
where the last fetch left it, and overwriting the first loses work that
|
|
470
|
+
exists nowhere else.
|
|
471
|
+
|
|
472
|
+
Telling them apart needs the materialisation record: disk differing from
|
|
473
|
+
what was placed here is an edit, and disk matching it is simply an old
|
|
474
|
+
copy. Only the edits are worth stopping for, and only when the incoming
|
|
475
|
+
version would actually change those files.
|
|
476
|
+
*/
|
|
477
|
+
const replacing = hasFlag(parsed, "replace", "f", "force");
|
|
478
|
+
/*
|
|
479
|
+
A folder connected by a client older than the materialisation record has
|
|
480
|
+
only the Version manifest, which says what the project held — not what
|
|
481
|
+
was placed here. The two are the same only until somebody's merge makes
|
|
482
|
+
them differ, so assuming it would be the very guess that lost work in the
|
|
483
|
+
first place.
|
|
484
|
+
|
|
485
|
+
It can be established rather than assumed: if the folder matches its own
|
|
486
|
+
recorded version exactly, then it demonstrably holds that version and the
|
|
487
|
+
manifest is the materialisation. If it does not match, an edit cannot be
|
|
488
|
+
told from a stale copy, and fetching is refused until somebody says which
|
|
489
|
+
it is.
|
|
490
|
+
*/
|
|
491
|
+
if (!link.local) {
|
|
492
|
+
const settled = await establishMaterialisation(folder, link);
|
|
493
|
+
if (settled) {
|
|
494
|
+
link.local = settled;
|
|
495
|
+
await (0, config_js_1.writeLink)(folder, link);
|
|
496
|
+
}
|
|
497
|
+
else if (!replacing) {
|
|
498
|
+
console.error(red("This folder was connected by an older version of CodeRook,"));
|
|
499
|
+
console.error("which did not record what it had received, so changed files here");
|
|
500
|
+
console.error("cannot be told apart from copies left behind by a merge.\n");
|
|
501
|
+
console.error(`Save them with ${accent("coderook submit")} if they are your work, or ` +
|
|
502
|
+
`take the\nsaved version exactly with ${accent("coderook get --replace")}.`);
|
|
503
|
+
return 1;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (link.local && !replacing) {
|
|
507
|
+
const rules = await (0, worktree_js_1.readRules)(folder);
|
|
508
|
+
const edited = await (0, worktree_js_1.changedFiles)(folder, rules, new Map(Object.entries(link.local)));
|
|
509
|
+
if (edited.length) {
|
|
510
|
+
const downloader = new download_js_1.Downloader(config_js_1.credentials);
|
|
511
|
+
const latest = (await downloader.versions(link.repositoryId))[0];
|
|
512
|
+
const incoming = new Map(latest
|
|
513
|
+
? (await downloader.files(link.repositoryId, latest.id)).map((file) => [
|
|
514
|
+
file.path,
|
|
515
|
+
file.sha256,
|
|
516
|
+
])
|
|
517
|
+
: []);
|
|
518
|
+
/*
|
|
519
|
+
Only a genuine collision stops the fetch: this folder changed the
|
|
520
|
+
file and so did the incoming version. An edit to a file the version
|
|
521
|
+
left alone is simply kept — fetching has nothing to say about it —
|
|
522
|
+
and stopping for those would make the warning something people learn
|
|
523
|
+
to click past.
|
|
524
|
+
*/
|
|
525
|
+
const atRisk = edited.filter((file) => !file.deleted &&
|
|
526
|
+
incoming.has(file.path) &&
|
|
527
|
+
incoming.get(file.path) !== link.local[file.path]);
|
|
528
|
+
if (atRisk.length) {
|
|
529
|
+
console.error(red(`${atRisk.length} file${atRisk.length === 1 ? "" : "s"} here ` +
|
|
530
|
+
`${atRisk.length === 1 ? "has" : "have"} changes that fetching would overwrite:`));
|
|
531
|
+
for (const file of atRisk.slice(0, 20))
|
|
532
|
+
console.error(` ${file.path}`);
|
|
533
|
+
if (atRisk.length > 20)
|
|
534
|
+
console.error(dim(` …and ${atRisk.length - 20} more`));
|
|
535
|
+
console.error(`\nSave them first with ${accent("coderook submit")}, or discard them ` +
|
|
536
|
+
`with ${accent("coderook get --replace")}.`);
|
|
537
|
+
return 1;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/*
|
|
542
|
+
Only what this folder received may be removed by catching up, and only
|
|
543
|
+
what the version actually changed is written over — unless somebody asks
|
|
544
|
+
for an exact copy, which is how a damaged folder is repaired.
|
|
545
|
+
*/
|
|
546
|
+
return fetchInto(link.repositoryId, folder, link.slug, link.local ?? null, replacing ? "replace" : "reconcile");
|
|
427
547
|
}
|
|
428
548
|
async function commandClone(parsed) {
|
|
429
549
|
const reference = parsed.positional[0];
|
|
@@ -450,7 +570,7 @@ async function fetchInto(repositoryId, destination, slug,
|
|
|
450
570
|
is removed, because "missing from the version" cannot then be told from
|
|
451
571
|
"never came from the version at all".
|
|
452
572
|
*/
|
|
453
|
-
held) {
|
|
573
|
+
held, mode = "replace") {
|
|
454
574
|
const downloader = new download_js_1.Downloader(config_js_1.credentials);
|
|
455
575
|
const latest = (await downloader.versions(repositoryId))[0];
|
|
456
576
|
if (!latest) {
|
|
@@ -464,7 +584,7 @@ held) {
|
|
|
464
584
|
},
|
|
465
585
|
// What this folder received last time, so a file the project has since
|
|
466
586
|
// dropped is removed while everything untracked is left alone.
|
|
467
|
-
held);
|
|
587
|
+
held, mode);
|
|
468
588
|
done(line);
|
|
469
589
|
// Fetching is how a folder catches up, so this is what moves its base.
|
|
470
590
|
await (0, config_js_1.writeLink)(destination, {
|
|
@@ -580,7 +700,8 @@ ${bold("Getting started")}
|
|
|
580
700
|
${bold("Working with a folder")}
|
|
581
701
|
coderook status [folder] What is here that is not saved yet
|
|
582
702
|
coderook submit [folder] -m "…" Send the changes as a new version
|
|
583
|
-
coderook get [folder]
|
|
703
|
+
coderook get [folder] Bring this folder up to date
|
|
704
|
+
(--replace for an exact copy)
|
|
584
705
|
coderook clone <project> [dir] Fetch a project into a new folder
|
|
585
706
|
coderook rules [folder] Show the ignore rules (--init to start one)
|
|
586
707
|
|
|
@@ -15,6 +15,24 @@ exports.Downloader = exports.DownloadCancelled = void 0;
|
|
|
15
15
|
const node_crypto_1 = require("node:crypto");
|
|
16
16
|
const promises_1 = require("node:fs/promises");
|
|
17
17
|
const node_path_1 = __importDefault(require("node:path"));
|
|
18
|
+
async function exists(target) {
|
|
19
|
+
try {
|
|
20
|
+
await (0, promises_1.stat)(target);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** The digest of what is on disk, or null when nothing is there to read. */
|
|
28
|
+
async function digestOf(target) {
|
|
29
|
+
try {
|
|
30
|
+
return (0, node_crypto_1.createHash)("sha256").update(await (0, promises_1.readFile)(target)).digest("hex");
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
18
36
|
/**
|
|
19
37
|
* Remove the directories a deletion just emptied, up to but never including
|
|
20
38
|
* the project folder itself. Left alone they accumulate as empty husks of
|
|
@@ -115,7 +133,15 @@ class Downloader {
|
|
|
115
133
|
* itself. A file the version no longer holds is removed only when this
|
|
116
134
|
* folder is known to have received it, which `held` supplies.
|
|
117
135
|
*/
|
|
118
|
-
async run(repositoryId, versionId, destination, report, held
|
|
136
|
+
async run(repositoryId, versionId, destination, report, held,
|
|
137
|
+
/*
|
|
138
|
+
`reconcile` brings the folder up to date without disturbing work in
|
|
139
|
+
progress: a file the incoming version did not change is left exactly as
|
|
140
|
+
it is, edits and all. `replace` makes the folder an exact copy of the
|
|
141
|
+
version, which is what repairs a damaged one — and what discards
|
|
142
|
+
anything unsaved.
|
|
143
|
+
*/
|
|
144
|
+
mode = "replace") {
|
|
119
145
|
const files = await this.files(repositoryId, versionId);
|
|
120
146
|
if (!files.length)
|
|
121
147
|
throw new Error("That version has no files");
|
|
@@ -165,6 +191,18 @@ class Downloader {
|
|
|
165
191
|
for (const file of files) {
|
|
166
192
|
const parts = file.path.split("/").filter(Boolean);
|
|
167
193
|
const target = node_path_1.default.join(destination, ...parts);
|
|
194
|
+
/*
|
|
195
|
+
Reconciling leaves alone whatever the incoming version did not
|
|
196
|
+
change. Writing it anyway would replace an edit in progress with a
|
|
197
|
+
copy of what the folder already had — destroying work to achieve
|
|
198
|
+
nothing, which is the least defensible way to lose someone's file.
|
|
199
|
+
*/
|
|
200
|
+
if (mode === "reconcile" &&
|
|
201
|
+
held &&
|
|
202
|
+
held[file.path] === manifest[file.path] &&
|
|
203
|
+
(await exists(target))) {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
168
206
|
await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
|
|
169
207
|
await (0, promises_1.rm)(target, { recursive: true, force: true });
|
|
170
208
|
await (0, promises_1.rename)(node_path_1.default.join(staging, ...parts), target);
|
|
@@ -175,14 +213,22 @@ class Downloader {
|
|
|
175
213
|
to remove — and without a record of what it received, nothing is
|
|
176
214
|
removed at all, which is the safe reading of an unknown folder.
|
|
177
215
|
*/
|
|
178
|
-
for (const
|
|
179
|
-
if (manifest[
|
|
216
|
+
for (const [gone, digest] of Object.entries(held ?? {})) {
|
|
217
|
+
if (manifest[gone] !== undefined)
|
|
180
218
|
continue;
|
|
181
|
-
const parts =
|
|
219
|
+
const parts = gone.split("/").filter(Boolean);
|
|
182
220
|
if (parts.some((part) => part === ".." || part.includes("\0")))
|
|
183
221
|
continue;
|
|
184
|
-
|
|
185
|
-
|
|
222
|
+
const target = node_path_1.default.join(destination, ...parts);
|
|
223
|
+
/*
|
|
224
|
+
The version dropped it — but if the bytes here are no longer the
|
|
225
|
+
ones this folder received, somebody has been working on it, and
|
|
226
|
+
somebody else's deletion is not permission to throw that away.
|
|
227
|
+
*/
|
|
228
|
+
if (mode === "reconcile" && (await digestOf(target)) !== digest)
|
|
229
|
+
continue;
|
|
230
|
+
await (0, promises_1.rm)(target, { force: true });
|
|
231
|
+
await pruneEmpty(destination, target);
|
|
186
232
|
}
|
|
187
233
|
await (0, promises_1.rm)(staging, { recursive: true, force: true });
|
|
188
234
|
report({
|
package/package.json
CHANGED
|
@@ -1,40 +1,43 @@
|
|
|
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
|
-
"test:e2e": "node test/e2e.mjs",
|
|
32
|
-
"test:matrix": "node test/state-matrix.mjs",
|
|
33
|
-
"test:attempt": "node test/attempt-identity.mjs",
|
|
34
|
-
"test:get": "node test/get-safety.mjs"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@coderook/cli",
|
|
3
|
+
"version": "0.6.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
|
+
"test:e2e": "node test/e2e.mjs",
|
|
32
|
+
"test:matrix": "node test/state-matrix.mjs",
|
|
33
|
+
"test:attempt": "node test/attempt-identity.mjs",
|
|
34
|
+
"test:get": "node test/get-safety.mjs",
|
|
35
|
+
"test:live": "node test/e2e.mjs --allow-production && node test/state-matrix.mjs --allow-production && node test/get-safety.mjs --allow-production && node test/get-protects-edits.mjs --allow-production && node test/upgrade-migration.mjs --allow-production && node test/attempt-identity.mjs --allow-production",
|
|
36
|
+
"test:getedits": "node test/get-protects-edits.mjs",
|
|
37
|
+
"test:upgrade": "node test/upgrade-migration.mjs"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "24.10.1",
|
|
41
|
+
"typescript": "5.9.3"
|
|
42
|
+
}
|
|
43
|
+
}
|