@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.
- package/README.md +7 -2
- package/cli/init.mjs +14 -6
- package/cli/init.test.mjs +8 -1
- package/cli/portolan.mjs +92 -13
- package/cli/portolan.test.mjs +47 -0
- package/package.json +7 -6
- package/plugins/extract-django/README.md +1 -1
- package/plugins/extract-django/extract_test.py +1 -1
- package/plugins/extract-django/openapi.py +4 -14
- package/plugins/extract-django/openapi_test.py +4 -4
- package/plugins/extract-go/options.schema.json +5 -0
- package/plugins/extract-project/options.schema.json +21 -0
- package/plugins/extract-ts/README.md +1 -1
- package/plugins/extract-ts/clients.ts +1 -1
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/graphql.test.ts +2 -2
- package/plugins/extract-ts/openapi.test.ts +6 -7
- package/plugins/extract-ts/openapi.ts +3 -10
- package/plugins/portolan-go.wasm +0 -0
- package/public/404-cat-v1.webp +0 -0
- package/public/cat-about-v1.webp +0 -0
- package/public/cat-clear-skies-v1.webp +0 -0
- package/public/cat-no-changes-v1.webp +0 -0
- package/public/cat-onboarding-v1.webp +0 -0
- package/public/cat-trial-success-v1.webp +0 -0
- package/schema/portolan.schema.json +61 -0
- package/scripts/builtin-plugins.mjs +3 -2
- package/scripts/catalog-sources.mjs +14 -2
- package/scripts/catalog-sources.test.mjs +45 -0
- package/scripts/delivery-presets.mjs +207 -24
- package/scripts/diff.mjs +5 -1
- package/scripts/gen-likec4.mjs +242 -85
- package/scripts/gen-likec4.test.mjs +168 -14
- package/scripts/gen.mjs +5 -3
- package/scripts/host-plugins/fetch-git.mjs +22 -12
- package/scripts/host-plugins/fetch-git.test.mjs +25 -5
- package/scripts/local-api.mjs +330 -346
- package/scripts/local-api.test.mjs +245 -1
- package/scripts/local-discovery.mjs +378 -0
- package/scripts/manifest.mjs +42 -1
- package/scripts/manifest.test.mjs +24 -1
- package/scripts/plugin-host.mjs +14 -3
- package/scripts/plugin-host.test.mjs +8 -0
- package/scripts/run-builtin.mjs +4 -2
- package/scripts/schema.mjs +11 -0
- package/scripts/site-docs.mjs +2 -2
- package/src/app/CatalogApp.tsx +23 -8
- package/src/app/CommandPalette.tsx +7 -2
- package/src/app/Sidebar.tsx +13 -730
- package/src/app/SidebarFlowSections.tsx +251 -0
- package/src/app/SidebarFooter.tsx +160 -0
- package/src/app/SidebarTree.tsx +322 -0
- package/src/app/toast.tsx +11 -7
- package/src/catalog-index.ts +485 -0
- package/src/catalog-model.ts +1339 -0
- package/src/catalog-validation.ts +1570 -0
- package/src/catalog.test.ts +49 -0
- package/src/catalog.ts +6 -3124
- package/src/components/CatIllustration.tsx +30 -0
- package/src/components/CommitLink.tsx +154 -0
- package/src/components/EditorLink.tsx +15 -8
- package/src/components/GrpcMethodReference.tsx +93 -0
- package/src/components/Markdown.tsx +17 -0
- package/src/components/MethodRows.tsx +8 -1
- package/src/components/PageHeader.test.tsx +23 -0
- package/src/components/PageHeader.tsx +34 -0
- package/src/components/ProblemRow.tsx +3 -0
- package/src/components/ShapeRows.tsx +12 -0
- package/src/components/SourceDoc.tsx +27 -12
- package/src/components/SourcePreview.tsx +125 -37
- package/src/enrich.test.ts +209 -2
- package/src/enrich.ts +284 -15
- package/src/er/RedisSchema.tsx +86 -5
- package/src/flow/FlowTable.tsx +19 -4
- package/src/flow/StepDetail.tsx +461 -101
- package/src/flow/StepRail.tsx +36 -13
- package/src/flow/answers-response.test.ts +100 -0
- package/src/flow/answers.ts +11 -8
- package/src/flow/chapters.ts +2 -1
- package/src/flow/labels.ts +10 -0
- package/src/flow/mermaid.test.ts +87 -3
- package/src/flow/mermaid.ts +26 -14
- package/src/index.css +132 -17
- package/src/landing/LandingPage.tsx +16 -12
- package/src/lib/all-problems.ts +1 -1
- package/src/lib/api.test.ts +4 -6
- package/src/lib/build-info.test.ts +29 -0
- package/src/lib/build-info.ts +21 -0
- package/src/lib/data-model.ts +14 -0
- package/src/lib/derive.ts +1 -0
- package/src/lib/flow-tree.ts +1 -1
- package/src/lib/github-catalog.test.ts +51 -0
- package/src/lib/github-catalog.ts +69 -0
- package/src/lib/grpc-reference.test.ts +72 -0
- package/src/lib/grpc-reference.ts +117 -0
- package/src/lib/local-api.ts +36 -11
- package/src/lib/motion.test.ts +4 -2
- package/src/lib/motion.tsx +5 -4
- package/src/lib/product.ts +7 -0
- package/src/lib/proto-problems.test.ts +170 -3
- package/src/lib/proto-problems.ts +176 -4
- package/src/lib/queries.ts +16 -1
- package/src/lib/readme-assets.test.ts +19 -0
- package/src/lib/readme-assets.ts +34 -0
- package/src/lib/setup-info.test.ts +7 -0
- package/src/lib/setup-info.ts +5 -0
- package/src/lib/source-link.test.ts +106 -18
- package/src/lib/source-link.ts +22 -6
- package/src/merge.test.ts +101 -0
- package/src/merge.ts +65 -1
- package/src/pages/AdrDetail.tsx +3 -2
- package/src/pages/AdrIndex.tsx +10 -7
- package/src/pages/AggregatePage.tsx +34 -4
- package/src/pages/BlockPage.tsx +39 -32
- package/src/pages/Changes.tsx +7 -9
- package/src/pages/ContextMap.tsx +11 -2
- package/src/pages/ContextPage.tsx +11 -4
- package/src/pages/FlowIndex.tsx +16 -7
- package/src/pages/GraphPage.tsx +21 -7
- package/src/pages/Language.tsx +9 -9
- package/src/pages/NotFound.tsx +58 -15
- package/src/pages/Overview.tsx +6 -0
- package/src/pages/Problems.tsx +10 -8
- package/src/pages/RegistryIndex.tsx +10 -8
- package/src/pages/ServicePage.tsx +1 -1
- package/src/pages/Settings.tsx +257 -44
- package/src/pages/settings/AboutSettings.tsx +129 -0
- package/src/pages/settings/DeliverySettings.tsx +71 -15
- package/src/routes.test.ts +2 -0
- package/src/routes.ts +2 -1
- package/src/selection/DetailPanel.tsx +213 -113
- package/src/selection/pages.test.ts +14 -1
- package/src/selection/pages.ts +9 -3
- package/src/testing/setup.ts +14 -0
- package/vite.config.ts +3 -1
package/scripts/local-api.mjs
CHANGED
|
@@ -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
|
|
344
|
-
const
|
|
345
|
-
const
|
|
346
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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
|
|
723
|
-
const
|
|
724
|
-
|
|
725
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
957
|
-
const plan =
|
|
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
|
-
|
|
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 =
|
|
1165
|
-
return send(res, 200,
|
|
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";
|