@coderook/cli 0.33.0 → 0.34.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.
@@ -28,6 +28,7 @@ const help_js_1 = require("./help.js");
28
28
  const progress_js_1 = require("./progress.js");
29
29
  const publish_js_1 = require("./publish.js");
30
30
  const attach_command_js_1 = require("./attach_command.js");
31
+ const build_command_js_1 = require("./build_command.js");
31
32
  const pages_command_js_1 = require("./pages_command.js");
32
33
  const project_commands_js_1 = require("./project_commands.js");
33
34
  const track_commands_js_1 = require("./track_commands.js");
@@ -52,6 +53,8 @@ const profile_js_1 = require("../../cbx/src/core/profile.js");
52
53
  const detect_js_1 = require("../../cbx/src/core/detect.js");
53
54
  const cbx_js_1 = require("../../cbx/src/core/cbx.js");
54
55
  const api_js_2 = require("./api.js");
56
+ const folder_match_js_1 = require("./folder_match.js");
57
+ const project_choice_js_1 = require("./project_choice.js");
55
58
  const import_command_js_1 = require("./import_command.js");
56
59
  const runner_js_1 = require("./runner.js");
57
60
  const config_js_1 = require("./config.js");
@@ -203,11 +206,22 @@ const folderFor = (parsed, index = 0) => node_path_1.default.resolve(parsed.posi
203
206
  */
204
207
  async function reconcile(folder, options = {}) {
205
208
  const link = await (0, config_js_1.readLink)(folder);
206
- const reference = link?.slug ?? node_path_1.default.basename(folder);
209
+ /*
210
+ A folder that is not linked is not linked.
211
+
212
+ This used to look the folder's name up and link it to whatever answered,
213
+ from any command — `status` included, and straight after `cbx unlink`.
214
+ That is how an inner folder named like its parent's project was saved into
215
+ it, laying a second copy of the app over the project's root. Which project
216
+ a folder belongs to is a question for the person, asked where it matters.
217
+ */
218
+ if (!link && !options.adopt) {
219
+ return { link: null, baseline: null, behind: null, member: true };
220
+ }
207
221
  let member = true;
208
222
  let project = link
209
223
  ? (await (0, api_js_2.projects)()).find((candidate) => candidate.id === link.repositoryId)
210
- : await (0, api_js_2.findProject)(reference);
224
+ : options.adopt;
211
225
  /*
212
226
  Not on the account's own list, which holds only projects it is in. A
213
227
  folder cloned from somebody else's public project is exactly that, and
@@ -291,9 +305,11 @@ async function reconcile(folder, options = {}) {
291
305
  local: Object.fromEntries(baseline),
292
306
  manifest: Object.fromEntries(baseline),
293
307
  };
294
- await (0, config_js_1.writeLink)(folder, fresh);
295
- if (!options.quiet && !link) {
296
- console.log(dim(`Linked this folder to ${project.slug}.`));
308
+ if (options.persist !== false) {
309
+ await (0, config_js_1.writeLink)(folder, fresh);
310
+ if (!options.quiet && !link) {
311
+ console.log(dim(`Linked this folder to ${project.slug}.`));
312
+ }
297
313
  }
298
314
  return { link: fresh, baseline, behind: null, member };
299
315
  }
@@ -358,7 +374,35 @@ async function commandUnlink(parsed) {
358
374
  console.log(`Detached ${bold(node_path_1.default.basename(folder))}` +
359
375
  (link?.slug ? dim(` from ${link.slug}`) : "") +
360
376
  ".");
361
- console.log(dim("Nothing was deleted. Submitting from here now starts a new project."));
377
+ console.log(dim("Nothing was deleted. The next submit from here asks which project it is."));
378
+ return 0;
379
+ }
380
+ async function commandLink(parsed) {
381
+ const reference = parsed.positional[0];
382
+ if (!reference) {
383
+ console.error(red("Say which project: cbx link <project> [folder]"));
384
+ return 1;
385
+ }
386
+ const folder = folderFor(parsed, 1);
387
+ const already = await (0, config_js_1.readLink)(folder);
388
+ if (already) {
389
+ console.error(red(`This folder is already linked to ${already.slug}.`));
390
+ console.error(dim(` Run ${accent("cbx unlink")} first to link it somewhere else.`));
391
+ return 1;
392
+ }
393
+ const project = await (0, api_js_2.findProject)(reference);
394
+ if (!project) {
395
+ console.error(red(`No project matching "${reference}" on your account.`));
396
+ return 1;
397
+ }
398
+ const { link } = await reconcile(folder, { adopt: project, quiet: true });
399
+ if (!link) {
400
+ console.error(red(`${project.slug} has nothing saved yet, so there is nothing to link to.`));
401
+ console.error(dim(` Save into it with ${accent(`cbx submit --into ${project.slug}`)}.`));
402
+ return 1;
403
+ }
404
+ console.log(`Linked ${bold(node_path_1.default.basename(folder))} to ${accent(project.slug)}` + dim(` at v${link.sequence}`) + ".");
405
+ console.log(dim(` ${accent("cbx status")} shows how this folder differs from it; nothing was sent or fetched.`));
362
406
  return 0;
363
407
  }
364
408
  async function commandStatus(parsed) {
@@ -395,7 +439,21 @@ async function commandStatus(parsed) {
395
439
  console.log(bold(node_path_1.default.basename(folder)) + dim(` ${folder}`));
396
440
  console.log(link
397
441
  ? `Account holds ${accent(`v${link.sequence}`)} · ${Object.keys(link.manifest).length} files ${dim(`(${link.slug})`)}`
398
- : dim("Not on your account yet — submitting will create it."));
442
+ : dim("This folder is not linked to a project."));
443
+ if (!link) {
444
+ const likes = (0, folder_match_js_1.similarProjects)(await (0, api_js_2.projects)().catch(() => []), node_path_1.default.basename(folder));
445
+ if (likes.length) {
446
+ const width = Math.max(...likes.map((like) => like.project.slug.length));
447
+ console.log(dim("Projects on your account like it:"));
448
+ for (const like of likes)
449
+ console.log(` ${await (0, project_choice_js_1.likenessLine)(folder, like, width)}`);
450
+ console.log(dim("Link it with ") + accent(`cbx link ${likes[0].project.slug}`) +
451
+ dim(", or start a separate project with ") + accent('cbx submit --new -m "…"') + dim("."));
452
+ }
453
+ else {
454
+ console.log(dim("Submitting will create a project for it."));
455
+ }
456
+ }
399
457
  /*
400
458
  A folder can have nothing to send and still not hold the whole version:
401
459
  when the service merges somebody else's save into yours, their files
@@ -592,6 +650,9 @@ async function commandImport(parsed) {
592
650
  */
593
651
  if (!submitFlags.has("name"))
594
652
  submitFlags.set("name", plan.name);
653
+ /* An import is a new project; one already called this is said, not joined. */
654
+ if (!submitFlags.has("into"))
655
+ submitFlags.set("new", true);
595
656
  if (!submitFlags.has("m") && !submitFlags.has("message")) {
596
657
  submitFlags.set("message", `Imported from ${url}`);
597
658
  }
@@ -606,6 +667,28 @@ async function commandImport(parsed) {
606
667
  }
607
668
  return code;
608
669
  }
670
+ /**
671
+ * Say so when this folder is a git repository with no CodeRook remote.
672
+ *
673
+ * Every save from one is a snapshot that forgets the commits it holds, and an
674
+ * assistant told only about `cbx submit` never learns there is another way:
675
+ * the remote helper, which saves each commit with its message. One line,
676
+ * after the save, and only until the remote exists.
677
+ */
678
+ function noteGitRoute(folder, slug) {
679
+ try {
680
+ const config = (0, node_fs_1.readFileSync)(node_path_1.default.join(folder, ".git", "config"), "utf8");
681
+ if (/coderook:/.test(config))
682
+ return;
683
+ }
684
+ catch {
685
+ return;
686
+ }
687
+ const name = slug ?? "<project>";
688
+ console.log(dim("This is a git repository. To save each commit with its message instead of snapshots: ") +
689
+ accent(`git remote add coderook coderook://${name}`) + dim(" then ") +
690
+ accent("git push -o adopt coderook main") + dim(" once, and plain git push after."));
691
+ }
609
692
  /** A file's SHA-256, streamed; null when it cannot be read. */
610
693
  async function digestOfFile(full) {
611
694
  try {
@@ -651,7 +734,38 @@ async function commandSubmit(parsed) {
651
734
  return 1;
652
735
  }
653
736
  const dryRun = hasFlag(parsed, "dry-run", "n");
654
- const { link, baseline, behind, member } = await reconcile(folder);
737
+ /*
738
+ Which project this goes into is settled before anything is scanned, and
739
+ by the person: a folder that is not linked is never joined to a project
740
+ because their names agree, nor quietly made into a second copy of one.
741
+ */
742
+ const already = await (0, config_js_1.readLink)(folder);
743
+ let adopt;
744
+ const intoFlag = flagText(parsed, "into");
745
+ if (already && (hasFlag(parsed, "new") || (intoFlag && (0, folder_match_js_1.slugFor)(intoFlag.split("/").pop()) !== already.slug))) {
746
+ console.error(red(`This folder is already linked to ${already.slug}.`));
747
+ console.error(dim(` Run ${accent("cbx unlink")} first to save it somewhere else.`));
748
+ return 1;
749
+ }
750
+ if (!already) {
751
+ const chosen = await (0, project_choice_js_1.chooseProject)({
752
+ folder,
753
+ name: flagText(parsed, "name") ?? node_path_1.default.basename(folder),
754
+ ...(intoFlag ? { into: intoFlag } : {}),
755
+ startNew: hasFlag(parsed, "new"),
756
+ command: "cbx submit",
757
+ });
758
+ if (!chosen)
759
+ return 1;
760
+ if ("id" in chosen)
761
+ adopt = chosen;
762
+ else
763
+ parsed.flags.set("name", chosen.name);
764
+ }
765
+ const { link, baseline, behind, member } = await reconcile(folder, {
766
+ ...(adopt ? { adopt } : {}),
767
+ persist: !dryRun,
768
+ });
655
769
  /*
656
770
  Somebody else's project, cloned from its public page. Reading and
657
771
  fetching are fine; saving into it is not this account's to do, and the
@@ -680,8 +794,8 @@ async function commandSubmit(parsed) {
680
794
  A dry run changes nothing, and that includes writing a LICENSE: it says
681
795
  what would be added instead.
682
796
  */
683
- const wouldLicence = dryRun && !link && !hasFlag(parsed, "no-licence") && !(0, node_fs_1.existsSync)(node_path_1.default.join(folder, "LICENSE"));
684
- const added = link || dryRun
797
+ const wouldLicence = dryRun && !link && !adopt && !hasFlag(parsed, "no-licence") && !(0, node_fs_1.existsSync)(node_path_1.default.join(folder, "LICENSE"));
798
+ const added = link || adopt || dryRun
685
799
  ? null
686
800
  : await (0, licence_commands_js_1.licenceNewProject)(folder,
687
801
  /*
@@ -899,7 +1013,7 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
899
1013
  console.log("");
900
1014
  }
901
1015
  const line = progressLine();
902
- const uploader = new upload_js_1.Uploader(config_js_1.credentials);
1016
+ let uploader = new upload_js_1.Uploader(config_js_1.credentials);
903
1017
  /*
904
1018
  Built by the shared description, so this and the git remote helper cannot
905
1019
  drift. They already had: the helper listed deletions in `deletions` alone
@@ -928,8 +1042,13 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
928
1042
  wins: renaming somebody's project because they passed a flag would be
929
1043
  a surprise, and the flag exists for projects that do not exist yet.
930
1044
  */
931
- projectName: link?.slug ?? flagText(parsed, "name") ?? node_path_1.default.basename(folder),
932
- repositoryId: link?.repositoryId ?? null,
1045
+ projectName: link?.slug ?? adopt?.slug ?? flagText(parsed, "name") ?? node_path_1.default.basename(folder),
1046
+ repositoryId: link?.repositoryId ?? adopt?.id ?? null,
1047
+ /*
1048
+ Settled above, so a project that appears under this name between the
1049
+ question and the save is a collision to report, not one to join.
1050
+ */
1051
+ joinExisting: false,
933
1052
  // Every current CLI publish states its ancestry. A brand-new project is
934
1053
  // explicitly based on an empty Track; a linked folder names the immutable
935
1054
  // Version it was last reconciled with.
@@ -1001,71 +1120,99 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
1001
1120
  }
1002
1121
  }
1003
1122
  let result;
1004
- try {
1005
- const plan = await uploader.plan(uploadRequest, (progress) => {
1006
- line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
1007
- `${"plan".padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
1008
- dim(progress.path.slice(-40)));
1009
- });
1010
- done(line);
1011
- console.log(`${accent("Upload plan")} ${bytes(plan.sourceBytes)} selected → ` +
1012
- `${bytes(plan.compactedBytes)} compacted; ${bytes(plan.chargeableBytes)} new storage.`);
1013
- console.log(plan.allowance.exempt
1014
- ? dim("Annual plan: the monthly staged upload allowance does not apply.")
1015
- : dim(`Monthly stage ${plan.allowance.stage}: ${plan.allowance.unlockedPercent}% unlocked, ` +
1016
- `${bytes(plan.allowance.remainingBytes)} remains after this reservation.`));
1017
- if (dryRun) {
1018
- await uploader.cancelPlan(plan, true);
1019
- const going = files.filter((file) => !file.deleted);
1020
- const removing = files.filter((file) => file.deleted);
1021
- console.log(`${going.length} file${going.length === 1 ? "" : "s"} would be sent or reused:`);
1022
- for (const file of going)
1023
- console.log(` ${file.path}`);
1024
- if (removing.length) {
1025
- console.log(`${removing.length} file${removing.length === 1 ? "" : "s"} would be removed from the save:`);
1026
- for (const file of removing)
1027
- console.log(` ${red("deleted")} ${file.path}`);
1123
+ /*
1124
+ How long to wait before picking a dropped upload up again by itself.
1125
+
1126
+ Each request is already retried for a few seconds, but a Wi-Fi drop or a
1127
+ router reconnecting outlasts that, and the upload ended with "run the same
1128
+ command again" for somebody to notice and act on. Running it again was
1129
+ always safe and cheap (pieces already stored are recognised and skipped,
1130
+ and the save itself is named so it can never be made twice), so the
1131
+ command now does it itself, a few times, before giving up.
1132
+ */
1133
+ const resumeAfter = [10, 30, 60];
1134
+ for (let resumed = 0;; resumed += 1) {
1135
+ try {
1136
+ const plan = await uploader.plan(uploadRequest, (progress) => {
1137
+ line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
1138
+ `${"plan".padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
1139
+ dim(progress.path.slice(-40)));
1140
+ });
1141
+ done(line);
1142
+ console.log(`${accent("Upload plan")} ${bytes(plan.sourceBytes)} selected → ` +
1143
+ `${bytes(plan.compactedBytes)} compacted; ${bytes(plan.chargeableBytes)} new storage.`);
1144
+ console.log(plan.allowance.exempt
1145
+ ? dim("Annual plan: the monthly staged upload allowance does not apply.")
1146
+ : dim(`Monthly stage ${plan.allowance.stage}: ${plan.allowance.unlockedPercent}% unlocked, ` +
1147
+ `${bytes(plan.allowance.remainingBytes)} remains after this reservation.`));
1148
+ if (dryRun) {
1149
+ await uploader.cancelPlan(plan, true);
1150
+ const going = files.filter((file) => !file.deleted);
1151
+ const removing = files.filter((file) => file.deleted);
1152
+ console.log(`${going.length} file${going.length === 1 ? "" : "s"} would be sent or reused:`);
1153
+ for (const file of going)
1154
+ console.log(` ${file.path}`);
1155
+ if (removing.length) {
1156
+ console.log(`${removing.length} file${removing.length === 1 ? "" : "s"} would be removed from the save:`);
1157
+ for (const file of removing)
1158
+ console.log(` ${red("deleted")} ${file.path}`);
1159
+ }
1160
+ noteKeptDeletions(folder, baseline, hasFlag(parsed, "sync"));
1161
+ console.log("Nothing was uploaded and no Version was created.");
1162
+ return 0;
1028
1163
  }
1029
- noteKeptDeletions(folder, baseline, hasFlag(parsed, "sync"));
1030
- console.log("Nothing was uploaded and no Version was created.");
1031
- return 0;
1164
+ result = await uploader.execute(uploadRequest, plan, (progress) => {
1165
+ line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
1166
+ `${progress.stage.padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
1167
+ dim(progress.path.slice(-40)));
1168
+ });
1169
+ break;
1032
1170
  }
1033
- result = await uploader.execute(uploadRequest, plan, (progress) => {
1034
- line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
1035
- `${progress.stage.padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
1036
- dim(progress.path.slice(-40)));
1037
- });
1038
- }
1039
- catch (error) {
1040
- done(line);
1041
- /*
1042
- Classified by the shared description rather than by a private regular
1043
- expression, so a push and a submit disagree about nothing — including
1044
- what counts as "the connection failed" and what that means for whether
1045
- the work was saved.
1046
- */
1047
- const failure = (0, publish_js_1.classifyPublishFailure)(error);
1048
- const text = (0, network_js_1.describeError)(error);
1049
- if (failure?.kind === "interrupted") {
1050
- console.error(red(`
1051
- The connection failed: ${text}`));
1052
- console.error(`Your work may already have been saved. Run the same command again —` +
1053
- ` it will not create a second version.`);
1054
- return 1;
1055
- }
1056
- if (failure?.kind === "conflict") {
1171
+ catch (error) {
1172
+ done(line);
1057
1173
  /*
1058
- Somebody else saved to this project first. Everything that did not
1059
- overlap has already been combined by the service; what is left is a
1060
- genuine decision, so the answer is to bring their work down and look
1061
- at it rather than to try again harder.
1174
+ Classified by the shared description rather than by a private regular
1175
+ expression, so a push and a submit disagree about nothing — including
1176
+ what counts as "the connection failed" and what that means for whether
1177
+ the work was saved.
1062
1178
  */
1063
- console.error(red(error instanceof Error ? error.message : String(error)));
1064
- console.error(`\nRun ${accent("cbx get")} to bring the latest version down, then` +
1065
- ` submit again. Nothing was changed on your account.`);
1066
- return 1;
1179
+ const failure = (0, publish_js_1.classifyPublishFailure)(error);
1180
+ const text = (0, network_js_1.describeError)(error);
1181
+ if (failure?.kind === "interrupted" && !dryRun && resumed < resumeAfter.length) {
1182
+ const wait = resumeAfter[resumed];
1183
+ console.error(dim(`The connection dropped (${text})`));
1184
+ console.error(dim(`Picking the upload up again in ${wait}s (try ${resumed + 2} of ${resumeAfter.length + 1}).` +
1185
+ ` Pieces already sent are kept; only the rest goes.`));
1186
+ /*
1187
+ The failed attempt's other lanes are still retrying; stopped first, so
1188
+ they do not carry on sending beside the attempt that replaces them.
1189
+ */
1190
+ uploader.cancel();
1191
+ await (0, network_js_1.pause)(wait * 1000);
1192
+ uploader = new upload_js_1.Uploader(config_js_1.credentials);
1193
+ continue;
1194
+ }
1195
+ if (failure?.kind === "interrupted") {
1196
+ console.error(red(`
1197
+ The connection failed: ${text}`));
1198
+ console.error(`Your work may already have been saved. Run the same command again —` +
1199
+ ` it will not create a second version.`);
1200
+ return 1;
1201
+ }
1202
+ if (failure?.kind === "conflict") {
1203
+ /*
1204
+ Somebody else saved to this project first. Everything that did not
1205
+ overlap has already been combined by the service; what is left is a
1206
+ genuine decision, so the answer is to bring their work down and look
1207
+ at it rather than to try again harder.
1208
+ */
1209
+ console.error(red(error instanceof Error ? error.message : String(error)));
1210
+ console.error(`\nRun ${accent("cbx get")} to bring the latest version down, then` +
1211
+ ` submit again. Nothing was changed on your account.`);
1212
+ return 1;
1213
+ }
1214
+ throw error;
1067
1215
  }
1068
- throw error;
1069
1216
  }
1070
1217
  done(line);
1071
1218
  if (result.mergeTrack) {
@@ -1167,6 +1314,7 @@ The connection failed: ${text}`));
1167
1314
  */
1168
1315
  ` · the save holds ${bytes(result.sourceBytes)}`);
1169
1316
  noteKeptDeletions(folder, baseline, hasFlag(parsed, "sync"));
1317
+ noteGitRoute(folder, link?.slug ?? adopt?.slug ?? (0, folder_match_js_1.slugFor)(flagText(parsed, "name") ?? node_path_1.default.basename(folder)));
1170
1318
  /*
1171
1319
  Where the time went, when asked for.
1172
1320
 
@@ -1198,6 +1346,8 @@ async function commandGet(parsed) {
1198
1346
  const { link } = await reconcile(folder);
1199
1347
  if (!link) {
1200
1348
  console.error(red("This folder is not linked to a project on your account."));
1349
+ console.error(dim(" Link it with ") + accent("cbx link <project>") +
1350
+ dim(", or fetch a project into a new folder with ") + accent("cbx clone <project>") + dim("."));
1201
1351
  return 1;
1202
1352
  }
1203
1353
  /*
@@ -2065,7 +2215,10 @@ const SPECS = [
2065
2215
  usage: 'submit [folder] -m "…"',
2066
2216
  detail: "Sends everything that changed since the last save. If the folder is\n" +
2067
2217
  "not linked to a project yet, one is created on your account, named\n" +
2068
- "after the folder and private to begin with.\n\n" +
2218
+ "after the folder and private to begin with — unless your account\n" +
2219
+ "already has a project with a name like it. Then you are asked which\n" +
2220
+ "you mean, and a script or assistant must say: --into <project> saves\n" +
2221
+ "into it, --new starts a separate one.\n\n" +
2069
2222
  "A save is not a version. Nobody outside the project can see one until\n" +
2070
2223
  "it is named, which is what `cbx release` does — so submitting is as\n" +
2071
2224
  "cheap and as private as you want it to be.",
@@ -2076,6 +2229,14 @@ const SPECS = [
2076
2229
  flags: "--name <name>",
2077
2230
  description: "name a new project this, instead of after the folder",
2078
2231
  },
2232
+ {
2233
+ flags: "--into <project>",
2234
+ description: "first save from this folder: save into this project and link the folder to it",
2235
+ },
2236
+ {
2237
+ flags: "--new",
2238
+ description: "first save from this folder: start a new project even if one has a similar name",
2239
+ },
2079
2240
  {
2080
2241
  flags: "--allow-secrets",
2081
2242
  description: "send files that look like credentials, and files with keys inside",
@@ -2095,6 +2256,7 @@ const SPECS = [
2095
2256
  ],
2096
2257
  examples: [
2097
2258
  'cbx submit -m "Fix the export dialog"',
2259
+ 'cbx submit --into voxai-coder -m "First save from the Linux machine"',
2098
2260
  'cbx submit --track spike -m "Try the other encoder"',
2099
2261
  ],
2100
2262
  run: commandSubmit,
@@ -2211,6 +2373,20 @@ const SPECS = [
2211
2373
  examples: ["cbx switch", "cbx switch -c spike", "cbx switch main"],
2212
2374
  run: (0, local_commands_js_1.localFirst)(local_commands_js_1.localSwitch, track_commands_js_1.commandTrack),
2213
2375
  },
2376
+ {
2377
+ name: "link",
2378
+ group: "Working with a folder",
2379
+ summary: "connect this folder to a project already on your account",
2380
+ usage: "link <project> [folder]",
2381
+ detail: "Says which project this folder is a copy of, without sending or\n" +
2382
+ "fetching anything. Use it for a folder that holds a project you saved\n" +
2383
+ "from somewhere else — another machine, another operating system, or a\n" +
2384
+ "copy made by hand — so its next save goes into that project instead\n" +
2385
+ "of starting a new one. Files that differ here are what the next save\n" +
2386
+ "sends; `cbx status` shows them first.",
2387
+ examples: ["cbx link voxai-coder", "cbx link my-project ./copy"],
2388
+ run: commandLink,
2389
+ },
2214
2390
  {
2215
2391
  name: "unlink",
2216
2392
  group: "Working with a folder",
@@ -3126,6 +3302,34 @@ function localTwin(name, original, local, row, extra) {
3126
3302
  run: (0, local_commands_js_1.localFirst)(local, spec.run),
3127
3303
  };
3128
3304
  }
3305
+ SPECS.push({
3306
+ name: "build",
3307
+ group: "Actions",
3308
+ summary: "build executables for Windows, Linux and macOS on this machine",
3309
+ usage: "build [folder] [--target win,linux,mac]",
3310
+ detail: "Builds the project in this folder for each target, using whatever this\n" +
3311
+ "machine has: Go, Bun, Deno and .NET build every platform from any one;\n" +
3312
+ "Rust does with cargo-zigbuild; Electron builds Windows and Linux in\n" +
3313
+ "Docker; PyInstaller builds Linux in Docker or WSL. A target this machine\n" +
3314
+ "cannot build is named, with what would build it. Nothing is uploaded\n" +
3315
+ "unless --ship is given. Output goes to .coderook/build/<target>/.\n\n" +
3316
+ "The toolchain is recognised from the files here. To fix it, or to give\n" +
3317
+ "a command of your own, describe the build in coderook.build.json\n" +
3318
+ "(cbx build init writes one), which is saved with the project.",
3319
+ options: [
3320
+ { flags: "-t, --target <list>", description: "windows-x64, linux-arm64, macos-arm64 … or win, linux, mac, all" },
3321
+ { flags: "--plan", description: "say how each target would be built, build nothing" },
3322
+ { flags: "--preset <name>", description: "go, bun, deno, rust, dotnet, electron, pyinstaller or custom" },
3323
+ { flags: "--ship", description: "attach what was built to the project's newest save" },
3324
+ ],
3325
+ examples: [
3326
+ "cbx build --plan",
3327
+ "cbx build --target win,linux,mac",
3328
+ "cbx build --target all --ship",
3329
+ "cbx build init",
3330
+ ],
3331
+ run: build_command_js_1.commandBuild,
3332
+ });
3129
3333
  SPECS.push(localTwin("push", "submit", local_commands_js_1.localPush, {
3130
3334
  summary: "send this line's saves to CodeRook",
3131
3335
  usage: "push",
@@ -402,6 +402,12 @@ async function forgetLink(localPath) {
402
402
  await writePrivate(storedFileFor(key), JSON.stringify(forgotten));
403
403
  }
404
404
  await (0, promises_1.rm)(folderLinkFile(localPath), { force: true });
405
+ /* The folder CodeRook made for the link goes too, unless it holds anything else. */
406
+ const directory = node_path_1.default.dirname(folderLinkFile(localPath));
407
+ const left = await (0, promises_1.readdir)(directory).catch(() => null);
408
+ if (left && left.length === 1 && left[0] === ".gitignore") {
409
+ await (0, promises_1.rm)(directory, { recursive: true, force: true });
410
+ }
405
411
  return true;
406
412
  }
407
413
  async function writeLink(localPath, link) {
@@ -0,0 +1,62 @@
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.slugFor = slugFor;
7
+ exports.similarProjects = similarProjects;
8
+ exports.pathsPresent = pathsPresent;
9
+ /*
10
+ Which of the account's projects a folder that is not linked might be.
11
+
12
+ A save from an unlinked folder used to decide this alone: a project with the
13
+ folder's name was joined without a word, and anything else started a new
14
+ project without one either. Both went wrong on a real account. An inner
15
+ folder called `voxai-coder` was joined to the project of the same name and
16
+ laid a second copy of the app over its root for thirteen versions; a Linux
17
+ machine that did not know the folder was linked started `voxai-coder-linux`
18
+ beside it. Neither was a choice anybody made.
19
+
20
+ So this only finds the candidates. Choosing between them is the person's.
21
+ */
22
+ const node_fs_1 = require("node:fs");
23
+ const node_path_1 = __importDefault(require("node:path"));
24
+ /** The slug the service gives a name: what a new project would be called. */
25
+ function slugFor(name) {
26
+ return (name
27
+ .trim()
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9]+/g, "-")
30
+ .replace(/^-|-$/g, "") || "project");
31
+ }
32
+ /**
33
+ * Projects whose name is this one, or this one with a word added or taken
34
+ * away: `voxai-coder` finds `voxai-coder-linux`, and the other way round.
35
+ *
36
+ * Words, not letters. `app` is not like `apple`, and a project somebody
37
+ * named `wallpaper-picker-backend` on purpose is offered, never assumed.
38
+ */
39
+ function similarProjects(all, name) {
40
+ const wanted = slugFor(name);
41
+ const related = (slug) => slug.startsWith(`${wanted}-`) || wanted.startsWith(`${slug}-`);
42
+ return all
43
+ .filter((project) => project.slug === wanted || related(project.slug))
44
+ .map((project) => ({ project, sameName: project.slug === wanted }))
45
+ .sort((left, right) => Number(right.sameName) - Number(left.sameName))
46
+ .slice(0, 5);
47
+ }
48
+ /**
49
+ * How many of a project's files are in this folder at the same path.
50
+ *
51
+ * Paths only, and only counted: reading every file to compare digests would
52
+ * make the question cost a full scan, and a folder that holds most of a
53
+ * project's paths is already the answer somebody needs to choose.
54
+ */
55
+ function pathsPresent(folder, paths) {
56
+ let present = 0;
57
+ for (const file of paths) {
58
+ if ((0, node_fs_1.existsSync)(node_path_1.default.join(folder, ...file.split("/"))))
59
+ present += 1;
60
+ }
61
+ return present;
62
+ }