magus-cli 7.13.0 → 7.14.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.
Files changed (43) hide show
  1. package/README.md +7 -4
  2. package/package.json +4 -4
  3. package/src/__tests__/market-r2-adapters.test.ts +190 -0
  4. package/src/__tests__/market-r2-company-cli.test.ts +350 -0
  5. package/src/__tests__/market-r2-company-unit.test.ts +224 -0
  6. package/src/__tests__/market-r2-local-items.test.ts +197 -0
  7. package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
  8. package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
  9. package/src/__tests__/market-r2-routing.test.ts +127 -0
  10. package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
  11. package/src/__tests__/market-r2b-display-path.test.ts +180 -0
  12. package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
  13. package/src/__tests__/market-r2b-local-items.test.ts +253 -0
  14. package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
  15. package/src/__tests__/market-router.test.ts +4 -4
  16. package/src/cli/market-view.ts +59 -18
  17. package/src/cli/market.ts +128 -44
  18. package/src/cli/router.ts +42 -10
  19. package/src/data/marketplaces.ts +1 -0
  20. package/src/main.tsx +1 -1
  21. package/src/market-tui.tsx +1 -1
  22. package/src/services/local-marketplace.ts +8 -0
  23. package/src/services/market-change.ts +6 -6
  24. package/src/services/market-local.ts +93 -0
  25. package/src/services/market-ops-plugin.ts +366 -47
  26. package/src/services/market-ops-publish.ts +363 -15
  27. package/src/services/market-ops-repo.ts +94 -13
  28. package/src/services/market-ops-skill.ts +93 -11
  29. package/src/services/market-ops.ts +72 -8
  30. package/src/services/market-publish.ts +10 -1
  31. package/src/services/market-types.ts +23 -0
  32. package/src/tui-runtime.tsx +2 -2
  33. package/src/types/index.ts +2 -0
  34. package/src/ui/adapters/marketAdapter.ts +123 -4
  35. package/src/ui/adapters/pluginsAdapter.ts +25 -1
  36. package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
  37. package/src/ui/market/MarketplaceScreen.tsx +55 -24
  38. package/src/ui/market/MarketplacesDetail.snowflake.tsx +60 -4
  39. package/src/ui/market/MarketplacesScreen.tsx +120 -29
  40. package/src/ui/market/PluginDetail.snowflake.tsx +12 -1
  41. package/src/ui/market/marketKeymap.ts +29 -2
  42. package/src/ui/market/useMarketState.ts +146 -5
  43. package/src/utils/display-path.ts +33 -0
@@ -15,6 +15,8 @@
15
15
  * the in-domain part of the plugin's skill, commits exactly the planned paths,
16
16
  * delivers, moves this machine's installs through `claude plugin` commands,
17
17
  * and removes the local copy only when it holds exactly the committed files.
18
+ * A plugin-wide publish does the same for every skill of one plugin edited
19
+ * here, as one commit and one version.
18
20
  */
19
21
 
20
22
  import fs from "node:fs";
@@ -56,6 +58,7 @@ import {
56
58
  installLines,
57
59
  installStep,
58
60
  pluginViewAt,
61
+ readPlugin,
59
62
  versionFiles,
60
63
  versionLine,
61
64
  } from "./market-ops-content.js";
@@ -73,6 +76,7 @@ import {
73
76
  planError,
74
77
  } from "./market-ops.js";
