@shortlink-org/portolan 0.2.1 → 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 (107) 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 +363 -28
  34. package/scripts/local-api.test.mjs +195 -1
  35. package/scripts/plugin-host.mjs +14 -3
  36. package/scripts/plugin-host.test.mjs +8 -0
  37. package/scripts/schema.mjs +7 -0
  38. package/src/app/CatalogApp.tsx +19 -4
  39. package/src/app/CommandPalette.tsx +7 -2
  40. package/src/app/toast.tsx +11 -7
  41. package/src/catalog.test.ts +33 -0
  42. package/src/catalog.ts +190 -8
  43. package/src/components/CatIllustration.tsx +30 -0
  44. package/src/components/CommitLink.tsx +154 -0
  45. package/src/components/EditorLink.tsx +15 -8
  46. package/src/components/GrpcMethodReference.tsx +93 -0
  47. package/src/components/Markdown.tsx +17 -0
  48. package/src/components/MethodRows.tsx +8 -1
  49. package/src/components/PageHeader.test.ts +24 -0
  50. package/src/components/PageHeader.tsx +34 -0
  51. package/src/components/ShapeRows.tsx +12 -0
  52. package/src/components/SourceDoc.tsx +27 -12
  53. package/src/components/SourcePreview.tsx +124 -36
  54. package/src/enrich.test.ts +209 -2
  55. package/src/enrich.ts +284 -15
  56. package/src/er/RedisSchema.tsx +86 -5
  57. package/src/flow/FlowTable.tsx +19 -4
  58. package/src/flow/StepDetail.tsx +461 -101
  59. package/src/flow/StepRail.tsx +36 -13
  60. package/src/flow/answers-response.test.ts +100 -0
  61. package/src/flow/answers.ts +11 -8
  62. package/src/flow/chapters.ts +2 -1
  63. package/src/flow/labels.ts +10 -0
  64. package/src/flow/mermaid.test.ts +87 -3
  65. package/src/flow/mermaid.ts +26 -14
  66. package/src/index.css +132 -0
  67. package/src/landing/LandingPage.tsx +12 -8
  68. package/src/lib/api.test.ts +4 -6
  69. package/src/lib/build-info.test.ts +29 -0
  70. package/src/lib/build-info.ts +21 -0
  71. package/src/lib/data-model.ts +14 -0
  72. package/src/lib/flow-tree.ts +1 -1
  73. package/src/lib/github-catalog.test.ts +51 -0
  74. package/src/lib/github-catalog.ts +69 -0
  75. package/src/lib/grpc-reference.test.ts +72 -0
  76. package/src/lib/grpc-reference.ts +117 -0
  77. package/src/lib/local-api.ts +17 -4
  78. package/src/lib/product.ts +7 -0
  79. package/src/lib/queries.ts +16 -1
  80. package/src/lib/readme-assets.test.ts +19 -0
  81. package/src/lib/readme-assets.ts +34 -0
  82. package/src/lib/setup-info.test.ts +7 -0
  83. package/src/lib/setup-info.ts +5 -0
  84. package/src/lib/source-link.test.ts +106 -18
  85. package/src/lib/source-link.ts +22 -6
  86. package/src/merge.test.ts +55 -0
  87. package/src/merge.ts +30 -0
  88. package/src/pages/AdrDetail.tsx +3 -2
  89. package/src/pages/AdrIndex.tsx +10 -7
  90. package/src/pages/AggregatePage.tsx +34 -4
  91. package/src/pages/BlockPage.tsx +39 -32
  92. package/src/pages/Changes.tsx +7 -9
  93. package/src/pages/ContextMap.tsx +11 -2
  94. package/src/pages/ContextPage.tsx +11 -4
  95. package/src/pages/FlowIndex.tsx +16 -7
  96. package/src/pages/GraphPage.tsx +21 -7
  97. package/src/pages/Language.tsx +9 -9
  98. package/src/pages/NotFound.tsx +58 -15
  99. package/src/pages/Overview.tsx +6 -0
  100. package/src/pages/Problems.tsx +10 -8
  101. package/src/pages/RegistryIndex.tsx +10 -8
  102. package/src/pages/ServicePage.tsx +1 -1
  103. package/src/pages/Settings.tsx +382 -44
  104. package/src/routes.test.ts +2 -0
  105. package/src/routes.ts +2 -1
  106. package/src/selection/DetailPanel.tsx +213 -113
  107. package/src/testing/setup.ts +14 -0
@@ -30,6 +30,7 @@ const MAX_FILES = 12_000;
30
30
  const MAX_COMPONENTS = 100;
31
31
  const MAX_SOURCE_BYTES = 1024 * 1024;
32
32
  const jobs = new Map();
33
+ const removalUndos = new Map();
33
34
  const repositoryCredentials = new Map();
34
35
  const SNAPSHOT_SKIP = new Set([".git", ".portolan", "dist", "node_modules", "target"]);
