@coderook/cli 0.33.1 → 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
  /*
@@ -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.
@@ -1195,6 +1314,7 @@ The connection failed: ${text}`));
1195
1314
  */
1196
1315
  ` · the save holds ${bytes(result.sourceBytes)}`);
1197
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)));
1198
1318
  /*
1199
1319
  Where the time went, when asked for.
1200
1320
 
@@ -1226,6 +1346,8 @@ async function commandGet(parsed) {
1226
1346
  const { link } = await reconcile(folder);
1227
1347
  if (!link) {
1228
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("."));
1229
1351
  return 1;
1230
1352
  }
1231
1353
  /*
@@ -2093,7 +2215,10 @@ const SPECS = [
2093
2215
  usage: 'submit [folder] -m "…"',
2094
2216
  detail: "Sends everything that changed since the last save. If the folder is\n" +
2095
2217
  "not linked to a project yet, one is created on your account, named\n" +
2096
- "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" +
2097
2222
  "A save is not a version. Nobody outside the project can see one until\n" +
2098
2223
  "it is named, which is what `cbx release` does — so submitting is as\n" +
2099
2224
  "cheap and as private as you want it to be.",
@@ -2104,6 +2229,14 @@ const SPECS = [
2104
2229
  flags: "--name <name>",
2105
2230
  description: "name a new project this, instead of after the folder",
2106
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
+ },
2107
2240
  {
2108
2241
  flags: "--allow-secrets",
2109
2242
  description: "send files that look like credentials, and files with keys inside",
@@ -2123,6 +2256,7 @@ const SPECS = [
2123
2256
  ],
2124
2257
  examples: [
2125
2258
  'cbx submit -m "Fix the export dialog"',
2259
+ 'cbx submit --into voxai-coder -m "First save from the Linux machine"',
2126
2260
  'cbx submit --track spike -m "Try the other encoder"',
2127
2261
  ],
2128
2262
  run: commandSubmit,
@@ -2239,6 +2373,20 @@ const SPECS = [
2239
2373
  examples: ["cbx switch", "cbx switch -c spike", "cbx switch main"],
2240
2374
  run: (0, local_commands_js_1.localFirst)(local_commands_js_1.localSwitch, track_commands_js_1.commandTrack),
2241
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
+ },
2242
2390
  {
2243
2391
  name: "unlink",
2244
2392
  group: "Working with a folder",
@@ -3154,6 +3302,34 @@ function localTwin(name, original, local, row, extra) {
3154
3302
  run: (0, local_commands_js_1.localFirst)(local, spec.run),
3155
3303
  };
3156
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
+ });
3157
3333
  SPECS.push(localTwin("push", "submit", local_commands_js_1.localPush, {
3158
3334
  summary: "send this line's saves to CodeRook",
3159
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
+ }
@@ -934,7 +934,7 @@ depth = null) {
934
934
  await (0, promises_1.rm)(scratch, { recursive: true, force: true });
935
935
  }
936
936
  }
937
- async function doPush(requests, url) {
937
+ async function doPush(requests, url, options = {}) {
938
938
  /*
939
939
  A push never goes to the account named in the URL — it goes to the one the
940
940
  token belongs to. Publishing somebody's work to a different account on
@@ -988,6 +988,19 @@ async function doPush(requests, url) {
988
988
  if (!repositoryId)
989
989
  repositoryId = (await (0, api_js_1.findProject)(slug))?.id ?? null;
990
990
  state = repositoryId ? await remoteState(repositoryId) : null;
991
+ /*
992
+ A clone knows commits the messages do not name: the ones it built when
993
+ it imported each version. Counted as published, so a branch started in
994
+ a clone forks from the version it was cloned at — and a clone of a
995
+ project saved with `cbx submit` is not mistaken for unrelated history.
996
+ */
997
+ if (state && marks) {
998
+ for (const [versionId, mark] of marks.ofVersion) {
999
+ const sha = marks.sha.get(mark);
1000
+ if (sha && !state.versionOfSha.has(sha))
1001
+ state.versionOfSha.set(sha, versionId);
1002
+ }
1003
+ }
991
1004
  };
