@coderook/cli 0.2.0 → 0.4.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/api.js +24 -0
- package/dist/cli/src/cli.js +237 -15
- package/dist/cli/src/config.js +32 -2
- package/dist/desktop-app/src/main/download.js +64 -7
- package/dist/desktop-app/src/main/publish_name.js +27 -0
- package/dist/desktop-app/src/main/rules.js +13 -0
- package/dist/desktop-app/src/main/upload.js +60 -1
- package/package.json +6 -2
package/dist/cli/src/api.js
CHANGED
|
@@ -4,6 +4,11 @@ exports.whoami = whoami;
|
|
|
4
4
|
exports.projects = projects;
|
|
5
5
|
exports.findProject = findProject;
|
|
6
6
|
exports.health = health;
|
|
7
|
+
exports.mergeTracks = mergeTracks;
|
|
8
|
+
exports.mergeTrack = mergeTrack;
|
|
9
|
+
exports.resolveMergeConflict = resolveMergeConflict;
|
|
10
|
+
exports.applyMerge = applyMerge;
|
|
11
|
+
exports.cancelMerge = cancelMerge;
|
|
7
12
|
/** The small part of the API the command-line tool needs directly. */
|
|
8
13
|
const config_js_1 = require("./config.js");
|
|
9
14
|
async function call(route, options = {}) {
|
|
@@ -65,3 +70,22 @@ async function health() {
|
|
|
65
70
|
});
|
|
66
71
|
return (await response.json());
|
|
67
72
|
}
|
|
73
|
+
async function mergeTracks(repositoryId) {
|
|
74
|
+
const body = await call(`/v1/repositories/${repositoryId}/merge-tracks`);
|
|
75
|
+
return body.mergeTracks;
|
|
76
|
+
}
|
|
77
|
+
async function mergeTrack(id) {
|
|
78
|
+
return call(`/v1/merge-tracks/${id}`);
|
|
79
|
+
}
|
|
80
|
+
async function resolveMergeConflict(mergeTrackId, conflictId, resolution) {
|
|
81
|
+
await call(`/v1/merge-tracks/${mergeTrackId}/conflicts/${conflictId}`, {
|
|
82
|
+
method: "PUT",
|
|
83
|
+
body: { resolution },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
async function applyMerge(mergeTrackId) {
|
|
87
|
+
return call(`/v1/merge-tracks/${mergeTrackId}/apply`, { method: "POST" });
|
|
88
|
+
}
|
|
89
|
+
async function cancelMerge(mergeTrackId) {
|
|
90
|
+
await call(`/v1/merge-tracks/${mergeTrackId}/cancel`, { method: "POST" });
|
|
91
|
+
}
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -18,11 +18,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
18
18
|
const promises_1 = require("node:readline/promises");
|
|
19
19
|
const node_path_1 = __importDefault(require("node:path"));
|
|
20
20
|
const node_process_1 = __importDefault(require("node:process"));
|
|
21
|
+
const api_js_1 = require("./api.js");
|
|
21
22
|
const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
|
|
22
23
|
const upload_js_1 = require("../../desktop-app/src/main/upload.js");
|
|
23
24
|
const download_js_1 = require("../../desktop-app/src/main/download.js");
|
|
24
25
|
const cbx_js_1 = require("../../desktop-app/src/main/cbx.js");
|
|
25
|
-
const
|
|
26
|
+
const api_js_2 = require("./api.js");
|
|
26
27
|
const config_js_1 = require("./config.js");
|
|
27
28
|
/*
|
|
28
29
|
Read from the package rather than written twice. A hardcoded copy had
|
|
@@ -122,8 +123,8 @@ async function reconcile(folder, options = {}) {
|
|
|
122
123
|
const link = await (0, config_js_1.readLink)(folder);
|
|
123
124
|
const reference = link?.slug ?? node_path_1.default.basename(folder);
|
|
124
125
|
const project = link
|
|
125
|
-
? (await (0,
|
|
126
|
-
: await (0,
|
|
126
|
+
? (await (0, api_js_2.projects)()).find((candidate) => candidate.id === link.repositoryId)
|
|
127
|
+
: await (0, api_js_2.findProject)(reference);
|
|
127
128
|
if (!project?.versionCount) {
|
|
128
129
|
// Nothing saved on the account, so everything here is outstanding.
|
|
129
130
|
return { link: link ?? null, baseline: null, behind: null };
|
|
@@ -147,7 +148,13 @@ async function reconcile(folder, options = {}) {
|
|
|
147
148
|
}
|
|
148
149
|
return {
|
|
149
150
|
link: known,
|
|
150
|
-
|
|
151
|
+
/*
|
|
152
|
+
The comparison is against what this folder actually holds, not what
|
|
153
|
+
the version holds. After a merge the two differ, and comparing
|
|
154
|
+
against the version would read a stale copy as a fresh edit and
|
|
155
|
+
push it back over the change that replaced it.
|
|
156
|
+
*/
|
|
157
|
+
baseline: new Map(Object.entries(known.local ?? known.manifest)),
|
|
151
158
|
behind,
|
|
152
159
|
};
|
|
153
160
|
}
|
|
@@ -160,6 +167,8 @@ async function reconcile(folder, options = {}) {
|
|
|
160
167
|
slug: project.slug,
|
|
161
168
|
sequence: latest.sequence,
|
|
162
169
|
versionId: latest.id,
|
|
170
|
+
// Adopted whole, so the folder is taken to hold what the version holds.
|
|
171
|
+
local: Object.fromEntries(baseline),
|
|
163
172
|
manifest: Object.fromEntries(baseline),
|
|
164
173
|
};
|
|
165
174
|
await (0, config_js_1.writeLink)(folder, fresh);
|
|
@@ -184,7 +193,7 @@ async function commandSignIn(parsed) {
|
|
|
184
193
|
}
|
|
185
194
|
await (0, config_js_1.storeToken)(token);
|
|
186
195
|
try {
|
|
187
|
-
const account = await (0,
|
|
196
|
+
const account = await (0, api_js_2.whoami)();
|
|
188
197
|
console.log(`Signed in as ${bold(account.displayName)} ${dim(`<${account.email}>`)} · ${account.plan}`);
|
|
189
198
|
console.log(dim(`Token stored in ${(0, config_js_1.configDirectory)()}`));
|
|
190
199
|
return 0;
|
|
@@ -201,12 +210,12 @@ async function commandSignOut() {
|
|
|
201
210
|
return 0;
|
|
202
211
|
}
|
|
203
212
|
async function commandWhoami() {
|
|
204
|
-
const account = await (0,
|
|
213
|
+
const account = await (0, api_js_2.whoami)();
|
|
205
214
|
console.log(`${bold(account.displayName)} <${account.email}> · ${account.plan}`);
|
|
206
215
|
return 0;
|
|
207
216
|
}
|
|
208
217
|
async function commandProjects() {
|
|
209
|
-
const all = await (0,
|
|
218
|
+
const all = await (0, api_js_2.projects)();
|
|
210
219
|
if (!all.length) {
|
|
211
220
|
console.log("No projects on this account yet.");
|
|
212
221
|
return 0;
|
|
@@ -228,8 +237,26 @@ async function commandStatus(parsed) {
|
|
|
228
237
|
console.log(link
|
|
229
238
|
? `Account holds ${accent(`v${link.sequence}`)} · ${Object.keys(link.manifest).length} files ${dim(`(${link.slug})`)}`
|
|
230
239
|
: dim("Not on your account yet — submitting will create it."));
|
|
240
|
+
/*
|
|
241
|
+
A folder can have nothing to send and still not hold the whole version:
|
|
242
|
+
when the service merges somebody else's save into yours, their files
|
|
243
|
+
join the version without ever arriving here. Saying so is the difference
|
|
244
|
+
between "you are level" and "you are level with your own work".
|
|
245
|
+
*/
|
|
246
|
+
const stale = link?.local
|
|
247
|
+
? Object.entries(link.manifest).filter(([path, digest]) => link.local[path] !== digest)
|
|
248
|
+
: [];
|
|
249
|
+
if (stale.length) {
|
|
250
|
+
console.log(dim(`${stale.length} file${stale.length === 1 ? "" : "s"} in the saved version ` +
|
|
251
|
+
`${stale.length === 1 ? "is" : "are"} newer than the cop${stale.length === 1 ? "y" : "ies"} here ` +
|
|
252
|
+
`— run ${accent("coderook get")} to bring ${stale.length === 1 ? "it" : "them"} down.`));
|
|
253
|
+
for (const [path] of stale.slice(0, 10))
|
|
254
|
+
console.log(dim(` behind ${path}`));
|
|
255
|
+
}
|
|
231
256
|
if (!files.length) {
|
|
232
|
-
console.log(
|
|
257
|
+
console.log(stale.length
|
|
258
|
+
? "Nothing to submit; your own work is all saved."
|
|
259
|
+
: "Nothing to submit; this folder matches the saved version.");
|
|
233
260
|
return 0;
|
|
234
261
|
}
|
|
235
262
|
console.log(`\n${files.length} file${files.length === 1 ? "" : "s"} to submit:`);
|
|
@@ -247,7 +274,17 @@ async function commandSubmit(parsed) {
|
|
|
247
274
|
const rules = await (0, worktree_js_1.readRules)(folder);
|
|
248
275
|
const files = await (0, worktree_js_1.changedFiles)(folder, rules, baseline);
|
|
249
276
|
if (!files.length) {
|
|
250
|
-
|
|
277
|
+
// Having nothing to send is not the same as holding the whole version:
|
|
278
|
+
// a merge can leave this folder with an older copy of somebody else's
|
|
279
|
+
// file. Saying so is what stops "nothing to do" from reading as "level".
|
|
280
|
+
const behindOn = link?.local
|
|
281
|
+
? Object.entries(link.manifest).filter(([path, digest]) => link.local[path] !== digest).length
|
|
282
|
+
: 0;
|
|
283
|
+
console.log(behindOn
|
|
284
|
+
? `Nothing to submit; your own work is all saved. ${behindOn} file` +
|
|
285
|
+
`${behindOn === 1 ? "" : "s"} here ${behindOn === 1 ? "is" : "are"} ` +
|
|
286
|
+
`behind the saved version — run ${accent("coderook get")}.`
|
|
287
|
+
: "Nothing to submit; this folder matches the saved version.");
|
|
251
288
|
return 0;
|
|
252
289
|
}
|
|
253
290
|
/*
|
|
@@ -297,6 +334,12 @@ async function commandSubmit(parsed) {
|
|
|
297
334
|
nothing rather than guessing, and publishes as it always did.
|
|
298
335
|
*/
|
|
299
336
|
...(link?.versionId ? { expectedHeadVersionId: link.versionId } : {}),
|
|
337
|
+
/*
|
|
338
|
+
What this folder believed the project held. It is how the service
|
|
339
|
+
tells a file this person deleted from one they never had, and
|
|
340
|
+
without it somebody else's work disappears at the next save.
|
|
341
|
+
*/
|
|
342
|
+
...(link ? { known: link.local ?? link.manifest ?? {} } : {}),
|
|
300
343
|
}, (progress) => {
|
|
301
344
|
line(` ${String(progress.percent).padStart(3)}% ${progress.stage.padEnd(7)} ` +
|
|
302
345
|
`${progress.files}/${progress.totalFiles} ${progress.path.slice(-48)}`);
|
|
@@ -305,6 +348,21 @@ async function commandSubmit(parsed) {
|
|
|
305
348
|
catch (error) {
|
|
306
349
|
done(line);
|
|
307
350
|
const code = error.code;
|
|
351
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
352
|
+
/*
|
|
353
|
+
A connection that fails part way through says nothing about whether
|
|
354
|
+
the work was done. It may well have been — the service records the
|
|
355
|
+
attempt, so running the same command again is answered with the
|
|
356
|
+
version it already made rather than a second one. Saying so is the
|
|
357
|
+
difference between a person retrying and a person wondering.
|
|
358
|
+
*/
|
|
359
|
+
if (!code && /fetch failed|ECONNRESET|socket hang up|network|ETIMEDOUT/i.test(text)) {
|
|
360
|
+
console.error(red(`
|
|
361
|
+
The connection failed: ${text}`));
|
|
362
|
+
console.error(`Your work may already have been saved. Run the same command again —` +
|
|
363
|
+
` it will not create a second version.`);
|
|
364
|
+
return 1;
|
|
365
|
+
}
|
|
308
366
|
if (code === "merge_required" || code === "track_moved") {
|
|
309
367
|
/*
|
|
310
368
|
Somebody else saved to this project first. Everything that did not
|
|
@@ -320,14 +378,37 @@ async function commandSubmit(parsed) {
|
|
|
320
378
|
throw error;
|
|
321
379
|
}
|
|
322
380
|
done(line);
|
|
381
|
+
if (result.mergeTrack) {
|
|
382
|
+
/*
|
|
383
|
+
The upload is complete and kept, but the project has not changed —
|
|
384
|
+
calling this "saved" would be false, and the base is deliberately left
|
|
385
|
+
alone so the folder still knows what it was working from.
|
|
386
|
+
*/
|
|
387
|
+
console.log(`\nSomebody else saved first, and ${result.mergeTrack.conflicts.length === 1
|
|
388
|
+
? "one file overlaps"
|
|
389
|
+
: `${result.mergeTrack.conflicts.length} files overlap`}.`);
|
|
390
|
+
console.log(`Your work is complete and kept as ${accent(result.mergeTrack.reference)}:`);
|
|
391
|
+
for (const conflict of result.mergeTrack.conflicts.slice(0, 20)) {
|
|
392
|
+
console.log(` ${red(conflict.path)} ${dim(conflict.kind)}`);
|
|
393
|
+
}
|
|
394
|
+
console.log(`\nRun ${accent(`coderook merge ${result.mergeTrack.reference}`)} to decide,` +
|
|
395
|
+
` or ${accent("coderook merges")} to see everything waiting.`);
|
|
396
|
+
return 0;
|
|
397
|
+
}
|
|
323
398
|
await (0, config_js_1.writeLink)(folder, {
|
|
324
399
|
repositoryId: result.repositoryId,
|
|
325
400
|
slug: link?.slug ?? node_path_1.default.basename(folder),
|
|
326
401
|
sequence: result.sequence,
|
|
327
402
|
// What this folder is now working from, so the next submit can say so.
|
|
328
403
|
versionId: result.versionId,
|
|
404
|
+
local: result.local,
|
|
329
405
|
manifest: result.manifest,
|
|
330
406
|
});
|
|
407
|
+
if (result.repeated) {
|
|
408
|
+
console.log(`Already saved as ${accent(`v${result.sequence}`)} by an earlier attempt;` +
|
|
409
|
+
` nothing was sent again.`);
|
|
410
|
+
return 0;
|
|
411
|
+
}
|
|
331
412
|
console.log(`Saved ${accent(`v${result.sequence}`)} · sent ${bytes(result.sentBytes)} in ` +
|
|
332
413
|
`${result.sentFiles} file${result.sentFiles === 1 ? "" : "s"}` +
|
|
333
414
|
(result.reusedFiles ? `, ${result.reusedFiles} already stored` : "") +
|
|
@@ -341,7 +422,8 @@ async function commandGet(parsed) {
|
|
|
341
422
|
console.error(red("This folder is not linked to a project on your account."));
|
|
342
423
|
return 1;
|
|
343
424
|
}
|
|
344
|
-
|
|
425
|
+
// Only what this folder received may be removed by catching up.
|
|
426
|
+
return fetchInto(link.repositoryId, folder, link.slug, link.local ?? null);
|
|
345
427
|
}
|
|
346
428
|
async function commandClone(parsed) {
|
|
347
429
|
const reference = parsed.positional[0];
|
|
@@ -349,7 +431,7 @@ async function commandClone(parsed) {
|
|
|
349
431
|
console.error(red("Which project? Try: coderook clone <project>"));
|
|
350
432
|
return 1;
|
|
351
433
|
}
|
|
352
|
-
const project = await (0,
|
|
434
|
+
const project = await (0, api_js_2.findProject)(reference);
|
|
353
435
|
if (!project) {
|
|
354
436
|
console.error(red(`No project named ${reference} on this account.`));
|
|
355
437
|
return 1;
|
|
@@ -361,7 +443,14 @@ async function commandClone(parsed) {
|
|
|
361
443
|
const destination = node_path_1.default.resolve(parsed.positional[1] ?? project.slug);
|
|
362
444
|
return fetchInto(project.id, destination, project.slug);
|
|
363
445
|
}
|
|
364
|
-
async function fetchInto(repositoryId, destination, slug
|
|
446
|
+
async function fetchInto(repositoryId, destination, slug,
|
|
447
|
+
/*
|
|
448
|
+
What this folder received before, so a file the project has since dropped
|
|
449
|
+
is removed. Absent — a clone, or a folder whose record was lost — nothing
|
|
450
|
+
is removed, because "missing from the version" cannot then be told from
|
|
451
|
+
"never came from the version at all".
|
|
452
|
+
*/
|
|
453
|
+
held) {
|
|
365
454
|
const downloader = new download_js_1.Downloader(config_js_1.credentials);
|
|
366
455
|
const latest = (await downloader.versions(repositoryId))[0];
|
|
367
456
|
if (!latest) {
|
|
@@ -372,7 +461,10 @@ async function fetchInto(repositoryId, destination, slug) {
|
|
|
372
461
|
const result = await downloader.run(repositoryId, latest.id, destination, (progress) => {
|
|
373
462
|
line(` ${String(progress.percent).padStart(3)}% ${progress.files}/${progress.totalFiles}` +
|
|
374
463
|
` ${progress.path.slice(-48)}`);
|
|
375
|
-
}
|
|
464
|
+
},
|
|
465
|
+
// What this folder received last time, so a file the project has since
|
|
466
|
+
// dropped is removed while everything untracked is left alone.
|
|
467
|
+
held);
|
|
376
468
|
done(line);
|
|
377
469
|
// Fetching is how a folder catches up, so this is what moves its base.
|
|
378
470
|
await (0, config_js_1.writeLink)(destination, {
|
|
@@ -380,6 +472,9 @@ async function fetchInto(repositoryId, destination, slug) {
|
|
|
380
472
|
slug,
|
|
381
473
|
sequence: latest.sequence,
|
|
382
474
|
versionId: latest.id,
|
|
475
|
+
// Fetching writes every file, so the folder now holds what the
|
|
476
|
+
// version holds and the two agree again.
|
|
477
|
+
local: result.manifest,
|
|
383
478
|
manifest: result.manifest,
|
|
384
479
|
});
|
|
385
480
|
console.log(`${accent(`v${latest.sequence}`)} · ${result.files} files · ${bytes(result.bytes)} into ${destination}`);
|
|
@@ -454,7 +549,7 @@ async function commandInspect(parsed) {
|
|
|
454
549
|
return 0;
|
|
455
550
|
}
|
|
456
551
|
async function commandDoctor() {
|
|
457
|
-
const service = await (0,
|
|
552
|
+
const service = await (0, api_js_2.health)().catch(() => null);
|
|
458
553
|
console.log(`CodeRook CLI ${VERSION} · Node ${node_process_1.default.versions.node} · ${node_process_1.default.platform}`);
|
|
459
554
|
console.log(`Config: ${(0, config_js_1.configDirectory)()}`);
|
|
460
555
|
console.log(service
|
|
@@ -467,7 +562,7 @@ async function commandDoctor() {
|
|
|
467
562
|
return 0;
|
|
468
563
|
}
|
|
469
564
|
try {
|
|
470
|
-
const account = await (0,
|
|
565
|
+
const account = await (0, api_js_2.whoami)();
|
|
471
566
|
console.log(`Account: ${account.email} · ${account.plan}`);
|
|
472
567
|
}
|
|
473
568
|
catch (error) {
|
|
@@ -489,6 +584,10 @@ ${bold("Working with a folder")}
|
|
|
489
584
|
coderook clone <project> [dir] Fetch a project into a new folder
|
|
490
585
|
coderook rules [folder] Show the ignore rules (--init to start one)
|
|
491
586
|
|
|
587
|
+
${bold("When somebody saved first")}
|
|
588
|
+
coderook merges Uploads of yours waiting on a decision
|
|
589
|
+
coderook merge <ref> Look at one (--mine --theirs --both --drop)
|
|
590
|
+
|
|
492
591
|
${bold("Bundles")}
|
|
493
592
|
coderook bundle [folder] [out] Pack the project as a .cbx
|
|
494
593
|
coderook unbundle <file> [dir] Extract a .cbx
|
|
@@ -506,6 +605,127 @@ ${bold("Options")}
|
|
|
506
605
|
|
|
507
606
|
${dim("The environment variable CODEROOK_TOKEN is used when set, so automated")}
|
|
508
607
|
${dim("runs need nothing on disk. CODEROOK_API_URL points at another service.")}`;
|
|
608
|
+
/**
|
|
609
|
+
* Everything waiting on a decision, for one folder's project.
|
|
610
|
+
*
|
|
611
|
+
* A diverted upload is easy to forget about — it is not an error and the
|
|
612
|
+
* project looks untouched — so this is the way to find out that work of
|
|
613
|
+
* yours is sitting somewhere, still complete, waiting.
|
|
614
|
+
*/
|
|
615
|
+
async function commandMerges(parsed) {
|
|
616
|
+
const folder = folderFor(parsed);
|
|
617
|
+
const link = await (0, config_js_1.readLink)(folder);
|
|
618
|
+
if (!link) {
|
|
619
|
+
console.error(red("This folder is not linked to a project on your account."));
|
|
620
|
+
return 1;
|
|
621
|
+
}
|
|
622
|
+
const waiting = (await (0, api_js_1.mergeTracks)(link.repositoryId)).filter((merge) => merge.state === "open");
|
|
623
|
+
if (!waiting.length) {
|
|
624
|
+
console.log("Nothing is waiting to be merged.");
|
|
625
|
+
return 0;
|
|
626
|
+
}
|
|
627
|
+
for (const merge of waiting) {
|
|
628
|
+
const counts = merge.conflicts;
|
|
629
|
+
console.log(`${accent(merge.reference)} ${counts ? `${counts.unresolved} of ${counts.total} still to decide` : ""} ${dim(new Date(merge.createdAt).toLocaleString())}`);
|
|
630
|
+
}
|
|
631
|
+
console.log(dim(`
|
|
632
|
+
Run coderook merge <reference> to look at one.`));
|
|
633
|
+
return 0;
|
|
634
|
+
}
|
|
635
|
+
/** Find a merge by the reference a person would type, such as M-2. */
|
|
636
|
+
async function findMerge(folder, reference) {
|
|
637
|
+
const link = await (0, config_js_1.readLink)(folder);
|
|
638
|
+
if (!link)
|
|
639
|
+
throw new Error("This folder is not linked to a project on your account.");
|
|
640
|
+
const all = await (0, api_js_1.mergeTracks)(link.repositoryId);
|
|
641
|
+
const found = all.find((merge) => merge.reference.toLowerCase() === reference.toLowerCase());
|
|
642
|
+
if (!found)
|
|
643
|
+
throw new Error(`No merge called ${reference} on this project.`);
|
|
644
|
+
return found;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Look at one merge, and optionally finish it.
|
|
648
|
+
*
|
|
649
|
+
* Deciding happens here rather than in a command of its own because the
|
|
650
|
+
* decision only means anything next to the thing it is about.
|
|
651
|
+
*/
|
|
652
|
+
async function commandMerge(parsed) {
|
|
653
|
+
const reference = parsed.positional[0];
|
|
654
|
+
if (!reference) {
|
|
655
|
+
console.error(red("Which merge? Try: coderook merge M-1"));
|
|
656
|
+
return 1;
|
|
657
|
+
}
|
|
658
|
+
const folder = folderFor({ ...parsed, positional: parsed.positional.slice(1) });
|
|
659
|
+
const summary = await findMerge(folder, reference);
|
|
660
|
+
const detail = await (0, api_js_1.mergeTrack)(summary.id);
|
|
661
|
+
if (hasFlag(parsed, "cancel")) {
|
|
662
|
+
await (0, api_js_1.cancelMerge)(summary.id);
|
|
663
|
+
console.log(`${summary.reference} cancelled. Your upload is still stored and nothing published was touched.`);
|
|
664
|
+
return 0;
|
|
665
|
+
}
|
|
666
|
+
const outstanding = detail.conflicts.filter((conflict) => !conflict.resolvedAt);
|
|
667
|
+
const decision = hasFlag(parsed, "mine")
|
|
668
|
+
? "take_candidate"
|
|
669
|
+
: hasFlag(parsed, "theirs")
|
|
670
|
+
? "take_target"
|
|
671
|
+
: hasFlag(parsed, "drop")
|
|
672
|
+
? "delete"
|
|
673
|
+
: hasFlag(parsed, "both", "keep-both")
|
|
674
|
+
? "keep_both"
|
|
675
|
+
: null;
|
|
676
|
+
if (decision) {
|
|
677
|
+
const only = flagText(parsed, "path");
|
|
678
|
+
const chosen = only
|
|
679
|
+
? outstanding.filter((conflict) => conflict.path === only)
|
|
680
|
+
: outstanding;
|
|
681
|
+
if (!chosen.length) {
|
|
682
|
+
console.error(red(only ? `${only} has no outstanding decision.` : "Nothing left to decide."));
|
|
683
|
+
return 1;
|
|
684
|
+
}
|
|
685
|
+
for (const conflict of chosen) {
|
|
686
|
+
await (0, api_js_1.resolveMergeConflict)(summary.id, conflict.id, decision);
|
|
687
|
+
console.log(` ${conflict.path} → ${decision === "take_candidate"
|
|
688
|
+
? "yours"
|
|
689
|
+
: decision === "take_target"
|
|
690
|
+
? "theirs"
|
|
691
|
+
: decision === "keep_both"
|
|
692
|
+
? `both, yours saved beside it`
|
|
693
|
+
: "removed"}`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
const now = await (0, api_js_1.mergeTrack)(summary.id);
|
|
697
|
+
console.log(`
|
|
698
|
+
${accent(now.mergeTrack.reference)} · ${now.provisional.fileCount} files · ` +
|
|
699
|
+
(now.provisional.ready
|
|
700
|
+
? "ready to apply"
|
|
701
|
+
: `${now.provisional.unresolvedPaths.length} still to decide`));
|
|
702
|
+
for (const conflict of now.conflicts) {
|
|
703
|
+
console.log(` ${conflict.resolvedAt ? accent("decided") : red("waiting")} ${conflict.path}` +
|
|
704
|
+
` ${dim(conflict.kind)}${conflict.resolution ? dim(` (${conflict.resolution})`) : ""}`);
|
|
705
|
+
}
|
|
706
|
+
if (hasFlag(parsed, "apply")) {
|
|
707
|
+
if (!now.provisional.ready) {
|
|
708
|
+
console.error(red("\nStill undecided files; nothing was applied."));
|
|
709
|
+
return 1;
|
|
710
|
+
}
|
|
711
|
+
const applied = await (0, api_js_1.applyMerge)(summary.id);
|
|
712
|
+
console.log(`
|
|
713
|
+
Applied as ${accent(`v${applied.version.sequence}`)}.`);
|
|
714
|
+
console.log(dim("Run coderook get to bring it down to this folder."));
|
|
715
|
+
return 0;
|
|
716
|
+
}
|
|
717
|
+
if (!decision) {
|
|
718
|
+
console.log(dim(`
|
|
719
|
+
--mine keeps yours, --theirs keeps what was already saved,` +
|
|
720
|
+
` --drop removes the file.
|
|
721
|
+
Add --path <file> for one file, then --apply when ready.`));
|
|
722
|
+
}
|
|
723
|
+
else if (now.provisional.ready) {
|
|
724
|
+
console.log(dim(`
|
|
725
|
+
Run coderook merge ${now.mergeTrack.reference} --apply to publish it.`));
|
|
726
|
+
}
|
|
727
|
+
return 0;
|
|
728
|
+
}
|
|
509
729
|
const COMMANDS = {
|
|
510
730
|
"sign-in": commandSignIn,
|
|
511
731
|
login: commandSignIn,
|
|
@@ -522,6 +742,8 @@ const COMMANDS = {
|
|
|
522
742
|
bundle: commandBundle,
|
|
523
743
|
unbundle: commandUnbundle,
|
|
524
744
|
inspect: commandInspect,
|
|
745
|
+
merges: commandMerges,
|
|
746
|
+
merge: commandMerge,
|
|
525
747
|
doctor: () => commandDoctor(),
|
|
526
748
|
};
|
|
527
749
|
async function main(argv) {
|
package/dist/cli/src/config.js
CHANGED
|
@@ -9,6 +9,7 @@ exports.configDirectory = configDirectory;
|
|
|
9
9
|
exports.storeToken = storeToken;
|
|
10
10
|
exports.loadToken = loadToken;
|
|
11
11
|
exports.clearToken = clearToken;
|
|
12
|
+
exports.keyFor = keyFor;
|
|
12
13
|
exports.readLink = readLink;
|
|
13
14
|
exports.writeLink = writeLink;
|
|
14
15
|
/**
|
|
@@ -19,6 +20,7 @@ exports.writeLink = writeLink;
|
|
|
19
20
|
* location follows each platform's convention rather than scattering dot
|
|
20
21
|
* directories about.
|
|
21
22
|
*/
|
|
23
|
+
const node_fs_1 = require("node:fs");
|
|
22
24
|
const promises_1 = require("node:fs/promises");
|
|
23
25
|
const node_os_1 = require("node:os");
|
|
24
26
|
const node_path_1 = __importDefault(require("node:path"));
|
|
@@ -75,9 +77,33 @@ exports.credentials = {
|
|
|
75
77
|
origin: apiOrigin,
|
|
76
78
|
token: loadToken,
|
|
77
79
|
};
|
|
80
|
+
/**
|
|
81
|
+
* One name for one folder, whatever route was taken to reach it.
|
|
82
|
+
*
|
|
83
|
+
* A folder can be addressed as `C:\project`, through a junction, through a
|
|
84
|
+
* symlink, or through a mapped drive, and every one of those is a different
|
|
85
|
+
* string. Keying on the string gives the same folder four independent
|
|
86
|
+
* connections, four independent records of what it holds, and four chances
|
|
87
|
+
* for one to overwrite another's work. Resolving to the real path first is
|
|
88
|
+
* what makes them one workspace.
|
|
89
|
+
*
|
|
90
|
+
* A path that does not exist yet — a clone destination — cannot be resolved,
|
|
91
|
+
* so it falls back to the plain form. It becomes resolvable the moment the
|
|
92
|
+
* folder is created, which is before anything is ever recorded against it.
|
|
93
|
+
*/
|
|
78
94
|
function keyFor(localPath) {
|
|
79
|
-
|
|
95
|
+
const absolute = node_path_1.default.resolve(localPath);
|
|
96
|
+
try {
|
|
97
|
+
// Windows returns an extended-length path here; the prefix is an
|
|
98
|
+
// addressing detail, not part of the identity, so it comes back off.
|
|
99
|
+
return node_fs_1.realpathSync.native(absolute).replace(/^\\\\\?\\/, "").toLowerCase();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return absolute.toLowerCase();
|
|
103
|
+
}
|
|
80
104
|
}
|
|
105
|
+
/** How the key used to be worked out, so existing links keep working. */
|
|
106
|
+
const legacyKeyFor = (localPath) => node_path_1.default.resolve(localPath).toLowerCase();
|
|
81
107
|
async function readLinks() {
|
|
82
108
|
try {
|
|
83
109
|
return JSON.parse(await (0, promises_1.readFile)(linksFile(), "utf8"));
|
|
@@ -87,10 +113,14 @@ async function readLinks() {
|
|
|
87
113
|
}
|
|
88
114
|
}
|
|
89
115
|
async function readLink(localPath) {
|
|
90
|
-
|
|
116
|
+
const links = await readLinks();
|
|
117
|
+
return links[keyFor(localPath)] ?? links[legacyKeyFor(localPath)] ?? null;
|
|
91
118
|
}
|
|
92
119
|
async function writeLink(localPath, link) {
|
|
93
120
|
const links = await readLinks();
|
|
121
|
+
// A folder recorded under the old key moves to the new one rather than
|
|
122
|
+
// being left behind as a second connection to the same place.
|
|
123
|
+
delete links[legacyKeyFor(localPath)];
|
|
94
124
|
links[keyFor(localPath)] = link;
|
|
95
125
|
await writePrivate(linksFile(), JSON.stringify(links, null, 2));
|
|
96
126
|
}
|
|
@@ -15,6 +15,27 @@ 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
|
+
/**
|
|
19
|
+
* Remove the directories a deletion just emptied, up to but never including
|
|
20
|
+
* the project folder itself. Left alone they accumulate as empty husks of
|
|
21
|
+
* directories the project no longer has; removed too eagerly they would take
|
|
22
|
+
* a directory holding ignored files with them, so this stops at the first
|
|
23
|
+
* one that still has something in it.
|
|
24
|
+
*/
|
|
25
|
+
async function pruneEmpty(root, removed) {
|
|
26
|
+
let directory = node_path_1.default.dirname(removed);
|
|
27
|
+
while (directory.startsWith(root) && directory !== root) {
|
|
28
|
+
try {
|
|
29
|
+
if ((await (0, promises_1.readdir)(directory)).length)
|
|
30
|
+
return;
|
|
31
|
+
await (0, promises_1.rm)(directory, { recursive: false, force: true });
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
directory = node_path_1.default.dirname(directory);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
18
39
|
class DownloadCancelled extends Error {
|
|
19
40
|
constructor() {
|
|
20
41
|
super("Download cancelled");
|
|
@@ -81,11 +102,20 @@ class Downloader {
|
|
|
81
102
|
}));
|
|
82
103
|
}
|
|
83
104
|
/**
|
|
84
|
-
* Write a version into `destination`.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
105
|
+
* Write a version into `destination`.
|
|
106
|
+
*
|
|
107
|
+
* Files land in a staging directory and are put in place only once every
|
|
108
|
+
* one has arrived and verified, so a failed download never leaves a
|
|
109
|
+
* half-project behind.
|
|
110
|
+
*
|
|
111
|
+
* What is put in place is the version's files, one at a time — not the
|
|
112
|
+
* whole directory. Replacing the directory wholesale also removes
|
|
113
|
+
* everything the version deliberately does not contain: the dependencies,
|
|
114
|
+
* the local credentials, the unfinished work and the git repository
|
|
115
|
+
* itself. A file the version no longer holds is removed only when this
|
|
116
|
+
* folder is known to have received it, which `held` supplies.
|
|
87
117
|
*/
|
|
88
|
-
async run(repositoryId, versionId, destination, report) {
|
|
118
|
+
async run(repositoryId, versionId, destination, report, held) {
|
|
89
119
|
const files = await this.files(repositoryId, versionId);
|
|
90
120
|
if (!files.length)
|
|
91
121
|
throw new Error("That version has no files");
|
|
@@ -125,9 +155,36 @@ class Downloader {
|
|
|
125
155
|
written += 1;
|
|
126
156
|
bytes += body.length;
|
|
127
157
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
158
|
+
/*
|
|
159
|
+
Everything has arrived and verified, so it can go into place. Each
|
|
160
|
+
file is renamed over its destination individually: a rename within
|
|
161
|
+
one volume is atomic, so no file is ever seen half-written, and
|
|
162
|
+
anything the version does not mention is left exactly as it is.
|
|
163
|
+
*/
|
|
164
|
+
await (0, promises_1.mkdir)(destination, { recursive: true });
|
|
165
|
+
for (const file of files) {
|
|
166
|
+
const parts = file.path.split("/").filter(Boolean);
|
|
167
|
+
const target = node_path_1.default.join(destination, ...parts);
|
|
168
|
+
await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
|
|
169
|
+
await (0, promises_1.rm)(target, { recursive: true, force: true });
|
|
170
|
+
await (0, promises_1.rename)(node_path_1.default.join(staging, ...parts), target);
|
|
171
|
+
}
|
|
172
|
+
/*
|
|
173
|
+
A file this folder received but the version no longer holds is gone
|
|
174
|
+
deliberately, so it goes. One it never received is not this fetch's
|
|
175
|
+
to remove — and without a record of what it received, nothing is
|
|
176
|
+
removed at all, which is the safe reading of an unknown folder.
|
|
177
|
+
*/
|
|
178
|
+
for (const path_ of Object.keys(held ?? {})) {
|
|
179
|
+
if (manifest[path_] !== undefined)
|
|
180
|
+
continue;
|
|
181
|
+
const parts = path_.split("/").filter(Boolean);
|
|
182
|
+
if (parts.some((part) => part === ".." || part.includes("\0")))
|
|
183
|
+
continue;
|
|
184
|
+
await (0, promises_1.rm)(node_path_1.default.join(destination, ...parts), { force: true });
|
|
185
|
+
await pruneEmpty(destination, node_path_1.default.join(destination, ...parts));
|
|
186
|
+
}
|
|
187
|
+
await (0, promises_1.rm)(staging, { recursive: true, force: true });
|
|
131
188
|
report({
|
|
132
189
|
files: written,
|
|
133
190
|
totalFiles: files.length,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.publishAttemptName = publishAttemptName;
|
|
4
|
+
/**
|
|
5
|
+
* A name for one publishing attempt, derived from what is being published.
|
|
6
|
+
*
|
|
7
|
+
* It has to be the same across retries of the same attempt and different for
|
|
8
|
+
* a genuinely new one — and it cannot be kept on disk, because the crash it
|
|
9
|
+
* exists to survive is exactly the kind that loses whatever was written just
|
|
10
|
+
* before it. Deriving it from the content solves both: the same files
|
|
11
|
+
* against the same head produce the same name, and anything else does not.
|
|
12
|
+
*
|
|
13
|
+
* Kept apart from the uploader because it depends on nothing, which is what
|
|
14
|
+
* lets it be tested without a network, a filesystem or an account.
|
|
15
|
+
*/
|
|
16
|
+
const node_crypto_1 = require("node:crypto");
|
|
17
|
+
function publishAttemptName(input) {
|
|
18
|
+
const shape = [
|
|
19
|
+
input.repositoryId ?? "new",
|
|
20
|
+
input.expectedHeadVersionId ?? "none",
|
|
21
|
+
input.message,
|
|
22
|
+
// Sorted, so the order the scanner happened to walk the folder in
|
|
23
|
+
// cannot make the same attempt look like a different one.
|
|
24
|
+
...[...input.files].map((file) => `${file.path}:${file.objectId}`).sort(),
|
|
25
|
+
].join("\n");
|
|
26
|
+
return (0, node_crypto_1.createHash)("sha256").update(shape).digest("hex").slice(0, 40);
|
|
27
|
+
}
|
|
@@ -52,6 +52,19 @@ function globToSource(pattern) {
|
|
|
52
52
|
let source = "";
|
|
53
53
|
for (let index = 0; index < pattern.length; index += 1) {
|
|
54
54
|
const character = pattern[index];
|
|
55
|
+
if (character === "\\" && index + 1 < pattern.length) {
|
|
56
|
+
/*
|
|
57
|
+
A backslash means the next character is a character, not syntax.
|
|
58
|
+
It is how a file genuinely called `#notes.txt` or `important!.md`
|
|
59
|
+
is written, and how a space is kept at the end of a name. Patterns
|
|
60
|
+
use forward slashes throughout, so a backslash is never a separator
|
|
61
|
+
here and always an escape.
|
|
62
|
+
*/
|
|
63
|
+
const literal = pattern[index + 1];
|
|
64
|
+
source += literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
65
|
+
index += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
55
68
|
if (character === "*") {
|
|
56
69
|
if (pattern[index + 1] === "*") {
|
|
57
70
|
source += ".*";
|
|
@@ -17,6 +17,7 @@ const node_fs_1 = require("node:fs");
|
|
|
17
17
|
const promises_1 = require("node:fs/promises");
|
|
18
18
|
const node_path_1 = __importDefault(require("node:path"));
|
|
19
19
|
const worktree_js_1 = require("./worktree.js");
|
|
20
|
+
const publish_name_js_1 = require("./publish_name.js");
|
|
20
21
|
/** Above this the API insists on a multipart session. */
|
|
21
22
|
const DIRECT_LIMIT = 95 * 1024 * 1024;
|
|
22
23
|
const PART_SIZE = 32 * 1024 * 1024;
|
|
@@ -123,7 +124,39 @@ class Uploader {
|
|
|
123
124
|
// Ticked files are sent; everything else the project still has keeps the
|
|
124
125
|
// copy already stored, and a file that is new but unticked is left out.
|
|
125
126
|
const sending = everything.filter((file) => ticked.has(file.path));
|
|
126
|
-
const
|
|
127
|
+
const onDiskNow = new Set(everything.map((file) => file.path));
|
|
128
|
+
/*
|
|
129
|
+
Everything the version already holds is kept, except what this
|
|
130
|
+
workspace deliberately removed.
|
|
131
|
+
|
|
132
|
+
"Deliberately removed" means the workspace had the file and it is now
|
|
133
|
+
gone. A file it never had — because somebody else added it while this
|
|
134
|
+
person was working — is not theirs to delete, and must survive their
|
|
135
|
+
next save. Reading this from the disk scan alone is what silently lost
|
|
136
|
+
other people's files.
|
|
137
|
+
*/
|
|
138
|
+
const knew = request.known ? new Set(Object.keys(request.known)) : null;
|
|
139
|
+
const reused = [...prior.entries()]
|
|
140
|
+
.filter(([path]) => {
|
|
141
|
+
if (ticked.has(path))
|
|
142
|
+
return false;
|
|
143
|
+
if (onDiskNow.has(path))
|
|
144
|
+
return true;
|
|
145
|
+
// Missing here. Only a deletion if this workspace ever had it.
|
|
146
|
+
return knew ? !knew.has(path) : true;
|
|
147
|
+
})
|
|
148
|
+
.map(([path, held]) => ({
|
|
149
|
+
path,
|
|
150
|
+
sourceSize: held.sourceSize,
|
|
151
|
+
storedSize: held.storedSize,
|
|
152
|
+
mediaType: held.mediaType,
|
|
153
|
+
added: 0,
|
|
154
|
+
removed: 0,
|
|
155
|
+
included: true,
|
|
156
|
+
deleted: false,
|
|
157
|
+
binary: false,
|
|
158
|
+
lines: 0,
|
|
159
|
+
}));
|
|
127
160
|
/*
|
|
128
161
|
A ticked path that the rescan cannot see is either a deletion or a file
|
|
129
162
|
that has gone since the changes list was drawn. Deletions are meant to
|
|
@@ -300,6 +333,16 @@ class Uploader {
|
|
|
300
333
|
...(request.expectedHeadVersionId === undefined
|
|
301
334
|
? {}
|
|
302
335
|
: { expectedHeadVersionId: request.expectedHeadVersionId }),
|
|
336
|
+
/*
|
|
337
|
+
Names this attempt so a retry after a lost connection is answered
|
|
338
|
+
with the version already made, rather than making a second one.
|
|
339
|
+
*/
|
|
340
|
+
idempotencyKey: (0, publish_name_js_1.publishAttemptName)({
|
|
341
|
+
repositoryId,
|
|
342
|
+
expectedHeadVersionId: request.expectedHeadVersionId,
|
|
343
|
+
message: request.message,
|
|
344
|
+
files: contents,
|
|
345
|
+
}),
|
|
303
346
|
sourceSize: sourceBytes,
|
|
304
347
|
storedSize: storedBytes,
|
|
305
348
|
files: contents.map((item) => ({
|
|
@@ -326,6 +369,8 @@ class Uploader {
|
|
|
326
369
|
repositoryId,
|
|
327
370
|
versionId: completed.version.id,
|
|
328
371
|
sequence: completed.version.sequence,
|
|
372
|
+
...(completed.mergeTrack ? { mergeTrack: completed.mergeTrack } : {}),
|
|
373
|
+
...(completed.repeated ? { repeated: true } : {}),
|
|
329
374
|
sourceBytes,
|
|
330
375
|
storedBytes,
|
|
331
376
|
sentBytes,
|
|
@@ -334,6 +379,20 @@ class Uploader {
|
|
|
334
379
|
// A file that kept its old object records the digest of *that* copy,
|
|
335
380
|
// not of the file on disk, so an unticked edit is still pending next
|
|
336
381
|
// time rather than looking as though it had been saved.
|
|
382
|
+
/*
|
|
383
|
+
What this folder holds after the save. A file that was sent holds
|
|
384
|
+
the bytes that were sent. Anything else keeps whatever digest was
|
|
385
|
+
recorded before: an unticked edit therefore stays pending rather
|
|
386
|
+
than looking saved, and a copy left stale by somebody else's merge
|
|
387
|
+
stays recognisably stale rather than looking like a new edit.
|
|
388
|
+
*/
|
|
389
|
+
local: Object.fromEntries(everything.flatMap((file) => {
|
|
390
|
+
const sent = declarations.find((one) => one.path === file.path);
|
|
391
|
+
if (sent)
|
|
392
|
+
return [[file.path, sent.sha256]];
|
|
393
|
+
const before = request.known?.[file.path] ?? prior.get(file.path)?.sha256;
|
|
394
|
+
return before ? [[file.path, before]] : [];
|
|
395
|
+
})),
|
|
337
396
|
manifest: {
|
|
338
397
|
...Object.fromEntries(reused.map((file) => [file.path, prior.get(file.path).sha256])),
|
|
339
398
|
...Object.fromEntries(declarations.map((declaration) => [declaration.path, declaration.sha256])),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coderook/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "CodeRook from the command line, on any operating system",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.txt",
|
|
6
6
|
"homepage": "https://coderook.com",
|
|
@@ -27,7 +27,11 @@
|
|
|
27
27
|
"check": "tsc -p tsconfig.json --noEmit",
|
|
28
28
|
"test": "tsc -p tsconfig.json && node --test --experimental-strip-types test/*.test.ts",
|
|
29
29
|
"start": "node dist/cli/src/cli.js",
|
|
30
|
-
"prepublishOnly": "npm run build"
|
|
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"
|
|
31
35
|
},
|
|
32
36
|
"devDependencies": {
|
|
33
37
|
"@types/node": "24.10.1",
|