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