@shortlink-org/portolan 0.2.0 → 0.2.2

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 (109) hide show
  1. package/cli/init.mjs +14 -6
  2. package/cli/init.test.mjs +8 -1
  3. package/cli/portolan.mjs +56 -7
  4. package/cli/portolan.test.mjs +47 -0
  5. package/package.json +7 -6
  6. package/plugins/extract-django/README.md +1 -1
  7. package/plugins/extract-django/extract_test.py +1 -1
  8. package/plugins/extract-django/openapi.py +4 -14
  9. package/plugins/extract-django/openapi_test.py +4 -4
  10. package/plugins/extract-go/options.schema.json +5 -0
  11. package/plugins/extract-project/options.schema.json +21 -0
  12. package/plugins/extract-ts/README.md +1 -1
  13. package/plugins/extract-ts/clients.ts +1 -1
  14. package/plugins/extract-ts/extract.test.ts +2 -2
  15. package/plugins/extract-ts/graphql.test.ts +2 -2
  16. package/plugins/extract-ts/openapi.test.ts +6 -7
  17. package/plugins/extract-ts/openapi.ts +3 -10
  18. package/plugins/portolan-go.wasm +0 -0
  19. package/public/404-cat-v1.webp +0 -0
  20. package/public/cat-about-v1.webp +0 -0
  21. package/public/cat-clear-skies-v1.webp +0 -0
  22. package/public/cat-no-changes-v1.webp +0 -0
  23. package/public/cat-onboarding-v1.webp +0 -0
  24. package/public/cat-trial-success-v1.webp +0 -0
  25. package/schema/portolan.schema.json +61 -0
  26. package/scripts/catalog-sources.mjs +12 -1
  27. package/scripts/catalog-sources.test.mjs +35 -0
  28. package/scripts/gen-likec4.mjs +242 -85
  29. package/scripts/gen-likec4.test.mjs +168 -14
  30. package/scripts/gen.mjs +5 -3
  31. package/scripts/host-plugins/fetch-git.mjs +22 -12
  32. package/scripts/host-plugins/fetch-git.test.mjs +25 -5
  33. package/scripts/local-api.mjs +368 -34
  34. package/scripts/local-api.test.mjs +197 -2
  35. package/scripts/package-smoke.mjs +19 -2
  36. package/scripts/plugin-host.mjs +14 -3
  37. package/scripts/plugin-host.test.mjs +8 -0
  38. package/scripts/schema.mjs +7 -0
  39. package/src/app/CatalogApp.tsx +19 -4
  40. package/src/app/CommandPalette.tsx +7 -2
  41. package/src/app/toast.tsx +11 -7
  42. package/src/catalog.test.ts +33 -0
  43. package/src/catalog.ts +190 -8
  44. package/src/components/CatIllustration.tsx +30 -0
  45. package/src/components/CommitLink.tsx +154 -0
  46. package/src/components/EditorLink.tsx +15 -8
  47. package/src/components/GrpcMethodReference.tsx +93 -0
  48. package/src/components/Markdown.tsx +17 -0
  49. package/src/components/MethodRows.tsx +8 -1
  50. package/src/components/PageHeader.test.ts +24 -0
  51. package/src/components/PageHeader.tsx +34 -0
  52. package/src/components/ShapeRows.tsx +12 -0
  53. package/src/components/SourceDoc.tsx +27 -12
  54. package/src/components/SourcePreview.tsx +124 -36
  55. package/src/enrich.test.ts +209 -2
  56. package/src/enrich.ts +284 -15
  57. package/src/er/RedisSchema.tsx +86 -5
  58. package/src/flow/FlowTable.tsx +19 -4
  59. package/src/flow/StepDetail.tsx +461 -101
  60. package/src/flow/StepRail.tsx +36 -13
  61. package/src/flow/answers-response.test.ts +100 -0
  62. package/src/flow/answers.ts +11 -8
  63. package/src/flow/chapters.ts +2 -1
  64. package/src/flow/labels.ts +10 -0
  65. package/src/flow/mermaid.test.ts +87 -3
  66. package/src/flow/mermaid.ts +26 -14
  67. package/src/index.css +132 -0
  68. package/src/landing/LandingPage.tsx +12 -8
  69. package/src/lib/api.test.ts +4 -6
  70. package/src/lib/build-info.test.ts +29 -0
  71. package/src/lib/build-info.ts +21 -0
  72. package/src/lib/data-model.ts +14 -0
  73. package/src/lib/flow-tree.ts +1 -1
  74. package/src/lib/github-catalog.test.ts +51 -0
  75. package/src/lib/github-catalog.ts +69 -0
  76. package/src/lib/grpc-reference.test.ts +72 -0
  77. package/src/lib/grpc-reference.ts +117 -0
  78. package/src/lib/local-api.ts +17 -4
  79. package/src/lib/product.ts +7 -0
  80. package/src/lib/queries.ts +16 -1
  81. package/src/lib/readme-assets.test.ts +19 -0
  82. package/src/lib/readme-assets.ts +34 -0
  83. package/src/lib/setup-info.test.ts +7 -0
  84. package/src/lib/setup-info.ts +5 -0
  85. package/src/lib/source-link.test.ts +106 -18
  86. package/src/lib/source-link.ts +22 -6
  87. package/src/merge.test.ts +55 -0
  88. package/src/merge.ts +30 -0
  89. package/src/pages/AdrDetail.tsx +3 -2
  90. package/src/pages/AdrIndex.tsx +10 -7
  91. package/src/pages/AggregatePage.tsx +34 -4
  92. package/src/pages/BlockPage.tsx +39 -32
  93. package/src/pages/Changes.tsx +7 -9
  94. package/src/pages/ContextMap.tsx +11 -2
  95. package/src/pages/ContextPage.tsx +11 -4
  96. package/src/pages/FlowIndex.tsx +16 -7
  97. package/src/pages/GraphPage.tsx +21 -7
  98. package/src/pages/Language.tsx +9 -9
  99. package/src/pages/NotFound.tsx +58 -15
  100. package/src/pages/Overview.tsx +6 -0
  101. package/src/pages/Problems.tsx +10 -8
  102. package/src/pages/RegistryIndex.tsx +10 -8
  103. package/src/pages/ServicePage.tsx +1 -1
  104. package/src/pages/Settings.tsx +382 -44
  105. package/src/routes.test.ts +2 -0
  106. package/src/routes.ts +2 -1
  107. package/src/selection/DetailPanel.tsx +213 -113
  108. package/src/testing/setup.ts +14 -0
  109. package/vite.config.ts +5 -1
