@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.
@@ -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.22.1",
5
+ "version": "0.23.0",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -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
- const { link, baseline } = await reconcile(folder);
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
- const rules = await (0, worktree_js_1.readRules)(folder);
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
- result = await uploader.run({
486
- localPath: folder,
487
- include: files.map((file) => file.path),
488
- deletions: files.filter((file) => file.deleted).map((file) => file.path),
489
- message,
490
- projectName: link?.slug ?? node_path_1.default.basename(folder),
491
- repositoryId: link?.repositoryId ?? null,
492
- // Every current CLI publish states its ancestry. A brand-new project
493
- // is explicitly based on an empty Track; a linked folder names the
494
- // immutable Version it was last reconciled with.
495
- baseVersionId: link?.baseVersionId ?? null,
496
- /*
497
- The line this save belongs on: named on the command, or whichever one
498
- the folder is set to. Sent by name rather than resolved here, because
499
- the service owns the rules about which names exist and which may be
500
- written to a rule enforced in two places is one that will disagree
501
- with itself.
502
- */
503
- track: flagText(parsed, "track") ?? (await (0, track_commands_js_1.trackFor)(folder)),
504
- /*
505
- Only claimed when this folder has actually been reconciled with a
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
+ }