35
36
  const PROJECT_PREVIEW_TTL_MS = 15 * 60 * 1000;
@@ -155,6 +156,51 @@ function componentCandidates(files) {
155
156
  });
156
157
  }
157
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
+
158
204
  function detected(plugin, candidates, options = {}, label = candidates[0], ambiguous = false, selected = true) {
159
205
  if (!candidates.length) return null;
160
206
  return {
@@ -190,7 +236,7 @@ function goDomainEvidence(root, files) {
190
236
  .join("");
191
237
  let source = "";
192
238
  try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
193
- const packagePattern = new RegExp(`\\bpackage\\s+${packageName}\\b`);
239
+ const packagePattern = new RegExp(`\\bpackage\\s+(?:${packageName}|domain)\\b`);
194
240
  const rootPattern = new RegExp(`\\btype\\s+${rootName}\\s+struct\\s*\\{`);
195
241
  if (packagePattern.test(source) && rootPattern.test(source)) return name;
196
242
  }
@@ -345,18 +391,30 @@ export function discoverProject(workspace, input) {
345
391
  const files = walk(absolute);
346
392
  const detections = detectionsFor(absolute, files);
347
393
  const components = componentCandidates(files);
348
- const id = slug(basename(absolute)) || "service";
394
+ const deployables = goDeployables(absolute, files);
349
395
  return {
350
396
  root,
351
397
  filesScanned: files.size,
352
398
  truncated: files.size >= MAX_FILES,
353
399
  components,
354
400
  componentsTruncated: components.length >= MAX_COMPONENTS,
355
- 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)),
356
403
  detections,
357
404
  };
358
405
  }
359
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
+
360
418
  function repositoryParts(repository) {
361
419
  const value = String(repository ?? "").trim();
362
420
  if (!value || value.startsWith("-") || /[\r\n\0]/.test(value)) throw new Error("Repository URL is required.");
@@ -585,7 +643,8 @@ export function prepareRepository(workspace, request) {
585
643
  }
586
644
  }
587
645
  if (sourcePath && !lstatExists(join(checkout, sourcePath))) throw new Error(`Repository path \"${sourcePath}\" does not exist at ${commit.slice(0, 7)}.`);
588
- const discovery = discoverProject(workspace, root);
646
+ const discovered = discoverProject(workspace, root);
647
+ const discovery = { ...discovered, defaults: externalProjectDefaults(repo.value, sourcePath) };
589
648
  return { repository: repo.value, ref, commit, sourcePath, checkoutRoot: root, discovery };
590
649
  }
591
650
 
@@ -628,13 +687,77 @@ function pluginOptions(plugin, project, detectedOptions = {}) {
628
687
  return {};
629
688
  }
630
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
+
631
752
  export function planProject(workspace, manifest, request) {
632
753
  const external = request.source === "external";
633
754
  const repo = external ? repositoryParts(request.repository) : null;
634
755
  const sourcePath = external ? cleanSourcePath(request.sourcePath) : "";
635
756
  if (external && !/^[0-9a-f]{40}$/i.test(String(request.commit ?? ""))) throw new Error("Inspect the repository to resolve an immutable commit first.");
636
757
  const inspectedRoot = external ? inspectionRoot(repo.value, String(request.commit), sourcePath) : request.root;
637
- 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");
638
761
  const declared = new Set([
639
762
  ...builtinPluginNames(),
640
763
  ...(manifest.plugins ?? []).map((plugin) => plugin.name),
@@ -650,31 +773,75 @@ export function planProject(workspace, manifest, request) {
650
773
  ? ["vendor", "repos", repo.owner, repo.name, sourcePath].filter(Boolean).join("/")
651
774
  : discovery.root;
652
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;
653
778
  const project = {
654
779
  id,
655
780
  name: String(request.name ?? "").trim() || discovery.defaults.name,
656
781
  root: finalRoot,
657
782
  ...(String(request.group ?? request.context ?? "").trim() ? { group: slug(String(request.group ?? request.context)) } : {}),
658
- ...(String(request.component ?? request.service ?? "").trim() ? { component: slug(String(request.component ?? request.service)) } : {}),
783
+ ...(!splitDeployables && String(request.component ?? request.service ?? "").trim() ? { component: requestedComponent } : {}),
659
784
  ...(String(request.groupKind ?? "").trim() ? { groupKind: String(request.groupKind).trim() } : {}),
660
- ...(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) } : {}),
661
787
  ...(String(request.repository ?? "").trim() ? { repository: String(request.repository).trim() } : {}),
662
788
  };
663
789
  const out = posix.join(finalRoot, "portolan");
664
790
  const detectionByPlugin = new Map(discovery.detections.map((item) => [item.plugin, item]));
665
791
  const hasDomainModel = plugins.some((plugin) => ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain"].includes(plugin));
666
- const steps = plugins.map((plugin) => ({
667
- plugin,
668
- in: finalRoot,
669
- out,
670
- options: pluginOptions(
671
- plugin,
672
- project,
673
- plugin === "project"
674
- ? { groupKind: hasDomainModel ? "bounded-context" : "system", ...(hasDomainModel ? { componentKind: "service" } : {}) }
675
- : detectionByPlugin.get(plugin)?.options,
676
- ),
677
- }));
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
+ });
678
845
  const source = external ? "vendor/repos/**/portolan/*.json" : `${out}/*.json`;
679
846
  const fetch = external ? { repo: repo.value, commit: String(request.commit), paths: sourcePath ? [sourcePath] : [] } : null;
680
847
  return { project, plugins, steps, source, discovery, fetch };
@@ -687,23 +854,114 @@ export function manifestWithProject(manifest, plan, { isolated = false } = {}) {
687
854
  if (!builtinPluginNames().has("git") && !manifest.plugins?.some((plugin) => plugin.name === "git")) throw new Error("The built-in git fetcher is not available.");
688
855
  if (!isolated && fetchIndex >= 0) {
689
856
  const fetchStep = extract[fetchIndex];
690
- 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 } };
691
862
  } else {
692
863
  extract.unshift({ plugin: "git", in: "vendor", out: "vendor/repos", options: { cache: "vendor/repos", repos: [plan.fetch] } });
693
864
  }
