@shortlink-org/portolan 0.2.1 → 0.2.3

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 (135) hide show
  1. package/README.md +7 -2
  2. package/cli/init.mjs +14 -6
  3. package/cli/init.test.mjs +8 -1
  4. package/cli/portolan.mjs +92 -13
  5. package/cli/portolan.test.mjs +47 -0
  6. package/package.json +7 -6
  7. package/plugins/extract-django/README.md +1 -1
  8. package/plugins/extract-django/extract_test.py +1 -1
  9. package/plugins/extract-django/openapi.py +4 -14
  10. package/plugins/extract-django/openapi_test.py +4 -4
  11. package/plugins/extract-go/options.schema.json +5 -0
  12. package/plugins/extract-project/options.schema.json +21 -0
  13. package/plugins/extract-ts/README.md +1 -1
  14. package/plugins/extract-ts/clients.ts +1 -1
  15. package/plugins/extract-ts/extract.test.ts +2 -2
  16. package/plugins/extract-ts/graphql.test.ts +2 -2
  17. package/plugins/extract-ts/openapi.test.ts +6 -7
  18. package/plugins/extract-ts/openapi.ts +3 -10
  19. package/plugins/portolan-go.wasm +0 -0
  20. package/public/404-cat-v1.webp +0 -0
  21. package/public/cat-about-v1.webp +0 -0
  22. package/public/cat-clear-skies-v1.webp +0 -0
  23. package/public/cat-no-changes-v1.webp +0 -0
  24. package/public/cat-onboarding-v1.webp +0 -0
  25. package/public/cat-trial-success-v1.webp +0 -0
  26. package/schema/portolan.schema.json +61 -0
  27. package/scripts/builtin-plugins.mjs +3 -2
  28. package/scripts/catalog-sources.mjs +14 -2
  29. package/scripts/catalog-sources.test.mjs +45 -0
  30. package/scripts/delivery-presets.mjs +207 -24
  31. package/scripts/diff.mjs +5 -1
  32. package/scripts/gen-likec4.mjs +242 -85
  33. package/scripts/gen-likec4.test.mjs +168 -14
  34. package/scripts/gen.mjs +5 -3
  35. package/scripts/host-plugins/fetch-git.mjs +22 -12
  36. package/scripts/host-plugins/fetch-git.test.mjs +25 -5
  37. package/scripts/local-api.mjs +330 -346
  38. package/scripts/local-api.test.mjs +245 -1
  39. package/scripts/local-discovery.mjs +378 -0
  40. package/scripts/manifest.mjs +42 -1
  41. package/scripts/manifest.test.mjs +24 -1
  42. package/scripts/plugin-host.mjs +14 -3
  43. package/scripts/plugin-host.test.mjs +8 -0
  44. package/scripts/run-builtin.mjs +4 -2
  45. package/scripts/schema.mjs +11 -0
  46. package/scripts/site-docs.mjs +2 -2
  47. package/src/app/CatalogApp.tsx +23 -8
  48. package/src/app/CommandPalette.tsx +7 -2
  49. package/src/app/Sidebar.tsx +13 -730
  50. package/src/app/SidebarFlowSections.tsx +251 -0
  51. package/src/app/SidebarFooter.tsx +160 -0
  52. package/src/app/SidebarTree.tsx +322 -0
  53. package/src/app/toast.tsx +11 -7
  54. package/src/catalog-index.ts +485 -0
  55. package/src/catalog-model.ts +1339 -0
  56. package/src/catalog-validation.ts +1570 -0
  57. package/src/catalog.test.ts +49 -0
  58. package/src/catalog.ts +6 -3124
  59. package/src/components/CatIllustration.tsx +30 -0
  60. package/src/components/CommitLink.tsx +154 -0
  61. package/src/components/EditorLink.tsx +15 -8
  62. package/src/components/GrpcMethodReference.tsx +93 -0
  63. package/src/components/Markdown.tsx +17 -0
  64. package/src/components/MethodRows.tsx +8 -1
  65. package/src/components/PageHeader.test.tsx +23 -0
  66. package/src/components/PageHeader.tsx +34 -0
  67. package/src/components/ProblemRow.tsx +3 -0
  68. package/src/components/ShapeRows.tsx +12 -0
  69. package/src/components/SourceDoc.tsx +27 -12
  70. package/src/components/SourcePreview.tsx +125 -37
  71. package/src/enrich.test.ts +209 -2
  72. package/src/enrich.ts +284 -15
  73. package/src/er/RedisSchema.tsx +86 -5
  74. package/src/flow/FlowTable.tsx +19 -4
  75. package/src/flow/StepDetail.tsx +461 -101
  76. package/src/flow/StepRail.tsx +36 -13
  77. package/src/flow/answers-response.test.ts +100 -0
  78. package/src/flow/answers.ts +11 -8
  79. package/src/flow/chapters.ts +2 -1
  80. package/src/flow/labels.ts +10 -0
  81. package/src/flow/mermaid.test.ts +87 -3
  82. package/src/flow/mermaid.ts +26 -14
  83. package/src/index.css +132 -17
  84. package/src/landing/LandingPage.tsx +16 -12
  85. package/src/lib/all-problems.ts +1 -1
  86. package/src/lib/api.test.ts +4 -6
  87. package/src/lib/build-info.test.ts +29 -0
  88. package/src/lib/build-info.ts +21 -0
  89. package/src/lib/data-model.ts +14 -0
  90. package/src/lib/derive.ts +1 -0
  91. package/src/lib/flow-tree.ts +1 -1
  92. package/src/lib/github-catalog.test.ts +51 -0
  93. package/src/lib/github-catalog.ts +69 -0
  94. package/src/lib/grpc-reference.test.ts +72 -0
  95. package/src/lib/grpc-reference.ts +117 -0
  96. package/src/lib/local-api.ts +36 -11
  97. package/src/lib/motion.test.ts +4 -2
  98. package/src/lib/motion.tsx +5 -4
  99. package/src/lib/product.ts +7 -0
  100. package/src/lib/proto-problems.test.ts +170 -3
  101. package/src/lib/proto-problems.ts +176 -4
  102. package/src/lib/queries.ts +16 -1
  103. package/src/lib/readme-assets.test.ts +19 -0
  104. package/src/lib/readme-assets.ts +34 -0
  105. package/src/lib/setup-info.test.ts +7 -0
  106. package/src/lib/setup-info.ts +5 -0
  107. package/src/lib/source-link.test.ts +106 -18
  108. package/src/lib/source-link.ts +22 -6
  109. package/src/merge.test.ts +101 -0
  110. package/src/merge.ts +65 -1
  111. package/src/pages/AdrDetail.tsx +3 -2
  112. package/src/pages/AdrIndex.tsx +10 -7
  113. package/src/pages/AggregatePage.tsx +34 -4
  114. package/src/pages/BlockPage.tsx +39 -32
  115. package/src/pages/Changes.tsx +7 -9
  116. package/src/pages/ContextMap.tsx +11 -2
  117. package/src/pages/ContextPage.tsx +11 -4
  118. package/src/pages/FlowIndex.tsx +16 -7
  119. package/src/pages/GraphPage.tsx +21 -7
  120. package/src/pages/Language.tsx +9 -9
  121. package/src/pages/NotFound.tsx +58 -15
  122. package/src/pages/Overview.tsx +6 -0
  123. package/src/pages/Problems.tsx +10 -8
  124. package/src/pages/RegistryIndex.tsx +10 -8
  125. package/src/pages/ServicePage.tsx +1 -1
  126. package/src/pages/Settings.tsx +257 -44
  127. package/src/pages/settings/AboutSettings.tsx +129 -0
  128. package/src/pages/settings/DeliverySettings.tsx +71 -15
  129. package/src/routes.test.ts +2 -0
  130. package/src/routes.ts +2 -1
  131. package/src/selection/DetailPanel.tsx +213 -113
  132. package/src/selection/pages.test.ts +14 -1
  133. package/src/selection/pages.ts +9 -3
  134. package/src/testing/setup.ts +14 -0
  135. package/vite.config.ts +3 -1