992
1005
  const knownRefs = () => {
993
1006
  const found = new Map();
@@ -1154,11 +1167,36 @@ async function doPush(requests, url) {
1154
1167
  the ordinary act of pushing a second branch to a project git already
1155
1168
  owns, which is the common case and not a conflict of any kind.
1156
1169
  */
1170
+ /*
1171
+ Unless the person says this repository *is* that project.
1172
+
1173
+ The usual way here is a working repository whose CodeRook project was
1174
+ started with `cbx submit` or the desktop app: same files, different
1175
+ history. Refusing outright sent people to make a second project, which
1176
+ is the duplicate this exists to prevent. `-o adopt` takes the middle
1177
+ road: the project's saves stay as they are, this commit's tree is saved
1178
+ on top as one save that names the commit, and from then on the project
1179
+ and the repository agree, so later pushes add one save per commit.
1180
+ */
1181
+ let adopting = null;
1157
1182
  if (state && !already && state.versionCount > 0 && state.versionOfSha.size === 0) {
1158
- send(`error ${request.dst} the project "${slug}" already has ${state.versionCount} version${state.versionCount === 1 ? "" : "s"} that did not come from git; pushing would publish this history a second time`);
1159
- say(`\n Push to a new project instead:\n` +
1160
- ` git remote set-url coderook coderook://<a-new-name>\n`);
1161
- continue;
1183
+ const line = state.tracks.find((candidate) => candidate.name === branch && candidate.kind === "line");
1184
+ if (options.adopt && line?.headVersionId) {
1185
+ adopting = { headVersionId: line.headVersionId };
1186
+ say(` adopting "${slug}": its ${state.versionCount} save${state.versionCount === 1 ? "" : "s"} stay as they are;` +
1187
+ ` ${tip.slice(0, 8)} is saved on top as one save,\n and later pushes add one save per commit.`);
1188
+ }
1189
+ else {
1190
+ send(`error ${request.dst} the project "${slug}" already has ${state.versionCount} version${state.versionCount === 1 ? "" : "s"} that did not come from git; pushing would publish this history a second time`);
1191
+ say(options.adopt
1192
+ ? `\n The project has no line called "${branch}" to adopt. Push the branch it saves on, usually main.\n`
1193
+ : `\n If this repository holds that project, save it on top and link the two:\n` +
1194
+ ` git push -o adopt coderook ${branch}\n` +
1195
+ ` Your git history is not replayed; the commit you push becomes one save,\n` +
1196
+ ` and every push after it sends one save per commit.\n\n` +
1197
+ ` Or start from the project instead: git clone coderook://${slug}\n`);
1198
+ continue;
1199
+ }
1162
1200
  }
1163
1201
  /*
1164
1202
  A branch the project has never seen starts at the newest commit it
@@ -1173,10 +1211,12 @@ async function doPush(requests, url) {
1173
1211
  }
1174
1212
  const from = already ?? fork?.sha ?? null;
1175
1213
  const range = from ? `${from}..${tip}` : tip;
1176
- const commits = git(["rev-list", "--reverse", "--topo-order", range])
1177
- .split("\n")
1178
- .map((line) => line.trim())
1179
- .filter(Boolean);
1214
+ const commits = adopting
1215
+ ? [tip]
1216
+ : git(["rev-list", "--reverse", "--topo-order", range])
1217
+ .split("\n")
1218
+ .map((line) => line.trim())
1219
+ .filter(Boolean);
1180
1220
  if (!commits.length) {
1181
1221
  /*
1182
1222
  Nothing to publish does not mean nothing to do. A branch whose tip
@@ -1236,7 +1276,14 @@ async function doPush(requests, url) {
1236
1276
  let previous = git_history_js_1.EMPTY_TREE;
1237
1277
  let currentRepositoryId = repositoryId;
1238
1278
  let published = 0;
1239
- if (from && repositoryId && state) {
1279
+ if (adopting && repositoryId) {
1280
+ baseVersionId = adopting.headVersionId;
1281
+ const { Downloader } = await Promise.resolve().then(() => __importStar(require("../../cbx/src/core/download.js")));
1282
+ const held = await new Downloader(config_js_1.credentials).files(repositoryId, baseVersionId);
1283
+ manifest = Object.fromEntries(held.map((file) => [file.path, file.sha256]));
1284
+ local = { ...manifest };
1285
+ }
1286
+ else if (from && repositoryId && state) {
1240
1287
  previous = from;
1241
1288
  if (already) {
1242
1289
  const track = state.tracks.find((candidate) => candidate.name === branch && candidate.kind === "line");
@@ -1281,6 +1328,17 @@ async function doPush(requests, url) {
1281
1328
  }
1282
1329
  const changes = await applyCommit(catFile, scratch, previous, sha);
1283
1330
  previous = sha;
1331
+ if (adopting) {
1332
+ /*
1333
+ The commit's tree is the whole truth: what the project holds and
1334
+ the commit does not is removed, exactly as a later push would.
1335
+ */
1336
+ const inTree = new Set(git(["ls-tree", "-r", "-z", "--name-only", sha]).split("\0").filter(Boolean));
1337
+ for (const file of Object.keys(manifest)) {
1338
+ if (!inTree.has(file) && !changes.deleted.includes(file))
1339
+ changes.deleted.push(file);
1340
+ }
1341
+ }
1284
1342
  if (changes.skipped.length) {
1285
1343
  say(` skipped ${changes.skipped.length} submodule path(s) in ${sha.slice(0, 8)}`);
1286
1344
  }
@@ -1463,6 +1521,8 @@ async function main(argv) {
1463
1521
  let unservable = false;
1464
1522
  /** `git clone --depth N`, when git passed one. */
1465
1523
  let depth = null;
1524
+ /** `git push -o adopt`. */
1525
+ let adopt = false;
1466
1526
  for await (const line of lines()) {
1467
1527
  const command = line.trim();
1468
1528
  if (command === "capabilities") {
@@ -1486,6 +1546,11 @@ async function main(argv) {
1486
1546
  depth = Number(value);
1487
1547
  send("ok");
1488
1548
  }
1549
+ else if (name === "push-option" && value === "adopt") {
1550
+ /* `git push -o adopt`: see the adopting note in doPush. */
1551
+ adopt = true;
1552
+ send("ok");
1553
+ }
1489
1554
  else {
1490
1555
  send("unsupported");
1491
1556
  }
@@ -1633,7 +1698,7 @@ async function main(argv) {
1633
1698
  send("");
1634
1699
  continue;
1635
1700
  }
1636
- await doPush(batch, url);
1701
+ await doPush(batch, url, { adopt });
1637
1702
  }
1638
1703
  continue;
1639
1704
  }
@@ -32,6 +32,7 @@ const repository_js_1 = require("../../cbx/src/local/repository.js");
32
32
  const remote_js_1 = require("../../cbx/src/local/remote.js");
33
33
  const api_js_1 = require("./api.js");
34
34
  const config_js_1 = require("./config.js");
35
+ const project_choice_js_1 = require("./project_choice.js");
35
36
  const project_commands_js_1 = require("./project_commands.js");
36
37
  const publish_js_1 = require("./publish.js");
37
38
  const ESC = String.fromCharCode(27);
@@ -363,11 +364,31 @@ async function recordLink(repository, line, repositoryId, head, manifest) {
363
364
  async function localPush(parsed, repository) {
364
365
  return reporting(async () => {
365
366
  const link = await (0, config_js_1.readLink)(repository.root);
367
+ /*
368
+ A history that has never been pushed, in a folder linked to nothing,
369
+ is asked which project it belongs to, as `cbx submit` asks: naming it
370
+ after the folder joined any project that shared the name.
371
+ */
372
+ let chosen = null;
373
+ if (!link && !(await (0, remote_js_1.readRemote)(repository)).repositoryId) {
374
+ const into = parsed.flags.get("into");
375
+ const named = parsed.flags.get("name");
376
+ chosen = await (0, project_choice_js_1.chooseProject)({
377
+ folder: repository.root,
378
+ name: typeof named === "string" ? named : node_path_1.default.basename(repository.root),
379
+ ...(typeof into === "string" ? { into } : {}),
380
+ startNew: has(parsed, "new"),
381
+ command: "cbx push",
382
+ });
383
+ if (!chosen)
384
+ return 1;
385
+ }
366
386
  const interactive = node_process_1.default.stdout.isTTY;
367
387
  const result = await (0, remote_js_1.push)(repository, {
368
388
  credentials: config_js_1.credentials,
369
- projectName: link?.slug ?? node_path_1.default.basename(repository.root),
370
- repositoryId: link?.repositoryId ?? null,
389
+ projectName: link?.slug ?? (chosen ? ("id" in chosen ? chosen.slug : chosen.name) : node_path_1.default.basename(repository.root)),
390
+ repositoryId: link?.repositoryId ?? (chosen && "id" in chosen ? chosen.id : null),
391
+ joinExisting: false,
371
392
  allowSecrets: has(parsed, "allow-secrets"),
372
393
  acknowledged: has(parsed, "yes", "y"),
373
394
  report: (event) => {
@@ -23,6 +23,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
23
23
  };
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
25
  exports.commandMcp = commandMcp;
26
+ const node_fs_1 = require("node:fs");
26
27
  const promises_1 = require("node:fs/promises");
27
28
  const node_os_1 = __importDefault(require("node:os"));
28
29
  const node_path_1 = __importDefault(require("node:path"));
@@ -236,9 +237,17 @@ const TOOLS = [
236
237
  : node_process_1.default.cwd());
237
238
  const link = await (0, config_js_1.readLink)(folder);
238
239
  const rules = await (0, worktree_js_1.readRules)(folder);
239
- const changed = await (0, worktree_js_1.changedFiles)(folder, rules, null);
240
+ /*
241
+ Measured against what the folder last held, as `cbx status` measures.
242
+ Passing nothing here called every file in the folder new.
243
+ */
244
+ const baseline = link?.manifest
245
+ ? new Map(Object.entries(link.local ?? link.manifest))
246
+ : null;
247
+ const changed = await (0, worktree_js_1.changedFiles)(folder, rules, baseline);
248
+ const saving = howToSave(folder, link?.slug ?? null);
240
249
  if (!changed.length)
241
- return "Nothing here has changed since the last version.";
250
+ return `Nothing here has changed since the last version.\n${saving}`;
242
251
  /*
243
252
  Said in the words the scan actually produces: a file the folder no
244
253
  longer has is a deletion, one with nothing removed is new, and the
@@ -256,10 +265,37 @@ const TOOLS = [
256
265
  lines.push(`… and ${changed.length - lines.length} more`);
257
266
  }
258
267
  const where = link ? `${link.slug} (v${link.sequence})` : "not linked to a project";
259
- return `${folder} — ${where}\n${changed.length} changed\n${lines.join("\n")}`;
268
+ return `${folder} — ${where}\n${changed.length} changed\n${lines.join("\n")}\n${saving}`;
260
269
  },
261
270
  },
262
271
  ];
272
+ /**
273
+ * Which way this folder is saved, said to the assistant reading the status.
274
+ *
275
+ * Saving stays something a person agrees to, so this names the command and
276
+ * runs nothing. A git repository is pointed at git: each commit then keeps its
277
+ * own message, where `cbx submit` would make one snapshot of all of them.
278
+ */
279
+ function howToSave(folder, slug) {
280
+ let config = null;
281
+ try {
282
+ config = (0, node_fs_1.readFileSync)(node_path_1.default.join(folder, ".git", "config"), "utf8");
283
+ }
284
+ catch {
285
+ /* Not a git repository. */
286
+ }
287
+ if (config !== null) {
288
+ const remote = config.match(/\[remote "([^"]+)"\][^[]*?url\s*=\s*coderook:/);
289
+ if (remote)
290
+ return `To save: commit, then git push ${remote[1]} <branch> (ask first).`;
291
+ return (`This is a git repository with no CodeRook remote. To save each commit: ` +
292
+ `git remote add coderook coderook://${slug ?? "<project>"}, then git push coderook <branch> ` +
293
+ `(git push -o adopt the first time if the project was saved with cbx submit). Ask first.`);
294
+ }
295
+ if (slug)
296
+ return `To save: cbx submit -m "…" (ask first).`;
297
+ return `Not linked. To save, ask which project it is: cbx submit --into <project> or cbx submit --new.`;
298
+ }
263
299
  /* ------------------------------------------------------------------ wire */
264
300
  function reply(id, result) {
265
301
  node_process_1.default.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`);