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