@@ -18,18 +18,25 @@ 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 { loadManifest } from "./manifest.mjs";
21
+ import { loadManifest, readManifest, readManifestText } from "./manifest.mjs";
22
22
  import { builtinPluginNames } from "./builtin-plugins.mjs";
23
23
  import { installDeliveryPreset, planDeliveryPreset, publicDeliveryPreset } from "./delivery-presets.mjs";
24
+ import {
25
+ discoverProject,
26
+ matches,
27
+ projectDefaults,
28
+ readLocalSource,
29
+ slug,
30
+ walk,
31
+ } from "./local-discovery.mjs";
32
+
33
+ export { discoverProject, readLocalSource } from "./local-discovery.mjs";
24
34
 
25
35
  export const LOCAL_API_PREFIX = "/__portolan";
26
36
  export const GENERATOR_EVENT_PREFIX = "::portolan-event::";
27
37
 
28
- const SKIP = new Set([".git", ".portolan", "build", "dist", "node_modules", "target", "vendor"]);
29
- const MAX_FILES = 12_000;
30
- const MAX_COMPONENTS = 100;
31
- const MAX_SOURCE_BYTES = 1024 * 1024;
32
38
  const jobs = new Map();
39
+ const removalUndos = new Map();
33
40
  const repositoryCredentials = new Map();
34
41
  const SNAPSHOT_SKIP = new Set([".git", ".portolan", "dist", "node_modules", "target"]);
35
42
  const PROJECT_PREVIEW_TTL_MS = 15 * 60 * 1000;
@@ -43,318 +50,11 @@ export function localApiPath(pathname, base = "/") {
43
50
  return pathname;
44
51
  }
45
52
 
