carrick 0.3.119 → 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/auth/read.d.ts +5 -0
- package/dist/auth/read.js +5 -0
- package/dist/auth/read.js.map +1 -1
- package/dist/init/doctor.d.ts +22 -2
- package/dist/init/doctor.js +44 -4
- package/dist/init/doctor.js.map +1 -1
- package/dist/init/hosted.d.ts +5 -1
- package/dist/init/hosted.js +8 -2
- package/dist/init/hosted.js.map +1 -1
- package/dist/init/run.d.ts +9 -2
- package/dist/init/run.js +613 -344
- package/dist/init/run.js.map +1 -1
- package/package.json +6 -6
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/sidecar/dist/src/position-rows.d.ts +18 -8
- package/sidecar/dist/src/position-rows.js +80 -14
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
|
-
|
|
685
|
-
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
interactive
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
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
|
-
|
|
752
|
-
projects = await listProjects(saved.token);
|
|
902
|
+
early.identity = await resolveRepos(saved.token, here);
|
|
753
903
|
}
|
|
754
904
|
catch {
|
|
755
|
-
|
|
756
|
-
|
|
905
|
+
// The read the proposal is built from reports the failure in its own
|
|
906
|
+
// words, below.
|
|
757
907
|
}
|
|
758
908
|
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
|
|
925
|
+
leftOut(excludedRepos(workspace));
|
|
926
|
+
cloud = await cloudLane({ parsed, workspace, derived, candidates, identity, credential: saved, out });
|
|
793
927
|
}
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
const
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
940
|
-
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
-
|
|
955
|
-
if (
|
|
956
|
-
|
|
957
|
-
|
|
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
|
-
|
|
961
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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");
|