carrick 0.3.118 → 0.3.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/init/run.js CHANGED
@@ -27,7 +27,7 @@ import { recordCliVersion } from "./outdated.js";
27
27
  import { findCarrick, offerGlobalInstall } from "../global-install.js";
28
28
  import { currentVersion } from "../update.js";
29
29
  import { writeIfChanged } from "./files.js";
30
- import { excludedRepos, writeSelection, WORKSPACE_FILE } from "./workspace-file.js";
30
+ import { excludedRepos, readWorkspaceFile, removeSelection, writeSelection, WORKSPACE_FILE } from "./workspace-file.js";
31
31
  import { createOutput, DOCS_EDITOR, DOCS_INDEX, DOCS_INIT_FILES, listBlock, PromptCancelled, tilde, } from "./output.js";
32
32
  import { renderTemplate, TEMPLATE_PATHS } from "../templates.js";
33
33
  /**
@@ -92,6 +92,7 @@ export function parseArgs(argv, cwd = process.cwd()) {
92
92
  assumeYes: false,
93
93
  allowMove: false,
94
94
  installGlobal: false,
95
+ local: false,
95
96
  };
96
97
  for (let index = 0; index < argv.length; index += 1) {
97
98
  const argument = argv[index];
@@ -106,6 +107,9 @@ export function parseArgs(argv, cwd = process.cwd()) {
106
107
  case "--allow-move":
107
108
  options.allowMove = true;
108
109
  break;
110
+ case "--local":
111
+ options.local = true;
112
+ break;
109
113
  case "--project": {
110
114
  const value = argv[index + 1];
111
115
  if (!value)
@@ -166,7 +170,7 @@ export function parseArgs(argv, cwd = process.cwd()) {
166
170
  }
167
171
  function help() {
168
172
  return [
169
- "carrick init [DIRECTORY] [--repo OWNER/REPO]... [--project SLUG] [--mcp EDITOR] [--allow-move]",
173
+ "carrick init [DIRECTORY] [--repo OWNER/REPO]... [--project SLUG] [--mcp EDITOR] [--allow-move] [--local]",
170
174
  "",
171
175
  "Sign in (here, or beforehand with carrick login), then set up a repository",
172
176
  "or a folder of repos: which repos this install covers, the project, the repo",
@@ -178,7 +182,9 @@ function help() {
178
182
  "machine can answer from it. That read re-reads your source here first, which",
179
183
  "takes minutes on a large workspace. It skips the re-read when .carrick already",
180
184
  "holds an index current for this checkout. Nothing is written, here or in",
181
- "Carrick, until you accept what it proposes.",
185
+ "Carrick, until you accept what it proposes. Where Carrick builds the index for",
186
+ "your workspace, init asks nothing after sign-in: it sets up the repos here that",
187
+ "the workspace holds, lists what it adds, and reads their index in.",
182
188
  "",
183
189
  " -w, --workspace DIR The folder holding the repos (default: this one)",
184
190
  " --repo OWNER/REPO A repo this install covers: repeatable, or one",
@@ -199,6 +205,9 @@ function help() {
199
205
  " --install-global Install carrick on this machine where there is none,",
200
206
  " so the agent hooks can run it by name after this run",
201
207
  " ends. A terminal is asked instead; --yes is not this",
208
+ " --local Set up for an index built on this machine: choose the",
209
+ " repos and the project here, and get the scan steps for",
210
+ " your agent",
202
211
  "",
203
212
  `What init installs, the hooks included: ${DOCS_INIT_FILES}`,
204
213
  `CI and a carrick.json written by hand: ${DOCS_INDEX}`,
@@ -298,11 +307,15 @@ export function installCommands(plan) {
298
307
  /**
299
308
  * The sentence that installs them, when there is anything to install: an
300
309
  * instruction to the agent, or a line to the reader when no instruction is
301
- * printed.
310
+ * printed. On a workspace whose index Carrick builds there is no next scan on
311
+ * this machine, so that reader's line ends on the read instead ("cloud").
302
312
  */
303
313
  export function installSentence(commands, to) {
304
314
  if (commands.length === 0)
305
315
  return null;
316
+ if (to === "cloud") {
317
+ return `Dependencies are not installed, so the index here was read without their types. Run ${listed(commands, 5)}, then carrick refresh.`;
318
+ }
306
319
  return to === "agent"
307
320
  ? `First install dependencies: run ${listed(commands, 5)}.`
308
321
  : `Dependencies are not installed. Run ${listed(commands, 5)} before the next scan.`;
@@ -606,6 +619,143 @@ export async function chooseEditors(offered, options, out) {
606
619
  hint: client.installed ? client.file : `${client.file}, not detected on this machine`,
607
620
  })), { initial: offered.filter((client) => client.installed).map((client) => client.name), required: false });
608
621
  }