46
- function safeRoot(workspace, input) {
47
- if (typeof input !== "string" || !input.trim()) throw new Error("Project path is required.");
48
- const clean = input.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
49
- if (clean.startsWith("/") || clean.split("/").includes("..")) throw new Error("Project path must stay inside this repository.");
50
- const workspaceReal = realpathSync(workspace);
51
- const targetReal = realpathSync(resolve(workspaceReal, clean));
52
- if (targetReal !== workspaceReal && !targetReal.startsWith(`${workspaceReal}${sep}`)) {
53
- throw new Error("Project path resolves outside this repository.");
54
- }
55
- const root = relative(workspaceReal, targetReal).replaceAll(sep, "/") || ".";
56
- return { root, absolute: targetReal };
57
- }
58
-
59
- /** A UTF-8 source file inside the served workspace, never outside it. */
60
- export function readLocalSource(workspace, input) {
61
- if (typeof input !== "string" || !input.trim()) throw new Error("Source path is required.");
62
- const clean = input.trim().replaceAll("\\", "/").replace(/^\.\//, "");
63
- if (clean.includes("\0") || clean.startsWith("/") || clean.split("/").includes("..")) {
64
- throw new Error("Source path must stay inside this repository.");
65
- }
66
- const workspaceReal = realpathSync(workspace);
67
- const targetReal = realpathSync(resolve(workspaceReal, clean));
68
- if (targetReal !== workspaceReal && !targetReal.startsWith(`${workspaceReal}${sep}`)) {
69
- throw new Error("Source path resolves outside this repository.");
70
- }
71
- const stat = statSync(targetReal);
72
- if (!stat.isFile()) throw new Error("Source path is not a file.");
73
- if (stat.size > MAX_SOURCE_BYTES) throw new Error("Source file is larger than the 1 MB preview limit.");
74
- const bytes = readFileSync(targetReal);
75
- if (bytes.includes(0)) throw new Error("Source file is binary.");
76
- let content;
77
- try {
78
- content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
79
- } catch {
80
- throw new Error("Source file is not UTF-8 text.");
81
- }
82
- return {
83
- path: relative(workspaceReal, targetReal).replaceAll(sep, "/"),
84
- content,
85
- };
86
- }
87
-
88
- function walk(root) {
89
- const files = new Set();
90
- const pending = [{ absolute: root, relative: "" }];
91
- while (pending.length && files.size < MAX_FILES) {
92
- const current = pending.pop();
93
- for (const entry of readdirSync(current.absolute, { withFileTypes: true })) {
94
- if (SKIP.has(entry.name) || (entry.isDirectory() && entry.name.startsWith("."))) continue;
95
- const name = current.relative ? `${current.relative}/${entry.name}` : entry.name;
96
- const absolute = join(current.absolute, entry.name);
97
- const stat = lstatSync(absolute);
98
- if (stat.isSymbolicLink()) continue;
99
- if (stat.isDirectory()) pending.push({ absolute, relative: name });
100
- else if (stat.isFile()) files.add(name);
101
- if (files.size >= MAX_FILES) break;
102
- }
103
- }
104
- return files;
105
- }
106
-
107
- function slug(value) {
108
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
109
- }
110
-
111
- function matches(files, pattern) {
112
- return [...files].filter((name) => pattern.test(name)).sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b));
113
- }
114
-
115
- function compactDirectories(paths) {
116
- const directories = [...new Set(paths.map((name) => posix.dirname(name)))].sort((a, b) => a.length - b.length);
117
- return directories.filter((dir, index) => !directories.some((parent, other) => other < index && (dir === parent || dir.startsWith(`${parent}/`))));
118
- }
119
-
120
- const COMPONENT_MARKERS = new Map([
121
- ["go.mod", "Go"],
122
- ["package.json", "Node.js"],
123
- ["Cargo.toml", "Rust"],
124
- ["pom.xml", "Java"],
125
- ["build.gradle", "Java"],
126
- ["build.gradle.kts", "Kotlin"],
127
- ["manage.py", "Django"],
128
- ]);
129
-
130
- function componentCandidates(files) {
131
- const roots = new Map();
132
- for (const name of files) {
133
- const marker = posix.basename(name);
134
- const technology = COMPONENT_MARKERS.get(marker);
135
- if (!technology) continue;
136
- const path = posix.dirname(name);
137
- const key = path === "." ? "." : path;
138
- if (key.split("/").some((segment) => ["fixture", "fixtures", "test", "tests", "testdata"].includes(segment.toLowerCase()))) continue;
139
- const found = roots.get(key) ?? { path: key, markers: new Set(), technologies: new Set() };
140
- found.markers.add(marker);
141
- found.technologies.add(technology);
142
- roots.set(key, found);
143
- }
144
- return [...roots.values()]
145
- .sort((a, b) => a.path === "." ? -1 : b.path === "." ? 1 : a.path.localeCompare(b.path))
146
- .slice(0, MAX_COMPONENTS)
147
- .map((candidate) => {
148
- const base = candidate.path === "." ? "repository root" : posix.basename(candidate.path);
149
- return {
150
- path: candidate.path,
151
- name: base.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" ") || base,
152
- markers: [...candidate.markers].sort(),
153
- technologies: [...candidate.technologies].sort(),
154
- };
155
- });
156
- }
157
-
158
- function detected(plugin, candidates, options = {}, label = candidates[0], ambiguous = false, selected = true) {
159
- if (!candidates.length) return null;
160
- return {
161
- plugin,
162
- confidence: ambiguous && candidates.length > 1 ? "medium" : "high",
163
- evidence: label,
164
- candidates,
165
- options,
166
- selected,
167
- };
168
- }
169
-
170
- function compatibleAdrs(root, candidates) {
171
- return candidates.filter((name) => {
172
- let source = "";
173
- try { source = readFileSync(join(root, name), "utf8"); } catch { return false; }
174
- return /^#\s+[^\n]+\.\d{4}\s+[—-]/m.test(source) && /^-\s+\*\*Status:\*\*/mi.test(source) && /^-\s+\*\*Date:\*\*/mi.test(source);
175
- });
176
- }
177
-
178
- function goDomainEvidence(root, files) {
179
- const layout = /^internal\/(?:domain\/([^/]+)|([^/]+)\/domain)\/[^/]+\.go$/i;
180
- const candidates = matches(files, layout);
181
- for (const name of candidates) {
182
- const match = layout.exec(name);
183
- if (!match) continue;
184
- const aggregate = match[1] ?? match[2];
185
- const packageName = aggregate.replace(/[^a-zA-Z0-9_]/g, "_");
186
- const rootName = aggregate
187
- .split(/[^a-zA-Z0-9]+|_/)
188
- .filter(Boolean)
189
- .map((part) => part[0]?.toUpperCase() + part.slice(1))
190
- .join("");
191
- let source = "";
192
- try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
193
- const packagePattern = new RegExp(`\\bpackage\\s+${packageName}\\b`);
194
- const rootPattern = new RegExp(`\\btype\\s+${rootName}\\s+struct\\s*\\{`);
195
- if (packagePattern.test(source) && rootPattern.test(source)) return name;
196
- }
197
- return "";
198
- }
199
-
200
- function laidOutDomainEvidence(root, files, language) {
201
- const extension = language === "typescript" ? "ts" : language === "rust" ? "rs" : "java";
202
- const prefix = language === "java" ? /(?:^|\/)domain\/([^/]+)\/[^/]+\.java$/i : /^src\/domain\/([^/]+)\/[^/]+\.(?:ts|rs)$/i;
203
- for (const name of matches(files, prefix)) {
204
- if (!name.endsWith(`.${extension}`)) continue;
205
- const match = prefix.exec(name);
206
- if (!match) continue;
207
- const rootName = match[1].split(/[^a-zA-Z0-9]+|_/).filter(Boolean).map((part) => part[0]?.toUpperCase() + part.slice(1)).join("");
208
- let source = "";
209
- try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
210
- const claim = language === "typescript"
211
- ? new RegExp(`\\b(?:export\\s+)?class\\s+${rootName}\\b`)
212
- : language === "rust"
213
- ? new RegExp(`\\bpub\\s+struct\\s+${rootName}\\b`)
214
- : /@AggregateRoot\b/.test(source) || new RegExp(`\\bclass\\s+${rootName}\\b`).test(source);
215
- if (claim instanceof RegExp ? claim.test(source) : claim) return name;
216
- }
217
- return "";
218
- }
219
-
220
- function goHTTPClientEvidence(root, files) {
221
- for (const name of matches(files, /\.go$/).filter((name) => !name.endsWith("_test.go"))) {
222
- let source = "";
223
- try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
224
- if (
225
- /\bhttp\.(?:NewRequest(?:WithContext)?|Get|Post|PostForm|Head)\s*\(/.test(source)
226
- || /ClientWithResponses(?:Interface)?\b/.test(source)
227
- || /github\.com\/hooklift\/gowsdl\/soap/.test(source)
228
- ) return name;
229
- }
230
- return "";
231
- }
232
-
233
- function goSOAPClientEvidence(root, files) {
234
- for (const name of matches(files, /\.go$/).filter((name) => !name.endsWith("_test.go"))) {
235
- let source = "";
236
- try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
237
- if (
238
- /github\.com\/hooklift\/gowsdl\/soap/.test(source)
239
- || /\bCallContext\s*\([^,]+,\s*[^,]*(?:soap)?action/i.test(source)
240
- || /(?:Header\.)?(?:Add|Set)\s*\(\s*["'](?:SOAPAction|Content-Type)["']/i.test(source)
241
- ) return name;
242
- }
243
- return "";
244
- }
245
-
246
- function goRedisEvidence(root, files) {
247
- const redisImport = /github\.com\/(?:redis\/go-redis(?:\/v\d+)?|go-redis\/redis(?:\/v\d+)?|redis\/rueidis|gomodule\/redigo\/redis)(?=\")/g;
248
- for (const name of matches(files, /\.go$/).filter((name) => !name.endsWith("_test.go"))) {
249
- let source = "";
250
- try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
251
- const imports = [...source.matchAll(redisImport)].map((match) => match[0]);
252
- if (!imports.length) continue;
253
-
254
- const aliases = new Set();
255
- for (const importPath of imports) {
256
- const escaped = importPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
257
- const declaration = new RegExp(`(?:^|\\n)\\s*(?:import\\s+)?(?:([A-Za-z_][A-Za-z0-9_]*)\\s+)?\"${escaped}\"`, "m").exec(source);
258
- const alias = declaration?.[1];
259
- if (alias && alias !== "_" && alias !== ".") aliases.add(alias);
260
- else aliases.add(importPath.includes("rueidis") ? "rueidis" : "redis");
261
- }
262
- if ([...aliases].some((alias) => new RegExp(`\\b${alias}\\.(?:NewClient|NewClusterClient|NewFailoverClient|NewFailoverClusterClient|Dial|DialURL)\\s*\\(`).test(source))) {
263
- return name;
264
- }
265
- }
266
- return "";
267
- }
268
-
269
- function detectionsFor(root, files) {
270
- let goMod = "";
271
- if (files.has("go.mod")) {
272
- try { goMod = readFileSync(join(root, "go.mod"), "utf8"); } catch {}
273
- }
274
- const openapi = matches(files, /(^|\/)(openapi|swagger)[^/]*\.(ya?ml|json)$/i);
275
- const wsdls = matches(files, /\.wsdl$/i);
276
- const asyncapi = matches(files, /(^|\/)asyncapi[^/]*\.(ya?ml|json)$/i);
277
- const graphql = matches(files, /\.graphqls?$/i);
278
- const protos = matches(files, /\.proto$/i);
279
- const sql = matches(files, /(^|\/)(migrations?|repository)(\/|.*\/).*\.sql$/i);
280
- const adrs = matches(files, /(^|\/)(docs\/adr|adr)\/.*\.md$/i);
281
- const supportedAdrs = compatibleAdrs(root, adrs);
282
- const glossaries = matches(files, /(^|\/)glossary\.md$/i);
283
- // The app module is the one file a Celery project always has; the tasks
284
- // and the calls that enqueue them are found from there.
285
- const celery = matches(files, /(^|\/)celery\.py$/);
286
- const sqlRoots = [...new Set(sql.map((name) => {
287
- const segments = name.split("/");
288
- const repository = segments.findIndex((part) => /^(repository|repositories)$/i.test(part));
289
- return repository >= 0 ? segments.slice(0, repository + 1).join("/") : "";
290
- }).filter(Boolean))];
291
- const featureSql = sqlRoots.some((name) => /^internal\/[^/]+\/infrastructure\/repository$/i.test(name));
292
- const sqlRoot = sqlRoots.length === 1 && !featureSql ? sqlRoots[0] : "";
293
- const graphqlDirs = compactDirectories(graphql);
294
- const protoDirs = compactDirectories(protos);
295
- const projectMarkers = ["go.mod", "package.json", "Cargo.toml", "pom.xml", "build.gradle", "build.gradle.kts", "manage.py", "Dockerfile", "README.md"].filter((name) => files.has(name));
296
- const projectEvidence = projectMarkers.length ? projectMarkers : [[...files].sort()[0]].filter(Boolean);
297
- const goDomain = files.has("go.mod") ? goDomainEvidence(root, files) : "";
298
- const goHTTPClient = files.has("go.mod") ? goHTTPClientEvidence(root, files) : "";
299
- const goSOAPClient = files.has("go.mod") ? goSOAPClientEvidence(root, files) : "";
300
- const goRedis = files.has("go.mod") ? goRedisEvidence(root, files) : "";
301
- const tsDomain = files.has("package.json") ? laidOutDomainEvidence(root, files, "typescript") : "";
302
- const rustDomain = files.has("Cargo.toml") ? laidOutDomainEvidence(root, files, "rust") : "";
303
- const javaDomain = ["pom.xml", "build.gradle", "build.gradle.kts"].some((name) => files.has(name)) ? laidOutDomainEvidence(root, files, "java") : "";
304
- return [
305
- detected("project", projectEvidence, {}, projectEvidence.join(", ")),
306
- detected("go-domain", goDomain ? [goDomain] : [], {}, goDomain),
307
- detected("ts-domain", tsDomain ? [tsDomain] : [], {}, tsDomain),
308
- detected("rust-domain", rustDomain ? [rustDomain] : [], {}, rustDomain),
309
- detected("java-domain", javaDomain ? [javaDomain] : [], {}, javaDomain),
310
- detected("django-domain", files.has("manage.py") && matches(files, /(^|\/)models(?:\/[^/]+)?\.py$/i).length ? ["manage.py"] : []),
311
- detected("celery", celery, {}, celery[0]),
312
- detected("openapi", openapi, openapi[0] ? { spec: openapi[0] } : {}, openapi[0], true),
313
- detected(
314
- "wsdl",
315
- wsdls,
316
- {
317
- ...(wsdls.length === 1 ? { spec: wsdls[0] } : {}),
318
- ...(goSOAPClient ? { mode: "external" } : {}),
319
- },
320
- wsdls.length === 1 ? wsdls[0] : `${wsdls.length} WSDL documents`,
321
- true,
322
- ),
323
- detected("http-clients", goHTTPClient ? [goHTTPClient] : [], {}, goHTTPClient),
324
- detected("redis", goRedis ? [goRedis] : [], {}, goRedis),
325
- detected("river", goMod.includes("github.com/riverqueue/river") ? ["go.mod"] : [], {}, "go.mod · github.com/riverqueue/river"),
326
- detected("watermill", goMod.includes("github.com/ThreeDotsLabs/watermill") ? ["go.mod"] : [], {}, "go.mod · github.com/ThreeDotsLabs/watermill"),
327
- detected("asyncapi", asyncapi, asyncapi[0] ? { spec: asyncapi[0] } : {}, asyncapi[0], true),
328
- detected("graphql", graphql, graphqlDirs[0] ? { schema: graphqlDirs.length === 1 ? graphqlDirs[0] : graphql[0] } : {}, graphqlDirs.length === 1 ? graphqlDirs[0] : graphql[0]),
329
- detected("proto", protos, protoDirs.length ? { paths: protoDirs } : {}, protoDirs.join(", ")),
330
- detected("sql", sql, sqlRoot ? { repositories: sqlRoot } : {}, sqlRoot || (sqlRoots.length > 1 ? `${sqlRoots.length} repository packages` : sql[0])),
331
- detected(
332
- "adr",
333
- adrs,
334
- supportedAdrs[0] ? { files: [`${posix.dirname(supportedAdrs[0])}/*.md`] } : {},
335
- supportedAdrs[0] ? `${posix.dirname(supportedAdrs[0])}/*.md` : `${posix.dirname(adrs[0] ?? "docs/adr/x.md")}/*.md (format not recognized)`,
336
- !supportedAdrs.length,
337
- supportedAdrs.length > 0,
338
- ),
339
- detected("glossary", glossaries, glossaries.length ? { files: glossaries } : {}, glossaries.join(", ")),
340
- ].filter(Boolean);
341
- }
342
53
 
343
- export function discoverProject(workspace, input) {
344
- const { root, absolute } = safeRoot(workspace, input);
345
- const files = walk(absolute);
346
- const detections = detectionsFor(absolute, files);
347
- const components = componentCandidates(files);
348
- const id = slug(basename(absolute)) || "service";
349
- return {
350
- root,
351
- filesScanned: files.size,
352
- truncated: files.size >= MAX_FILES,
353
- components,
354
- 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 },
356
- detections,
357
- };
54
+ export function externalProjectDefaults(repository, sourcePath = "") {
55
+ const repo = repositoryParts(repository);
56
+ const clean = cleanSourcePath(sourcePath);
57
+ return projectDefaults(clean ? posix.basename(clean) : repo.name);
358
58
  }
359
59
 
360
60
  function repositoryParts(repository) {
@@ -585,7 +285,8 @@ export function prepareRepository(workspace, request) {
585
285
  }
586
286
  }
587
287
  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);
288
+ const discovered = discoverProject(workspace, root);
289
+ const discovery = { ...discovered, defaults: externalProjectDefaults(repo.value, sourcePath) };
589
290
  return { repository: repo.value, ref, commit, sourcePath, checkoutRoot: root, discovery };
590
291
  }
591
292
 
@@ -628,13 +329,77 @@ function pluginOptions(plugin, project, detectedOptions = {}) {
628
329
  return {};
629
330
  }
630
331
 
332
+ function deployableProtoPaths(paths, deployable) {
333
+ const prefixes = [`internal/${deployable.slug}/`, `cmd/${deployable.slug}/`, `services/${deployable.slug}/`];
334
+ return (paths ?? []).filter((path) => prefixes.some((prefix) => `${path}/`.startsWith(prefix)));
335
+ }
336
+
337
+ function deployableProtoPeers(root, files, deployables, context) {
338
+ const peers = new Map();
339
+ const ambiguous = new Set();
340
+ for (const name of matches(files, /\.proto$/i)) {
341
+ const owner = deployables.find((candidate) =>
342
+ name.startsWith(`internal/${candidate.slug}/`) || name.startsWith(`cmd/${candidate.slug}/`) || name.startsWith(`services/${candidate.slug}/`),
343
+ );
344
+ if (!owner) continue;
345
+ let source = "";
346
+ try { source = readFileSync(join(root, name), "utf8"); } catch { continue; }
347
+ const packageName = /(?:^|[;\n])\s*package\s+([A-Za-z_][A-Za-z0-9_.]*)\s*;/m.exec(source)?.[1];
348
+ if (!packageName || ambiguous.has(packageName)) continue;
349
+ const service = `${context}.${owner.slug}`;
350
+ const existing = peers.get(packageName);
351
+ if (existing && existing !== service) {
352
+ peers.delete(packageName);
353
+ ambiguous.add(packageName);
354
+ } else {
355
+ peers.set(packageName, service);
356
+ }
357
+ }
358
+ return Object.fromEntries([...peers].sort(([a], [b]) => a.localeCompare(b)));
359
+ }
360
+
361
+ function goModule(root) {
362
+ try {
363
+ const match = /^\s*module\s+(\S+)/m.exec(readFileSync(join(root, "go.mod"), "utf8"));
364
+ return match?.[1] ?? "";
365
+ } catch { return ""; }
366
+ }
367
+
368
+ function deployableEvidenceOwners(root, files, deployables, evidence) {
369
+ const module = goModule(root);
370
+ if (!module) return [];
371
+ const evidenceDirs = [...new Set((evidence ?? []).flatMap((name) => {
372
+ const parts = posix.dirname(name).split("/");
373
+ const out = [];
374
+ while (parts.length > 1) { out.push(parts.join("/")); parts.pop(); }
375
+ return out;
376
+ }))];
377
+ return deployables.filter((deployable) => {
378
+ if ((evidence ?? []).some((name) => name.startsWith(`internal/${deployable.slug}/`) || name.startsWith(`cmd/${deployable.slug}/`))) return true;
379
+ const scoped = [...files].filter((name) =>
380
+ name.endsWith(".go") && (
381
+ name.startsWith(`cmd/${deployable.slug}/`) ||
382
+ name.startsWith(`internal/${deployable.slug}/`) ||
383
+ name === `internal/di/${deployable.slug}.go`
384
+ ),
385
+ );
386
+ return scoped.some((name) => {
387
+ let source = "";
388
+ try { source = readFileSync(join(root, name), "utf8"); } catch { return false; }
389
+ return evidenceDirs.some((dir) => source.includes(`"${module}/${dir}"`));
390
+ });
391
+ });
392
+ }
393
+
631
394
  export function planProject(workspace, manifest, request) {
632
395
  const external = request.source === "external";
633
396
  const repo = external ? repositoryParts(request.repository) : null;
634
397
  const sourcePath = external ? cleanSourcePath(request.sourcePath) : "";
635
398
  if (external && !/^[0-9a-f]{40}$/i.test(String(request.commit ?? ""))) throw new Error("Inspect the repository to resolve an immutable commit first.");
636
399
  const inspectedRoot = external ? inspectionRoot(repo.value, String(request.commit), sourcePath) : request.root;
637
- const discovery = discoverProject(workspace, inspectedRoot);
400
+ const discovered = discoverProject(workspace, inspectedRoot);
401
+ const discovery = external ? { ...discovered, defaults: externalProjectDefaults(repo.value, sourcePath) } : discovered;
402
+ const confirmedDeployables = discovery.deployables.filter((candidate) => candidate.confidence === "high");
638
403
  const declared = new Set([
639
404
  ...builtinPluginNames(),
640
405
  ...(manifest.plugins ?? []).map((plugin) => plugin.name),
@@ -650,31 +415,75 @@ export function planProject(workspace, manifest, request) {
650
415
  ? ["vendor", "repos", repo.owner, repo.name, sourcePath].filter(Boolean).join("/")
651
416
  : discovery.root;
652
417
  if ((manifest.projects ?? []).some((project) => project.root === finalRoot)) throw new Error(`Project path \"${finalRoot}\" already exists.`);
418
+ const requestedComponent = slug(String(request.component ?? request.service ?? ""));
419
+ const splitDeployables = confirmedDeployables.length > 1 && requestedComponent === discovery.defaults.component;
653
420
  const project = {
654
421
  id,
655
422
  name: String(request.name ?? "").trim() || discovery.defaults.name,
656
423
  root: finalRoot,
657
424
  ...(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)) } : {}),
425
+ ...(!splitDeployables && String(request.component ?? request.service ?? "").trim() ? { component: requestedComponent } : {}),
659
426
  ...(String(request.groupKind ?? "").trim() ? { groupKind: String(request.groupKind).trim() } : {}),
660
- ...(String(request.componentKind ?? "").trim() ? { componentKind: String(request.componentKind).trim() } : {}),
427
+ ...(!splitDeployables && String(request.componentKind ?? "").trim() ? { componentKind: String(request.componentKind).trim() } : {}),
428
+ ...(splitDeployables ? { components: confirmedDeployables.map((candidate) => candidate.slug) } : {}),
661
429
  ...(String(request.repository ?? "").trim() ? { repository: String(request.repository).trim() } : {}),
662
430
  };
663
431
  const out = posix.join(finalRoot, "portolan");
664
432
  const detectionByPlugin = new Map(discovery.detections.map((item) => [item.plugin, item]));
665
433
  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
- }));
434
+ const projectDetectionOptions = {
435
+ groupKind: splitDeployables ? "system" : hasDomainModel ? "bounded-context" : "system",
436
+ ...(hasDomainModel ? { componentKind: "service" } : {}),
437
+ ...(String(request.contextName ?? "").trim() ? { groupName: String(request.contextName).trim() } : {}),
438
+ ...(String(request.contextSummary ?? "").trim() ? { groupSummary: String(request.contextSummary).trim() } : {}),
439
+ ...(String(request.classification ?? "").trim() ? { classification: String(request.classification).trim() } : {}),
440
+ ...(splitDeployables ? { components: confirmedDeployables.map(({ slug, name, kind }) => ({ slug, name, kind })) } : {}),
441
+ };
442
+ const domainDetectionOptions = (plugin) => ({
443
+ ...detectionByPlugin.get(plugin)?.options,
444
+ ...(String(request.contextName ?? "").trim() ? { contextName: String(request.contextName).trim() } : {}),
445
+ ...(String(request.contextSummary ?? "").trim() ? { contextSummary: String(request.contextSummary).trim() } : {}),
446
+ ...(String(request.classification ?? "").trim() ? { classification: String(request.classification).trim() } : {}),
447
+ });
448
+ const rootAbsolute = resolve(workspace, inspectedRoot);
449
+ const rootFiles = splitDeployables ? walk(rootAbsolute) : new Set();
450
+ const protoPeers = splitDeployables ? deployableProtoPeers(rootAbsolute, rootFiles, confirmedDeployables, project.group ?? project.context ?? id) : {};
451
+ const redisOwners = splitDeployables
452
+ ? new Set(deployableEvidenceOwners(rootAbsolute, rootFiles, confirmedDeployables, detectionByPlugin.get("redis")?.candidates).map((owner) => owner.slug))
453
+ : new Set();
454
+ const steps = plugins.flatMap((plugin) => {
455
+ if (!splitDeployables) {
456
+ const options = plugin === "project"
457
+ ? projectDetectionOptions
458
+ : ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain"].includes(plugin)
459
+ ? domainDetectionOptions(plugin)
460
+ : detectionByPlugin.get(plugin)?.options;
461
+ return [{ plugin, in: finalRoot, out, options: pluginOptions(plugin, project, options) }];
462
+ }
463
+ if (plugin === "project") {
464
+ return [{ plugin, in: finalRoot, out, options: pluginOptions(plugin, project, projectDetectionOptions) }];
465
+ }
466
+ const detectedOptions = detectionByPlugin.get(plugin)?.options ?? {};
467
+ let owners = confirmedDeployables;
468
+ if (plugin === "proto") owners = confirmedDeployables.filter((candidate) => deployableProtoPaths(detectedOptions.paths, candidate).length > 0);
469
+ else if (plugin === "redis") owners = deployableEvidenceOwners(rootAbsolute, rootFiles, confirmedDeployables, detectionByPlugin.get(plugin)?.candidates);
470
+ else if (plugin === "go-domain") owners = confirmedDeployables;
471
+ else owners = confirmedDeployables.slice(0, 1);
472
+ return owners.map((owner) => {
473
+ const scopedProject = { ...project, component: owner.slug, componentKind: owner.kind, name: owner.name };
474
+ const scopedOptions = plugin === "proto"
475
+ ? { ...detectedOptions, paths: deployableProtoPaths(detectedOptions.paths, owner) }
476
+ : plugin === "go-domain"
477
+ ? { ...detectedOptions, scope: owner.slug, ...(Object.keys(protoPeers).length ? { peers: protoPeers } : {}), ...(redisOwners.has(owner.slug) ? { store: "redis" } : {}) }
478
+ : detectedOptions;
479
+ return {
480
+ plugin,
481
+ in: finalRoot,
482
+ out,
483
+ options: { ...pluginOptions(plugin, scopedProject, scopedOptions), out: `${plugin}-${owner.slug}.json` },
484
+ };
485
+ });
486
+ });
678
487
  const source = external ? "vendor/repos/**/portolan/*.json" : `${out}/*.json`;
679
488
  const fetch = external ? { repo: repo.value, commit: String(request.commit), paths: sourcePath ? [sourcePath] : [] } : null;
680
489
  return { project, plugins, steps, source, discovery, fetch };
@@ -687,23 +496,114 @@ export function manifestWithProject(manifest, plan, { isolated = false } = {}) {
687
496
  if (!builtinPluginNames().has("git") && !manifest.plugins?.some((plugin) => plugin.name === "git")) throw new Error("The built-in git fetcher is not available.");
688
497
  if (!isolated && fetchIndex >= 0) {
689
498
  const fetchStep = extract[fetchIndex];
690
- extract[fetchIndex] = { ...fetchStep, options: { ...fetchStep.options, repos: [...(fetchStep.options?.repos ?? []), plan.fetch] } };
499
+ const repos = [...(fetchStep.options?.repos ?? []), plan.fetch].filter((repo, index, all) => {
500
+ const key = `${repo.repo}\0${repo.commit}\0${[...(repo.paths ?? [])].sort().join("\0")}`;
501
+ return all.findIndex((candidate) => `${candidate.repo}\0${candidate.commit}\0${[...(candidate.paths ?? [])].sort().join("\0")}` === key) === index;
502
+ });
503
+ extract[fetchIndex] = { ...fetchStep, options: { ...fetchStep.options, repos } };
691
504
  } else {
692
505
  extract.unshift({ plugin: "git", in: "vendor", out: "vendor/repos", options: { cache: "vendor/repos", repos: [plan.fetch] } });
693
506
  }
694
507
  }
695
508
  extract.push(...plan.steps);
509
+ const emptyStarterSources = !isolated
510
+ && (manifest.projects ?? []).length === 0
511
+ && (manifest.extract ?? []).length === 0
512
+ && (manifest.sources ?? []).length === 1
513
+ && manifest.sources[0] === "portolan/*.json";
514
+ const targetCatalog = manifest.defaultCatalog ?? manifest.catalogs?.[0]?.id;
515
+ const catalogs = !isolated && manifest.catalogs
516
+ ? manifest.catalogs.map((catalog) => catalog.id === targetCatalog ? {
517
+ ...catalog,
518
+ sources: [...new Set([...catalog.sources, plan.source])],
519
+ contexts: [...new Set([...catalog.contexts, plan.project.group ?? plan.project.context].filter(Boolean))],
520
+ projects: [...new Set([...catalog.projects, plan.project.id])],
521
+ } : catalog)
522
+ : manifest.catalogs;
696
523
  return {
697
524
  ...manifest,
698
525
  projects: isolated ? [plan.project] : [...(manifest.projects ?? []), plan.project],
699
526
  sources: isolated
700
527
  ? [...(plan.fetch ? ["vendor/repos/*/*/git.repo.json"] : []), plan.source]
701
- : [...new Set([...(manifest.sources ?? []), ...(plan.fetch ? ["vendor/repos/*/*/git.repo.json"] : []), plan.source])],
528
+ : [...new Set([...(emptyStarterSources ? [] : (manifest.sources ?? [])), ...(plan.fetch ? ["vendor/repos/*/*/git.repo.json"] : []), plan.source])],
702
529
  extract,
530
+ ...(catalogs ? { catalogs } : {}),
703
531
  ...(isolated ? { verify: [], generate: [] } : {}),
704
532
  };
705
533
  }