@@ -18,7 +18,6 @@ import { tmpdir } from "node:os";
18
18
  import { createServer as createNetServer } from "node:net";
19
19
  import { basename, dirname, join, posix, relative, resolve, sep } from "node:path";
20
20
 
21
- import { publicSetupFrom } from "../src/lib/setup-info.ts";
22
21
  import { loadManifest } from "./manifest.mjs";
23
22
  import { builtinPluginNames } from "./builtin-plugins.mjs";
24
23
  import { installDeliveryPreset, planDeliveryPreset, publicDeliveryPreset } from "./delivery-presets.mjs";
@@ -31,6 +30,7 @@ const MAX_FILES = 12_000;
31
30
  const MAX_COMPONENTS = 100;
32
31
  const MAX_SOURCE_BYTES = 1024 * 1024;
33
32
  const jobs = new Map();
33
+ const removalUndos = new Map();
34
34
  const repositoryCredentials = new Map();
35
35
  const SNAPSHOT_SKIP = new Set([".git", ".portolan", "dist", "node_modules", "target"]);
36
36
  const PROJECT_PREVIEW_TTL_MS = 15 * 60 * 1000;
@@ -156,6 +156,51 @@ function componentCandidates(files) {
156
156
  });
157
157
  }
158
158
 
159
+ function titleFromSlug(value) {
160
+ const acronyms = new Set(["api", "cli", "grpc", "http"]);
161
+ return value.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => acronyms.has(part.toLowerCase()) ? part.toUpperCase() : part[0]?.toUpperCase() + part.slice(1)).join(" ") || value;
162
+ }
163
+
164
+ // cmd/* is a convention, not an architectural boundary by itself: many Go
165
+ // repositories keep migrations and administrative tools there. A runnable is
166
+ // promoted to a deployable only when build/deployment evidence independently
167
+ // names the same entrypoint.
168
+ function goDeployables(root, files) {
169
+ if (!files.has("go.mod")) return [];
170
+ const mains = new Map();
171
+ for (const name of matches(files, /^cmd\/[^/]+\/[^/]+\.go$/)) {
172
+ let source = "";
173
+ try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
174
+ if (!/^\s*package\s+main\b/m.test(source) || !/\bfunc\s+main\s*\(/.test(source)) continue;
175
+ const component = name.split("/")[1];
176
+ const found = mains.get(component) ?? [];
177
+ found.push(name);
178
+ mains.set(component, found);
179
+ }
180
+
181
+ const buildFiles = matches(files, /(^|\/)(?:Dockerfile|[^/]+\.Dockerfile|Makefile|[^/]*compose[^/]*\.ya?ml|[^/]+\.ya?ml)$/i);
182
+ const out = [];
183
+ for (const [component, entrypoints] of [...mains].sort(([a], [b]) => a.localeCompare(b))) {
184
+ const escaped = component.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
185
+ const target = new RegExp(`(?:^|[\\s"'=])(?:\\./)?cmd/${escaped}(?=$|[\\s"'])`, "m");
186
+ const corroboration = [];
187
+ for (const name of buildFiles) {
188
+ let source = "";
189
+ try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
190
+ if (target.test(source)) corroboration.push(name);
191
+ }
192
+ out.push({
193
+ slug: slug(component),
194
+ name: titleFromSlug(component),
195
+ path: `cmd/${component}`,
196
+ kind: "service",
197
+ confidence: corroboration.length ? "high" : "medium",
198
+ evidence: [...entrypoints, ...corroboration],
199
+ });
200
+ }
201
+ return out;
202
+ }
203
+
159
204
  function detected(plugin, candidates, options = {}, label = candidates[0], ambiguous = false, selected = true) {
160
205
  if (!candidates.length) return null;
161
206
  return {
@@ -191,7 +236,7 @@ function goDomainEvidence(root, files) {
191
236
  .join("");
192
237
  let source = "";
193
238
  try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
194
- const packagePattern = new RegExp(`\\bpackage\\s+${packageName}\\b`);
239
+ const packagePattern = new RegExp(`\\bpackage\\s+(?:${packageName}|domain)\\b`);
195
240
  const rootPattern = new RegExp(`\\btype\\s+${rootName}\\s+struct\\s*\\{`);
196
241
  if (packagePattern.test(source) && rootPattern.test(source)) return name;
197
242
  }
@@ -346,18 +391,30 @@ export function discoverProject(workspace, input) {
346
391
  const files = walk(absolute);
347
392
  const detections = detectionsFor(absolute, files);
348
393
  const components = componentCandidates(files);
349
- const id = slug(basename(absolute)) || "service";
394
+ const deployables = goDeployables(absolute, files);
350
395
  return {
351
396
  root,
352
397
  filesScanned: files.size,
353
398
  truncated: files.size >= MAX_FILES,
354
399
  components,
355
400
  componentsTruncated: components.length >= MAX_COMPONENTS,
356
- defaults: { id, name: id.split("-").map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" "), group: id, component: id, context: id, service: id },
401
+ deployables,
402
+ defaults: projectDefaults(basename(absolute)),
357
403
  detections,
358
404
  };
359
405
  }
360
406
 
407
+ function projectDefaults(value) {
408
+ const id = slug(value) || "service";
409
+ return { id, name: id.split("-").map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" "), group: id, component: id, context: id, service: id };
410
+ }
411
+
412
+ export function externalProjectDefaults(repository, sourcePath = "") {
413
+ const repo = repositoryParts(repository);
414
+ const clean = cleanSourcePath(sourcePath);
415
+ return projectDefaults(clean ? posix.basename(clean) : repo.name);
416
+ }
417
+
361
418
  function repositoryParts(repository) {
362
419
  const value = String(repository ?? "").trim();
363
420
  if (!value || value.startsWith("-") || /[\r\n\0]/.test(value)) throw new Error("Repository URL is required.");
@@ -586,7 +643,8 @@ export function prepareRepository(workspace, request) {
586
643
  }
587
644
  }
588
645
  if (sourcePath && !lstatExists(join(checkout, sourcePath))) throw new Error(`Repository path \"${sourcePath}\" does not exist at ${commit.slice(0, 7)}.`);
589
- const discovery = discoverProject(workspace, root);
646
+ const discovered = discoverProject(workspace, root);
647
+ const discovery = { ...discovered, defaults: externalProjectDefaults(repo.value, sourcePath) };
590
648
  return { repository: repo.value, ref, commit, sourcePath, checkoutRoot: root, discovery };
591
649
  }