622
+ /** The GitHub repos these folders name, once each. */
623
+ function repoNames(repos) {
624
+ return [...new Set(repos.map((repo) => repo.name).filter((name) => name !== null))];
625
+ }
626
+ /** Whether two lists name the same GitHub repos, as GitHub compares them. */
627
+ function sameRepos(left, right) {
628
+ const names = new Set(left.map((name) => name.toLowerCase()));
629
+ return names.size === new Set(right.map((name) => name.toLowerCase())).size && right.every((name) => names.has(name.toLowerCase()));
630
+ }
631
+ /**
632
+ * What a run inside one repo says about the folder above, where it does not
633
+ * ask about it: the repos beside this one, or a folder that is itself a repo.
634
+ */
635
+ function aloneLines(current, parent, siblings, out) {
636
+ if (siblings.length > 0) {
637
+ out.say(thisRepoOnly(path.basename(current.path)));
638
+ }
639
+ else if (parent.repos.some((repo) => realPath(repo) === realPath(parent.directory))) {
640
+ // Not a folder of repos but a repository this directory sits inside,
641
+ // which is where a scan of it belongs: the one case where "run it in
642
+ // the folder above" is still the answer.
643
+ out.warn(`The parent folder ${parent.directory} is itself a repo. Run carrick init .. to initialise that workspace.`);
644
+ }
645
+ }
646
+ /**
647
+ * The repos that name no GitHub repository, said before anything is requested
648
+ * for the others (carrick#991).
649
+ *
650
+ * A repo with no identity is left out of the project step, the connection
651
+ * check and the workspace read, and a run that dropped it in silence read as a
652
+ * complete one. A folder of clones can hold dozens, so the list is capped and
653
+ * the rest is counted rather than dropped.
654
+ */
655
+ function warnMissingIdentities(missing, out) {
656
+ for (const repo of missing.slice(0, 10)) {
657
+ out.warn(`${repo.path} contributes no GitHub identity: ${repo.problem}.`);
658
+ }
659
+ if (missing.length > 10) {
660
+ out.warn(`${missing.length - 10} more repos here name no GitHub identity either.`);
661
+ }
662
+ if (missing.length > 0) {
663
+ const one = missing.length === 1;
664
+ // The ssh fix is said as the one it is: "the alias" named nothing for a
665
+ // repo with no remote at all (carrick#1574).
666
+ out.warn(`Carrick has nothing to connect ${one ? "it" : "them"} to. Run carrick init --repo owner/repo${one ? "" : " in each of them"}, or, for an origin on an ssh alias, give that alias a HostName github.com line in your ssh config.`);
667
+ }
668
+ }
669
+ /** What a run with no credential and no terminal to sign in from stops on. */
670
+ const LOGIN_REQUIRED = "carrick init requires a Carrick login. Run carrick login, or set CARRICK_TOKEN.";
671
+ /**
672
+ * Decide a run on a workspace whose index Carrick builds, asking nothing.
673
+ *
674
+ * The laptop lane asks which repos, which project, which editors and whether
675
+ * to go ahead, because each answer starts something: a project, a move, a
676
+ * scan somebody pays for. Here the workspace has already answered all of
677
+ * them, so the run reads the answers and prints what it adds. It writes
678
+ * nothing: a stop here leaves the machine as it was, like every stop before
679
+ * the yes on the other lane.
680
+ */
681
+ async function cloudLane(input) {
682
+ const { parsed, workspace, derived, candidates, identity, credential, out } = input;
683
+ const urls = workspaceUrls(identity.workspace.slug);
684
+ const parent = derived.plan.parent_proposal;
685
+ if (parent !== null && candidates.length === 1) {
686
+ aloneLines(candidates[0], parent, siblingRepos(derived.plan, excludedRepos(parent.directory)), out);
687
+ }
688
+ // `--repo` still narrows the run, and names nothing the reader did not.
689
+ const chosen = parsed.repos.length === 0
690
+ ? candidates
691
+ : await chooseRepos(derived.plan, candidates, { repos: parsed.repos, assumeYes: true, interactive: false }, out);
692
+ const held = (repo) => {
693
+ const row = identity.repos.find((entry) => entry.full_name.toLowerCase() === repo.name?.toLowerCase());
694
+ return row?.connected === true ? row : null;
695
+ };
696
+ const selected = chosen.filter((repo) => held(repo) !== null);
697
+ warnMissingIdentities(chosen.filter((repo) => repo.name === null), out);
698
+ if (selected.length === 0) {
699
+ out.refuse(`None of the repos here is in your Carrick workspace ${identity.workspace.slug}. Add them at ${urls.repos}. If they are in another workspace, run carrick logout, then carrick init, and choose that workspace when you sign in.`);
700
+ throw new Stop(1);
701
+ }
702
+ const groups = [];
703
+ for (const repo of selected) {
704
+ const slug = held(repo).project_slug;
705
+ const group = groups.find((entry) => entry.slug === slug);
706
+ if (group === undefined)
707
+ groups.push({ slug, repos: [path.basename(repo.path)] });
708
+ else
709
+ group.repos.push(path.basename(repo.path));
710
+ }
711
+ const project = groups.length === 1 ? groups[0].slug : null;
712
+ // The project is read here and changed on the dashboard, so a flag that
713
+ // would move a repo is refused rather than half-honoured.
714
+ if (parsed.project !== null && parsed.project !== project) {
715
+ out.refuse(`On this workspace repos change system on the dashboard: ${urls.repos}. Run carrick init without --project.`);
716
+ throw new Stop(1);
717
+ }
718
+ const absent = repoNames(chosen.filter((repo) => held(repo) === null));
719
+ if (absent.length > 0) {
720
+ const one = absent.length === 1;
721
+ out.warn(`${listed(absent, 10)} ${one ? "is" : "are"} not in your Carrick workspace ${identity.workspace.slug}, so this run leaves ${one ? "it" : "them"} out. Add repos at ${urls.repos}, then run carrick init again.`);
722
+ }
723
+ const names = repoNames(selected);
724
+ const projects = await listProjects(credential.token);
725
+ for (const missing of derived.plan.missing)
726
+ out.warn(`${WORKSPACE_FILE} lists ${missing}, which is not a directory on this machine.`);
727
+ // The editors the picker would have ticked: the ones whose own command
728
+ // answers on this machine. `--mcp` still names them outright.
729
+ const offered = offeredFileClients();
730
+ const editors = parsed.editors.length > 0
731
+ ? await chooseEditors(offered, { editors: parsed.editors, assumeYes: true, interactive: false }, out)
732
+ : offered.filter((client) => client.installed).map((client) => client.name);
733
+ const kept = new Set(selected.map((repo) => repo.path));
734
+ const deselected = candidates.filter((repo) => !kept.has(repo.path)).map((repo) => path.basename(repo.path));
735
+ // The same list the other lane asks "Go ahead?" about, with no question
736
+ // under it: every file is still named before it is written.
737
+ out.say(nextBlock([
738
+ writesLine({
739
+ workspaceFile: deselected.length > 0,
740
+ editorFiles: offered.filter((client) => editors.includes(client.name)).map((client) => client.file),
741
+ claudeCode: claudeCodeFound(),
742
+ folder: selected.some((repo) => realPath(repo.path) !== realPath(workspace)) ? tilde(realPath(workspace)) : null,
743
+ }),
744
+ ]));
745
+ return {
746
+ credential,
747
+ identity,
748
+ derived: selectedProposal(derived, selected.map((repo) => repo.path)),
749
+ selected,
750
+ names,
751
+ deselected,
752
+ projects,
753
+ project,
754
+ groups,
755
+ editors,
756
+ waiting: repoNames(selected.filter((repo) => held(repo).services.length === 0)),
757
+ };
758
+ }
609
759
  export async function init(argv) {
610
760
  return initWith(argv, createOutput(), process.stdin.isTTY === true);
611
761
  }