694
865
  }
695
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;
696
881
  return {
697
882
  ...manifest,
698
883
  projects: isolated ? [plan.project] : [...(manifest.projects ?? []), plan.project],
699
884
  sources: isolated
700
885
  ? [...(plan.fetch ? ["vendor/repos/*/*/git.repo.json"] : []), plan.source]
701
- : [...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])],
702
887
  extract,
888
+ ...(catalogs ? { catalogs } : {}),
703
889
  ...(isolated ? { verify: [], generate: [] } : {}),
704
890
  };
705
891
  }
706
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
+
707
965
  export function writeManifest(path, manifest) {
708
966
  const staging = mkdtempSync(join(dirname(path), ".portolan-manifest-"));
709
967
  const temp = join(staging, "portolan.json");
@@ -719,10 +977,77 @@ export function writeManifest(path, manifest) {
719
977
 
720
978
  export function writeProject(workspace, request) {
721
979
  const manifestPath = join(workspace, "portolan.json");
722
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
723
- const plan = planProject(workspace, manifest, request);
724
- writeManifest(manifestPath, manifestWithProject(manifest, plan));
725
- 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 };
986
+ }
987
+
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 };
726
1051
  }
727
1052
 
728
1053
  function setup(workspace, publicSetupFrom) {
@@ -954,7 +1279,7 @@ export function summarizeProjectTrial(snapshot, plan, events) {
954
1279
 
955
1280
  function prepareProjectTrial(workspace, request) {
956
1281
  const manifest = JSON.parse(readFileSync(join(workspace, "portolan.json"), "utf8"));
957
- const plan = planProject(workspace, manifest, request);
1282
+ const { plan } = projectRequestPlan(workspace, manifest, request);
958
1283
  const fingerprint = workspaceFingerprint(workspace);
959
1284
  const snapshot = snapshotWorkspace(workspace);
960
1285
  if (workspaceFingerprint(workspace) !== fingerprint) {
@@ -1018,7 +1343,7 @@ async function startProjectPreview(job) {
1018
1343
  job.previewChild = child;
1019
1344
  await waitForPreview(origin, child);
1020
1345
  const context = job.projectPlan.project.group ?? job.projectPlan.discovery.defaults.group;
1021
- 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;
1022
1347
  job.previewUrl = `${origin}/c/${encodeURIComponent(context)}/${encodeURIComponent(component)}`;
1023
1348
  job.previewTimer = setTimeout(() => disposeProjectTrial(job), PROJECT_PREVIEW_TTL_MS);
1024
1349
  job.previewTimer.unref();
@@ -1162,12 +1487,22 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
1162
1487
  }
1163
1488
  if (url.pathname === `${LOCAL_API_PREFIX}/projects/preview`) {
1164
1489
  const manifest = JSON.parse(readFileSync(join(workspace, "portolan.json"), "utf8"));
1165
- return send(res, 200, planProject(workspace, manifest, input));
1490
+ return send(res, 200, projectRequestPlan(workspace, manifest, input).plan);
1166
1491
  }
1167
1492
  if (url.pathname === `${LOCAL_API_PREFIX}/projects`) {
1168
1493
  const result = writeProject(workspace, input);
1169
1494
  return send(res, 201, { ...result, setup: setup(workspace, publicSetupFrom) });
1170
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) });
1505
+ }
1171
1506
  if (url.pathname === `${LOCAL_API_PREFIX}/runs`) {
1172
1507
  if ([...jobs.values()].some((job) => job.status === "running")) return send(res, 409, { error: "A generator run is already active." });
1173
1508
  const mode = ["check", "preview"].includes(input.mode) ? input.mode : "write";