592
650
 
@@ -629,13 +687,77 @@ function pluginOptions(plugin, project, detectedOptions = {}) {
629
687
  return {};
630
688
  }
631
689
 
690
+ function deployableProtoPaths(paths, deployable) {
691
+ const prefixes = [`internal/${deployable.slug}/`, `cmd/${deployable.slug}/`, `services/${deployable.slug}/`];
692
+ return (paths ?? []).filter((path) => prefixes.some((prefix) => `${path}/`.startsWith(prefix)));
693
+ }
694
+
695
+ function deployableProtoPeers(root, files, deployables, context) {
696
+ const peers = new Map();
697
+ const ambiguous = new Set();
698
+ for (const name of matches(files, /\.proto$/i)) {
699
+ const owner = deployables.find((candidate) =>
700
+ name.startsWith(`internal/${candidate.slug}/`) || name.startsWith(`cmd/${candidate.slug}/`) || name.startsWith(`services/${candidate.slug}/`),
701
+ );
702
+ if (!owner) continue;
703
+ let source = "";
704
+ try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
705
+ const packageName = /(?:^|[;\n])\s*package\s+([A-Za-z_][A-Za-z0-9_.]*)\s*;/m.exec(source)?.[1];
706
+ if (!packageName || ambiguous.has(packageName)) continue;
707
+ const service = `${context}.${owner.slug}`;
708
+ const existing = peers.get(packageName);
709
+ if (existing && existing !== service) {
710
+ peers.delete(packageName);
711
+ ambiguous.add(packageName);
712
+ } else {
713
+ peers.set(packageName, service);
714
+ }
715
+ }
716
+ return Object.fromEntries([...peers].sort(([a], [b]) => a.localeCompare(b)));
717
+ }
718
+
719
+ function goModule(root) {
720
+ try {
721
+ const match = /^\s*module\s+(\S+)/m.exec(readFileSync(join(root, "go.mod"), "utf8"));
722
+ return match?.[1] ?? "";
723
+ } catch { return ""; }
724
+ }
725
+
726
+ function deployableEvidenceOwners(root, files, deployables, evidence) {
727
+ const module = goModule(root);
728
+ if (!module) return [];
729
+ const evidenceDirs = [...new Set((evidence ?? []).flatMap((name) => {
730
+ const parts = posix.dirname(name).split("/");
731
+ const out = [];
732
+ while (parts.length > 1) { out.push(parts.join("/")); parts.pop(); }
733
+ return out;
734
+ }))];
735
+ return deployables.filter((deployable) => {
736
+ if ((evidence ?? []).some((name) => name.startsWith(`internal/${deployable.slug}/`) || name.startsWith(`cmd/${deployable.slug}/`))) return true;
737
+ const scoped = [...files].filter((name) =>
738
+ name.endsWith(".go") && (
739
+ name.startsWith(`cmd/${deployable.slug}/`) ||
740
+ name.startsWith(`internal/${deployable.slug}/`) ||
741
+ name === `internal/di/${deployable.slug}.go`
742
+ ),
743
+ );
744
+ return scoped.some((name) => {
745
+ let source = "";
746
+ try { source = readFileSync(join(root, name), "utf8"); } catch { return false; }
747
+ return evidenceDirs.some((dir) => source.includes(`"${module}/${dir}"`));
748
+ });
749
+ });
750
+ }
751
+
632
752
  export function planProject(workspace, manifest, request) {
633
753
  const external = request.source === "external";
634
754
  const repo = external ? repositoryParts(request.repository) : null;
635
755
  const sourcePath = external ? cleanSourcePath(request.sourcePath) : "";
636
756
  if (external && !/^[0-9a-f]{40}$/i.test(String(request.commit ?? ""))) throw new Error("Inspect the repository to resolve an immutable commit first.");
637
757
  const inspectedRoot = external ? inspectionRoot(repo.value, String(request.commit), sourcePath) : request.root;
638
- const discovery = discoverProject(workspace, inspectedRoot);
758
+ const discovered = discoverProject(workspace, inspectedRoot);
759
+ const discovery = external ? { ...discovered, defaults: externalProjectDefaults(repo.value, sourcePath) } : discovered;
760
+ const confirmedDeployables = discovery.deployables.filter((candidate) => candidate.confidence === "high");
639
761
  const declared = new Set([
640
762
  ...builtinPluginNames(),
641
763
  ...(manifest.plugins ?? []).map((plugin) => plugin.name),
@@ -651,31 +773,75 @@ export function planProject(workspace, manifest, request) {
651
773
  ? ["vendor", "repos", repo.owner, repo.name, sourcePath].filter(Boolean).join("/")
652
774
  : discovery.root;
653
775
  if ((manifest.projects ?? []).some((project) => project.root === finalRoot)) throw new Error(`Project path \"${finalRoot}\" already exists.`);
776
+ const requestedComponent = slug(String(request.component ?? request.service ?? ""));
777
+ const splitDeployables = confirmedDeployables.length > 1 && requestedComponent === discovery.defaults.component;
654
778
  const project = {
655
779
  id,
656
780
  name: String(request.name ?? "").trim() || discovery.defaults.name,
657
781
  root: finalRoot,
658
782
  ...(String(request.group ?? request.context ?? "").trim() ? { group: slug(String(request.group ?? request.context)) } : {}),
659
- ...(String(request.component ?? request.service ?? "").trim() ? { component: slug(String(request.component ?? request.service)) } : {}),
783
+ ...(!splitDeployables && String(request.component ?? request.service ?? "").trim() ? { component: requestedComponent } : {}),
660
784
  ...(String(request.groupKind ?? "").trim() ? { groupKind: String(request.groupKind).trim() } : {}),
661
- ...(String(request.componentKind ?? "").trim() ? { componentKind: String(request.componentKind).trim() } : {}),
785
+ ...(!splitDeployables && String(request.componentKind ?? "").trim() ? { componentKind: String(request.componentKind).trim() } : {}),
786
+ ...(splitDeployables ? { components: confirmedDeployables.map((candidate) => candidate.slug) } : {}),
662
787
  ...(String(request.repository ?? "").trim() ? { repository: String(request.repository).trim() } : {}),
663
788
  };
664
789
  const out = posix.join(finalRoot, "portolan");
665
790
  const detectionByPlugin = new Map(discovery.detections.map((item) => [item.plugin, item]));
666
791
  const hasDomainModel = plugins.some((plugin) => ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain"].includes(plugin));
667
- const steps = plugins.map((plugin) => ({
668
- plugin,
669
- in: finalRoot,
670
- out,
671
- options: pluginOptions(
672
- plugin,
673
- project,
674
- plugin === "project"
675
- ? { groupKind: hasDomainModel ? "bounded-context" : "system", ...(hasDomainModel ? { componentKind: "service" } : {}) }
676
- : detectionByPlugin.get(plugin)?.options,
677
- ),
678
- }));
792
+ const projectDetectionOptions = {
793
+ groupKind: splitDeployables ? "system" : hasDomainModel ? "bounded-context" : "system",
794
+ ...(hasDomainModel ? { componentKind: "service" } : {}),
795
+ ...(String(request.contextName ?? "").trim() ? { groupName: String(request.contextName).trim() } : {}),
796
+ ...(String(request.contextSummary ?? "").trim() ? { groupSummary: String(request.contextSummary).trim() } : {}),
797
+ ...(String(request.classification ?? "").trim() ? { classification: String(request.classification).trim() } : {}),
798
+ ...(splitDeployables ? { components: confirmedDeployables.map(({ slug, name, kind }) => ({ slug, name, kind })) } : {}),
799
+ };
800
+ const domainDetectionOptions = (plugin) => ({
801
+ ...detectionByPlugin.get(plugin)?.options,
802
+ ...(String(request.contextName ?? "").trim() ? { contextName: String(request.contextName).trim() } : {}),
803
+ ...(String(request.contextSummary ?? "").trim() ? { contextSummary: String(request.contextSummary).trim() } : {}),
804
+ ...(String(request.classification ?? "").trim() ? { classification: String(request.classification).trim() } : {}),
805
+ });
806
+ const rootAbsolute = resolve(workspace, inspectedRoot);
807
+ const rootFiles = splitDeployables ? walk(rootAbsolute) : new Set();
808
+ const protoPeers = splitDeployables ? deployableProtoPeers(rootAbsolute, rootFiles, confirmedDeployables, project.group ?? project.context ?? id) : {};
809
+ const redisOwners = splitDeployables
810
+ ? new Set(deployableEvidenceOwners(rootAbsolute, rootFiles, confirmedDeployables, detectionByPlugin.get("redis")?.candidates).map((owner) => owner.slug))
811
+ : new Set();
812
+ const steps = plugins.flatMap((plugin) => {
813
+ if (!splitDeployables) {
814
+ const options = plugin === "project"
815
+ ? projectDetectionOptions
816
+ : ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain"].includes(plugin)
817
+ ? domainDetectionOptions(plugin)
818
+ : detectionByPlugin.get(plugin)?.options;
819
+ return [{ plugin, in: finalRoot, out, options: pluginOptions(plugin, project, options) }];
820
+ }
821
+ if (plugin === "project") {
822
+ return [{ plugin, in: finalRoot, out, options: pluginOptions(plugin, project, projectDetectionOptions) }];
823
+ }
824
+ const detectedOptions = detectionByPlugin.get(plugin)?.options ?? {};
825
+ let owners = confirmedDeployables;
826
+ if (plugin === "proto") owners = confirmedDeployables.filter((candidate) => deployableProtoPaths(detectedOptions.paths, candidate).length > 0);
827
+ else if (plugin === "redis") owners = deployableEvidenceOwners(rootAbsolute, rootFiles, confirmedDeployables, detectionByPlugin.get(plugin)?.candidates);
828
+ else if (plugin === "go-domain") owners = confirmedDeployables;
829
+ else owners = confirmedDeployables.slice(0, 1);
830
+ return owners.map((owner) => {
831
+ const scopedProject = { ...project, component: owner.slug, componentKind: owner.kind, name: owner.name };
832
+ const scopedOptions = plugin === "proto"
833
+ ? { ...detectedOptions, paths: deployableProtoPaths(detectedOptions.paths, owner) }
834
+ : plugin === "go-domain"
835
+ ? { ...detectedOptions, scope: owner.slug, ...(Object.keys(protoPeers).length ? { peers: protoPeers } : {}), ...(redisOwners.has(owner.slug) ? { store: "redis" } : {}) }
836
+ : detectedOptions;
837
+ return {
838
+ plugin,
839
+ in: finalRoot,
840
+ out,
841
+ options: { ...pluginOptions(plugin, scopedProject, scopedOptions), out: `${plugin}-${owner.slug}.json` },
842
+ };
843
+ });
844
+ });
679
845
  const source = external ? "vendor/repos/**/portolan/*.json" : `${out}/*.json`;
680
846
  const fetch = external ? { repo: repo.value, commit: String(request.commit), paths: sourcePath ? [sourcePath] : [] } : null;
681
847
  return { project, plugins, steps, source, discovery, fetch };
@@ -688,23 +854,114 @@ export function manifestWithProject(manifest, plan, { isolated = false } = {}) {
688
854
  if (!builtinPluginNames().has("git") && !manifest.plugins?.some((plugin) => plugin.name === "git")) throw new Error("The built-in git fetcher is not available.");
689
855
  if (!isolated && fetchIndex >= 0) {
690
856
  const fetchStep = extract[fetchIndex];
691
- extract[fetchIndex] = { ...fetchStep, options: { ...fetchStep.options, repos: [...(fetchStep.options?.repos ?? []), plan.fetch] } };
857
+ const repos = [...(fetchStep.options?.repos ?? []), plan.fetch].filter((repo, index, all) => {
858
+ const key = `${repo.repo}\0${repo.commit}\0${[...(repo.paths ?? [])].sort().join("\0")}`;
859
+ return all.findIndex((candidate) => `${candidate.repo}\0${candidate.commit}\0${[...(candidate.paths ?? [])].sort().join("\0")}` === key) === index;
860
+ });
861
+ extract[fetchIndex] = { ...fetchStep, options: { ...fetchStep.options, repos } };
692
862
  } else {
693
863
  extract.unshift({ plugin: "git", in: "vendor", out: "vendor/repos", options: { cache: "vendor/repos", repos: [plan.fetch] } });
694
864
  }
695
865
  }
696
866
  extract.push(...plan.steps);
867
+ const emptyStarterSources = !isolated
868
+ && (manifest.projects ?? []).length === 0
869
+ && (manifest.extract ?? []).length === 0
870
+ && (manifest.sources ?? []).length === 1
871
+ && manifest.sources[0] === "portolan/*.json";
872
+ const targetCatalog = manifest.defaultCatalog ?? manifest.catalogs?.[0]?.id;
873
+ const catalogs = !isolated && manifest.catalogs
874
+ ? manifest.catalogs.map((catalog) => catalog.id === targetCatalog ? {
875
+ ...catalog,
876
+ sources: [...new Set([...catalog.sources, plan.source])],
877
+ contexts: [...new Set([...catalog.contexts, plan.project.group ?? plan.project.context].filter(Boolean))],
878
+ projects: [...new Set([...catalog.projects, plan.project.id])],
879
+ } : catalog)
880
+ : manifest.catalogs;
697
881
  return {
698
882
  ...manifest,
699
883
  projects: isolated ? [plan.project] : [...(manifest.projects ?? []), plan.project],
700
884
  sources: isolated
701
885
  ? [...(plan.fetch ? ["vendor/repos/*/*/git.repo.json"] : []), plan.source]
702
- : [...new Set([...(manifest.sources ?? []), ...(plan.fetch ? ["vendor/repos/*/*/git.repo.json"] : []), plan.source])],
886
+ : [...new Set([...(emptyStarterSources ? [] : (manifest.sources ?? [])), ...(plan.fetch ? ["vendor/repos/*/*/git.repo.json"] : []), plan.source])],
703
887
  extract,
888
+ ...(catalogs ? { catalogs } : {}),
704
889
  ...(isolated ? { verify: [], generate: [] } : {}),
705
890
  };
706
891
  }
707
892
 
893
+ /**
894
+ * Remove one project's complete catalog slice while leaving neighbouring
895
+ * projects and shared estate inputs intact. A single placeholder source is
896
+ * retained when the workspace becomes empty because `sources` is the one
897
+ * required manifest field; the next added project replaces that placeholder.
898
+ */
899
+ export function manifestWithoutProject(manifest, projectId) {
900
+ const id = String(projectId ?? "").trim();
901
+ const project = (manifest.projects ?? []).find((candidate) => candidate.id === id);
902
+ if (!project) throw new Error(`Project \"${id}\" does not exist.`);
903
+
904
+ const projects = (manifest.projects ?? []).filter((candidate) => candidate.id !== id);
905
+ const projectOut = posix.join(project.root, "portolan");
906
+ const projectSource = `${projectOut}/*.json`;
907
+ const projectGroup = project.group ?? project.context;
908
+ const groupStillUsed = projectGroup && projects.some((candidate) => (candidate.group ?? candidate.context) === projectGroup);
909
+ const belongsToProject = (step) => step?.out === projectOut;
910
+ const extract = (manifest.extract ?? []).filter((step) => !belongsToProject(step));
911
+ const verify = (manifest.verify ?? []).filter((step) => !belongsToProject(step));
912
+ let sources = (manifest.sources ?? []).filter((source) => source !== projectSource);
913
+ if (sources.length === 0) sources = ["portolan/*.json"];
914
+
915
+ const removedCatalogs = [];
916
+ const catalogs = (manifest.catalogs ?? []).flatMap((catalog) => {
917
+ const affected = catalog.projects.includes(id) || catalog.sources.includes(projectSource);
918
+ if (!affected) return [catalog];
919
+ const next = {
920
+ ...catalog,
921
+ sources: catalog.sources.filter((source) => source !== projectSource),
922
+ contexts: projectGroup && !groupStillUsed ? catalog.contexts.filter((context) => context !== projectGroup) : catalog.contexts,
923
+ projects: catalog.projects.filter((candidate) => candidate !== id),
924
+ };
925
+ if (next.sources.length > 0 && next.contexts.length > 0) return [next];
926
+ removedCatalogs.push(catalog.id);
927
+ return [];
928
+ });
929
+ const generate = (manifest.generate ?? []).filter((step) => !removedCatalogs.includes(step.catalog));
930
+ const removedOutputs = [
931
+ ...new Set((manifest.generate ?? [])
932
+ .filter((step) => removedCatalogs.includes(step.catalog) || (!(manifest.catalogs ?? []).length && (manifest.projects ?? []).length === 1))
933
+ .map((step) => step.out)
934
+ .filter(Boolean)),
935
+ ];
936
+
937
+ const next = { ...manifest, projects, sources, extract, verify, generate };
938
+ if (manifest.catalogs) {
939
+ if (catalogs.length > 0) next.catalogs = catalogs;
940
+ else delete next.catalogs;
941
+ }
942
+ if (removedCatalogs.includes(manifest.defaultCatalog)) {
943
+ if (catalogs[0]) next.defaultCatalog = catalogs[0].id;
944
+ else delete next.defaultCatalog;
945
+ }
946
+ return { manifest: next, project, projectOut, removedOutputs };
947
+ }
948
+
949
+ export function starterManifestProject(manifest) {
950
+ if ((manifest.projects ?? []).length !== 1) return null;
951
+ const project = manifest.projects[0];
952
+ if (project.root !== "." || project.groupKind || project.componentKind) return null;
953
+ const output = posix.join(project.root, "portolan");
954
+ const steps = (manifest.extract ?? []).filter((step) => step.out === output);
955
+ return steps.length === 1 && steps[0]?.plugin === "project" ? project : null;
956
+ }
957
+
958
+ function projectRequestPlan(workspace, manifest, request) {
959
+ const starter = request.replaceStarter ? starterManifestProject(manifest) : null;
960
+ const base = starter ? manifestWithoutProject(manifest, starter.id).manifest : manifest;
961
+ const plan = planProject(workspace, base, request);
962
+ return { base, plan, starter };
963
+ }
964
+
708
965
  export function writeManifest(path, manifest) {
709
966
  const staging = mkdtempSync(join(dirname(path), ".portolan-manifest-"));
710
967
  const temp = join(staging, "portolan.json");
@@ -720,13 +977,80 @@ export function writeManifest(path, manifest) {
720
977
 
721
978
  export function writeProject(workspace, request) {
722
979
  const manifestPath = join(workspace, "portolan.json");
723
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
724
- const plan = planProject(workspace, manifest, request);
725
- writeManifest(manifestPath, manifestWithProject(manifest, plan));
726
- return plan;
980
+ const before = readFileSync(manifestPath, "utf8");
981
+ const manifest = JSON.parse(before);
982
+ const { base, plan, starter } = projectRequestPlan(workspace, manifest, request);
983
+ writeManifest(manifestPath, manifestWithProject(base, plan));
984
+ const undoToken = rememberManifestUndo(workspace, before, readFileSync(manifestPath, "utf8"));
985
+ return { ...plan, ...(starter ? { replacedProject: starter } : {}), undoToken };
727
986
  }
728
987
 
729
- function setup(workspace) {
988
+ function rememberManifestUndo(workspace, before, after) {
989
+ const undoToken = randomUUID();
990
+ removalUndos.set(undoToken, {
991
+ workspace: realpathSync(workspace),
992
+ before,
993
+ afterSha256: createHash("sha256").update(after).digest("hex"),
994
+ expiresAt: Date.now() + 15 * 60 * 1000,
995
+ });
996
+ return undoToken;
997
+ }
998
+
999
+ function backupGeneratedSlice(workspace, output) {
1000
+ const root = resolve(workspace);
1001
+ const target = resolve(root, output);
1002
+ if (target === root || !target.startsWith(`${root}${sep}`)) throw new Error(`Generated output \"${output}\" resolves outside this repository.`);
1003
+ if (!lstatExists(target)) return null;
1004
+ const holder = mkdtempSync(join(tmpdir(), "portolan-undo-"));
1005
+ const backup = join(holder, "slice");
1006
+ cpSync(target, backup, { recursive: true });
1007
+ return { target, holder, backup };
1008
+ }
1009
+
1010
+ export function removeProject(workspace, projectId) {
1011
+ const manifestPath = join(workspace, "portolan.json");
1012
+ const before = readFileSync(manifestPath, "utf8");
1013
+ const manifest = JSON.parse(before);
1014
+ const result = manifestWithoutProject(manifest, projectId);
1015
+ const generated = backupGeneratedSlice(workspace, result.projectOut);
1016
+ try {
1017
+ writeManifest(manifestPath, result.manifest);
1018
+ if (generated) rmSync(generated.target, { recursive: true, force: true });
1019
+ } catch (cause) {
1020
+ writeManifest(manifestPath, manifest);
1021
+ if (generated) {
1022
+ rmSync(generated.target, { recursive: true, force: true });
1023
+ mkdirSync(dirname(generated.target), { recursive: true });
1024
+ cpSync(generated.backup, generated.target, { recursive: true });
1025
+ rmSync(generated.holder, { recursive: true, force: true });
1026
+ }
1027
+ throw cause;
1028
+ }
1029
+ const after = readFileSync(manifestPath, "utf8");
1030
+ const undoToken = rememberManifestUndo(workspace, before, after);
1031
+ removalUndos.get(undoToken).generated = generated;
1032
+ return { project: result.project, removedOutputs: result.removedOutputs, undoToken };
1033
+ }
1034
+
1035
+ export function undoProjectRemoval(workspace, undoToken) {
1036
+ const undo = removalUndos.get(String(undoToken ?? ""));
1037
+ if (!undo || undo.expiresAt < Date.now() || undo.workspace !== realpathSync(workspace)) throw new Error("This removal can no longer be undone.");
1038
+ const manifestPath = join(workspace, "portolan.json");
1039
+ const current = readFileSync(manifestPath, "utf8");
1040
+ if (createHash("sha256").update(current).digest("hex") !== undo.afterSha256) throw new Error("portolan.json changed after the removal; undo would overwrite newer work.");
1041
+ const manifest = JSON.parse(undo.before);
1042
+ writeManifest(manifestPath, manifest);
1043
+ if (undo.generated) {
1044
+ rmSync(undo.generated.target, { recursive: true, force: true });
1045
+ mkdirSync(dirname(undo.generated.target), { recursive: true });
1046
+ cpSync(undo.generated.backup, undo.generated.target, { recursive: true });
1047
+ rmSync(undo.generated.holder, { recursive: true, force: true });
1048
+ }
1049
+ removalUndos.delete(String(undoToken));
1050
+ return { restored: true };
1051
+ }
1052
+
1053
+ function setup(workspace, publicSetupFrom) {
730
1054
  const manifestText = readFileSync(join(workspace, "portolan.json"), "utf8");
731
1055
  let report;
732
1056
  try { report = JSON.parse(readFileSync(join(workspace, ".portolan/build-report.json"), "utf8")); } catch {}
@@ -955,7 +1279,7 @@ export function summarizeProjectTrial(snapshot, plan, events) {
955
1279
 
956
1280
  function prepareProjectTrial(workspace, request) {
957
1281
  const manifest = JSON.parse(readFileSync(join(workspace, "portolan.json"), "utf8"));
958
- const plan = planProject(workspace, manifest, request);
1282
+ const { plan } = projectRequestPlan(workspace, manifest, request);
959
1283
  const fingerprint = workspaceFingerprint(workspace);
960
1284
  const snapshot = snapshotWorkspace(workspace);
961
1285
  if (workspaceFingerprint(workspace) !== fingerprint) {
@@ -1019,7 +1343,7 @@ async function startProjectPreview(job) {
1019
1343
  job.previewChild = child;
1020
1344
  await waitForPreview(origin, child);
1021
1345
  const context = job.projectPlan.project.group ?? job.projectPlan.discovery.defaults.group;
1022
- const component = job.projectPlan.project.component ?? job.projectPlan.discovery.defaults.component;
1346
+ const component = job.projectPlan.project.components?.[0] ?? job.projectPlan.project.component ?? job.projectPlan.discovery.defaults.component;
1023
1347
  job.previewUrl = `${origin}/c/${encodeURIComponent(context)}/${encodeURIComponent(component)}`;
1024
1348
  job.previewTimer = setTimeout(() => disposeProjectTrial(job), PROJECT_PREVIEW_TTL_MS);
1025
1349
  job.previewTimer.unref();
@@ -1095,7 +1419,7 @@ function startProjectTrial(workspace, request) {
1095
1419
  catch (cause) { rmSync(prepared.holder, { recursive: true, force: true }); throw cause; }
1096
1420
  }
1097
1421
 
1098
- export function localApiPlugin(workspace = process.cwd()) {
1422
+ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
1099
1423
  return {
1100
1424
  name: "portolan-local-api",
1101
1425
  apply: "serve",
@@ -1108,7 +1432,7 @@ export function localApiPlugin(workspace = process.cwd()) {
1108
1432
  try {
1109
1433
  if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/status`) {
1110
1434
  const active = [...jobs.values()].find((job) => job.status === "running");
1111
- return send(res, 200, { local: true, workspace: realpathSync(workspace), setup: setup(workspace), activeRun: active ? { id: active.id, mode: active.mode } : null });
1435
+ return send(res, 200, { local: true, workspace: realpathSync(workspace), setup: setup(workspace, publicSetupFrom), activeRun: active ? { id: active.id, mode: active.mode } : null });
1112
1436
  }
1113
1437
  if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/delivery-presets`) {
1114
1438
  return send(res, 200, publicDeliveryPreset(planDeliveryPreset(workspace, url.searchParams.get("provider"))));
@@ -1151,7 +1475,7 @@ export function localApiPlugin(workspace = process.cwd()) {
1151
1475
  const result = writeProject(workspace, trial.projectRequest);
1152
1476
  trial.applied = true;
1153
1477
  const generation = input.generate ? startJob(workspace, "write", trial) : null;
1154
- return send(res, 201, { ...result, setup: setup(workspace), run: generation ? { runId: generation.id, mode: generation.mode } : null });
1478
+ return send(res, 201, { ...result, setup: setup(workspace, publicSetupFrom), run: generation ? { runId: generation.id, mode: generation.mode } : null });
1155
1479
  }
1156
1480
  const disposeTrialMatch = url.pathname.match(/^\/__portolan\/projects\/trials\/([^/]+)\/dispose$/);
1157
1481
  if (disposeTrialMatch) {
@@ -1163,11 +1487,21 @@ export function localApiPlugin(workspace = process.cwd()) {
1163
1487
  }
1164
1488
  if (url.pathname === `${LOCAL_API_PREFIX}/projects/preview`) {
1165
1489
  const manifest = JSON.parse(readFileSync(join(workspace, "portolan.json"), "utf8"));
1166
- return send(res, 200, planProject(workspace, manifest, input));
1490
+ return send(res, 200, projectRequestPlan(workspace, manifest, input).plan);
1167
1491
  }
1168
1492
  if (url.pathname === `${LOCAL_API_PREFIX}/projects`) {
1169
1493
  const result = writeProject(workspace, input);
1170
- return send(res, 201, { ...result, setup: setup(workspace) });
1494
+ return send(res, 201, { ...result, setup: setup(workspace, publicSetupFrom) });
1495
+ }
1496
+ const removeProjectMatch = url.pathname.match(/^\/__portolan\/projects\/([^/]+)\/remove$/);
1497
+ if (removeProjectMatch) {
1498
+ if ([...jobs.values()].some((job) => job.status === "running")) return send(res, 409, { error: "A generator run is already active." });
1499
+ const result = removeProject(workspace, decodeURIComponent(removeProjectMatch[1]));
1500
+ return send(res, 200, { ...result, setup: setup(workspace, publicSetupFrom) });
1501
+ }
1502
+ if (url.pathname === `${LOCAL_API_PREFIX}/projects/removals/undo`) {
1503
+ const result = undoProjectRemoval(workspace, input.undoToken);
1504
+ return send(res, 200, { ...result, setup: setup(workspace, publicSetupFrom) });
1171
1505
  }
1172
1506
  if (url.pathname === `${LOCAL_API_PREFIX}/runs`) {
1173
1507
  if ([...jobs.values()].some((job) => job.status === "running")) return send(res, 409, { error: "A generator run is already active." });