@@ -671,6 +821,37 @@ export async function initWith(argv, out, interactive) {
671
821
  // The repos this install covers, for the closing line that names what is
672
822
  // still to set up in them.
673
823
  let selectedRepos = [];
824
+ let candidates;
825
+ let saved;
826
+ // The workspace read made before the first question, and the names it was
827
+ // made with (carrick#2219). It is what says who builds this workspace's
828
+ // index, and the questions below reuse it rather than asking again. Null
829
+ // for `--local` and where no repo here names a GitHub repository; its
830
+ // `identity` is null where the read did not answer, which leaves the lane
831
+ // unknown and the run on the laptop lane.
832
+ let early = null;
833
+ // Set where that read says Carrick builds the index.
834
+ let cloud = null;
835
+ // Puts the workspace file back as it was found, for a cloud-lane run that
836
+ // stops before it writes anything.
837
+ let restoreSelection = null;
838
+ // Where a decision that does not end in a yes ends, on either lane.
839
+ const ended = (error) => {
840
+ restoreSelection?.();
841
+ // A question the reader ended stops the run where it stands. Nothing above
842
+ // this line writes, so there is nothing to undo (carrick#1338).
843
+ if (error instanceof PromptCancelled) {
844
+ out.refuse(CANCELLED);
845
+ return 1;
846
+ }
847
+ if (error instanceof Stop) {
848
+ if (error.message !== "")
849
+ process.stderr.write(`carrick init: ${error.message}\n`);
850
+ return error.code;
851
+ }
852
+ process.stderr.write(`carrick init: ${error.message}\n`);
853
+ return 1;
854
+ };
674
855
  try {
675
856
  // What a previous run wrote down, before anything is derived from it: the
676
857
  // scanner has already dropped an excluded repo by the time the proposal
@@ -681,302 +862,348 @@ export async function initWith(argv, out, interactive) {
681
862
  if (asked.length > 0) {
682
863
  throw new Error(`${WORKSPACE_FILE} leaves ${asked.join(" and ")} out of this workspace, so --repo cannot cover ${asked.length === 1 ? "it" : "them"}. Take ${asked.length === 1 ? "that name" : "those names"} out of the exclude list there and run carrick init again.`);
683
864
  }
684
- if (excluded.length > 0) {
685
- out.say(`${WORKSPACE_FILE} leaves ${excluded.join(", ")} out of this workspace, so nothing below covers ${excluded.length === 1 ? "it" : "them"}.`);
865
+ // Said once the lane is known, because on a workspace whose index Carrick
866
+ // builds the names an earlier run recorded are looked at again below. The
867
+ // derivation prints nothing, so on every other run the line is where it
868
+ // always was.
869
+ const leftOut = (names) => {
870
+ if (names.length === 0)
871
+ return;
872
+ out.say(`${WORKSPACE_FILE} leaves ${names.join(", ")} out of this workspace, so nothing below covers ${names.length === 1 ? "it" : "them"}.`);
873
+ };
874
+ // The derivation and the identities are local reads: they name the choice.
875
+ try {
876
+ derived = deriveWorkspace(workspace);
686
877
  }
687
- // The derivation and the identities are local reads: they name the choice,
688
- // and a run nobody has chosen in yet must not have signed anything in.
689
- derived = deriveWorkspace(workspace);
690
- let candidates = derived.plan.repos.map((repo) => repoIdentity(repo.path));
691
- // The repos beside this one, asked about here rather than named with an
692
- // instruction to start again in the folder above (carrick#1512). A yes to
693
- // any of them carries on as if the run had started there: the same
694
- // derivation, the same proposal, and the repos left unticked written to
695
- // the same exclude list a folder run writes.
696
- let ticked = null;
697
- const parent = derived.plan.parent_proposal;
698
- if (parent !== null && candidates.length === 1) {
699
- const current = candidates[0];
700
- const siblings = siblingRepos(derived.plan, excludedRepos(parent.directory));
701
- const asking = siblings.length > 0 &&
702
- siblings.length <= MAX_SIBLINGS &&
703
- interactive &&
704
- !parsed.assumeYes &&
705
- parsed.repos.length === 0;
706
- if (asking) {
707
- const picked = new Set((await chooseSiblings(current, siblings.map((repo) => repoIdentity(repo)), parent.directory, out)).map(realPath));
708
- if (picked.size > 1 || !picked.has(realPath(current.path))) {
709
- workspace = parent.directory;
710
- derived = deriveWorkspace(workspace);
711
- candidates = derived.plan.repos.map((repo) => repoIdentity(repo.path));
712
- const chosen = candidates.filter((repo) => picked.has(realPath(repo.path)));
713
- // This repo first, where the reader kept it: it is the one they
714
- // started in, and every sentence below names it first.
715
- ticked = [
716
- ...chosen.filter((repo) => realPath(repo.path) === realPath(current.path)),
717
- ...chosen.filter((repo) => realPath(repo.path) !== realPath(current.path)),
718
- ];
878
+ catch (error) {
879
+ leftOut(excluded);
880
+ throw error;
881
+ }
882
+ candidates = derived.plan.repos.map((repo) => repoIdentity(repo.path));
883
+ // One read before the first question (carrick#2219): who builds this
884
+ // workspace's index decides whether there are questions at all, and only
885
+ // Carrick can say. So a machine nobody has signed in on signs in here, and
886
+ // a run cancelled at a question after this has minted a key. `--local` is
887
+ // the run that does none of it. The read names the repos of this folder,
888
+ // or the `--repo` values alone: a repo the reader left out is named in no
889
+ // request (carrick#1338).
890
+ saved = readCredential();
891
+ const here = parsed.repos.length > 0 ? parsed.repos : repoNames(candidates);
892
+ if (!parsed.local && here.length > 0) {
893
+ if (saved === null) {
894
+ if (!interactive) {
895
+ leftOut(excluded);
896
+ throw new Error(LOGIN_REQUIRED);
719
897
  }
898
+ saved = await signIn(out.say);
720
899
  }
721
- else if (siblings.length > 0) {
722
- out.say(thisRepoOnly(path.basename(current.path)));
723
- }
724
- else if (parent.repos.some((repo) => realPath(repo) === realPath(parent.directory))) {
725
- // Not a folder of repos but a repository this directory sits inside,
726
- // which is where a scan of it belongs: the one case where "run it in
727
- // the folder above" is still the answer.
728
- out.warn(`The parent folder ${parent.directory} is itself a repo. Run carrick init .. to initialise that workspace.`);
729
- }
730
- }
731
- // What Carrick already knows about this folder, before the picker draws
732
- // its defaults. A read, not a write: the credential has to exist already,
733
- // so a machine nobody has signed in on still chooses first and signs in
734
- // afterwards, which is what keeps "nothing written, here or in Carrick"
735
- // true of a cancelled run (carrick#1338). A read that does not answer
736
- // preselects nothing rather than guessing, and the authoritative read
737
- // below then reports the failure in its own words.
738
- const saved = readCredential();
739
- let known = null;
740
- projects = null;
741
- const knownNames = [...new Set(candidates.map((repo) => repo.name).filter((name) => name !== null))];
742
- // Only where a picker is actually going to draw. Every other path has its
743
- // answer already — `--repo` names it, `--yes` takes the list as derived, a
744
- // single repo is not a choice, the question about the repos beside this
745
- // one was the choice — and asking about a repo whose name the reader never
746
- // offered is a request that buys nothing (carrick#1338's test pins it: a
747
- // repo left out is named in no request at all).
748
- const picking = ticked === null && parsed.repos.length === 0 && !parsed.assumeYes && interactive && candidates.length > 1;
749
- if (picking && saved !== null && knownNames.length > 0) {
900
+ early = { names: here, identity: null };
750
901
  try {
751
- known = await resolveRepos(saved.token, knownNames);
752
- projects = await listProjects(saved.token);
902
+ early.identity = await resolveRepos(saved.token, here);
753
903
  }
754
904
  catch {
755
- known = null;
756
- projects = null;
905
+ // The read the proposal is built from reports the failure in its own
906
+ // words, below.
757
907
  }
758
908
  }
759
- const selected = ticked ??
760
- (await chooseRepos(derived.plan, candidates, {
761
- repos: parsed.repos,
762
- assumeYes: parsed.assumeYes,
763
- interactive,
764
- identity: known,
765
- label: (slug) => projectLabel(slug, projects),
766
- }, out));
767
- if (selected.length === 0)
768
- throw new Stop(0);
769
- selectedRepos = selected;
770
- derived = selectedProposal(derived, selected.map((repo) => repo.path));
771
- const kept = new Set(selected.map((repo) => repo.path));
772
- deselected = candidates
773
- .filter((repo) => !kept.has(repo.path))
774
- .map((repo) => path.basename(repo.path));
775
- const dropped = candidates.length - selected.length;
776
- // Not after the question about the repos beside this one: its rows were
777
- // the whole answer, and a folder repo that had no row there (one with no
778
- // JavaScript or TypeScript in it) is not news.
779
- if (dropped > 0 && ticked === null) {
780
- out.done(`${selected.length} of ${candidates.length} repos covered; ${dropped} left out of the proposal, the project and the connection`);
781
- }
782
- // A machine that has never signed in signs in here rather than being told
783
- // to run another command: `carrick login` is the same browser round trip,
784
- // and refusing to take it was the first thing a new install did
785
- // (carrick#955). Without a terminal there is no browser to hand it to.
786
- if (saved)
787
- credential = saved;
788
- else {
789
- if (!interactive) {
790
- throw new Error("carrick init requires a Carrick login. Run carrick login, or set CARRICK_TOKEN.");
909
+ if (saved !== null && early?.identity?.workspace.index_builder === "cloud") {
910
+ let identity = early.identity;
911
+ // A repo that has joined the workspace since the last run. The names
912
+ // init recorded in the workspace file are a result on this lane, not a
913
+ // choice, so they are taken back out and the folder is read whole again.
914
+ // A name somebody wrote into `exclude` by hand stays, and is named in no
915
+ // request. `--repo` is a choice, and leaves the record alone.
916
+ if (parsed.repos.length === 0) {
917
+ const before = readWorkspaceFile(workspace);
918
+ if (before !== null && removeSelection(workspace) !== null) {
919
+ restoreSelection = () => fs.writeFileSync(path.join(workspace, WORKSPACE_FILE), before);
920
+ derived = deriveWorkspace(workspace);
921
+ candidates = derived.plan.repos.map((repo) => repoIdentity(repo.path));
922
+ identity = await resolveRepos(saved.token, repoNames(candidates));
923
+ }
791
924
  }
792
- credential = await signIn(out.say);
925
+ leftOut(excludedRepos(workspace));
926
+ cloud = await cloudLane({ parsed, workspace, derived, candidates, identity, credential: saved, out });
793
927
  }
794
- names = [...new Set(selected.map((repo) => repo.name).filter((name) => name !== null))];
795
- if (names.length > 200)
796
- throw new Error("This workspace has more than 200 GitHub repos. Initialise smaller workspace groups.");
797
- const missingIdentities = selected.filter((repo) => repo.name === null);
798
- // Said before anything is requested, and said whether or not --project was
799
- // given: a repo with no identity is left out of the project step, the
800
- // connection check and the workspace read, and a run that dropped it in
801
- // silence read as a complete one (carrick#991).
802
- // A folder of clones can hold dozens, so the list is capped and the rest
803
- // is counted rather than dropped.
804
- for (const repo of missingIdentities.slice(0, 10)) {
805
- out.warn(`${repo.path} contributes no GitHub identity: ${repo.problem}.`);
806
- }
807
- if (missingIdentities.length > 10) {
808
- out.warn(`${missingIdentities.length - 10} more repos here name no GitHub identity either.`);
809
- }
810
- if (missingIdentities.length > 0) {
811
- const one = missingIdentities.length === 1;
812
- // The ssh fix is said as the one it is: "the alias" named nothing for a
813
- // repo with no remote at all (carrick#1574).
814
- out.warn(`Carrick has nothing to connect ${one ? "it" : "them"} to. Run carrick init --repo owner/repo${one ? "" : " in each of them"}, or, for an origin on an ssh alias, give that alias a HostName github.com line in your ssh config.`);
815
- }
816
- if (parsed.project && missingIdentities.length > 0) {
817
- throw new Error(`--project ${parsed.project} cannot verify ${missingIdentities.map((repo) => repo.path).join(", ")} because ${missingIdentities.length === 1 ? "it has" : "they have"} no GitHub origin. No project assignment was verified.`);
818
- }
819
- if (parsed.project && names.length === 0) {
820
- throw new Error(`--project ${parsed.project} found no GitHub repos to verify.`);
821
- }
822
- if (names.length === 0) {
823
- // The sentence that stops the rest of this run from reading as a
824
- // complete one: no project is chosen, no connection is checked, and a
825
- // later upload has no repo identity to resolve a project from.
826
- out.warn("No repo here names a GitHub repository, so this run chooses no project and checks no connection.");
928
+ else {
929
+ leftOut(excluded);
827
930
  }
828
- // The read the rest of this run is decided from. The one the picker's
829
- // defaults came from is reused only where the selection kept every repo,
830
- // because `project_repos` answers for the repos that were ASKED about: a
831
- // wider read would have the absent-repo lines below naming projects this
832
- // install does not cover (carrick#993 row 18).
833
- const everyCandidate = known !== null && names.length === knownNames.length;
834
- initial = everyCandidate && known !== null ? known : await resolveRepos(credential.token, names);
835
- // Read once, for the names: every project this run prints is printed as
836
- // the dashboard shows it, with the slug beside it (carrick#1338). A
837
- // workspace whose API has no such action answers null and the lines carry
838
- // slugs alone, as they did.
839
- if (projects === null)
840
- projects = names.length > 0 ? await listProjects(credential.token) : null;
841
- // The project half of the browser round trip, where this API can do it
842
- // from here. Nothing is created or moved in it: it settles what this run
843
- // would do, and the proposal below is where that is accepted. Without
844
- // --project the step reads the assignment the repos already have and
845
- // offers the list, rather than doing nothing at all (carrick#987).
846
- const dashboard = workspaceUrls(initial.workspace.slug).projects;
847
- // The repos by the folder names the reader has on disk, in the order the
848
- // questions name them. Only those with a GitHub remote: a repo with none
849
- // goes into no project, so no project line names it.
850
- const named = selected.filter((repo) => repo.name !== null).map((repo) => path.basename(repo.path));
851
- const shortName = (fullName) => {
852
- const repo = selected.find((entry) => entry.name?.toLowerCase() === fullName.toLowerCase());
853
- return repo === undefined ? fullName : path.basename(repo.path);
854
- };
855
- const prompts = {
856
- repos: named,
857
- say: out.say,
858
- ask: out.ask,
859
- confirm: out.confirm,
860
- pick: out.pick,
861
- dashboard: { url: dashboard, open: (url) => void openBrowser(url).catch(() => false) },
862
- interactive,
863
- assumeYes: parsed.assumeYes,
864
- };
865
- decision =
866
- parsed.project === null
867
- ? await projectStep(projectAssignments(initial, names), projects, prompts)
868
- : reposAreInProject(initial, names, parsed.project)
869
- ? { slug: parsed.project, exists: true, create: false }
870
- : await planProject(parsed.project, projects, prompts);
871
- const project = decision.slug;
872
- projectSlug = project;
873
- const label = (slug) => projectLabel(slug, projects);
874
- // What the server would be asked to change, before it is asked: the repos
875
- // that would leave a project somebody put them in, and the ones the grant
876
- // has yet to connect at all.
877
- const current = projectAssignments(initial, names);
878
- const assigned = new Map(names.map((name, index) => [name, current[index] ?? null]));
879
- const moving = project === null ? [] : names.filter((name) => {
880
- const current = assigned.get(name);
881
- return current !== null && current !== undefined && current !== project;
882
- });
883
- const joining = project === null ? [] : names.filter((name) => assigned.get(name) == null);
884
- // The proposal: what this run covers, what it would change, and what the
885
- // scanner said about it. Everything above is a read; everything below the
886
- // confirm is a write.
887
- const plan = derived.plan;
888
- // The scanner's own warnings about what it proposed. Capped: the proposal
889
- // file carries every one of them, and it is named on the line above.
890
- const warnings = plan.repos.flatMap((repo) => repo.warnings);
891
- for (const warning of warnings.slice(0, 3))
892
- out.warn(warning);
893
- if (warnings.length > 3)
894
- out.warn(`${warnings.length - 3} more notes on the proposal are in ${PROPOSAL_FILE}.`);
895
- // What the scanner's `missing` holds, in the reader's terms: a path the
896
- // workspace file lists that is not here (carrick#1574).
897
- for (const missing of plan.missing)
898
- out.warn(`${WORKSPACE_FILE} lists ${missing}, which is not a directory on this machine.`);
899
- // Which editors get an entry, asked before the yes that covers it. The
900
- // files are outside the workspace and belong to editors this reader may not
901
- // use, and they used to be written on the strength of a yes to the proposal
902
- // (carrick#1365). An editor with no configuration directory is not offered,
903
- // and one this machine has no command for is offered unticked.
904
- const offered = offeredFileClients();
905
- editors = await chooseEditors(offered, { editors: parsed.editors, assumeYes: parsed.assumeYes, interactive }, out);
906
- // What the yes is given to: every thing this run will do, one line each —
907
- // the project, the GitHub App, the files — and nothing created before it
908
- // (carrick#1512). The files are named, editor files included, because a
909
- // question that does not name a file is not consent to write it
910
- // (carrick#1365, carrick#1489).
911
- const unconnected = unconnectedRepos(initial, names);
912
- // The project as the list and the move question both spell it: the name
913
- // typed for one this run creates, the dashboard's label for one that is
914
- // there.
915
- const target = project === null ? null : decision.create ? (decision.name ?? project) : label(project);
916
- const moves = moving.map((name) => ({ repo: shortName(name), from: label(assigned.get(name) ?? "") }));
917
- const next = nextLines({
918
- create: decision.create,
919
- project: target,
920
- repos: named,
921
- joining: joining.map(shortName),
922
- moving: moves,
923
- connect: unconnected.length === 0
924
- ? null
925
- : connectItem(unconnected.map(shortName), names.length, interactive, workspaceUrls(initial.workspace.slug).connect),
926
- writes: writesLine({
927
- workspaceFile: deselected.length > 0,
928
- editorFiles: offered.filter((client) => editors.includes(client.name)).map((client) => client.file),
929
- claudeCode: claudeCodeFound(),
930
- // A folder of repos: the copy in each repo is written as well.
931
- folder: selected.some((repo) => realPath(repo.path) !== realPath(workspace)) ? tilde(realPath(workspace)) : null,
932
- }),
933
- });
934
- out.say(nextBlock(next));
935
- if (!parsed.assumeYes) {
936
- if (!interactive) {
937
- throw new Stop(1, `use --yes to accept this proposal without a terminal${moving.length > 0 && !parsed.allowMove ? ", and --allow-move to accept the move above" : ""}.`);
931
+ }
932
+ catch (error) {
933
+ return ended(error);
934
+ }
935
+ if (cloud !== null) {
936
+ // Read, not chosen: nothing is created, assigned or moved on this lane.
937
+ derived = cloud.derived;
938
+ credential = cloud.credential;
939
+ names = cloud.names;
940
+ projects = cloud.projects;
941
+ decision = { slug: cloud.project, exists: true, create: false };
942
+ projectSlug = cloud.project;
943
+ deselected = cloud.deselected;
944
+ editors = cloud.editors;
945
+ selectedRepos = cloud.selected;
946
+ }
947
+ else
948
+ try {
949
+ // The repos beside this one, asked about here rather than named with an
950
+ // instruction to start again in the folder above (carrick#1512). A yes to
951
+ // any of them carries on as if the run had started there: the same
952
+ // derivation, the same proposal, and the repos left unticked written to
953
+ // the same exclude list a folder run writes.
954
+ let ticked = null;
955
+ const parent = derived.plan.parent_proposal;
956
+ if (parent !== null && candidates.length === 1) {
957
+ const current = candidates[0];
958
+ const siblings = siblingRepos(derived.plan, excludedRepos(parent.directory));
959
+ const asking = siblings.length > 0 &&
960
+ siblings.length <= MAX_SIBLINGS &&
961
+ interactive &&
962
+ !parsed.assumeYes &&
963
+ parsed.repos.length === 0;
964
+ if (asking) {
965
+ const picked = new Set((await chooseSiblings(current, siblings.map((repo) => repoIdentity(repo)), parent.directory, out)).map(realPath));
966
+ if (picked.size > 1 || !picked.has(realPath(current.path))) {
967
+ workspace = parent.directory;
968
+ derived = deriveWorkspace(workspace);
969
+ candidates = derived.plan.repos.map((repo) => repoIdentity(repo.path));
970
+ const chosen = candidates.filter((repo) => picked.has(realPath(repo.path)));
971
+ // This repo first, where the reader kept it: it is the one they
972
+ // started in, and every sentence below names it first.
973
+ ticked = [
974
+ ...chosen.filter((repo) => realPath(repo.path) === realPath(current.path)),
975
+ ...chosen.filter((repo) => realPath(repo.path) !== realPath(current.path)),
976
+ ];
977
+ }
978
+ }
979
+ else {
980
+ aloneLines(current, parent, siblings, out);
981
+ }
938
982
  }
939
- if (!(await out.confirm("Go ahead?"))) {
940
- out.refuse(NOTHING_WRITTEN);
983
+ // What Carrick already knows about this folder, before the picker draws
984
+ // its defaults. A read, not a write: the credential has to exist already,
985
+ // so with `--local` a machine nobody has signed in on still chooses first
986
+ // and signs in afterwards, which is what keeps "nothing written, here or
987
+ // in Carrick" true of a cancelled run (carrick#1338). A read that does not
988
+ // answer preselects nothing rather than guessing, and the authoritative
989
+ // read below then reports the failure in its own words.
990
+ let known = null;
991
+ projects = null;
992
+ const knownNames = repoNames(candidates);
993
+ // Only where a picker is actually going to draw. Every other path has its
994
+ // answer already — `--repo` names it, `--yes` takes the list as derived, a
995
+ // single repo is not a choice, the question about the repos beside this
996
+ // one was the choice — and asking about a repo whose name the reader never
997
+ // offered is a request that buys nothing (carrick#1338's test pins it: a
998
+ // repo left out is named in no request at all).
999
+ const picking = ticked === null && parsed.repos.length === 0 && !parsed.assumeYes && interactive && candidates.length > 1;
1000
+ if (picking && saved !== null && knownNames.length > 0) {
1001
+ try {
1002
+ // The read made before the first question named these same repos.
1003
+ known = early === null ? await resolveRepos(saved.token, knownNames) : early.identity;
1004
+ projects = known === null ? null : await listProjects(saved.token);
1005
+ }
1006
+ catch {
1007
+ known = null;
1008
+ projects = null;
1009
+ }
1010
+ }
1011
+ const selected = ticked ??
1012
+ (await chooseRepos(derived.plan, candidates, {
1013
+ repos: parsed.repos,
1014
+ assumeYes: parsed.assumeYes,
1015
+ interactive,
1016
+ identity: known,
1017
+ label: (slug) => projectLabel(slug, projects),
1018
+ }, out));
1019
+ if (selected.length === 0)
941
1020
  throw new Stop(0);
1021
+ selectedRepos = selected;
1022
+ derived = selectedProposal(derived, selected.map((repo) => repo.path));
1023
+ const kept = new Set(selected.map((repo) => repo.path));
1024
+ deselected = candidates
1025
+ .filter((repo) => !kept.has(repo.path))
1026
+ .map((repo) => path.basename(repo.path));
1027
+ const dropped = candidates.length - selected.length;
1028
+ // Not after the question about the repos beside this one: its rows were
1029
+ // the whole answer, and a folder repo that had no row there (one with no
1030
+ // JavaScript or TypeScript in it) is not news.
1031
+ if (dropped > 0 && ticked === null) {
1032
+ out.done(`${selected.length} of ${candidates.length} repos covered; ${dropped} left out of the proposal, the project and the connection`);
942
1033
  }
943
- }
944
- // A move is its own question. `--yes` accepts a list of packages and a set
945
- // of local files; taking a repo out of a project changes what every agent
946
- // querying that project can see, and that is not the same answer
947
- // (carrick#1338).
948
- if (moving.length > 0 && target !== null && !parsed.allowMove) {
949
- const from = [...new Set(moves.map((entry) => entry.from))].join(" and ");
950
- const repos = reposPhrase(moves.map((entry) => entry.repo));
951
- if (!interactive) {
952
- throw new Stop(1, `${repos} ${moving.length === 1 ? "is" : "are"} in project ${from}. Moving ${moving.length === 1 ? "it" : "them"} into ${target} changes what every agent querying either project can see, so it needs --allow-move.`);
1034
+ // A machine that has never signed in signs in here rather than being told
1035
+ // to run another command: `carrick login` is the same browser round trip,
1036
+ // and refusing to take it was the first thing a new install did
1037
+ // (carrick#955). Without a terminal there is no browser to hand it to.
1038
+ if (saved)
1039
+ credential = saved;
1040
+ else {
1041
+ if (!interactive) {
1042
+ throw new Error(LOGIN_REQUIRED);
1043
+ }
1044
+ credential = await signIn(out.say);
953
1045
  }
954
- // The same repo and project spelling as the line in the list above it.
955
- if (!(await out.confirm(`${moveLines(moves, target).join("; ")}?`))) {
956
- out.refuse(NOTHING_WRITTEN);
957
- throw new Stop(0);
1046
+ names = repoNames(selected);
1047
+ if (names.length > 200)
1048
+ throw new Error("This workspace has more than 200 GitHub repos. Initialise smaller workspace groups.");
1049
+ const missingIdentities = selected.filter((repo) => repo.name === null);
1050
+ // Said before anything is requested, and said whether or not --project was
1051
+ // given.
1052
+ warnMissingIdentities(missingIdentities, out);
1053
+ if (parsed.project && missingIdentities.length > 0) {
1054
+ throw new Error(`--project ${parsed.project} cannot verify ${missingIdentities.map((repo) => repo.path).join(", ")} because ${missingIdentities.length === 1 ? "it has" : "they have"} no GitHub origin. No project assignment was verified.`);
958
1055
  }
1056
+ if (parsed.project && names.length === 0) {
1057
+ throw new Error(`--project ${parsed.project} found no GitHub repos to verify.`);
1058
+ }
1059
+ if (names.length === 0) {
1060
+ // The sentence that stops the rest of this run from reading as a
1061
+ // complete one: no project is chosen, no connection is checked, and a
1062
+ // later upload has no repo identity to resolve a project from.
1063
+ out.warn("No repo here names a GitHub repository, so this run chooses no project and checks no connection.");
1064
+ }
1065
+ // The read the rest of this run is decided from. The one made before the
1066
+ // first question, or the one the picker's defaults came from, is reused
1067
+ // only where it named exactly the repos this run covers, because
1068
+ // `project_repos` answers for the repos that were ASKED about: a wider
1069
+ // read would have the absent-repo lines below naming projects this install
1070
+ // does not cover (carrick#993 row 18).
1071
+ const read = early ?? { names: knownNames, identity: known };
1072
+ initial =
1073
+ read.identity !== null && sameRepos(read.names, names) ? read.identity : await resolveRepos(credential.token, names);
1074
+ // Read once, for the names: every project this run prints is printed as
1075
+ // the dashboard shows it, with the slug beside it (carrick#1338). A
1076
+ // workspace whose API has no such action answers null and the lines carry
1077
+ // slugs alone, as they did.
1078
+ if (projects === null)
1079
+ projects = names.length > 0 ? await listProjects(credential.token) : null;
1080
+ // The project half of the browser round trip, where this API can do it
1081
+ // from here. Nothing is created or moved in it: it settles what this run
1082
+ // would do, and the proposal below is where that is accepted. Without
1083
+ // --project the step reads the assignment the repos already have and
1084
+ // offers the list, rather than doing nothing at all (carrick#987).
1085
+ const dashboard = workspaceUrls(initial.workspace.slug).projects;
1086
+ // The repos by the folder names the reader has on disk, in the order the
1087
+ // questions name them. Only those with a GitHub remote: a repo with none
1088
+ // goes into no project, so no project line names it.
1089
+ const named = selected.filter((repo) => repo.name !== null).map((repo) => path.basename(repo.path));
1090
+ const shortName = (fullName) => {
1091
+ const repo = selected.find((entry) => entry.name?.toLowerCase() === fullName.toLowerCase());
1092
+ return repo === undefined ? fullName : path.basename(repo.path);
1093
+ };
1094
+ const prompts = {
1095
+ repos: named,
1096
+ say: out.say,
1097
+ ask: out.ask,
1098
+ confirm: out.confirm,
1099
+ pick: out.pick,
1100
+ dashboard: { url: dashboard, open: (url) => void openBrowser(url).catch(() => false) },
1101
+ interactive,
1102
+ assumeYes: parsed.assumeYes,
1103
+ };
1104
+ decision =
1105
+ parsed.project === null
1106
+ ? await projectStep(projectAssignments(initial, names), projects, prompts)
1107
+ : reposAreInProject(initial, names, parsed.project)
1108
+ ? { slug: parsed.project, exists: true, create: false }
1109
+ : await planProject(parsed.project, projects, prompts);
1110
+ const project = decision.slug;
1111
+ projectSlug = project;
1112
+ const label = (slug) => projectLabel(slug, projects);
1113
+ // What the server would be asked to change, before it is asked: the repos
1114
+ // that would leave a project somebody put them in, and the ones the grant
1115
+ // has yet to connect at all.
1116
+ const current = projectAssignments(initial, names);
1117
+ const assigned = new Map(names.map((name, index) => [name, current[index] ?? null]));
1118
+ const moving = project === null ? [] : names.filter((name) => {
1119
+ const current = assigned.get(name);
1120
+ return current !== null && current !== undefined && current !== project;
1121
+ });
1122
+ const joining = project === null ? [] : names.filter((name) => assigned.get(name) == null);
1123
+ // The proposal: what this run covers, what it would change, and what the
1124
+ // scanner said about it. Everything above is a read; everything below the
1125
+ // confirm is a write.
1126
+ const plan = derived.plan;
1127
+ // The scanner's own warnings about what it proposed. Capped: the proposal
1128
+ // file carries every one of them, and it is named on the line above.
1129
+ const warnings = plan.repos.flatMap((repo) => repo.warnings);
1130
+ for (const warning of warnings.slice(0, 3))
1131
+ out.warn(warning);
1132
+ if (warnings.length > 3)
1133
+ out.warn(`${warnings.length - 3} more notes on the proposal are in ${PROPOSAL_FILE}.`);
1134
+ // What the scanner's `missing` holds, in the reader's terms: a path the
1135
+ // workspace file lists that is not here (carrick#1574).
1136
+ for (const missing of plan.missing)
1137
+ out.warn(`${WORKSPACE_FILE} lists ${missing}, which is not a directory on this machine.`);
1138
+ // Which editors get an entry, asked before the yes that covers it. The
1139
+ // files are outside the workspace and belong to editors this reader may not
1140
+ // use, and they used to be written on the strength of a yes to the proposal
1141
+ // (carrick#1365). An editor with no configuration directory is not offered,
1142
+ // and one this machine has no command for is offered unticked.
1143
+ const offered = offeredFileClients();
1144
+ editors = await chooseEditors(offered, { editors: parsed.editors, assumeYes: parsed.assumeYes, interactive }, out);
1145
+ // What the yes is given to: every thing this run will do, one line each —
1146
+ // the project, the GitHub App, the files — and nothing created before it
1147
+ // (carrick#1512). The files are named, editor files included, because a
1148
+ // question that does not name a file is not consent to write it
1149
+ // (carrick#1365, carrick#1489).
1150
+ const unconnected = unconnectedRepos(initial, names);
1151
+ // The project as the list and the move question both spell it: the name
1152
+ // typed for one this run creates, the dashboard's label for one that is
1153
+ // there.
1154
+ const target = project === null ? null : decision.create ? (decision.name ?? project) : label(project);
1155
+ const moves = moving.map((name) => ({ repo: shortName(name), from: label(assigned.get(name) ?? "") }));
1156
+ const next = nextLines({
1157
+ create: decision.create,
1158
+ project: target,
1159
+ repos: named,
1160
+ joining: joining.map(shortName),
1161
+ moving: moves,
1162
+ connect: unconnected.length === 0
1163
+ ? null
1164
+ : connectItem(unconnected.map(shortName), names.length, interactive, workspaceUrls(initial.workspace.slug).connect),
1165
+ writes: writesLine({
1166
+ workspaceFile: deselected.length > 0,
1167
+ editorFiles: offered.filter((client) => editors.includes(client.name)).map((client) => client.file),
1168
+ claudeCode: claudeCodeFound(),
1169
+ // A folder of repos: the copy in each repo is written as well.
1170
+ folder: selected.some((repo) => realPath(repo.path) !== realPath(workspace)) ? tilde(realPath(workspace)) : null,
1171
+ }),
1172
+ });
1173
+ out.say(nextBlock(next));
1174
+ if (!parsed.assumeYes) {
1175
+ if (!interactive) {
1176
+ throw new Stop(1, `use --yes to accept this proposal without a terminal${moving.length > 0 && !parsed.allowMove ? ", and --allow-move to accept the move above" : ""}.`);
1177
+ }
1178
+ if (!(await out.confirm("Go ahead?"))) {
1179
+ out.refuse(NOTHING_WRITTEN);
1180
+ throw new Stop(0);
1181
+ }
1182
+ }
1183
+ // A move is its own question. `--yes` accepts a list of packages and a set
1184
+ // of local files; taking a repo out of a project changes what every agent
1185
+ // querying that project can see, and that is not the same answer
1186
+ // (carrick#1338).
1187
+ if (moving.length > 0 && target !== null && !parsed.allowMove) {
1188
+ const from = [...new Set(moves.map((entry) => entry.from))].join(" and ");
1189
+ const repos = reposPhrase(moves.map((entry) => entry.repo));
1190
+ if (!interactive) {
1191
+ throw new Stop(1, `${repos} ${moving.length === 1 ? "is" : "are"} in project ${from}. Moving ${moving.length === 1 ? "it" : "them"} into ${target} changes what every agent querying either project can see, so it needs --allow-move.`);
1192
+ }
1193
+ // The same repo and project spelling as the line in the list above it.
1194
+ if (!(await out.confirm(`${moveLines(moves, target).join("; ")}?`))) {
1195
+ out.refuse(NOTHING_WRITTEN);
1196
+ throw new Stop(0);
1197
+ }
1198
+ }
1199
+ for (const name of [...moving, ...joining])
1200
+ movable.add(name.toLowerCase());
1201
+ for (const name of moving)
1202
+ announced.add(name.toLowerCase());
959
1203
  }
960
- for (const name of [...moving, ...joining])
961
- movable.add(name.toLowerCase());
962
- for (const name of moving)
963
- announced.add(name.toLowerCase());
964
- }
965
- catch (error) {
966
- // A question the reader ended stops the run where it stands. Nothing above
967
- // this line writes, so there is nothing to undo (carrick#1338).
968
- if (error instanceof PromptCancelled) {
969
- out.refuse(CANCELLED);
970
- return 1;
971
- }
972
- if (error instanceof Stop) {
973
- if (error.message !== "")
974
- process.stderr.write(`carrick init: ${error.message}\n`);
975
- return error.code;
1204
+ catch (error) {
1205
+ return ended(error);
976
1206
  }
977
- process.stderr.write(`carrick init: ${error.message}\n`);
978
- return 1;
979
- }
980
1207
  // Accepted. From here the run writes: the project, the assignment, the
981
1208
  // proposal, the hooks, the skills and the MCP entries.
982
1209
  const plan = derived.plan;
@@ -1036,7 +1263,9 @@ export async function initWith(argv, out, interactive) {
1036
1263
  // was brought level before this command started.
1037
1264
  try {
1038
1265
  await offerGlobalInstall({
1039
- assumeYes: parsed.assumeYes,
1266
+ // Nothing is asked where Carrick builds the index, so that run gets the
1267
+ // line `--yes` gets: the command, and no install.
1268
+ assumeYes: parsed.assumeYes || cloud !== null,
1040
1269
  install: parsed.installGlobal,
1041
1270
  confirm: (question) => out.confirm(question),
1042
1271
  say: (line) => out.warn(line),
@@ -1130,6 +1359,25 @@ export async function initWith(argv, out, interactive) {
1130
1359
  if (!mcp.some((outcome) => outcome.client === "Claude Code")) {
1131
1360
  todo.push(`No claude command on this machine, so Claude Code has no Carrick MCP server. Once it is installed: ${mcpLine()}`);
1132
1361
  }
1362
+ // The rest of the project, which this machine does not hold. Carrick
1363
+ // answers across every repo in a project, so a folder holding half of one
1364
+ // is a partial index and nothing here would otherwise say so
1365
+ // (carrick#993 row 18). "This machine" is every repo in the folder that
1366
+ // holds this run's repos, set up here or not: a repo in the folder beside
1367
+ // this one is not missing (carrick#1512). Only read where the run's own
1368
+ // repos leave something unaccounted for, because each is a git call.
1369
+ const absentHere = (identity) => {
1370
+ const absent = absentRepos(identity, names, project, label);
1371
+ if (absent.length === 0)
1372
+ return absent;
1373
+ const folder = selectedRepos.some((repo) => realPath(repo.path) === realPath(workspace))
1374
+ ? path.dirname(workspace)
1375
+ : workspace;
1376
+ const here = repoRoots(folder)
1377
+ .map((root) => repoIdentity(root).name)
1378
+ .filter((name) => name !== null);
1379
+ return absentRepos(identity, [...names, ...here], project, label);
1380
+ };
1133
1381
  // The connection, last of the steps that talk to Carrick, so a read that
1134
1382
  // fails costs nothing already decided: the files above are written
1135
1383
  // (carrick#1489 review).
@@ -1139,73 +1387,77 @@ export async function initWith(argv, out, interactive) {
1139
1387
  // prompt — and a first project may have just taken the repos. The
1140
1388
  // connection lines used to come from the old read, and sent a reader to the
1141
1389
  // grant page for repos that were already connected (carrick#1489).
1142
- try {
1143
- const current = await resolveRepos(credential.token, names);
1144
- const role = current.workspace.role;
1145
- const identity = await connectRepos(credential.token, names, current, {
1146
- interactive,
1147
- project: project ?? undefined,
1148
- projectExists,
1149
- movable,
1150
- announce: announced,
1151
- label,
1152
- // A role the read does not state is unknown, not a member's.
1153
- member: role === "member",
1154
- again: again(),
1155
- say: out.say,
1156
- track: (text, work, report) => out.step(text, work, report),
1157
- });
1158
- if (project !== null && !reposAreInProject(identity, names, project)) {
1159
- // An unverified project does not end the run, whether it was named on
1160
- // the command line or picked here. Stopping cost someone their hooks and
1161
- // their proposal for a browser step they could only take afterwards, and
1162
- // the documented command then needed two runs (carrick#993 row 8).
1163
- todo.push(`Finish the browser steps above to put these repos in ${label(project)}, then ${again(` --project ${project}`)} to verify.`);
1164
- }
1165
- // After a wait that was stopped, the grant is still the thing to do, and
1166
- // the line that said so is above the wait.
1167
- const unconnected = unconnectedRepos(identity, names);
1168
- if (interactive && unconnected.length > 0) {
1169
- todo.push(connectLine(unconnected, names.length, workspaceUrls(identity.workspace.slug).connect));
1170
- }
1171
- if (identity.allowance_sentence)
1172
- out.say(identity.allowance_sentence);
1173
- for (const repo of identity.repos) {
1174
- if (repo.connected && repo.services.length > 0)
1175
- hostedIndex.push(repo.full_name);
1390
+ //
1391
+ // Where Carrick builds the index there was no prompt to wait at and nothing
1392
+ // here connects, assigns or moves a repo, so the one read made before this
1393
+ // run printed anything is the read these lines come from (carrick#2219).
1394
+ if (cloud !== null) {
1395
+ if (cloud.identity.allowance_sentence)
1396
+ out.say(cloud.identity.allowance_sentence);
1397
+ todo.push(...absentHere(cloud.identity));
1398
+ }
1399
+ else
1400
+ try {
1401
+ const current = await resolveRepos(credential.token, names);
1402
+ const role = current.workspace.role;
1403
+ const identity = await connectRepos(credential.token, names, current, {
1404
+ interactive,
1405
+ project: project ?? undefined,
1406
+ projectExists,
1407
+ movable,
1408
+ announce: announced,
1409
+ label,
1410
+ // A role the read does not state is unknown, not a member's.
1411
+ member: role === "member",
1412
+ again: again(),
1413
+ say: out.say,
1414
+ track: (text, work, report) => out.step(text, work, report),
1415
+ });
1416
+ if (project !== null && !reposAreInProject(identity, names, project)) {
1417
+ // An unverified project does not end the run, whether it was named on
1418
+ // the command line or picked here. Stopping cost someone their hooks and
1419
+ // their proposal for a browser step they could only take afterwards, and
1420
+ // the documented command then needed two runs (carrick#993 row 8).
1421
+ todo.push(`Finish the browser steps above to put these repos in ${label(project)}, then ${again(` --project ${project}`)} to verify.`);
1422
+ }
1423
+ // After a wait that was stopped, the grant is still the thing to do, and
1424
+ // the line that said so is above the wait.
1425
+ const unconnected = unconnectedRepos(identity, names);
1426
+ if (interactive && unconnected.length > 0) {
1427
+ todo.push(connectLine(unconnected, names.length, workspaceUrls(identity.workspace.slug).connect));
1428
+ }
1429
+ if (identity.allowance_sentence)
1430
+ out.say(identity.allowance_sentence);
1431
+ for (const repo of identity.repos) {
1432
+ if (repo.connected && repo.services.length > 0)
1433
+ hostedIndex.push(repo.full_name);
1434
+ }
1435
+ todo.push(...absentHere(identity));
1176
1436
  }
1177
- // The rest of the project, which this machine does not hold. Carrick
1178
- // answers across every repo in a project, so a folder holding half of one
1179
- // is a partial index and nothing here would otherwise say so
1180
- // (carrick#993 row 18). "This machine" is every repo in the folder that
1181
- // holds this run's repos, set up here or not: a repo in the folder beside
1182
- // this one is not missing (carrick#1512). Only read where the run's own
1183
- // repos leave something unaccounted for, because each is a git call.
1184
- let absent = absentRepos(identity, names, project, label);
1185
- if (absent.length > 0) {
1186
- const folder = selectedRepos.some((repo) => realPath(repo.path) === realPath(workspace))
1187
- ? path.dirname(workspace)
1188
- : workspace;
1189
- const here = repoRoots(folder)
1190
- .map((root) => repoIdentity(root).name)
1191
- .filter((name) => name !== null);
1192
- absent = absentRepos(identity, [...names, ...here], project, label);
1437
+ catch (error) {
1438
+ // The server's own sentence says what failed; this one says what is left.
1439
+ todo.push(`${error.message} The files here are written; ${again()} to verify the connection and the project.`);
1193
1440
  }
1194
- todo.push(...absent);
1195
- }
1196
- catch (error) {
1197
- // The server's own sentence says what failed; this one says what is left.
1198
- todo.push(`${error.message} The files here are written; ${again()} to verify the connection and the project.`);
1199
- }
1200
1441
  // What is set up, in one line; then the index; then what is left to do;
1201
- // then the instruction for the agent (carrick#1489).
1202
- out.done(summaryLine(project === null ? null : label(project), selectedRepos.filter((repo) => repo.name !== null).map((repo) => path.basename(repo.path))));
1442
+ // then the instruction for the agent (carrick#1489). Where the workspace
1443
+ // has this folder's repos in several projects, one line for each.
1444
+ if (cloud !== null) {
1445
+ for (const group of cloud.groups)
1446
+ out.done(summaryLine(label(group.slug), group.repos));
1447
+ }
1448
+ else
1449
+ out.done(summaryLine(project === null ? null : label(project), selectedRepos.filter((repo) => repo.name !== null).map((repo) => path.basename(repo.path))));
1203
1450
  // No paid scan ran here, and that is the point (carrick-cloud#799): the one
1204
1451
  // scan runs against a config someone has read. Where CI has already built an
1205
1452
  // index, this run reads it onto the machine instead — the hosted index is
1206
1453
  // the whole workspace's row, and `carrick index` from a laptop on a branch
1207
1454
  // replaces it for everyone who queries it (carrick#993 row 2, carrick#1020).
1208
- if (hostedIndex.length > 0) {
1455
+ //
1456
+ // Where Carrick builds the index the read always runs, index or not: it is
1457
+ // what leaves `.carrick/index.json` here, and the session-start hook reads a
1458
+ // later hosted index in only onto a machine that has one (carrick#2219).
1459
+ let downloaded = false;
1460
+ if (hostedIndex.length > 0 || cloud !== null) {
1209
1461
  // The step reports its own outcome, so the read is one line in a terminal
1210
1462
  // as well as in a pipe: a spinner that stopped with its label spent a
1211
1463
  // second line on the same event (carrick#1032). What it says while it runs
@@ -1215,10 +1467,28 @@ export async function initWith(argv, out, interactive) {
1215
1467
  // re-read this tree, and a repo the index does not cover is the one way
1216
1468
  // that decision cannot be read off a drift count (carrick#1373).
1217
1469
  const startedAt = Date.now();
1218
- await out.step(STEP_LABEL, (progress) => downloadHostedIndex(plan.workspace, nativeRunner(out.quiet, progress), plan.repos.map((repo) => repo.path), progress), (outcome) => hostedReport(outcome, (Date.now() - startedAt) / 1000));
1470
+ const read = await out.step(STEP_LABEL, (progress) => downloadHostedIndex(plan.workspace, nativeRunner(out.quiet, progress), plan.repos.map((repo) => repo.path), progress), (outcome) => hostedReport(outcome, (Date.now() - startedAt) / 1000, cloud !== null ? "cloud" : "laptop"));
1471
+ downloaded = read.kind === "downloaded";
1219
1472
  }
1220
1473
  for (const line of todo)
1221
1474
  out.warn(line);
1475
+ const installs = installCommands(plan);
1476
+ // Where Carrick builds the index nobody scaffolds: there is no workflow to
1477
+ // write, no config to seed and no scan to run from here, so the run closes
1478
+ // on what this machine holds and on what it is still waiting for
1479
+ // (carrick#2219).
1480
+ if (cloud !== null) {
1481
+ if (cloud.waiting.length > 0) {
1482
+ const one = cloud.waiting.length === 1;
1483
+ out.warn(`Carrick has no index for ${listed(cloud.waiting, 10)} yet. It builds ${one ? "it" : "them"} in the cloud, and ${workspaceUrls(cloud.identity.workspace.slug).repos} shows where ${one ? "it is" : "they are"}. Run carrick refresh here once ${one ? "it is" : "they are"} ready.`);
1484
+ }
1485
+ const install = installSentence(installs, "cloud");
1486
+ if (install !== null)
1487
+ out.warn(install);
1488
+ if (downloaded)
1489
+ out.done(READY_SENTENCE);
1490
+ return 0;
1491
+ }
1222
1492
  // The scaffold tool writes the files a repo needs; a repo that has them needs
1223
1493
  // nothing pasted anywhere. The line used to close every run, including one
1224
1494
  // where every covered repo already had its config and its workflow, and
@@ -1226,7 +1496,6 @@ export async function initWith(argv, out, interactive) {
1226
1496
  //
1227
1497
  // An install the first scan would be refused over goes in front of it: the
1228
1498
  // agent otherwise found out from the refusal (carrick#1489).
1229
- const installs = installCommands(plan);
1230
1499
  const unscaffolded = reposToScaffold(selectedRepos);
1231
1500
  if (unscaffolded.length > 0) {
1232
1501
  const install = installSentence(installs, "agent");