@coderook/cli 0.22.1 → 0.23.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 +1 -1
- package/dist/cli/src/cli.js +231 -58
- package/dist/cli/src/import_command.js +150 -0
- package/dist/cli/src/offline.js +28 -0
- package/dist/desktop-app/src/main/compress.js +6 -31
- package/dist/desktop-app/src/main/download.js +200 -4
- package/dist/desktop-app/src/main/upload.js +307 -34
- package/dist/desktop-app/src/shared/chunking.js +21 -1
- package/dist/desktop-app/src/shared/compression_policy.js +35 -0
- package/dist/desktop-app/src/shared/telemetry.js +47 -0
- package/package.json +51 -51
|
@@ -2,7 +2,7 @@
|
|
|
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.23.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ACCA Gaming Productions",
|
|
8
8
|
"url": "https://coderook.com"
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -17,6 +17,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
17
17
|
*/
|
|
18
18
|
const promises_1 = require("node:readline/promises");
|
|
19
19
|
const node_os_1 = __importDefault(require("node:os"));
|
|
20
|
+
const promises_2 = require("node:fs/promises");
|
|
20
21
|
const node_path_1 = __importDefault(require("node:path"));
|
|
21
22
|
const node_process_1 = __importDefault(require("node:process"));
|
|
22
23
|
const api_js_1 = require("./api.js");
|
|
@@ -37,6 +38,7 @@ const identify_js_1 = require("../../desktop-app/src/main/identify.js");
|
|
|
37
38
|
const detect_js_1 = require("../../desktop-app/src/main/detect.js");
|
|
38
39
|
const cbx_js_1 = require("../../desktop-app/src/main/cbx.js");
|
|
39
40
|
const api_js_2 = require("./api.js");
|
|
41
|
+
const import_command_js_1 = require("./import_command.js");
|
|
40
42
|
const runner_js_1 = require("./runner.js");
|
|
41
43
|
const config_js_1 = require("./config.js");
|
|
42
44
|
/*
|
|
@@ -265,7 +267,18 @@ async function commandProjects() {
|
|
|
265
267
|
}
|
|
266
268
|
async function commandStatus(parsed) {
|
|
267
269
|
const folder = folderFor(parsed);
|
|
268
|
-
|
|
270
|
+
let link;
|
|
271
|
+
let baseline;
|
|
272
|
+
try {
|
|
273
|
+
({ link, baseline } = await reconcile(folder));
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
link = await (0, config_js_1.readLink)(folder);
|
|
277
|
+
if (!link?.manifest)
|
|
278
|
+
throw error;
|
|
279
|
+
baseline = new Map(Object.entries(link.local ?? link.manifest));
|
|
280
|
+
console.log(dim("Offline — comparing with the last Version cached for this folder."));
|
|
281
|
+
}
|
|
269
282
|
const rules = await (0, worktree_js_1.readRules)(folder);
|
|
270
283
|
const files = await (0, worktree_js_1.changedFiles)(folder, rules, baseline);
|
|
271
284
|
console.log(bold(node_path_1.default.basename(folder)) + dim(` ${folder}`));
|
|
@@ -302,6 +315,103 @@ async function commandStatus(parsed) {
|
|
|
302
315
|
console.log(dim(` …and ${files.length - 50} more`));
|
|
303
316
|
return 0;
|
|
304
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Bring an existing repository in from another host.
|
|
320
|
+
*
|
|
321
|
+
* Snapshot only, and it says so. The files arrive, the history does not —
|
|
322
|
+
* see `import_command.ts` for why that is a limit rather than an omission.
|
|
323
|
+
* Everything after the fetch is the ordinary save path, so an import
|
|
324
|
+
* produces exactly the Version that saving the same folder would.
|
|
325
|
+
*/
|
|
326
|
+
async function commandImport(parsed) {
|
|
327
|
+
const url = parsed.positional[0];
|
|
328
|
+
if (!url) {
|
|
329
|
+
console.error(red("Nothing to import from."));
|
|
330
|
+
console.error("Give the address of a repository, for example:");
|
|
331
|
+
console.error(` ${accent("coderook import https://github.com/owner/project")}`);
|
|
332
|
+
return 1;
|
|
333
|
+
}
|
|
334
|
+
if (!(0, import_command_js_1.looksLikeRepositoryUrl)(url)) {
|
|
335
|
+
console.error(red(`${url} does not look like a repository address.`));
|
|
336
|
+
console.error("Expected something like https://github.com/owner/project or\n" +
|
|
337
|
+
"git@github.com:owner/project.git");
|
|
338
|
+
return 1;
|
|
339
|
+
}
|
|
340
|
+
if (!(await (0, import_command_js_1.gitAvailable)())) {
|
|
341
|
+
console.error(red("Import needs git on this machine, and it was not found."));
|
|
342
|
+
console.error("Git is used to fetch the files once; nothing about your project\n" +
|
|
343
|
+
"afterwards depends on it.");
|
|
344
|
+
return 1;
|
|
345
|
+
}
|
|
346
|
+
const destination = parsed.positional[1] ?? null;
|
|
347
|
+
let plan;
|
|
348
|
+
try {
|
|
349
|
+
plan = await (0, import_command_js_1.fetchSnapshot)(url, destination, (line) => console.log(dim(line)));
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
console.error(red("Could not fetch that repository."));
|
|
353
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
354
|
+
/*
|
|
355
|
+
git puts the useful sentence last and a stack of its own noise first.
|
|
356
|
+
A private repository with no credentials is by far the most common
|
|
357
|
+
failure, so it is named rather than left to be inferred.
|
|
358
|
+
*/
|
|
359
|
+
console.error(detail.split("\n").filter(Boolean).slice(-3).join("\n"));
|
|
360
|
+
if (/authentication|denied|not found|could not read/i.test(detail)) {
|
|
361
|
+
console.error("\nIf it is private, sign in to it with git first — CodeRook uses\n" +
|
|
362
|
+
"the credentials git already has and never asks for a token.");
|
|
363
|
+
}
|
|
364
|
+
return 1;
|
|
365
|
+
}
|
|
366
|
+
const { files, bytes } = await (0, import_command_js_1.measure)(plan.folder);
|
|
367
|
+
if (!files) {
|
|
368
|
+
console.error(red("That repository has no files in it."));
|
|
369
|
+
if (plan.temporary)
|
|
370
|
+
await (0, promises_2.rm)(node_path_1.default.dirname(plan.folder), { recursive: true, force: true });
|
|
371
|
+
return 1;
|
|
372
|
+
}
|
|
373
|
+
console.log(`Fetched ${files} file${files === 1 ? "" : "s"}, ${(0, import_command_js_1.humanBytes)(bytes)}, ` +
|
|
374
|
+
`into ${plan.folder}`);
|
|
375
|
+
/*
|
|
376
|
+
Said before the upload rather than after it fails. The window is a share
|
|
377
|
+
of the plan allowance per week, so a large import is a thing somebody
|
|
378
|
+
should know about while they can still choose a smaller repository.
|
|
379
|
+
*/
|
|
380
|
+
if (bytes > 1024 ** 3) {
|
|
381
|
+
console.log(dim("This is a large import. Uploads are limited to a share of your\n" +
|
|
382
|
+
"allowance each week, so a project this size may need more than one\n" +
|
|
383
|
+
"sitting; the save resumes rather than starting over."));
|
|
384
|
+
}
|
|
385
|
+
console.log(dim("History is not imported — this becomes the first version."));
|
|
386
|
+
/*
|
|
387
|
+
Hand the fetched folder to the ordinary save path. --allow-ignored is set
|
|
388
|
+
because git tracks files committed before the rule that excludes them,
|
|
389
|
+
and dropping those would make the import quietly lossy.
|
|
390
|
+
*/
|
|
391
|
+
const submitFlags = new Map(parsed.flags);
|
|
392
|
+
submitFlags.set("allow-ignored", true);
|
|
393
|
+
/*
|
|
394
|
+
Name the project after the repository, not after wherever the files were
|
|
395
|
+
put. Without this an import into a temporary folder produces a project
|
|
396
|
+
called something like `tmp-4f21`, and the name is the thing somebody
|
|
397
|
+
types afterwards to fetch it.
|
|
398
|
+
*/
|
|
399
|
+
if (!submitFlags.has("name"))
|
|
400
|
+
submitFlags.set("name", plan.name);
|
|
401
|
+
if (!submitFlags.has("m") && !submitFlags.has("message")) {
|
|
402
|
+
submitFlags.set("message", `Imported from ${url}`);
|
|
403
|
+
}
|
|
404
|
+
const code = await commandSubmit({
|
|
405
|
+
positional: [plan.folder],
|
|
406
|
+
flags: submitFlags,
|
|
407
|
+
});
|
|
408
|
+
if (code === 0 && plan.temporary) {
|
|
409
|
+
console.log(dim(`The working copy stays at ${plan.folder} until you remove it.
|
|
410
|
+
` +
|
|
411
|
+
`Run ${accent("coderook get " + plan.name)} anywhere to fetch it fresh.`));
|
|
412
|
+
}
|
|
413
|
+
return code;
|
|
414
|
+
}
|
|
305
415
|
async function commandSubmit(parsed) {
|
|
306
416
|
const folder = folderFor(parsed);
|
|
307
417
|
const message = flagText(parsed, "m", "message") ?? "";
|
|
@@ -329,7 +439,22 @@ async function commandSubmit(parsed) {
|
|
|
329
439
|
await (0, api_js_2.whoami)()
|
|
330
440
|
.then((account) => account.displayName || account.username || "")
|
|
331
441
|
.catch(() => ""), hasFlag(parsed, "no-licence"));
|
|
332
|
-
|
|
442
|
+
/*
|
|
443
|
+
Import sends what it fetched, filtering nothing.
|
|
444
|
+
|
|
445
|
+
The ignore rules exist to keep build output and local mess out of a
|
|
446
|
+
working folder, and applying them to a fresh clone gets the wrong answer
|
|
447
|
+
twice over: a clone holds exactly the files the source repository tracked
|
|
448
|
+
and nothing else, so there is no mess to exclude — while a repository
|
|
449
|
+
that tracks something its own `.gitignore` now names (which git does,
|
|
450
|
+
for anything committed before the rule) would have those files dropped.
|
|
451
|
+
Dropping them makes an import quietly lossy, which is worse than
|
|
452
|
+
refusing to import at all.
|
|
453
|
+
*/
|
|
454
|
+
const importing = hasFlag(parsed, "allow-ignored");
|
|
455
|
+
const rules = importing
|
|
456
|
+
? { shared: "", local: "" }
|
|
457
|
+
: await (0, worktree_js_1.readRules)(folder);
|
|
333
458
|
const files = await (0, worktree_js_1.changedFiles)(folder, rules, baseline);
|
|
334
459
|
/*
|
|
335
460
|
An upgraded folder with no materialisation record and something that
|
|
@@ -393,17 +518,17 @@ async function commandSubmit(parsed) {
|
|
|
393
518
|
*/
|
|
394
519
|
const shielded = (await (0, worktree_js_1.detectPrivateDirectories)(folder)).filter((finding) => [...sending].some((file) => file === finding.path || file.startsWith(`${finding.path}/`)));
|
|
395
520
|
if (shielded.length && !hasFlag(parsed, "allow-private")) {
|
|
396
|
-
console.log(red(`
|
|
521
|
+
console.log(red(`
|
|
397
522
|
${shielded.length} folder${shielded.length === 1 ? "" : "s"} here belong${shielded.length === 1 ? "s" : ""} to a program, not to your project:`));
|
|
398
523
|
for (const finding of shielded) {
|
|
399
524
|
console.log(` ${finding.path} ${dim(`— ${finding.because}`)}`);
|
|
400
525
|
}
|
|
401
|
-
console.log(`
|
|
526
|
+
console.log(`
|
|
402
527
|
Nothing was sent. To leave them behind:`);
|
|
403
528
|
for (const finding of shielded) {
|
|
404
529
|
console.log(` ${accent(`echo "${finding.rule}" >> .gitignore`)}`);
|
|
405
530
|
}
|
|
406
|
-
console.error(`
|
|
531
|
+
console.error(`
|
|
407
532
|
Or pass ${accent("--allow-private")} if they genuinely belong in the project.`);
|
|
408
533
|
return 1;
|
|
409
534
|
}
|
|
@@ -438,7 +563,7 @@ Or pass ${accent("--allow-private")} if they genuinely belong in the project.`);
|
|
|
438
563
|
const pasted = await (0, worktree_js_1.detectPastedCredentials)(folder, files.map((file) => file.path));
|
|
439
564
|
const stillPasted = pasted.filter((finding) => !exposed.includes(finding.path));
|
|
440
565
|
if (stillPasted.length) {
|
|
441
|
-
console.log(red(`
|
|
566
|
+
console.log(red(`
|
|
442
567
|
${stillPasted.length} file${stillPasted.length === 1 ? " has a credential" : "s have credentials"} inside:`));
|
|
443
568
|
for (const finding of stillPasted.slice(0, 20)) {
|
|
444
569
|
console.log(` ${finding.path}`);
|
|
@@ -453,22 +578,16 @@ ${stillPasted.length} file${stillPasted.length === 1 ? " has a credential" : "s
|
|
|
453
578
|
console.log(dim(` …and ${stillPasted.length - 20} more`));
|
|
454
579
|
}
|
|
455
580
|
if (!hasFlag(parsed, "allow-secrets")) {
|
|
456
|
-
console.error(`
|
|
581
|
+
console.error(`
|
|
457
582
|
Nothing was sent. Move the key into an environment variable, and if` +
|
|
458
583
|
` it has ever been published, replace it at the service that issued` +
|
|
459
584
|
` it — a key that has leaked stays leaked.` +
|
|
460
|
-
`
|
|
585
|
+
`
|
|
461
586
|
Pass ${accent("--allow-secrets")} if these are not real keys.`);
|
|
462
587
|
return 1;
|
|
463
588
|
}
|
|
464
589
|
console.log(dim("Sending them anyway, because --allow-secrets was given."));
|
|
465
590
|
}
|
|
466
|
-
if (hasFlag(parsed, "dry-run", "n")) {
|
|
467
|
-
console.log(`${files.length} file${files.length === 1 ? "" : "s"} would be sent:`);
|
|
468
|
-
for (const file of files)
|
|
469
|
-
console.log(` ${file.path}`);
|
|
470
|
-
return 0;
|
|
471
|
-
}
|
|
472
591
|
if (added) {
|
|
473
592
|
console.log(`${accent("Added an MIT licence")}, so other people may use this.`);
|
|
474
593
|
console.log(dim(` Change it with `) +
|
|
@@ -480,42 +599,60 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
|
|
|
480
599
|
}
|
|
481
600
|
const line = progressLine();
|
|
482
601
|
const uploader = new upload_js_1.Uploader(config_js_1.credentials);
|
|
602
|
+
const uploadRequest = {
|
|
603
|
+
localPath: folder,
|
|
604
|
+
include: files.map((file) => file.path),
|
|
605
|
+
deletions: files.filter((file) => file.deleted).map((file) => file.path),
|
|
606
|
+
message,
|
|
607
|
+
/*
|
|
608
|
+
A folder's name is the right default and the wrong answer for import,
|
|
609
|
+
where the folder is somewhere temporary and the project should carry
|
|
610
|
+
the name it had at the place it came from. An existing link always
|
|
611
|
+
wins: renaming somebody's project because they passed a flag would be
|
|
612
|
+
a surprise, and the flag exists for projects that do not exist yet.
|
|
613
|
+
*/
|
|
614
|
+
projectName: link?.slug ?? flagText(parsed, "name") ?? node_path_1.default.basename(folder),
|
|
615
|
+
repositoryId: link?.repositoryId ?? null,
|
|
616
|
+
// Every current CLI publish states its ancestry. A brand-new project is
|
|
617
|
+
// explicitly based on an empty Track; a linked folder names the immutable
|
|
618
|
+
// Version it was last reconciled with.
|
|
619
|
+
baseVersionId: link?.baseVersionId ?? null,
|
|
620
|
+
track: flagText(parsed, "track") ?? (await (0, track_commands_js_1.trackFor)(folder)),
|
|
621
|
+
/*
|
|
622
|
+
Only import sets this. Git keeps tracking files committed before the
|
|
623
|
+
rule that excludes them, so a faithful import carries paths the
|
|
624
|
+
project's own ignore rules now refuse; without the override the server
|
|
625
|
+
would reject the publication and the import would be lossy.
|
|
626
|
+
*/
|
|
627
|
+
...(hasFlag(parsed, "allow-ignored") ? { allowIgnored: true } : {}),
|
|
628
|
+
...(link?.baseVersionId
|
|
629
|
+
? { expectedHeadVersionId: link.baseVersionId }
|
|
630
|
+
: {}),
|
|
631
|
+
...(link ? { known: link.local ?? link.manifest ?? {} } : {}),
|
|
632
|
+
};
|
|
483
633
|
let result;
|
|
484
634
|
try {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
known version. A folder linked before versions were recorded says
|
|
507
|
-
nothing rather than guessing, and publishes as it always did.
|
|
508
|
-
*/
|
|
509
|
-
...(link?.baseVersionId
|
|
510
|
-
? { expectedHeadVersionId: link.baseVersionId }
|
|
511
|
-
: {}),
|
|
512
|
-
/*
|
|
513
|
-
What this folder believed the project held. It is how the service
|
|
514
|
-
tells a file this person deleted from one they never had, and
|
|
515
|
-
without it somebody else's work disappears at the next save.
|
|
516
|
-
*/
|
|
517
|
-
...(link ? { known: link.local ?? link.manifest ?? {} } : {}),
|
|
518
|
-
}, (progress) => {
|
|
635
|
+
const plan = await uploader.plan(uploadRequest, (progress) => {
|
|
636
|
+
line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
|
|
637
|
+
`${"plan".padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
|
|
638
|
+
dim(progress.path.slice(-40)));
|
|
639
|
+
});
|
|
640
|
+
done(line);
|
|
641
|
+
console.log(`${accent("Upload plan")} ${bytes(plan.sourceBytes)} selected → ` +
|
|
642
|
+
`${bytes(plan.compactedBytes)} compacted; ${bytes(plan.chargeableBytes)} new storage.`);
|
|
643
|
+
console.log(plan.allowance.exempt
|
|
644
|
+
? dim("Annual plan: the monthly staged upload allowance does not apply.")
|
|
645
|
+
: dim(`Monthly stage ${plan.allowance.stage}: ${plan.allowance.unlockedPercent}% unlocked, ` +
|
|
646
|
+
`${bytes(plan.allowance.remainingBytes)} remains after this reservation.`));
|
|
647
|
+
if (hasFlag(parsed, "dry-run", "n")) {
|
|
648
|
+
await uploader.cancelPlan(plan, true);
|
|
649
|
+
console.log(`${files.length} file${files.length === 1 ? "" : "s"} would be sent or reused:`);
|
|
650
|
+
for (const file of files)
|
|
651
|
+
console.log(` ${file.path}`);
|
|
652
|
+
console.log("Nothing was uploaded and no Version was created.");
|
|
653
|
+
return 0;
|
|
654
|
+
}
|
|
655
|
+
result = await uploader.execute(uploadRequest, plan, (progress) => {
|
|
519
656
|
line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
|
|
520
657
|
`${progress.stage.padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
|
|
521
658
|
dim(progress.path.slice(-40)));
|
|
@@ -533,7 +670,7 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
|
|
|
533
670
|
difference between a person retrying and a person wondering.
|
|
534
671
|
*/
|
|
535
672
|
if (!code && /fetch failed|ECONNRESET|socket hang up|network|ETIMEDOUT/i.test(text)) {
|
|
536
|
-
console.error(red(`
|
|
673
|
+
console.error(red(`
|
|
537
674
|
The connection failed: ${text}`));
|
|
538
675
|
console.error(`Your work may already have been saved. Run the same command again —` +
|
|
539
676
|
` it will not create a second version.`);
|
|
@@ -715,7 +852,7 @@ async function commandGet(parsed) {
|
|
|
715
852
|
`changed while the interrupted fetch was stopped:`));
|
|
716
853
|
for (const file of changedInTheGap.slice(0, 20))
|
|
717
854
|
console.error(` ${file.path}`);
|
|
718
|
-
console.error(`
|
|
855
|
+
console.error(`
|
|
719
856
|
Save them with ${accent("coderook submit")}, or finish the fetch and ` +
|
|
720
857
|
`discard them with ${accent("coderook get --replace")}.`);
|
|
721
858
|
return 1;
|
|
@@ -868,8 +1005,8 @@ async function suggestRules(folder, apply) {
|
|
|
868
1005
|
const addition = (0, detect_js_1.rulesFromSuggestions)(recommended);
|
|
869
1006
|
const shared = rules.shared.trimEnd();
|
|
870
1007
|
await (0, worktree_js_1.writeRules)(folder, {
|
|
871
|
-
shared: shared ? `${shared}
|
|
872
|
-
|
|
1008
|
+
shared: shared ? `${shared}
|
|
1009
|
+
|
|
873
1010
|
${addition}` : addition,
|
|
874
1011
|
local: rules.local,
|
|
875
1012
|
});
|
|
@@ -996,7 +1133,7 @@ async function commandMerges(parsed) {
|
|
|
996
1133
|
const counts = merge.conflicts;
|
|
997
1134
|
console.log(`${accent(merge.reference)} ${counts ? `${counts.unresolved} of ${counts.total} still to decide` : ""} ${dim(new Date(merge.createdAt).toLocaleString())}`);
|
|
998
1135
|
}
|
|
999
|
-
console.log(dim(`
|
|
1136
|
+
console.log(dim(`
|
|
1000
1137
|
Run coderook merge <reference> to look at one.`));
|
|
1001
1138
|
return 0;
|
|
1002
1139
|
}
|
|
@@ -1062,7 +1199,7 @@ async function commandMerge(parsed) {
|
|
|
1062
1199
|
}
|
|
1063
1200
|
}
|
|
1064
1201
|
const now = await (0, api_js_1.mergeTrack)(summary.id);
|
|
1065
|
-
console.log(`
|
|
1202
|
+
console.log(`
|
|
1066
1203
|
${accent(now.mergeTrack.reference)} · ${now.provisional.fileCount} files · ` +
|
|
1067
1204
|
(now.provisional.ready
|
|
1068
1205
|
? "ready to apply"
|
|
@@ -1077,19 +1214,19 @@ ${accent(now.mergeTrack.reference)} · ${now.provisional.fileCount} files · ` +
|
|
|
1077
1214
|
return 1;
|
|
1078
1215
|
}
|
|
1079
1216
|
const applied = await (0, api_js_1.applyMerge)(summary.id);
|
|
1080
|
-
console.log(`
|
|
1217
|
+
console.log(`
|
|
1081
1218
|
Applied as ${accent(`v${applied.version.sequence}`)}.`);
|
|
1082
1219
|
console.log(dim("Run coderook get to bring it down to this folder."));
|
|
1083
1220
|
return 0;
|
|
1084
1221
|
}
|
|
1085
1222
|
if (!decision) {
|
|
1086
|
-
console.log(dim(`
|
|
1223
|
+
console.log(dim(`
|
|
1087
1224
|
--mine keeps yours, --theirs keeps what was already saved,` +
|
|
1088
|
-
` --drop removes the file.
|
|
1225
|
+
` --drop removes the file.
|
|
1089
1226
|
Add --path <file> for one file, then --apply when ready.`));
|
|
1090
1227
|
}
|
|
1091
1228
|
else if (now.provisional.ready) {
|
|
1092
|
-
console.log(dim(`
|
|
1229
|
+
console.log(dim(`
|
|
1093
1230
|
Run coderook merge ${now.mergeTrack.reference} --apply to publish it.`));
|
|
1094
1231
|
}
|
|
1095
1232
|
return 0;
|
|
@@ -1266,6 +1403,10 @@ const SPECS = [
|
|
|
1266
1403
|
options: [
|
|
1267
1404
|
{ flags: "-m, --message <text>", description: "what changed, in a sentence" },
|
|
1268
1405
|
{ flags: "-n, --dry-run", description: "show what would be sent, send nothing" },
|
|
1406
|
+
{
|
|
1407
|
+
flags: "--name <name>",
|
|
1408
|
+
description: "name a new project this, instead of after the folder",
|
|
1409
|
+
},
|
|
1269
1410
|
{
|
|
1270
1411
|
flags: "--allow-secrets",
|
|
1271
1412
|
description: "send files that look like credentials, and files with keys inside",
|
|
@@ -1285,6 +1426,38 @@ const SPECS = [
|
|
|
1285
1426
|
],
|
|
1286
1427
|
run: commandSubmit,
|
|
1287
1428
|
},
|
|
1429
|
+
{
|
|
1430
|
+
name: "import",
|
|
1431
|
+
group: "Getting started",
|
|
1432
|
+
summary: "bring a project in from another host",
|
|
1433
|
+
usage: "import <address> [folder]",
|
|
1434
|
+
detail: "Fetches a repository from GitHub, GitLab or anywhere else git can\n" +
|
|
1435
|
+
"reach, and saves it as the first version of a CodeRook project.\n\n" +
|
|
1436
|
+
"The files come across; the history does not. Every past commit would\n" +
|
|
1437
|
+
"have to be published as its own version, which on a large project\n" +
|
|
1438
|
+
"takes days — so this takes an honest snapshot rather than leaving a\n" +
|
|
1439
|
+
"half-finished import behind. What arrives is the current state of the\n" +
|
|
1440
|
+
"default branch.\n\n" +
|
|
1441
|
+
"A public repository needs nothing. A private one uses the credentials\n" +
|
|
1442
|
+
"git already has on this machine; CodeRook never asks for, stores or\n" +
|
|
1443
|
+
"forwards a token.\n\n" +
|
|
1444
|
+
"Files that the project's own ignore rules exclude are sent anyway,\n" +
|
|
1445
|
+
"because git keeps tracking anything committed before the rule that\n" +
|
|
1446
|
+
"excludes it, and leaving them out would lose files the source has.",
|
|
1447
|
+
options: [
|
|
1448
|
+
{ flags: "-m, --message <text>", description: "the first version's message" },
|
|
1449
|
+
{ flags: "--track <name>", description: "save onto this line" },
|
|
1450
|
+
{
|
|
1451
|
+
flags: "--no-licence",
|
|
1452
|
+
description: "do not add a licence to the new project",
|
|
1453
|
+
},
|
|
1454
|
+
],
|
|
1455
|
+
examples: [
|
|
1456
|
+
"coderook import https://github.com/owner/project",
|
|
1457
|
+
"coderook import git@github.com:owner/project.git ./project",
|
|
1458
|
+
],
|
|
1459
|
+
run: commandImport,
|
|
1460
|
+
},
|
|
1288
1461
|
{
|
|
1289
1462
|
/*
|
|
1290
1463
|
Where the next save goes, which is a property of this folder rather
|
|
@@ -0,0 +1,150 @@
|
|
|
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.projectNameFromUrl = projectNameFromUrl;
|
|
7
|
+
exports.looksLikeRepositoryUrl = looksLikeRepositoryUrl;
|
|
8
|
+
exports.gitAvailable = gitAvailable;
|
|
9
|
+
exports.fetchSnapshot = fetchSnapshot;
|
|
10
|
+
exports.measure = measure;
|
|
11
|
+
exports.humanBytes = humanBytes;
|
|
12
|
+
const node_child_process_1 = require("node:child_process");
|
|
13
|
+
const promises_1 = require("node:fs/promises");
|
|
14
|
+
const node_os_1 = require("node:os");
|
|
15
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
16
|
+
const node_util_1 = require("node:util");
|
|
17
|
+
const run = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
18
|
+
/**
|
|
19
|
+
* The repository's own name, from the last path segment.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately not the host: an address with no path at all is not a
|
|
22
|
+
* repository, and naming somebody's project `github.com` because the URL was
|
|
23
|
+
* incomplete is the sort of thing they would only notice later.
|
|
24
|
+
*/
|
|
25
|
+
function projectNameFromUrl(url) {
|
|
26
|
+
const trimmed = url.trim().replace(/\/+$/, "");
|
|
27
|
+
/* Drop the scheme and authority so only path segments remain. */
|
|
28
|
+
const withoutScheme = trimmed.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
|
|
29
|
+
const afterHost = /^[^/]*:/.test(withoutScheme)
|
|
30
|
+
? /* scp-style git@host:owner/name */
|
|
31
|
+
withoutScheme.slice(withoutScheme.indexOf(":") + 1)
|
|
32
|
+
: withoutScheme.slice(withoutScheme.indexOf("/") + 1);
|
|
33
|
+
const hasPath = withoutScheme.includes("/") || /^[^/]*:/.test(withoutScheme);
|
|
34
|
+
if (!hasPath)
|
|
35
|
+
return "imported-project";
|
|
36
|
+
const tail = afterHost.split("/").filter(Boolean).pop() ?? "";
|
|
37
|
+
const name = tail.replace(/\.git$/i, "").trim();
|
|
38
|
+
return name || "imported-project";
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Refuse anything that is not a repository location.
|
|
42
|
+
*
|
|
43
|
+
* `git clone` will happily treat a local path as a source, and a URL typed
|
|
44
|
+
* with a scheme this does not expect is more likely a mistake than an
|
|
45
|
+
* intention. Being narrow here keeps the command from doing something
|
|
46
|
+
* surprising with an argument that was meant for something else.
|
|
47
|
+
*/
|
|
48
|
+
function looksLikeRepositoryUrl(url) {
|
|
49
|
+
const value = url.trim();
|
|
50
|
+
if (/^(https?|git|ssh):\/\//i.test(value))
|
|
51
|
+
return true;
|
|
52
|
+
/* scp-style: git@host:owner/name.git */
|
|
53
|
+
if (/^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[^\s]+$/.test(value))
|
|
54
|
+
return true;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
async function exists(target) {
|
|
58
|
+
try {
|
|
59
|
+
await (0, promises_1.access)(target);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function isEmptyDirectory(target) {
|
|
67
|
+
try {
|
|
68
|
+
const entries = await (0, promises_1.readdir)(target);
|
|
69
|
+
return entries.length === 0;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function gitAvailable() {
|
|
76
|
+
try {
|
|
77
|
+
await run("git", ["--version"], { windowsHide: true });
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Shallow-clone into a working folder and strip the Git metadata.
|
|
86
|
+
*
|
|
87
|
+
* Returns where the files landed. The caller publishes from there with the
|
|
88
|
+
* ordinary save path, so an import produces exactly the Version an ordinary
|
|
89
|
+
* save of the same files would.
|
|
90
|
+
*/
|
|
91
|
+
async function fetchSnapshot(url, into, log = () => { }) {
|
|
92
|
+
const name = projectNameFromUrl(url);
|
|
93
|
+
let folder;
|
|
94
|
+
let temporary = false;
|
|
95
|
+
if (into) {
|
|
96
|
+
folder = node_path_1.default.resolve(into);
|
|
97
|
+
if ((await exists(folder)) && !(await isEmptyDirectory(folder))) {
|
|
98
|
+
throw new Error(`${folder} already has files in it. Import needs an empty folder, ` +
|
|
99
|
+
`so that nothing here is overwritten by what arrives.`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const base = await (0, promises_1.mkdtemp)(node_path_1.default.join((0, node_os_1.tmpdir)(), "coderook-import-"));
|
|
104
|
+
folder = node_path_1.default.join(base, name);
|
|
105
|
+
temporary = true;
|
|
106
|
+
}
|
|
107
|
+
log(`Fetching ${url}`);
|
|
108
|
+
/*
|
|
109
|
+
--depth 1 for the reason in the header. --single-branch keeps it to the
|
|
110
|
+
default branch: an import takes a snapshot of one line of work, and
|
|
111
|
+
fetching every branch's tip would cost time to produce content this
|
|
112
|
+
command then discards.
|
|
113
|
+
*/
|
|
114
|
+
await run("git", ["clone", "--depth", "1", "--single-branch", url, folder], { windowsHide: true, maxBuffer: 32 * 1024 * 1024 });
|
|
115
|
+
const gitDirectory = node_path_1.default.join(folder, ".git");
|
|
116
|
+
if (await exists(gitDirectory)) {
|
|
117
|
+
await (0, promises_1.rm)(gitDirectory, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
return { folder, name, temporary };
|
|
120
|
+
}
|
|
121
|
+
/** Total bytes and file count of the fetched tree, for the summary line. */
|
|
122
|
+
async function measure(folder) {
|
|
123
|
+
let files = 0;
|
|
124
|
+
let bytes = 0;
|
|
125
|
+
const walk = async (directory) => {
|
|
126
|
+
for (const entry of await (0, promises_1.readdir)(directory, { withFileTypes: true })) {
|
|
127
|
+
const full = node_path_1.default.join(directory, entry.name);
|
|
128
|
+
if (entry.isDirectory()) {
|
|
129
|
+
await walk(full);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!entry.isFile())
|
|
133
|
+
continue;
|
|
134
|
+
files += 1;
|
|
135
|
+
bytes += (await (0, promises_1.stat)(full)).size;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
await walk(folder);
|
|
139
|
+
return { files, bytes };
|
|
140
|
+
}
|
|
141
|
+
function humanBytes(value) {
|
|
142
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
143
|
+
let size = value;
|
|
144
|
+
let unit = 0;
|
|
145
|
+
while (size >= 1024 && unit < units.length - 1) {
|
|
146
|
+
size /= 1024;
|
|
147
|
+
unit += 1;
|
|
148
|
+
}
|
|
149
|
+
return `${unit === 0 ? size : size.toFixed(size < 10 ? 2 : 1)} ${units[unit]}`;
|
|
150
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
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.queueOfflineCandidate = queueOfflineCandidate;
|
|
7
|
+
exports.readOfflineCandidate = readOfflineCandidate;
|
|
8
|
+
const promises_1 = require("node:fs/promises");
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
async function queueOfflineCandidate(folder, candidate) {
|
|
11
|
+
const directory = node_path_1.default.join(folder, ".coderook", "outbox");
|
|
12
|
+
await (0, promises_1.mkdir)(directory, { recursive: true });
|
|
13
|
+
const body = {
|
|
14
|
+
format: "coderook-offline-candidate-v1",
|
|
15
|
+
createdAt: new Date().toISOString(),
|
|
16
|
+
...candidate,
|
|
17
|
+
};
|
|
18
|
+
const name = `${Date.now()}-${crypto.randomUUID()}.json`;
|
|
19
|
+
const destination = node_path_1.default.join(directory, name);
|
|
20
|
+
await (0, promises_1.writeFile)(destination, JSON.stringify(body, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
21
|
+
return destination;
|
|
22
|
+
}
|
|
23
|
+
async function readOfflineCandidate(file) {
|
|
24
|
+
const value = JSON.parse(await (0, promises_1.readFile)(file, "utf8"));
|
|
25
|
+
if (value.format !== "coderook-offline-candidate-v1")
|
|
26
|
+
throw new Error("Unsupported offline candidate");
|
|
27
|
+
return value;
|
|
28
|
+
}
|