75
78
  import {
79
+ type InstalledAt,
76
80
  type SourceSets,
77
81
  copyMismatch,
78
82
  installReaches,
@@ -498,6 +502,41 @@ function sourceFolder(cwd: string, args: PublishArgs): string {
498
502
  const FORCE_FIX =
499
503
  "to replace the plugin's version with this copy, run again with --force";
500
504
 
505
+ /**
506
+ * Why a publish keeps the local copy at `folder`, or null when it will be
507
+ * removed once the push lands and every install moves. Read-only.
508
+ */
509
+ async function keepReasonFor(input: {
510
+ folder: string;
511
+ skill: string;
512
+ from: "project" | "global";
513
+ keepLocal: boolean;
514
+ plugin: string;
515
+ pluginId: string;
516
+ installs: readonly InstalledAt[];
517
+ committed: readonly SkillFile[];
518
+ cwd: string;
519
+ }): Promise<{ reason: string | null; managed: boolean }> {
520
+ const managed =
521
+ input.from === "project" && (await profileManaged(input.cwd, input.skill));
522
+ const reason = input.keepLocal
523
+ ? "--keep-local"
524
+ : managed
525
+ ? PROFILE_KEEPS
526
+ : !installReaches(
527
+ input.from === "global" ? "user" : "project",
528
+ input.installs.map((i) => i.scope),
529
+ )
530
+ ? `${input.plugin} is not installed where this copy loads`
531
+ : ((await offHereReason(input.plugin, input.pluginId, input.cwd)) ??
532
+ (await copyMismatch(
533
+ input.folder,
534
+ input.committed,
535
+ "will not be published",
536
+ )));
537
+ return { reason, managed };
538
+ }
539
+
501
540
  export async function planPublish(
502
541
  args: PublishArgs,
503
542
  cwd: string,
@@ -584,23 +623,21 @@ export async function planPublish(
584
623
  const target = await deliveryTarget(m.source, m.name);
585
624
  const installs = await installedScopes(pluginId, cwd);
586
625
  const copyScope = args.from === "global" ? "user" : "project";
587
- const managed =
588
- args.from === "project" && (await profileManaged(cwd, args.skill));
589
626
  const committed: SkillFile[] = [
590
627
  ...cmp.sets.published,
591
628
  ...cmp.theirsOut.map((f) => ({ path: f.path, oid: f.oid })),
592
629
  ];
593
- const keepReason = args.keepLocal
594
- ? "--keep-local"
595
- : managed
596
- ? PROFILE_KEEPS
597
- : !installReaches(
598
- copyScope,
599
- installs.map((i) => i.scope),
600
- )
601
- ? `${plugin} is not installed where this copy loads`
602
- : ((await offHereReason(plugin, pluginId, cwd)) ??
603
- (await copyMismatch(folder, committed, "will not be published")));
630
+ const { reason: keepReason, managed } = await keepReasonFor({
631
+ folder,
632
+ skill: args.skill,
633
+ from: args.from,
634
+ keepLocal: args.keepLocal,
635
+ plugin,
636
+ pluginId,
637
+ installs,
638
+ committed,
639
+ cwd,
640
+ });
604
641
  const predicted: LocalCopy = keepReason
605
642
  ? {
606
643
  scope: copyScope,
@@ -624,7 +661,7 @@ export async function planPublish(
624
661
  message: `${plugin}: update ${args.skill} skill (${to})`,
625
662
  slug: `${plugin}-${to}`,
626
663
  prBody: `Updates the skill \`${args.skill}\` in \`${plugin}\` and bumps it to ${to}.\n\nChanged: ${changeList(cmp.changes)}.`,
627
- verify: { dir: skillDir, files: committed },
664
+ verify: [{ dir: skillDir, files: committed }],
628
665
  state,
629
666
  }),
630
667
  installStep(view, installs, to, state),
@@ -730,6 +767,316 @@ export async function planPublish(
730
767
  }
731
768
  }
732
769
 
770
+ // ─── publish a plugin ────────────────────────────────────────────────────────
771
+
772
+ export interface PluginPublishArgs {
773
+ /** `plugin@marketplace`; the marketplace may be left out when there is one. */
774
+ plugin: string;
775
+ keepLocal: boolean;
776
+ }
777
+
778
+ const STATE_WORDS: Record<"no-base" | "conflict", string> = {
779
+ "no-base": "origin unknown",
780
+ conflict: "changed here and on GitHub",
781
+ };
782
+
783
+ /** One local copy a plugin-wide publish sends. */
784
+ interface PublishedCopy {
785
+ skill: string;
786
+ from: "project" | "global";
787
+ folder: string;
788
+ skillDir: string;
789
+ cmp: LocalCompare;
790
+ committed: SkillFile[];
791
+ keepReason: string | null;
792
+ managed: boolean;
793
+ predicted: LocalCopy;
794
+ }
795
+
796
+ /**
797
+ * `market publish --plugin <p@m>` with no skill: every skill of the plugin
798
+ * whose local copy here has edits the plugin has not seen (`edited` in
799
+ * `market status`), sent as one commit and one patch version. A copy whose
800
+ * origin is unknown, or that changed on both sides, is listed and left out:
801
+ * it goes on its own, by name, with --force. Read-only.
802
+ */
803
+ export async function planPluginPublish(
804
+ args: PluginPublishArgs,
805
+ cwd: string,
806
+ ): Promise<PlanAnswer> {
807
+ const command: MarketCommand = "market publish";
808
+ try {
809
+ const view = await readPlugin(args.plugin);
810
+ const { m, snap, plugin } = view;
811
+ const pluginId = `${plugin}@${m.name}`;
812
+ const prefix = joinRel(view.dir, "skills", "");
813
+ const skills = new Set<string>();
814
+ for (const p of snap.entries.keys()) {
815
+ if (!p.startsWith(`${prefix}/`)) continue;
816
+ const rest = p.slice(prefix.length + 1).split("/");
817
+ if (rest.length === 2 && rest[1] === "SKILL.md" && rest[0]) {
818
+ skills.add(rest[0]);
819
+ }
820
+ }
821
+ const folders = localSkillFolders(cwd).filter((f) => skills.has(f.skill));
822
+ const edited: Array<{ f: LocalFolder; cmp: LocalCompare }> = [];
823
+ const leftOut: Array<{
824
+ skill: string;
825
+ from: "project" | "global";
826
+ state: "no-base" | "conflict";
827
+ }> = [];
828
+ for (const f of folders) {
829
+ const cmp = await compareLocal(f.path, view, f.skill);
830
+ if (cmp.state === "fast") edited.push({ f, cmp });
831
+ else if (cmp.state === "no-base" || cmp.state === "conflict") {
832
+ leftOut.push({
833
+ skill: f.skill,
834
+ from: f.from === "user" ? "global" : "project",
835
+ state: cmp.state,
836
+ });
837
+ }
838
+ }
839
+ const twice = edited.find(
840
+ (e, i) => edited.findIndex((o) => o.f.skill === e.f.skill) !== i,
841
+ );
842
+ if (twice) {
843
+ throw new MarketError(
844
+ "ambiguous",
845
+ `Both this project and your USER skills have edits to ${twice.f.skill}`,
846
+ `publish one at a time: \`magus market publish ${twice.f.skill} --plugin ${pluginId} --from project\` (or --from global)`,
847
+ );
848
+ }
849
+ const leftOutLines = leftOut.map(
850
+ (l) =>
851
+ `Not included: ${l.skill} (${STATE_WORDS[l.state]}) — publish it on its own with \`magus market publish ${l.skill} --plugin ${pluginId}${l.from === "global" ? " --from global" : ""} --force\``,
852
+ );
853
+ const base = { marketplace: m.name, plugin };
854
+ if (edited.length === 0) {
855
+ if (leftOut.length > 0) {
856
+ const message = `No skill of ${pluginId} has edits here that it can publish as they are — nothing changed`;
857
+ return {
858
+ kind: "result",
859
+ result: {
860
+ command,
861
+ outcome: "failed",
862
+ message,
863
+ ...base,
864
+ plan: leftOutLines,
865
+ steps: [],
866
+ error: {
867
+ code: leftOut[0]?.state ?? "no-base",
868
+ message,
869
+ fix: "publish each one on its own with --force, which asks before it replaces the plugin's version",
870
+ },
871
+ },
872
+ };
873
+ }
874
+ return {
875
+ kind: "result",
876
+ result: {
877
+ command,
878
+ outcome: "unchanged",
879
+ message: `No skill of ${pluginId} has edits here to publish`,
880
+ ...base,
881
+ steps: [],
882
+ },
883
+ };
884
+ }
885
+
886
+ const { from, to } = bumpFrom(view);
887
+ const target = await deliveryTarget(m.source, m.name);
888
+ const installs = await installedScopes(pluginId, cwd);
889
+ const copies: PublishedCopy[] = [];
890
+ for (const { f, cmp } of edited) {
891
+ const fromWord = f.from === "user" ? "global" : "project";
892
+ const committed: SkillFile[] = [
893
+ ...cmp.sets.published,
894
+ ...cmp.theirsOut.map((t) => ({ path: t.path, oid: t.oid })),
895
+ ];
896
+ const { reason, managed } = await keepReasonFor({
897
+ folder: f.path,
898
+ skill: f.skill,
899
+ from: fromWord,
900
+ keepLocal: args.keepLocal,
901
+ plugin,
902
+ pluginId,
903
+ installs,
904
+ committed,
905
+ cwd,
906
+ });
907
+ const scope = f.from;
908
+ copies.push({
909
+ skill: f.skill,
910
+ from: fromWord,
911
+ folder: f.path,
912
+ skillDir: joinRel(view.dir, "skills", f.skill),
913
+ cmp,
914
+ committed,
915
+ keepReason: reason,
916
+ managed,
917
+ predicted: reason
918
+ ? { scope, name: f.skill, action: "kept", reason }
919
+ : { scope, name: f.skill, action: "removed" },
920
+ });
921
+ }
922
+ const files = [
923
+ ...copies.flatMap((c) =>
924
+ c.cmp.changes.map((ch) => ({
925
+ path: joinRel(c.skillDir, ch.path),
926
+ change: ch.change,
927
+ })),
928
+ ),
929
+ ...versionFiles(view),
930
+ ];
931
+ const names = copies.map((c) => c.skill);
932
+ const state: ChangeSpec["state"] & { moved?: boolean } = {};
933
+ const steps: MarketStep[] = [
934
+ ...copies.map((c) =>
935
+ replaceStep(
936
+ c.folder,
937
+ c.skillDir,
938
+ plugin,
939
+ c.skill,
940
+ c.cmp,
941
+ c.committed,
942
+ `replace:${c.skill}`,
943
+ ),
944
+ ),
945
+ bumpStep(view, to),
946
+ ...changeSteps({
947
+ label: m.name,
948
+ source: m.source,
949
+ branch: snap.branch,
950
+ target,
951
+ stage: [
952
+ ...copies.map((c) => c.skillDir),
953
+ ...versionFiles(view).map((f) => f.path),
954
+ ],
955
+ files,
956
+ message: `${plugin}: update ${names.join(", ")} (${to})`,
957
+ slug: `${plugin}-${to}`,
958
+ prBody: `Updates ${plural(names.length, "skill")} in \`${plugin}\` and bumps it to ${to}.\n\n${copies.map((c) => `- \`${c.skill}\`: ${changeList(c.cmp.changes)}`).join("\n")}`,
959
+ verify: copies.map((c) => ({ dir: c.skillDir, files: c.committed })),
960
+ state,
961
+ }).map((s) =>
962
+ s.id === "deliver"
963
+ ? {
964
+ ...s,
965
+ // A pull request stops the apply here: nothing is installed,
966
+ // so every local copy stays until it is merged.
967
+ run: async (ctx: Parameters<MarketStep["run"]>[0]) => {
968
+ const out = await s.run(ctx);
969
+ if (!out?.stop) return out;
970
+ const kept: LocalCopy[] = copies.map((c) => ({
971
+ scope: c.predicted.scope,
972
+ name: c.skill,
973
+ action: "kept",
974
+ reason:
975
+ "the change went out as a pull request; the plugin's version replaces this copy once it is merged",
976
+ }));
977
+ return {
978
+ ...out,
979
+ patch: { ...out.patch, localCopies: kept },
980
+ };
981
+ },
982
+ }
983
+ : s,
984
+ ),
985
+ installStep(view, installs, to, state),
986
+ {
987
+ id: "local-copy",
988
+ label: "settled the local copies",
989
+ outside: true,
990
+ async run() {
991
+ const settled: LocalCopy[] = [];
992
+ for (const c of copies) {
993
+ const copy = await settleLocalCopy({
994
+ folder: c.folder,
995
+ committed: c.committed,
996
+ copy: { scope: c.predicted.scope, name: c.skill },
997
+ keepReason: c.keepReason,
998
+ moved: state.moved === true,
999
+ plugin,
1000
+ pluginId,
1001
+ cwd,
1002
+ });
1003
+ if (
1004
+ copy.action === "kept" &&
1005
+ !copy.partial &&
1006
+ !c.managed &&
1007
+ state.commit
1008
+ ) {
1009
+ recordBase(c.folder, {
1010
+ marketplace: m.name,
1011
+ repo: m.source,
1012
+ plugin,
1013
+ skill: c.skill,
1014
+ commit: state.commit,
1015
+ tree: c.cmp.sets.hash,
1016
+ });
1017
+ }
1018
+ settled.push(copy);
1019
+ }
1020
+ return { patch: { localCopies: settled } };
1021
+ },
1022
+ },
1023
+ ];
1024
+ const lines = [
1025
+ `Publishes ${plural(copies.length, "edited skill")} to ${pluginId} as one new version: ${names.join(", ")}`,
1026
+ versionLine(plugin, from, to),
1027
+ ...copies.flatMap((c) => [
1028
+ `${c.skill} (${whoseCopy(c.from)}): ${plural(c.cmp.changes.length, "file")} — ${changeList(c.cmp.changes)}`,
1029
+ ...(c.cmp.sets.notPublished.length > 0
1030
+ ? [
1031
+ `${c.skill}, not published: ${c.cmp.sets.notPublished.map((n) => `${n.path} (${n.reason})`).join(", ")}`,
1032
+ ]
1033
+ : []),
1034
+ ]),
1035
+ ...leftOutLines,
1036
+ targetLine(target, snap.branch),
1037
+ "A pull request moves no install and keeps the local copies",
1038
+ ...installLines(plugin, installs, to),
1039
+ ...copies.map((c) =>
1040
+ c.predicted.action === "removed"
1041
+ ? `Afterwards removes ${whoseCopy(c.from)} of ${c.skill}`
1042
+ : `Keeps ${whoseCopy(c.from)} of ${c.skill}: ${c.predicted.reason}`,
1043
+ ),
1044
+ ];
1045
+ const plan: MarketPlan = {
1046
+ command,
1047
+ lines,
1048
+ planId: computePlanId({
1049
+ op: "publish-plugin",
1050
+ plugin: pluginId,
1051
+ keepLocal: args.keepLocal,
1052
+ branch: snap.branch,
1053
+ commit: snap.commit,
1054
+ skills: copies.map((c) => ({
1055
+ skill: c.skill,
1056
+ from: c.from,
1057
+ base: c.cmp.baseCommit,
1058
+ hash: c.cmp.sets.hash,
1059
+ notPublished: c.cmp.sets.notPublished,
1060
+ localCopy: c.predicted,
1061
+ })),
1062
+ leftOut,
1063
+ target,
1064
+ version: { from, to },
1065
+ installs,
1066
+ }),
1067
+ summary: { ...base, version: { from, to }, files, target },
1068
+ steps,
1069
+ sync: { name: m.name, url: cloneUrl(m.source) },
1070
+ lock: m.name,
1071
+ replan: () => planPluginPublish({ ...args, plugin: pluginId }, cwd),
1072
+ doneMessage: `${names.join(", ")} ${names.length === 1 ? "is" : "are"} published in ${pluginId} ${to}. Takes effect in new sessions, or after /reload-plugins.`,
1073
+ };
1074
+ return { kind: "plan", plan };
1075
+ } catch (error) {
1076
+ return planError(command, error);
1077
+ }
1078
+ }
1079
+
733
1080
  function capital(text: string): string {
734
1081
  return text.charAt(0).toUpperCase() + text.slice(1);
735
1082
  }
@@ -760,9 +1107,10 @@ function replaceStep(
760
1107
  skill: string,
761
1108
  cmp: LocalCompare,
762
1109
  committed: readonly SkillFile[],
1110
+ id = "replace",
763
1111
  ): MarketStep {
764
1112
  return {
765
- id: "replace",
1113
+ id,
766
1114
  label: `copied ${skill} into ${plugin}`,
767
1115
  outside: false,
768
1116
  async run(ctx) {
@@ -71,10 +71,53 @@ import { fastForwardCloneIfPresent } from "./marketplace-refresh.js";
71
71
  const MANIFEST = ".claude-plugin/marketplace.json";
72
72
  const README = "README.md";
73
73
 
74
- function newManifest(name: string, owner: string, description?: string) {
74
+ /** The longest company name a marketplace may carry, in characters. */
75
+ export const COMPANY_NAME_MAX = 64;
76
+
77
+ /**
78
+ * Check a company name for `owner.name`: one line, not blank, at most
79
+ * {@link COMPANY_NAME_MAX} characters once trimmed. Returns it trimmed; a
80
+ * bad one is a usage error (exit 2).
81
+ */
82
+ export function checkCompanyName(value: string): string {
83
+ const company = value.trim();
84
+ if (company === "") {
85
+ throw new MarketError(
86
+ "invalid-name",
87
+ "The company name is empty",
88
+ "pass --company <name>",
89
+ 2,
90
+ );
91
+ }
92
+ // Every control character (C0, DEL, C1 — NEL included) and the Unicode
93
+ // line and paragraph separators.
94
+ if (/[\p{Cc}\u2028\u2029]/u.test(company)) {
95
+ throw new MarketError(
96
+ "invalid-name",
97
+ "The company name must be a single line",
98
+ "pass --company <name>",
99
+ 2,
100
+ );
101
+ }
102
+ if ([...company].length > COMPANY_NAME_MAX) {
103
+ throw new MarketError(
104
+ "invalid-name",
105
+ `The company name is longer than ${COMPANY_NAME_MAX} characters`,
106
+ "pass a shorter --company <name>",
107
+ 2,
108
+ );
109
+ }
110
+ return company;
111
+ }
112
+
113
+ function companyLine(company: string): string {
114
+ return `Company name: ${company} (magus shows the marketplace as "by ${company}")`;
115
+ }
116
+
117
+ function newManifest(name: string, company: string, description?: string) {
75
118
  return withMarker({
76
119
  name,
77
- owner: { name: owner },
120
+ owner: { name: company },
78
121
  metadata: {
79
122
  description: description ?? `${name} company plugin marketplace`,
80
123
  },
@@ -171,7 +214,7 @@ function trustStep(
171
214
  function initSteps(
172
215
  name: string,
173
216
  source: string,
174
- owner: string,
217
+ company: string,
175
218
  branch: string,
176
219
  description: string | undefined,
177
220
  ): MarketStep[] {
@@ -186,7 +229,7 @@ function initSteps(
186
229
  fs.rmSync(dir, { recursive: true, force: true });
187
230
  const r = await initWithRemote(dir, cloneUrl(source), branch);
188
231
  if (!r.ok) throw gitError(r, name);
189
- writeJson(dir, MANIFEST, newManifest(name, owner, description), name);
232
+ writeJson(dir, MANIFEST, newManifest(name, company, description), name);
190
233
  writeFileUnder(dir, README, readme(name, source), linkRefusal(name));
191
234
  return undefined;
192
235
  },
@@ -290,6 +333,11 @@ export interface CreateArgs {
290
333
  visibility: "private" | "public";
291
334
  name?: string;
292
335
  description?: string;
336
+ /**
337
+ * The company name written as the manifest's `owner.name`; the GitHub
338
+ * owner of `repo` when absent.
339
+ */
340
+ company?: string;
293
341
  }
294
342
 
295
343
  /**
@@ -324,6 +372,7 @@ export async function planCreate(args: CreateArgs): Promise<PlanAnswer> {
324
372
  );
325
373
  }
326
374
  const [owner = "", repoName = ""] = args.repo.split("/");
375
+ const company = checkCompanyName(args.company ?? owner);
327
376
  const name = args.name ?? repoName.toLowerCase();
328
377
  if (!validPackageName(name)) {
329
378
  throw new MarketError(
@@ -382,7 +431,7 @@ export async function planCreate(args: CreateArgs): Promise<PlanAnswer> {
382
431
  },
383
432
  },
384
433
  ...[
385
- ...initSteps(name, source, owner, branch, args.description),
434
+ ...initSteps(name, source, company, branch, args.description),
386
435
  registerStep(name, source),
387
436
  trustStep(name, source, "create"),
388
437
  ].map((s) => afterCreate(s, args.repo)),
@@ -392,9 +441,16 @@ export async function planCreate(args: CreateArgs): Promise<PlanAnswer> {
392
441
  lines: [
393
442
  `Creates the ${args.visibility} GitHub repository ${args.repo}`,
394
443
  `Writes a company marketplace named ${name} into it and pushes it to ${branch}`,
444
+ companyLine(company),
395
445
  `Adds ${name} to this machine at USER scope and marks it as a company marketplace here`,
396
446
  ],
397
- planId: computePlanId({ op: "create", ...args, name, branch }),
447
+ planId: computePlanId({
448
+ op: "create",
449
+ ...args,
450
+ name,
451
+ company,
452
+ branch,
453
+ }),
398
454
  summary: { marketplace: name },
399
455
  steps: steps.map((s) =>
400
456
  s.id === "init" ||
@@ -427,6 +483,12 @@ function teammateNote(name: string): string {
427
483
  export interface ConnectArgs {
428
484
  source: string;
429
485
  name?: string;
486
+ /**
487
+ * The company name for a manifest connect writes (`init` and `add` only);
488
+ * the GitHub owner of the source when absent. A repository that already
489
+ * holds a marketplace keeps the `owner.name` its manifest gives.
490
+ */
491
+ company?: string;
430
492
  }
431
493
 
432
494
  async function readRemoteManifest(
@@ -470,6 +532,8 @@ export async function planConnect(
470
532
  ): Promise<PlanAnswer> {
471
533
  const command: MarketCommand = "market connect";
472
534
  try {
535
+ // The value's form is checked before any GitHub or git work.
536
+ if (args.company !== undefined) checkCompanyName(args.company);
473
537
  const source = normaliseSource(args.source, cwd);
474
538
  const github = isGithubSource(source);
475
539
  const url = cloneUrl(source);
@@ -495,6 +559,14 @@ export async function planConnect(
495
559
  2,
496
560
  );
497
561
  }
562
+ if (args.company !== undefined && !fresh) {
563
+ throw new MarketError(
564
+ "other",
565
+ "--company applies only when connect sets up a repository with no marketplace in it; this one keeps the company name its manifest gives",
566
+ "run it again without --company",
567
+ 2,
568
+ );
569
+ }
498
570
  const base = path
499
571
  .basename(source)
500
572
  .replace(/\.git$/, "")
@@ -549,18 +621,25 @@ export async function planConnect(
549
621
  : source;
550
622
  const steps: MarketStep[] = [];
551
623
  const lines: string[] = [];
552
- const owner = github ? (source.split("/")[0] ?? name) : name;
624
+ const company = fresh
625
+ ? checkCompanyName(
626
+ args.company ?? (github ? (source.split("/")[0] ?? name) : name),
627
+ )
628
+ : null;
553
629
  const delivered: ChangeSpec["state"] = {};
554
630
  if (action === "init") {
555
631
  lines.push(
556
632
  `${source} holds no marketplace yet: writes a company marketplace named ${name} into it and pushes it to ${branch}`,
633
+ companyLine(company as string),
557
634
  );
558
635
  steps.push(
559
- ...initSteps(name, source, owner, branch, undefined).map((s) => ({
560
- ...s,
561
- run: (ctx: Parameters<MarketStep["run"]>[0]) =>
562
- s.run({ ...ctx, clone: workingClonePath(name) }),
563
- })),
636
+ ...initSteps(name, source, company as string, branch, undefined).map(
637
+ (s) => ({
638
+ ...s,
639
+ run: (ctx: Parameters<MarketStep["run"]>[0]) =>
640
+ s.run({ ...ctx, clone: workingClonePath(name) }),
641
+ }),
642
+ ),
564
643
  );
565
644
  } else if (action === "mark" || action === "add") {
566
645
  const target = await deliveryTarget(source, name);
@@ -568,6 +647,7 @@ export async function planConnect(
568
647
  action === "mark"
569
648
  ? `${name} is a marketplace without the company marker: commits the marker to its manifest`
570
649
  : `${source} holds no marketplace yet: commits a company marketplace named ${name} on top of its ${branch} history, leaving its files as they are`,
650
+ ...(action === "add" ? [companyLine(company as string)] : []),
571
651
  targetLine(target, branch),
572
652
  );
573
653
  const state = delivered;
@@ -597,7 +677,7 @@ export async function planConnect(
597
677
  writeJson(
598
678
  clone,
599
679
  MANIFEST,
600
- newManifest(name, owner, undefined),
680
+ newManifest(name, company as string, undefined),
601
681
  name,
602
682
  );
603
683
  return undefined;
@@ -689,6 +769,7 @@ export async function planConnect(
689
769
  branch,
690
770
  action,
691
771
  name,
772
+ company,
692
773
  registered,
693
774
  }),
694
775
  summary: { marketplace: name },