706
534
 
535
+ /**
536
+ * Remove one project's complete catalog slice while leaving neighbouring
537
+ * projects and shared estate inputs intact. A single placeholder source is
538
+ * retained when the workspace becomes empty because `sources` is the one
539
+ * required manifest field; the next added project replaces that placeholder.
540
+ */
541
+ export function manifestWithoutProject(manifest, projectId) {
542
+ const id = String(projectId ?? "").trim();
543
+ const project = (manifest.projects ?? []).find((candidate) => candidate.id === id);
544
+ if (!project) throw new Error(`Project \"${id}\" does not exist.`);
545
+
546
+ const projects = (manifest.projects ?? []).filter((candidate) => candidate.id !== id);
547
+ const projectOut = posix.join(project.root, "portolan");
548
+ const projectSource = `${projectOut}/*.json`;
549
+ const projectGroup = project.group ?? project.context;
550
+ const groupStillUsed = projectGroup && projects.some((candidate) => (candidate.group ?? candidate.context) === projectGroup);
551
+ const belongsToProject = (step) => step?.out === projectOut;
552
+ const extract = (manifest.extract ?? []).filter((step) => !belongsToProject(step));
553
+ const verify = (manifest.verify ?? []).filter((step) => !belongsToProject(step));
554
+ let sources = (manifest.sources ?? []).filter((source) => source !== projectSource);
555
+ if (sources.length === 0) sources = ["portolan/*.json"];
556
+
557
+ const removedCatalogs = [];
558
+ const catalogs = (manifest.catalogs ?? []).flatMap((catalog) => {
559
+ const affected = catalog.projects.includes(id) || catalog.sources.includes(projectSource);
560
+ if (!affected) return [catalog];
561
+ const next = {
562
+ ...catalog,
563
+ sources: catalog.sources.filter((source) => source !== projectSource),
564
+ contexts: projectGroup && !groupStillUsed ? catalog.contexts.filter((context) => context !== projectGroup) : catalog.contexts,
565
+ projects: catalog.projects.filter((candidate) => candidate !== id),
566
+ };
567
+ if (next.sources.length > 0 && next.contexts.length > 0) return [next];
568
+ removedCatalogs.push(catalog.id);
569
+ return [];
570
+ });
571
+ const generate = (manifest.generate ?? []).filter((step) => !removedCatalogs.includes(step.catalog));
572
+ const removedOutputs = [
573
+ ...new Set((manifest.generate ?? [])
574
+ .filter((step) => removedCatalogs.includes(step.catalog) || (!(manifest.catalogs ?? []).length && (manifest.projects ?? []).length === 1))
575
+ .map((step) => step.out)
576
+ .filter(Boolean)),
577
+ ];
578
+
579
+ const next = { ...manifest, projects, sources, extract, verify, generate };
580
+ if (manifest.catalogs) {
581
+ if (catalogs.length > 0) next.catalogs = catalogs;
582
+ else delete next.catalogs;
583
+ }
584
+ if (removedCatalogs.includes(manifest.defaultCatalog)) {
585
+ if (catalogs[0]) next.defaultCatalog = catalogs[0].id;
586
+ else delete next.defaultCatalog;
587
+ }
588
+ return { manifest: next, project, projectOut, removedOutputs };
589
+ }
590
+
591
+ export function starterManifestProject(manifest) {
592
+ if ((manifest.projects ?? []).length !== 1) return null;
593
+ const project = manifest.projects[0];
594
+ if (project.root !== "." || project.groupKind || project.componentKind) return null;
595
+ const output = posix.join(project.root, "portolan");
596
+ const steps = (manifest.extract ?? []).filter((step) => step.out === output);
597
+ return steps.length === 1 && steps[0]?.plugin === "project" ? project : null;
598
+ }
599
+
600
+ function projectRequestPlan(workspace, manifest, request) {
601
+ const starter = request.replaceStarter ? starterManifestProject(manifest) : null;
602
+ const base = starter ? manifestWithoutProject(manifest, starter.id).manifest : manifest;
603
+ const plan = planProject(workspace, base, request);
604
+ return { base, plan, starter };
605
+ }
606
+
707
607
  export function writeManifest(path, manifest) {
708
608
  const staging = mkdtempSync(join(dirname(path), ".portolan-manifest-"));
709
609
  const temp = join(staging, "portolan.json");
@@ -719,17 +619,85 @@ export function writeManifest(path, manifest) {
719
619
 
720
620
  export function writeProject(workspace, request) {
721
621
  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;
622
+ const before = readFileSync(manifestPath, "utf8");
623
+ const manifest = readManifestText(before, manifestPath);
624
+ const { base, plan, starter } = projectRequestPlan(workspace, manifest, request);
625
+ writeManifest(manifestPath, manifestWithProject(base, plan));
626
+ const undoToken = rememberManifestUndo(workspace, before, readFileSync(manifestPath, "utf8"));
627
+ return { ...plan, ...(starter ? { replacedProject: starter } : {}), undoToken };
628
+ }
629
+
630
+ function rememberManifestUndo(workspace, before, after) {
631
+ const undoToken = randomUUID();
632
+ removalUndos.set(undoToken, {
633
+ workspace: realpathSync(workspace),
634
+ before,
635
+ afterSha256: createHash("sha256").update(after).digest("hex"),
636
+ expiresAt: Date.now() + 15 * 60 * 1000,
637
+ });
638
+ return undoToken;
639
+ }
640
+
641
+ function backupGeneratedSlice(workspace, output) {
642
+ const root = resolve(workspace);
643
+ const target = resolve(root, output);
644
+ if (target === root || !target.startsWith(`${root}${sep}`)) throw new Error(`Generated output \"${output}\" resolves outside this repository.`);
645
+ if (!lstatExists(target)) return null;
646
+ const holder = mkdtempSync(join(tmpdir(), "portolan-undo-"));
647
+ const backup = join(holder, "slice");
648
+ cpSync(target, backup, { recursive: true });
649
+ return { target, holder, backup };
650
+ }
651
+
652
+ export function removeProject(workspace, projectId) {
653
+ const manifestPath = join(workspace, "portolan.json");
654
+ const before = readFileSync(manifestPath, "utf8");
655
+ const manifest = readManifestText(before, manifestPath);
656
+ const result = manifestWithoutProject(manifest, projectId);
657
+ const generated = backupGeneratedSlice(workspace, result.projectOut);
658
+ try {
659
+ writeManifest(manifestPath, result.manifest);
660
+ if (generated) rmSync(generated.target, { recursive: true, force: true });
661
+ } catch (cause) {
662
+ writeManifest(manifestPath, manifest);
663
+ if (generated) {
664
+ rmSync(generated.target, { recursive: true, force: true });
665
+ mkdirSync(dirname(generated.target), { recursive: true });
666
+ cpSync(generated.backup, generated.target, { recursive: true });
667
+ rmSync(generated.holder, { recursive: true, force: true });
668
+ }
669
+ throw cause;
670
+ }
671
+ const after = readFileSync(manifestPath, "utf8");
672
+ const undoToken = rememberManifestUndo(workspace, before, after);
673
+ removalUndos.get(undoToken).generated = generated;
674
+ return { project: result.project, removedOutputs: result.removedOutputs, undoToken };
675
+ }
676
+
677
+ export function undoProjectRemoval(workspace, undoToken) {
678
+ const undo = removalUndos.get(String(undoToken ?? ""));
679
+ if (!undo || undo.expiresAt < Date.now() || undo.workspace !== realpathSync(workspace)) throw new Error("This removal can no longer be undone.");
680
+ const manifestPath = join(workspace, "portolan.json");
681
+ const current = readFileSync(manifestPath, "utf8");
682
+ if (createHash("sha256").update(current).digest("hex") !== undo.afterSha256) throw new Error("portolan.json changed after the removal; undo would overwrite newer work.");
683
+ const manifest = readManifestText(undo.before, manifestPath);
684
+ writeManifest(manifestPath, manifest);
685
+ if (undo.generated) {
686
+ rmSync(undo.generated.target, { recursive: true, force: true });
687
+ mkdirSync(dirname(undo.generated.target), { recursive: true });
688
+ cpSync(undo.generated.backup, undo.generated.target, { recursive: true });
689
+ rmSync(undo.generated.holder, { recursive: true, force: true });
690
+ }
691
+ removalUndos.delete(String(undoToken));
692
+ return { restored: true };
726
693
  }
727
694
 
728
695
  function setup(workspace, publicSetupFrom) {
729
- const manifestText = readFileSync(join(workspace, "portolan.json"), "utf8");
696
+ const manifestPath = join(workspace, "portolan.json");
697
+ const manifestText = readFileSync(manifestPath, "utf8");
730
698
  let report;
731
699
  try { report = JSON.parse(readFileSync(join(workspace, ".portolan/build-report.json"), "utf8")); } catch {}
732
- return publicSetupFrom(JSON.parse(manifestText), report, createHash("sha256").update(manifestText).digest("hex"));
700
+ return publicSetupFrom(readManifestText(manifestText, manifestPath), report, createHash("sha256").update(manifestText).digest("hex"));
733
701
  }
734
702
 
735
703
  function send(res, status, value) {
@@ -953,8 +921,8 @@ export function summarizeProjectTrial(snapshot, plan, events) {
953
921
  }
954
922
 
955
923
  function prepareProjectTrial(workspace, request) {
956
- const manifest = JSON.parse(readFileSync(join(workspace, "portolan.json"), "utf8"));
957
- const plan = planProject(workspace, manifest, request);
924
+ const manifest = readManifest(join(workspace, "portolan.json"));
925
+ const { plan } = projectRequestPlan(workspace, manifest, request);
958
926
  const fingerprint = workspaceFingerprint(workspace);
959
927
  const snapshot = snapshotWorkspace(workspace);
960
928
  if (workspaceFingerprint(workspace) !== fingerprint) {
@@ -1018,7 +986,7 @@ async function startProjectPreview(job) {
1018
986
  job.previewChild = child;
1019
987
  await waitForPreview(origin, child);
1020
988
  const context = job.projectPlan.project.group ?? job.projectPlan.discovery.defaults.group;
1021
- const component = job.projectPlan.project.component ?? job.projectPlan.discovery.defaults.component;
989
+ const component = job.projectPlan.project.components?.[0] ?? job.projectPlan.project.component ?? job.projectPlan.discovery.defaults.component;
1022
990
  job.previewUrl = `${origin}/c/${encodeURIComponent(context)}/${encodeURIComponent(component)}`;
1023
991
  job.previewTimer = setTimeout(() => disposeProjectTrial(job), PROJECT_PREVIEW_TTL_MS);
1024
992
  job.previewTimer.unref();
@@ -1110,7 +1078,13 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
1110
1078
  return send(res, 200, { local: true, workspace: realpathSync(workspace), setup: setup(workspace, publicSetupFrom), activeRun: active ? { id: active.id, mode: active.mode } : null });
1111
1079
  }
1112
1080
  if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/delivery-presets`) {
1113
- return send(res, 200, publicDeliveryPreset(planDeliveryPreset(workspace, url.searchParams.get("provider"))));
1081
+ const features = url.searchParams.has("features")
1082
+ ? url.searchParams.get("features").split(",").filter(Boolean)
1083
+ : undefined;
1084
+ return send(res, 200, publicDeliveryPreset(planDeliveryPreset(workspace, {
1085
+ provider: url.searchParams.get("provider") || undefined,
1086
+ features,
1087
+ })));
1114
1088
  }
1115
1089
  const eventMatch = url.pathname.match(/^\/__portolan\/runs\/([^/]+)\/events$/);
1116
1090
  if (req.method === "GET" && eventMatch) {
@@ -1161,13 +1135,23 @@ export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
1161
1135
  return send(res, 200, { runId: trial.id, status: "disposed" });
1162
1136
  }
1163
1137
  if (url.pathname === `${LOCAL_API_PREFIX}/projects/preview`) {
1164
- const manifest = JSON.parse(readFileSync(join(workspace, "portolan.json"), "utf8"));
1165
- return send(res, 200, planProject(workspace, manifest, input));
1138
+ const manifest = readManifest(join(workspace, "portolan.json"));
1139
+ return send(res, 200, projectRequestPlan(workspace, manifest, input).plan);
1166
1140
  }
1167
1141
  if (url.pathname === `${LOCAL_API_PREFIX}/projects`) {
1168
1142
  const result = writeProject(workspace, input);
1169
1143
  return send(res, 201, { ...result, setup: setup(workspace, publicSetupFrom) });
1170
1144
  }
1145
+ const removeProjectMatch = url.pathname.match(/^\/__portolan\/projects\/([^/]+)\/remove$/);
1146
+ if (removeProjectMatch) {
1147
+ if ([...jobs.values()].some((job) => job.status === "running")) return send(res, 409, { error: "A generator run is already active." });
1148
+ const result = removeProject(workspace, decodeURIComponent(removeProjectMatch[1]));
1149
+ return send(res, 200, { ...result, setup: setup(workspace, publicSetupFrom) });
1150
+ }
1151
+ if (url.pathname === `${LOCAL_API_PREFIX}/projects/removals/undo`) {
1152
+ const result = undoProjectRemoval(workspace, input.undoToken);
1153
+ return send(res, 200, { ...result, setup: setup(workspace, publicSetupFrom) });
1154
+ }
1171
1155
  if (url.pathname === `${LOCAL_API_PREFIX}/runs`) {
1172
1156
  if ([...jobs.values()].some((job) => job.status === "running")) return send(res, 409, { error: "A generator run is already active." });
1173
1157
  const mode = ["check", "preview"].includes(input.mode) ? input.mode : "write";