create-egregore 0.18.17 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/local.js CHANGED
@@ -539,14 +539,14 @@ async function maybeSetupTelegramGroup(ui, {
539
539
  ui.info(`Bot add link: ${ui.cyan(botLink)}`);
540
540
  openBrowser(botLink);
541
541
 
542
- await ui.prompt("Press Enter here after the bot is in the group");
542
+ await ui.prompt("Press Enter here after the bot is in the group", { hint: "enter continue · ctrl-c quit" });
543
543
  ui.note([
544
544
  "Step 4: In Telegram, copy a group invite link.",
545
545
  "Paste it below so teammate invites include the Telegram group too.",
546
546
  "If you skip this, Egregore will still work, but invites will not include Telegram.",
547
547
  ]);
548
548
 
549
- const groupLink = await ui.prompt("Telegram group join link (paste here, or Enter to skip)");
549
+ const groupLink = await ui.prompt("Telegram group join link (paste here, or Enter to skip)", { hint: "paste the link · enter skip" });
550
550
  const cleaned = groupLink.trim();
551
551
  let storedGroupLink = "";
552
552
  if (cleaned && !looksLikeUrl(cleaned)) {
@@ -653,105 +653,178 @@ async function localFounderFlow(ui, ctx = {}) {
653
653
  // 2. Get user info
654
654
  const user = await getUser(githubToken);
655
655
 
656
- // 3. Where — we always have a gh token (the only local auth path), which
657
- // carries org read, so we can list the user's accounts and offer a picker.
658
- ui.section("Pick a home", "where should this Egregore live?");
659
- const s = ui.spinner("Checking your GitHub accounts...");
660
- const orgs = await listOrgs(githubToken);
661
- s.stop("Accounts loaded");
662
-
663
- let githubOrg, isOrg;
664
- if (orgs.length === 0) {
665
- githubOrg = user.login;
666
- isOrg = false;
667
- ui.info(`Using your personal account: ${ui.bold(githubOrg)}`);
668
- } else {
669
- const orgChoices = [{ label: user.login, description: "Personal account" }];
670
- for (const o of orgs) {
671
- orgChoices.push({ label: o.login, description: "Organization" });
656
+ // 3–6. Home → new/existing → naming → repo picker → repo names, as a
657
+ // step machine so esc walks BACKWARDS through the flow (field request:
658
+ // a wrong org pick shouldn't mean Ctrl-C and start over). Every answer
659
+ // lives in `st`, so a revisited step re-offers the previous choice.
660
+ // Creation (step 7 below) is the point of no return — no back from there.
661
+ const slugifyName = (s) => (s || "")
662
+ .toLowerCase()
663
+ .replace(/[^a-z0-9-]/g, "-")
664
+ .replace(/-+/g, "-")
665
+ .replace(/^-|-$/g, "");
666
+ const BACK = ui.BACK;
667
+ const SKIP = Symbol("step-skip"); // deterministic step — passes through in both directions
668
+
669
+ const st = {
670
+ orgs: null, // fetched once
671
+ githubOrg: null, isOrg: false,
672
+ isNewProject: null,
673
+ orgName: null, description: "", newProjectRepo: null,
674
+ selectedRepoNames: null, // raw picker selection (preserved across back)
675
+ selectedRepos: [], // enriched {name, base_branch}
676
+ repoName: null, memoryRepoName: null,
677
+ reposCache: null, // {org, repos, teamRepoCount}
678
+ };
679
+
680
+ const stepHome = async () => {
681
+ if (!st.orgs) {
682
+ ui.section("Pick a home", "where should this Egregore live?");
683
+ const s = ui.spinner("Checking your GitHub accounts...");
684
+ st.orgs = await listOrgs(githubToken);
685
+ s.stop("Accounts loaded");
686
+ }
687
+ if (st.orgs.length === 0) {
688
+ if (!st.githubOrg) {
689
+ st.githubOrg = user.login;
690
+ st.isOrg = false;
691
+ ui.info(`Using your personal account: ${ui.bold(st.githubOrg)}`);
692
+ }
693
+ return SKIP;
672
694
  }
673
695
  const orgChoice = await ui.choose(
674
696
  "Where should Egregore live?",
675
- orgChoices,
697
+ [
698
+ { label: user.login, description: "Personal account" },
699
+ ...st.orgs.map((o) => ({ label: o.login, description: "Organization" })),
700
+ ],
676
701
  "Don't see your org? Authorize it for your account on GitHub, then re-run.",
702
+ { escAction: "back" },
677
703
  );
678
- githubOrg = orgChoice.label;
679
- isOrg = githubOrg !== user.login;
680
- }
704
+ if (orgChoice === BACK) return BACK; // first step — driver clamps to a re-ask
705
+ st.githubOrg = orgChoice.label;
706
+ st.isOrg = st.githubOrg !== user.login;
707
+ return true;
708
+ };
681
709
 
682
- // 4. What — new or existing?
683
- // If the user came in via the in-repo detection and chose "install
684
- // alongside", they already told us they have an existing project — don't
685
- // re-ask. Go straight to the existing-project path.
686
- let isNewProject;
687
- if (ctx.prefilledRepo) {
688
- isNewProject = false;
689
- ui.info(`${ui.green("→")} existing project — we'll manage ${ui.cyan(ctx.prefilledRepo.localName)} plus any others you pick.`);
690
- } else {
710
+ const stepWorkType = async () => {
711
+ // In-repo detection already answered this — pass through both ways.
712
+ if (ctx.prefilledRepo) {
713
+ if (st.isNewProject === null) {
714
+ st.isNewProject = false;
715
+ ui.info(`${ui.green("→")} existing project — we'll manage ${ui.cyan(ctx.prefilledRepo.localName)} plus any others you pick.`);
716
+ }
717
+ return SKIP;
718
+ }
691
719
  const action = await ui.choose("What are you working on?", [
692
720
  { label: "Starting something new", description: "Create a project from scratch" },
693
721
  { label: "Existing project", description: "Connect repos I already have" },
694
- ]);
695
- isNewProject = action.label.startsWith("Starting");
696
- }
722
+ ], undefined, { escAction: "back" });
723
+ if (action === BACK) return BACK;
724
+ st.isNewProject = action.label.startsWith("Starting");
725
+ return true;
726
+ };
697
727
 
698
- // 5. Collect project info
699
- ui.section("Name it", "what's this Egregore called?");
700
- let orgName, description, selectedRepos = [], newProjectRepo = null;
701
-
702
- if (isNewProject) {
703
- orgName = await ui.prompt("name your Egregore (team or group name)", { default: githubOrg });
704
- description = await ui.prompt("what are you building? (one line)");
705
-
706
- // Derive the egregore + memory repo names now so we can validate the
707
- // optional project repo name against them before the user commits to it.
708
- // Matches the final derivation at line ~395 below.
709
- const slugifyRepo = (s) => s.toLowerCase()
710
- .replace(/[^a-z0-9-]/g, "-")
711
- .replace(/-+/g, "-")
712
- .replace(/^-|-$/g, "") || "egregore";
713
- const predictedRepoName = slugifyRepo(orgName);
714
- const predictedMemoryRepoName = `${predictedRepoName}-memory`;
715
-
716
- // Ask about project repo
717
- ui.note([
718
- "Your Egregore gets its own repo (memory + config) in your account.",
719
- "Have a code project too? Egregore can add a repo for it in your account.",
720
- ]);
728
+ const stepNameManifest = async () => {
729
+ // One live manifest instead of scattered naming prompts: name the thing
730
+ // first, watch the repo plan derive from it in place, override any line.
731
+ // Every collision check runs against names VISIBLE on screen — the old
732
+ // flow rejected a code-repo name for colliding with an Egregore repo
733
+ // name the user had never been shown.
734
+ console.log("");
735
+ const isNew = st.isNewProject;
736
+ const validSlug = (v) => (slugifyName(v)
737
+ ? null
738
+ : "That doesn't produce a valid repo name. Try letters, numbers, and dashes.");
739
+ const fields = [
740
+ {
741
+ key: "orgName",
742
+ label: "Egregore",
743
+ note: "team or group name",
744
+ hint: "type your preferred Egregore name or hit enter to pick the naming below",
745
+ initial: st.orgName || undefined,
746
+ derive: () => st.githubOrg,
747
+ },
748
+ {
749
+ key: "repoName",
750
+ label: "Egregore repo",
751
+ note: "config + skills",
752
+ hint: `enter keeps this name · type to change · created in ${st.githubOrg}`,
753
+ tree: true,
754
+ initial: st.repoName || undefined,
755
+ derive: (v) => slugifyName(v.orgName) || "egregore",
756
+ normalize: (raw) => slugifyName(raw),
757
+ validate: (v) => validSlug(v),
758
+ },
759
+ {
760
+ key: "memoryRepoName",
761
+ label: "memory repo",
762
+ note: "shared memory",
763
+ hint: "enter keeps this name · type to change",
764
+ tree: true,
765
+ initial: st.memoryRepoName || undefined,
766
+ derive: (v) => `${v.repoName || "egregore"}-memory`,
767
+ normalize: (raw) => slugifyName(raw),
768
+ validate: (v, others) => validSlug(v)
769
+ || (v === others.repoName ? "Memory repo can't share the Egregore repo's name." : null),
770
+ },
771
+ ...(isNew ? [{
772
+ key: "codeRepo",
773
+ label: "code repo",
774
+ note: "your project · optional",
775
+ hint: "add a repo for your code project — or enter to skip",
776
+ tree: true,
777
+ optional: true,
778
+ initial: st.newProjectRepo || undefined,
779
+ normalize: (raw) => slugifyName(raw),
780
+ validate: (v, others) => validSlug(v)
781
+ || (v === others.repoName ? `"${v}" is the Egregore repo above. Pick a different name — say, ${v}-app.` : null)
782
+ || (v === others.memoryRepoName ? `"${v}" is the memory repo above. Pick a different name.` : null),
783
+ }] : []),
784
+ ];
785
+ const result = await ui.manifestForm({
786
+ title: "Name it",
787
+ subtitle: `repos land in ${st.githubOrg}`,
788
+ fields,
789
+ });
790
+ if (result === BACK) return BACK;
791
+ const v = result.values;
792
+ st.orgName = v.orgName;
793
+ st.repoName = v.repoName;
794
+ st.memoryRepoName = v.memoryRepoName;
795
+ st.newProjectRepo = isNew ? (v.codeRepo || null) : null;
796
+ return true;
797
+ };
721
798
 
722
- while (true) {
723
- const projectName = await ui.prompt("code project repo name (Enter to skip)");
724
- if (!projectName) {
725
- newProjectRepo = null;
726
- break;
727
- }
728
- const slug = slugifyRepo(projectName);
729
- if (!slug) {
730
- ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes.");
731
- continue;
732
- }
733
- if (slug === predictedRepoName) {
734
- ui.warn(`"${slug}" is already the name of your Egregore repo. Pick a different name.`);
735
- ui.info(` ${ui.dim("suggestion:")} ${ui.cyan(`${slug}-app`)}`);
736
- continue;
737
- }
738
- if (slug === predictedMemoryRepoName) {
739
- ui.warn(`"${slug}" collides with your memory repo. Pick a different name.`);
740
- continue;
741
- }
742
- newProjectRepo = slug;
743
- break;
799
+ // The one-liner is repo metadata, not a naming decision — it lives OUTSIDE
800
+ // the manifest so enter on the name lands directly in the naming tree
801
+ // (the manifest's hint promises exactly that).
802
+ const stepDescription = async () => {
803
+ if (!st.isNewProject) {
804
+ st.description = "";
805
+ return SKIP;
744
806
  }
745
- } else {
746
- // "Existing project"
747
- orgName = await ui.prompt("what should we call this Egregore?", { default: githubOrg });
748
- description = "";
807
+ console.log("");
808
+ const desc = await ui.promptOrBack("what are you building? (one line)", {
809
+ default: st.description || undefined,
810
+ hint: "one line on what you're making · enter to skip · ↑ back",
811
+ });
812
+ if (desc === BACK) return BACK;
813
+ st.description = desc;
814
+ return true;
815
+ };
749
816
 
817
+ const stepRepos = async () => {
818
+ if (st.isNewProject) {
819
+ st.selectedRepoNames = null;
820
+ st.selectedRepos = [];
821
+ return SKIP;
822
+ }
750
823
  // Auto-adopt the user's current repo if it's under the chosen GitHub owner.
751
824
  const autoAdopt =
752
825
  ctx.prefilledRepo &&
753
826
  ctx.prefilledRepo.owner &&
754
- ctx.prefilledRepo.owner.toLowerCase() === githubOrg.toLowerCase()
827
+ ctx.prefilledRepo.owner.toLowerCase() === st.githubOrg.toLowerCase()
755
828
  ? ctx.prefilledRepo.name
756
829
  : null;
757
830
 
@@ -759,47 +832,58 @@ async function localFounderFlow(ui, ctx = {}) {
759
832
  // 500 most recently updated repos, which buries a team's repos in a big
760
833
  // org — so the user's team repos are loaded too and boosted to the top,
761
834
  // and typing in the picker live-searches the WHOLE org (see
762
- // searchOrgRepos), so no repo is unreachable.
763
- const s0 = ui.spinner("Loading repos...");
764
- let repos = [];
765
- let teamRepoCount = 0;
766
- try {
767
- const [orgRepos, teams] = await Promise.all([
768
- listOrgRepos(githubToken, githubOrg, isOrg),
769
- isOrg ? listUserTeams(githubToken, githubOrg) : Promise.resolve([]),
770
- ]);
771
- const teamRepoLists = await Promise.all(
772
- teams.map((t) => listTeamRepos(githubToken, githubOrg, t.slug).catch(() => [])),
773
- );
774
- const seen = new Set();
775
- for (const list of teamRepoLists) {
776
- for (const r of list) {
835
+ // searchOrgRepos), so no repo is unreachable. The load is cached per
836
+ // owner so back-navigation doesn't refetch.
837
+ if (!st.reposCache || st.reposCache.org !== st.githubOrg) {
838
+ const s0 = ui.spinner("Loading repos...");
839
+ const repos = [];
840
+ let teamRepoCount = 0;
841
+ try {
842
+ const [orgRepos, teams] = await Promise.all([
843
+ listOrgRepos(githubToken, st.githubOrg, st.isOrg),
844
+ st.isOrg ? listUserTeams(githubToken, st.githubOrg) : Promise.resolve([]),
845
+ ]);
846
+ const teamRepoLists = await Promise.all(
847
+ teams.map((t) => listTeamRepos(githubToken, st.githubOrg, t.slug).catch(() => [])),
848
+ );
849
+ const seen = new Set();
850
+ for (const list of teamRepoLists) {
851
+ for (const r of list) {
852
+ if (seen.has(r.name)) continue;
853
+ seen.add(r.name);
854
+ repos.push(r);
855
+ }
856
+ }
857
+ teamRepoCount = repos.length;
858
+ for (const r of orgRepos) {
777
859
  if (seen.has(r.name)) continue;
778
860
  seen.add(r.name);
779
861
  repos.push(r);
780
862
  }
863
+ s0.stop(teamRepoCount > 0 ? `Loaded repos — your teams' ${teamRepoCount} first` : "Loaded repos");
864
+ } catch {
865
+ s0.stop("Could not load repos");
781
866
  }
782
- teamRepoCount = repos.length;
783
- for (const r of orgRepos) {
784
- if (seen.has(r.name)) continue;
785
- seen.add(r.name);
786
- repos.push(r);
787
- }
788
- s0.stop(teamRepoCount > 0 ? `Loaded repos — your teams' ${teamRepoCount} first` : "Loaded repos");
789
- } catch {
790
- s0.stop("Could not load repos");
867
+ st.reposCache = { org: st.githubOrg, repos, teamRepoCount };
791
868
  }
869
+ const repos = st.reposCache.repos;
792
870
 
793
871
  const filtered = autoAdopt ? repos.filter((r) => r.name !== autoAdopt) : repos;
794
872
  if (autoAdopt) {
795
873
  console.log("");
796
874
  ui.info(`${ui.cyan(autoAdopt)} will be managed automatically (your current repo)`);
797
875
  }
798
- selectedRepos = await ui.multiSelect(
876
+ const picked = await ui.multiSelect(
799
877
  "Which other repos should Egregore manage?",
800
878
  filtered,
801
- { remoteSearch: (term) => searchOrgRepos(githubToken, githubOrg, isOrg, term) },
879
+ {
880
+ remoteSearch: (term) => searchOrgRepos(githubToken, st.githubOrg, st.isOrg, term),
881
+ preselected: st.selectedRepoNames || [],
882
+ escAction: "back",
883
+ },
802
884
  );
885
+ if (picked === BACK) return BACK;
886
+ let selectedRepos = picked;
803
887
 
804
888
  // Non-interactive terminals get no live search — keep a typed-names
805
889
  // escape hatch there, validated so a typo doesn't become a ghost repo.
@@ -808,11 +892,11 @@ async function localFounderFlow(ui, ctx = {}) {
808
892
  for (const raw of (extraRaw || "").split(",")) {
809
893
  const name = raw.trim();
810
894
  if (!name || selectedRepos.includes(name)) continue;
811
- if (await repoExists(githubToken, githubOrg, name)) {
895
+ if (await repoExists(githubToken, st.githubOrg, name)) {
812
896
  selectedRepos.push(name);
813
897
  ui.success(`Added ${name}`);
814
898
  } else {
815
- ui.warn(`No repo ${githubOrg}/${name} — skipping`);
899
+ ui.warn(`No repo ${st.githubOrg}/${name} — skipping`);
816
900
  }
817
901
  }
818
902
  }
@@ -820,6 +904,7 @@ async function localFounderFlow(ui, ctx = {}) {
820
904
  selectedRepos.unshift(autoAdopt);
821
905
  }
822
906
  if (autoAdopt) skipRepos.add(autoAdopt);
907
+ st.selectedRepoNames = [...selectedRepos];
823
908
 
824
909
  // Detect default branch for each selected repo and convert to {name, base_branch} format
825
910
  if (selectedRepos.length > 0) {
@@ -827,14 +912,13 @@ async function localFounderFlow(ui, ctx = {}) {
827
912
  const reposWithBranch = [];
828
913
  for (const repoName of selectedRepos) {
829
914
  try {
830
- const info = await detectRepoBranches(githubToken, githubOrg, repoName);
915
+ const info = await detectRepoBranches(githubToken, st.githubOrg, repoName);
831
916
  reposWithBranch.push({ name: repoName, base_branch: info ? info.defaultBranch : "main" });
832
917
  } catch {
833
918
  reposWithBranch.push({ name: repoName, base_branch: "main" });
834
919
  }
835
920
  }
836
921
  s0b.stop("Checked branches");
837
- // Replace flat list with enriched objects
838
922
  selectedRepos = reposWithBranch;
839
923
  console.log("");
840
924
  ui.info("Detected branch setup:");
@@ -843,42 +927,48 @@ async function localFounderFlow(ui, ctx = {}) {
843
927
  }
844
928
  console.log("");
845
929
  }
930
+ st.selectedRepos = selectedRepos;
931
+ return true;
932
+ };
933
+
934
+ // Direction-aware driver: BACK rewinds one step; SKIP (deterministic
935
+ // steps) passes through in whichever direction we're moving, so backing
936
+ // over an auto-answered step doesn't bounce the user forward again.
937
+ // ALL naming (Egregore + repo plan) happens in the manifest BEFORE the
938
+ // existing-project repo picker — name the thing first, then attach repos.
939
+ const steps = [stepHome, stepWorkType, stepNameManifest, stepDescription, stepRepos];
940
+ let stepIdx = 0;
941
+ let stepDir = 1;
942
+ while (stepIdx < steps.length) {
943
+ const result = await steps[stepIdx]();
944
+ if (result === BACK) {
945
+ stepDir = -1;
946
+ stepIdx = Math.max(0, stepIdx - 1);
947
+ continue;
948
+ }
949
+ if (result === SKIP) {
950
+ stepIdx += stepDir;
951
+ if (stepIdx < 0) { stepIdx = 0; stepDir = 1; }
952
+ continue;
953
+ }
954
+ stepDir = 1;
955
+ stepIdx++;
846
956
  }
847
957
 
848
- // 6. Derive repo names from team name — then let the founder edit them.
849
- // Creating repos with silently derived names was a repeated field
850
- // complaint: founders want to choose what lands in their org.
851
- const slugifyName = (s) => (s || "")
852
- .toLowerCase()
853
- .replace(/[^a-z0-9-]/g, "-")
854
- .replace(/-+/g, "-")
855
- .replace(/^-|-$/g, "");
958
+ const githubOrg = st.githubOrg;
959
+ const isOrg = st.isOrg;
960
+ const isNewProject = st.isNewProject;
961
+ const orgName = st.orgName;
962
+ const description = st.description;
963
+ let newProjectRepo = st.newProjectRepo;
964
+ let selectedRepos = st.selectedRepos;
965
+ const repoName = st.repoName;
966
+ const memoryRepoName = st.memoryRepoName;
856
967
  const nameSlug = slugifyName(orgName) || "egregore";
857
968
  // Org slug prefixed with GitHub owner — globally unique across all egregores
858
969
  const ghPrefix = slugifyName(githubOrg);
859
970
  const slug = ghPrefix === nameSlug ? nameSlug : `${ghPrefix}-${nameSlug}`;
860
971
 
861
- console.log("");
862
- ui.note([
863
- `Two repos will be created in ${ui.bold(githubOrg)}: the Egregore repo`,
864
- "(config + skills) and its shared-memory repo. Enter keeps the defaults.",
865
- ]);
866
- let repoName;
867
- for (;;) {
868
- repoName = slugifyName(await ui.prompt("Egregore repo name", { default: nameSlug }));
869
- if (!repoName) { ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes."); continue; }
870
- if (newProjectRepo && repoName === newProjectRepo) { ui.warn(`"${repoName}" is already your project repo. Pick a different name.`); continue; }
871
- break;
872
- }
873
- let memoryRepoName;
874
- for (;;) {
875
- memoryRepoName = slugifyName(await ui.prompt("memory repo name", { default: `${repoName}-memory` }));
876
- if (!memoryRepoName) { ui.warn("That doesn't produce a valid repo name. Try letters, numbers, and dashes."); continue; }
877
- if (memoryRepoName === repoName) { ui.warn("Memory repo can't share the Egregore repo's name."); continue; }
878
- if (newProjectRepo && memoryRepoName === newProjectRepo) { ui.warn(`"${memoryRepoName}" is already your project repo. Pick a different name.`); continue; }
879
- break;
880
- }
881
-
882
972
  // Guard: if egregore repo already exists with a config, don't overwrite
883
973
  console.log("");
884
974
  const s1 = ui.spinner("Checking for existing Egregore...");
@@ -1067,7 +1157,10 @@ async function localFounderFlow(ui, ctx = {}) {
1067
1157
 
1068
1158
  // 14. "What should we call you?" + write state
1069
1159
  ui.section("Your name", "this is how teammates will see you in handoffs");
1070
- const displayName = await ui.prompt("what should we call you?", { default: user.login });
1160
+ const displayName = await ui.prompt("what should we call you?", {
1161
+ default: user.login,
1162
+ hint: `type a display name · enter keeps ${user.login}`,
1163
+ });
1071
1164
  const memberRole = await ensureInstallerMemberRole(
1072
1165
  { usage_type: "founder_group", member_role: ctx.memberRole },
1073
1166
  {},