@shortlink-org/portolan 0.1.0 → 0.2.1
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 +69 -205
- package/cli/init.mjs +285 -0
- package/cli/init.test.mjs +158 -0
- package/cli/portolan.mjs +37 -73
- package/index.html +19 -0
- package/package.json +5 -10
- package/plugins/README.md +66 -27
- package/plugins/portolan-go.wasm +0 -0
- package/portolan.json +52 -73
- package/public/landing/cats/bug-hunter.webp +0 -0
- package/public/landing/cats/compass-nap.webp +0 -0
- package/public/landing/cats/diagrammer.webp +0 -0
- package/public/landing/cats/docs-reader.webp +0 -0
- package/public/landing/cats/laptop.webp +0 -0
- package/public/landing/cats/map-inspector.webp +0 -0
- package/public/landing/cats/server-break.webp +0 -0
- package/public/landing/cats/star-mapper.webp +0 -0
- package/public/landing/cats/system-builder.webp +0 -0
- package/public/landing/cats/thread-tangle.webp +0 -0
- package/public/og.png +0 -0
- package/public/readme-header.webp +0 -0
- package/schema/portolan.schema.json +180 -0
- package/scripts/builtin-plugins.mjs +14 -0
- package/scripts/delivery-presets.mjs +353 -0
- package/scripts/gen.mjs +37 -4
- package/scripts/history.mjs +126 -0
- package/scripts/history.test.mjs +110 -0
- package/scripts/host-plugins/fetch-bsr.mjs +351 -0
- package/{plugins/fetch-bsr/options.schema.json → scripts/host-plugins/fetch-bsr.options.json} +4 -0
- package/scripts/host-plugins/fetch-bsr.test.mjs +212 -0
- package/scripts/host-plugins/fetch-csr.mjs +386 -0
- package/scripts/host-plugins/fetch-csr.test.mjs +178 -0
- package/scripts/host-plugins/fetch-git.mjs +379 -0
- package/scripts/host-plugins/fetch-git.test.mjs +210 -0
- package/scripts/local-api.mjs +27 -10
- package/scripts/local-api.test.mjs +85 -2
- package/scripts/manifest.mjs +3 -2
- package/scripts/package-smoke.mjs +24 -3
- package/scripts/plugin-host.mjs +77 -18
- package/scripts/plugin-host.test.mjs +50 -1
- package/scripts/plugin-wasm-worker.mjs +6 -3
- package/scripts/run-builtin.mjs +6 -26
- package/scripts/schema.mjs +6 -1
- package/src/app/App.tsx +24 -429
- package/src/app/Breadcrumbs.test.ts +7 -0
- package/src/app/Breadcrumbs.tsx +15 -2
- package/src/app/CatalogApp.tsx +436 -0
- package/src/app/Sidebar.tsx +0 -1
- package/src/chat/ChatPanel.tsx +98 -41
- package/src/chat/Composer.tsx +4 -2
- package/src/chat/Conversation.tsx +79 -15
- package/src/chat/Header.tsx +43 -9
- package/src/chat/Starter.tsx +64 -3
- package/src/chat/Steps.tsx +1 -1
- package/src/chat/flags.test.ts +51 -14
- package/src/chat/flags.ts +8 -2
- package/src/chat/page-context.test.ts +51 -0
- package/src/chat/page-context.ts +128 -0
- package/src/chat/prompt.test.ts +49 -1
- package/src/chat/prompt.ts +42 -1
- package/src/chat/tools.ts +7 -2
- package/src/chat/transport.ts +16 -4
- package/src/components/ProblemRow.tsx +182 -0
- package/src/data.ts +20 -5
- package/src/er/ErCanvas.tsx +1 -1
- package/src/graph/DependencyGraph.tsx +10 -1
- package/src/graph/FocusedEventGraph.tsx +1 -1
- package/src/graph/GraphToolbar.tsx +2 -4
- package/src/index.css +220 -2
- package/src/landing/DraggableReveal.tsx +168 -0
- package/src/landing/EstateGraph.tsx +28 -0
- package/src/landing/FlowPlayback.tsx +282 -0
- package/src/landing/HeroMap.tsx +128 -0
- package/src/landing/LandingPage.tsx +757 -0
- package/src/landing/ProductFrame.tsx +68 -0
- package/src/landing/ProductTour.tsx +371 -0
- package/src/landing/catalog.ts +25 -0
- package/src/landing/motion.tsx +52 -0
- package/src/lib/all-problems.ts +28 -0
- package/src/lib/local-api.ts +35 -1
- package/src/lib/setup-info.test.ts +8 -0
- package/src/lib/setup-info.ts +3 -2
- package/src/lib/source-code.ts +2 -1
- package/src/likec4/C4View.tsx +5 -0
- package/src/likec4/InteractiveView.tsx +5 -1
- package/src/map/ContextMapGraph.tsx +47 -20
- package/src/pages/ContextMap.tsx +5 -1
- package/src/pages/FlowDetail.tsx +24 -7
- package/src/pages/Overview.tsx +68 -12
- package/src/pages/Problems.tsx +6 -193
- package/src/pages/Settings.tsx +133 -108
- package/src/pages/settings/DeliverySettings.tsx +229 -0
- package/src/pages/settings/PreferencesSettings.tsx +101 -0
- package/src/routes.test.ts +20 -0
- package/src/routes.ts +13 -1
- package/vite.config.ts +17 -9
- package/catalog/enum_test.go +0 -46
- package/catalog/model.go +0 -932
- package/catalog/roundtrip_test.go +0 -185
- package/catalog/via_test.go +0 -38
- package/go.mod +0 -14
- package/go.sum +0 -14
- package/internal/commands/cargo.go +0 -154
- package/internal/commands/commands.go +0 -143
- package/internal/commands/commands_test.go +0 -323
- package/internal/commands/gradle.go +0 -77
- package/internal/commands/justfile.go +0 -82
- package/internal/commands/makefile.go +0 -106
- package/internal/commands/maven.go +0 -93
- package/internal/commands/packagejson.go +0 -159
- package/internal/commands/pyproject.go +0 -169
- package/internal/commands/taskfile.go +0 -101
- package/internal/commands/testdata/estate/.cargo/config.toml +0 -7
- package/internal/commands/testdata/estate/Makefile +0 -33
- package/internal/commands/testdata/estate/Taskfile.yml +0 -21
- package/internal/commands/testdata/estate/build.gradle.kts +0 -21
- package/internal/commands/testdata/estate/justfile +0 -20
- package/internal/commands/testdata/estate/package.json +0 -13
- package/internal/commands/testdata/estate/pom.xml +0 -29
- package/internal/commands/testdata/estate/pyproject.toml +0 -27
- package/internal/commands/testdata/estate/xtask/src/main.rs +0 -27
- package/internal/commands/testdata/golden/commands.json +0 -305
- package/internal/gohttp/analyze.go +0 -2500
- package/internal/gohttp/endpoints.go +0 -1067
- package/internal/gohttp/roots.go +0 -320
- package/internal/gohttp/typed.go +0 -143
- package/internal/goscan/constants.go +0 -85
- package/internal/goscan/goscan_test.go +0 -227
- package/internal/goscan/names.go +0 -52
- package/internal/goscan/parse_test.go +0 -11
- package/internal/goscan/source.go +0 -36
- package/internal/goscan/tree.go +0 -155
- package/internal/goscan/types.go +0 -99
- package/internal/wsdl/ids.go +0 -127
- package/internal/wsdl/ids_test.go +0 -21
- package/internal/wsdl/model.go +0 -62
- package/internal/wsdl/parse.go +0 -920
- package/internal/wsdl/parse_test.go +0 -133
- package/plugin/describe.go +0 -107
- package/plugin/describe_test.go +0 -114
- package/plugin/protocol.go +0 -117
- package/plugin/schematest/schematest.go +0 -126
- package/plugins/extract-adr/describe.go +0 -19
- package/plugins/extract-adr/describe_test.go +0 -11
- package/plugins/extract-adr/extract.go +0 -153
- package/plugins/extract-adr/extract_test.go +0 -350
- package/plugins/extract-adr/history.go +0 -99
- package/plugins/extract-adr/main.go +0 -65
- package/plugins/extract-adr/parse.go +0 -642
- package/plugins/extract-adr/parse_test.go +0 -405
- package/plugins/extract-asyncapi/describe.go +0 -19
- package/plugins/extract-asyncapi/describe_test.go +0 -11
- package/plugins/extract-asyncapi/extract.go +0 -315
- package/plugins/extract-asyncapi/extract_test.go +0 -197
- package/plugins/extract-asyncapi/main.go +0 -48
- package/plugins/extract-asyncapi/spec.go +0 -151
- package/plugins/extract-commands/describe.go +0 -19
- package/plugins/extract-commands/describe_test.go +0 -11
- package/plugins/extract-commands/extract.go +0 -79
- package/plugins/extract-commands/extract_test.go +0 -92
- package/plugins/extract-commands/main.go +0 -55
- package/plugins/extract-csr/avro.go +0 -258
- package/plugins/extract-csr/describe.go +0 -19
- package/plugins/extract-csr/describe_test.go +0 -11
- package/plugins/extract-csr/extract.go +0 -338
- package/plugins/extract-csr/extract_test.go +0 -374
- package/plugins/extract-csr/jsonschema.go +0 -343
- package/plugins/extract-csr/lock.go +0 -25
- package/plugins/extract-csr/main.go +0 -84
- package/plugins/extract-csr/subject.go +0 -102
- package/plugins/extract-flows/describe.go +0 -19
- package/plugins/extract-flows/describe_test.go +0 -11
- package/plugins/extract-flows/extract.go +0 -91
- package/plugins/extract-flows/main.go +0 -45
- package/plugins/extract-flows/parse.go +0 -593
- package/plugins/extract-flows/parse_test.go +0 -204
- package/plugins/extract-glossary/describe.go +0 -19
- package/plugins/extract-glossary/describe_test.go +0 -11
- package/plugins/extract-glossary/extract.go +0 -115
- package/plugins/extract-glossary/extract_test.go +0 -220
- package/plugins/extract-glossary/main.go +0 -59
- package/plugins/extract-glossary/parse.go +0 -214
- package/plugins/extract-glossary/parse_test.go +0 -203
- package/plugins/extract-go/aggregate.go +0 -214
- package/plugins/extract-go/client.go +0 -409
- package/plugins/extract-go/client_test.go +0 -266
- package/plugins/extract-go/describe.go +0 -19
- package/plugins/extract-go/describe_test.go +0 -11
- package/plugins/extract-go/enum.go +0 -195
- package/plugins/extract-go/enum_test.go +0 -82
- package/plugins/extract-go/event.go +0 -99
- package/plugins/extract-go/extract.go +0 -191
- package/plugins/extract-go/extract_test.go +0 -261
- package/plugins/extract-go/flow.go +0 -1441
- package/plugins/extract-go/flow_test.go +0 -609
- package/plugins/extract-go/httpclient.go +0 -174
- package/plugins/extract-go/httpclient_test.go +0 -296
- package/plugins/extract-go/ids.go +0 -92
- package/plugins/extract-go/layout.go +0 -225
- package/plugins/extract-go/layout_test.go +0 -125
- package/plugins/extract-go/lifecycle.go +0 -324
- package/plugins/extract-go/lifecycle_test.go +0 -108
- package/plugins/extract-go/main.go +0 -79
- package/plugins/extract-go/operation.go +0 -157
- package/plugins/extract-go/source.go +0 -316
- package/plugins/extract-go/transport.go +0 -321
- package/plugins/extract-go/transport_test.go +0 -145
- package/plugins/extract-go/wiring.go +0 -434
- package/plugins/extract-go-nats/describe.go +0 -19
- package/plugins/extract-go-nats/describe_test.go +0 -11
- package/plugins/extract-go-nats/extract.go +0 -177
- package/plugins/extract-go-nats/extract_test.go +0 -389
- package/plugins/extract-go-nats/index.go +0 -379
- package/plugins/extract-go-nats/main.go +0 -42
- package/plugins/extract-go-nats/resolve.go +0 -161
- package/plugins/extract-go-nats/sites.go +0 -224
- package/plugins/extract-graphql/describe.go +0 -19
- package/plugins/extract-graphql/describe_test.go +0 -11
- package/plugins/extract-graphql/extract.go +0 -433
- package/plugins/extract-graphql/extract_test.go +0 -256
- package/plugins/extract-graphql/ids.go +0 -49
- package/plugins/extract-graphql/lex.go +0 -237
- package/plugins/extract-graphql/main.go +0 -51
- package/plugins/extract-graphql/parse.go +0 -621
- package/plugins/extract-graphql/parse_test.go +0 -122
- package/plugins/extract-http-clients/describe.go +0 -19
- package/plugins/extract-http-clients/describe_test.go +0 -11
- package/plugins/extract-http-clients/extract.go +0 -705
- package/plugins/extract-http-clients/extract_test.go +0 -1263
- package/plugins/extract-http-clients/main.go +0 -42
- package/plugins/extract-openapi/describe.go +0 -19
- package/plugins/extract-openapi/describe_test.go +0 -11
- package/plugins/extract-openapi/discover.go +0 -243
- package/plugins/extract-openapi/extract.go +0 -526
- package/plugins/extract-openapi/extract_test.go +0 -545
- package/plugins/extract-openapi/main.go +0 -75
- package/plugins/extract-openapi/spec.go +0 -350
- package/plugins/extract-project/describe.go +0 -19
- package/plugins/extract-project/describe_test.go +0 -11
- package/plugins/extract-project/extract.go +0 -221
- package/plugins/extract-project/extract_test.go +0 -109
- package/plugins/extract-project/main.go +0 -41
- package/plugins/extract-proto/ast.go +0 -125
- package/plugins/extract-proto/consumes.go +0 -77
- package/plugins/extract-proto/describe.go +0 -19
- package/plugins/extract-proto/extract.go +0 -293
- package/plugins/extract-proto/extract_test.go +0 -459
- package/plugins/extract-proto/ids.go +0 -89
- package/plugins/extract-proto/ids_test.go +0 -57
- package/plugins/extract-proto/lex.go +0 -285
- package/plugins/extract-proto/main.go +0 -100
- package/plugins/extract-proto/module.go +0 -120
- package/plugins/extract-proto/parse.go +0 -720
- package/plugins/extract-proto/parse_test.go +0 -307
- package/plugins/extract-proto/provides.go +0 -236
- package/plugins/extract-proto/resolve.go +0 -222
- package/plugins/extract-proto/resolve_test.go +0 -119
- package/plugins/extract-redis/describe.go +0 -19
- package/plugins/extract-redis/describe_test.go +0 -11
- package/plugins/extract-redis/extract.go +0 -183
- package/plugins/extract-redis/extract_test.go +0 -168
- package/plugins/extract-redis/keyspaces.go +0 -469
- package/plugins/extract-redis/main.go +0 -44
- package/plugins/extract-river/describe.go +0 -19
- package/plugins/extract-river/describe_test.go +0 -11
- package/plugins/extract-river/extract.go +0 -507
- package/plugins/extract-river/extract_test.go +0 -132
- package/plugins/extract-river/main.go +0 -42
- package/plugins/extract-sql/ddl.go +0 -893
- package/plugins/extract-sql/ddl_test.go +0 -401
- package/plugins/extract-sql/describe.go +0 -19
- package/plugins/extract-sql/describe_test.go +0 -11
- package/plugins/extract-sql/layout.go +0 -221
- package/plugins/extract-sql/layout_test.go +0 -55
- package/plugins/extract-sql/lineage.go +0 -107
- package/plugins/extract-sql/main.go +0 -141
- package/plugins/extract-sql/maps.go +0 -564
- package/plugins/extract-sql/maps_java.go +0 -117
- package/plugins/extract-sql/maps_rust.go +0 -333
- package/plugins/extract-sql/maps_rust_test.go +0 -70
- package/plugins/extract-sql/maps_test.go +0 -204
- package/plugins/extract-sql/maps_ts.go +0 -398
- package/plugins/extract-sql/maps_ts_test.go +0 -136
- package/plugins/extract-sql/projection.go +0 -70
- package/plugins/extract-sql/projection_test.go +0 -50
- package/plugins/extract-sql/store.go +0 -420
- package/plugins/extract-sql/store_test.go +0 -233
- package/plugins/extract-sql/view.go +0 -295
- package/plugins/extract-watermill/describe.go +0 -19
- package/plugins/extract-watermill/describe_test.go +0 -11
- package/plugins/extract-watermill/extract.go +0 -1228
- package/plugins/extract-watermill/extract_test.go +0 -234
- package/plugins/extract-watermill/main.go +0 -41
- package/plugins/extract-wsdl/describe.go +0 -19
- package/plugins/extract-wsdl/describe_test.go +0 -11
- package/plugins/extract-wsdl/extract.go +0 -148
- package/plugins/extract-wsdl/extract_test.go +0 -45
- package/plugins/extract-wsdl/main.go +0 -53
- package/plugins/fetch-bsr/auth.go +0 -114
- package/plugins/fetch-bsr/bsr.go +0 -240
- package/plugins/fetch-bsr/cache.go +0 -66
- package/plugins/fetch-bsr/describe.go +0 -19
- package/plugins/fetch-bsr/fetch.go +0 -192
- package/plugins/fetch-bsr/fetch_test.go +0 -465
- package/plugins/fetch-bsr/lock.go +0 -71
- package/plugins/fetch-bsr/main.go +0 -117
- package/plugins/fetch-csr/auth.go +0 -95
- package/plugins/fetch-csr/cache.go +0 -69
- package/plugins/fetch-csr/describe.go +0 -19
- package/plugins/fetch-csr/describe_test.go +0 -11
- package/plugins/fetch-csr/fetch.go +0 -254
- package/plugins/fetch-csr/fetch_test.go +0 -494
- package/plugins/fetch-csr/lock.go +0 -95
- package/plugins/fetch-csr/main.go +0 -122
- package/plugins/fetch-csr/registry.go +0 -209
- package/plugins/fetch-git/cache.go +0 -65
- package/plugins/fetch-git/describe.go +0 -19
- package/plugins/fetch-git/describe_test.go +0 -11
- package/plugins/fetch-git/fetch.go +0 -181
- package/plugins/fetch-git/fetch_test.go +0 -332
- package/plugins/fetch-git/git.go +0 -169
- package/plugins/fetch-git/lock.go +0 -72
- package/plugins/fetch-git/main.go +0 -127
- package/plugins/fetch-git/offline.go +0 -39
- package/plugins/fetch-git/pin.go +0 -90
- package/plugins/fetch-git/pin_test.go +0 -74
- package/plugins/gen-backstage/describe.go +0 -17
- package/plugins/gen-backstage/main.go +0 -22
- package/plugins/gen-backstage/plugin.go +0 -473
- package/plugins/gen-backstage/plugin_test.go +0 -145
- package/plugins/gen-backstage.wasm +0 -0
- package/plugins/gen-markdown/adr.go +0 -162
- package/plugins/gen-markdown/aggregate.go +0 -362
- package/plugins/gen-markdown/canonical.go +0 -93
- package/plugins/gen-markdown/context.go +0 -90
- package/plugins/gen-markdown/coverage_test.go +0 -89
- package/plugins/gen-markdown/describe.go +0 -19
- package/plugins/gen-markdown/describe_test.go +0 -11
- package/plugins/gen-markdown/external.go +0 -71
- package/plugins/gen-markdown/flow.go +0 -277
- package/plugins/gen-markdown/glossary.go +0 -76
- package/plugins/gen-markdown/glossary_test.go +0 -148
- package/plugins/gen-markdown/llms.go +0 -302
- package/plugins/gen-markdown/main.go +0 -22
- package/plugins/gen-markdown/markdown.go +0 -254
- package/plugins/gen-markdown/markdown_test.go +0 -100
- package/plugins/gen-markdown/module.go +0 -107
- package/plugins/gen-markdown/plugin.go +0 -39
- package/plugins/gen-markdown/quality_test.go +0 -176
- package/plugins/gen-markdown/redis_test.go +0 -38
- package/plugins/gen-markdown/render.go +0 -344
- package/plugins/gen-markdown/render_test.go +0 -186
- package/plugins/gen-markdown/service.go +0 -456
- package/plugins/gen-markdown/source.go +0 -177
- package/plugins/gen-markdown/store.go +0 -201
- package/plugins/gen-markdown.wasm +0 -0
- package/plugins/gen-mermaid/describe.go +0 -17
- package/plugins/gen-mermaid/main.go +0 -22
- package/plugins/gen-mermaid/plugin.go +0 -104
- package/plugins/gen-mermaid/plugin_test.go +0 -33
- package/plugins/gen-mermaid.wasm +0 -0
- package/plugins/openapi/ids.go +0 -261
- package/plugins/openapi/ids_test.go +0 -98
- package/plugins/verify-codeowners/describe.go +0 -19
- package/plugins/verify-codeowners/describe_test.go +0 -11
- package/plugins/verify-codeowners/main.go +0 -75
- package/plugins/verify-codeowners/match.go +0 -85
- package/plugins/verify-codeowners/match_test.go +0 -47
- package/plugins/verify-codeowners/owners.go +0 -164
- package/plugins/verify-codeowners/owners_test.go +0 -225
- package/plugins/verify-codeowners/parse.go +0 -90
- package/plugins/verify-codeowners/parse_test.go +0 -62
- package/plugins/verify-otel/describe.go +0 -19
- package/plugins/verify-otel/describe_test.go +0 -11
- package/plugins/verify-otel/main.go +0 -53
- package/plugins/verify-otel/match.go +0 -336
- package/plugins/verify-otel/otlp.go +0 -200
- package/plugins/verify-otel/verify.go +0 -734
- package/plugins/verify-otel/verify_test.go +0 -460
- /package/{plugins/fetch-csr/options.schema.json → scripts/host-plugins/fetch-csr.options.json} +0 -0
- /package/{plugins/fetch-git/options.schema.json → scripts/host-plugins/fetch-git.options.json} +0 -0
package/scripts/local-api.mjs
CHANGED
|
@@ -18,9 +18,9 @@ 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";
|
|
23
|
+
import { installDeliveryPreset, planDeliveryPreset, publicDeliveryPreset } from "./delivery-presets.mjs";
|
|
24
24
|
|
|
25
25
|
export const LOCAL_API_PREFIX = "/__portolan";
|
|
26
26
|
export const GENERATOR_EVENT_PREFIX = "::portolan-event::";
|
|
@@ -34,6 +34,15 @@ const repositoryCredentials = new Map();
|
|
|
34
34
|
const SNAPSHOT_SKIP = new Set([".git", ".portolan", "dist", "node_modules", "target"]);
|
|
35
35
|
const PROJECT_PREVIEW_TTL_MS = 15 * 60 * 1000;
|
|
36
36
|
|
|
37
|
+
/** Remove Vite's configured base before matching a local control-plane route. */
|
|
38
|
+
export function localApiPath(pathname, base = "/") {
|
|
39
|
+
const root = base.endsWith("/") ? base : `${base}/`;
|
|
40
|
+
if (root !== "/" && pathname.startsWith(root)) {
|
|
41
|
+
return `/${pathname.slice(root.length)}`;
|
|
42
|
+
}
|
|
43
|
+
return pathname;
|
|
44
|
+
}
|
|
45
|
+
|
|
37
46
|
function safeRoot(workspace, input) {
|
|
38
47
|
if (typeof input !== "string" || !input.trim()) throw new Error("Project path is required.");
|
|
39
48
|
const clean = input.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
@@ -651,7 +660,7 @@ export function planProject(workspace, manifest, request) {
|
|
|
651
660
|
...(String(request.componentKind ?? "").trim() ? { componentKind: String(request.componentKind).trim() } : {}),
|
|
652
661
|
...(String(request.repository ?? "").trim() ? { repository: String(request.repository).trim() } : {}),
|
|
653
662
|
};
|
|
654
|
-
const out =
|
|
663
|
+
const out = posix.join(finalRoot, "portolan");
|
|
655
664
|
const detectionByPlugin = new Map(discovery.detections.map((item) => [item.plugin, item]));
|
|
656
665
|
const hasDomainModel = plugins.some((plugin) => ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain"].includes(plugin));
|
|
657
666
|
const steps = plugins.map((plugin) => ({
|
|
@@ -671,7 +680,7 @@ export function planProject(workspace, manifest, request) {
|
|
|
671
680
|
return { project, plugins, steps, source, discovery, fetch };
|
|
672
681
|
}
|
|
673
682
|
|
|
674
|
-
function manifestWithProject(manifest, plan, { isolated = false } = {}) {
|
|
683
|
+
export function manifestWithProject(manifest, plan, { isolated = false } = {}) {
|
|
675
684
|
const fetchIndex = (manifest.extract ?? []).findIndex((step) => step.plugin === "git");
|
|
676
685
|
const extract = isolated ? [] : [...(manifest.extract ?? [])];
|
|
677
686
|
if (plan.fetch) {
|
|
@@ -695,7 +704,7 @@ function manifestWithProject(manifest, plan, { isolated = false } = {}) {
|
|
|
695
704
|
};
|
|
696
705
|
}
|
|
697
706
|
|
|
698
|
-
function writeManifest(path, manifest) {
|
|
707
|
+
export function writeManifest(path, manifest) {
|
|
699
708
|
const staging = mkdtempSync(join(dirname(path), ".portolan-manifest-"));
|
|
700
709
|
const temp = join(staging, "portolan.json");
|
|
701
710
|
try {
|
|
@@ -716,7 +725,7 @@ export function writeProject(workspace, request) {
|
|
|
716
725
|
return plan;
|
|
717
726
|
}
|
|
718
727
|
|
|
719
|
-
function setup(workspace) {
|
|
728
|
+
function setup(workspace, publicSetupFrom) {
|
|
720
729
|
const manifestText = readFileSync(join(workspace, "portolan.json"), "utf8");
|
|
721
730
|
let report;
|
|
722
731
|
try { report = JSON.parse(readFileSync(join(workspace, ".portolan/build-report.json"), "utf8")); } catch {}
|
|
@@ -1085,19 +1094,23 @@ function startProjectTrial(workspace, request) {
|
|
|
1085
1094
|
catch (cause) { rmSync(prepared.holder, { recursive: true, force: true }); throw cause; }
|
|
1086
1095
|
}
|
|
1087
1096
|
|
|
1088
|
-
export function localApiPlugin(workspace = process.cwd()) {
|
|
1097
|
+
export function localApiPlugin(workspace = process.cwd(), publicSetupFrom) {
|
|
1089
1098
|
return {
|
|
1090
1099
|
name: "portolan-local-api",
|
|
1091
1100
|
apply: "serve",
|
|
1092
1101
|
configureServer(server) {
|
|
1093
1102
|
server.middlewares.use(async (req, res, next) => {
|
|
1094
1103
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1104
|
+
url.pathname = localApiPath(url.pathname, server.config.base);
|
|
1095
1105
|
if (!url.pathname.startsWith(LOCAL_API_PREFIX)) return next();
|
|
1096
1106
|
if (!localRequest(req)) return send(res, 403, { error: "The local API is available only through localhost." });
|
|
1097
1107
|
try {
|
|
1098
1108
|
if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/status`) {
|
|
1099
1109
|
const active = [...jobs.values()].find((job) => job.status === "running");
|
|
1100
|
-
return send(res, 200, { local: true, workspace: realpathSync(workspace), setup: setup(workspace), activeRun: active ? { id: active.id, mode: active.mode } : null });
|
|
1110
|
+
return send(res, 200, { local: true, workspace: realpathSync(workspace), setup: setup(workspace, publicSetupFrom), activeRun: active ? { id: active.id, mode: active.mode } : null });
|
|
1111
|
+
}
|
|
1112
|
+
if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/delivery-presets`) {
|
|
1113
|
+
return send(res, 200, publicDeliveryPreset(planDeliveryPreset(workspace, url.searchParams.get("provider"))));
|
|
1101
1114
|
}
|
|
1102
1115
|
const eventMatch = url.pathname.match(/^\/__portolan\/runs\/([^/]+)\/events$/);
|
|
1103
1116
|
if (req.method === "GET" && eventMatch) {
|
|
@@ -1113,6 +1126,9 @@ export function localApiPlugin(workspace = process.cwd()) {
|
|
|
1113
1126
|
return send(res, 405, { error: "Use a local JSON request." });
|
|
1114
1127
|
}
|
|
1115
1128
|
const input = await body(req);
|
|
1129
|
+
if (url.pathname === `${LOCAL_API_PREFIX}/delivery-presets/install`) {
|
|
1130
|
+
return send(res, 201, installDeliveryPreset(workspace, input));
|
|
1131
|
+
}
|
|
1116
1132
|
if (url.pathname === `${LOCAL_API_PREFIX}/source`) return send(res, 200, readLocalSource(workspace, input.path));
|
|
1117
1133
|
if (url.pathname === `${LOCAL_API_PREFIX}/repositories/credentials`) return send(res, 201, storeRepositoryCredential(input));
|
|
1118
1134
|
if (url.pathname === `${LOCAL_API_PREFIX}/repositories/credentials/forget`) return send(res, 200, forgetRepositoryCredential(input));
|
|
@@ -1134,7 +1150,7 @@ export function localApiPlugin(workspace = process.cwd()) {
|
|
|
1134
1150
|
const result = writeProject(workspace, trial.projectRequest);
|
|
1135
1151
|
trial.applied = true;
|
|
1136
1152
|
const generation = input.generate ? startJob(workspace, "write", trial) : null;
|
|
1137
|
-
return send(res, 201, { ...result, setup: setup(workspace), run: generation ? { runId: generation.id, mode: generation.mode } : null });
|
|
1153
|
+
return send(res, 201, { ...result, setup: setup(workspace, publicSetupFrom), run: generation ? { runId: generation.id, mode: generation.mode } : null });
|
|
1138
1154
|
}
|
|
1139
1155
|
const disposeTrialMatch = url.pathname.match(/^\/__portolan\/projects\/trials\/([^/]+)\/dispose$/);
|
|
1140
1156
|
if (disposeTrialMatch) {
|
|
@@ -1150,7 +1166,7 @@ export function localApiPlugin(workspace = process.cwd()) {
|
|
|
1150
1166
|
}
|
|
1151
1167
|
if (url.pathname === `${LOCAL_API_PREFIX}/projects`) {
|
|
1152
1168
|
const result = writeProject(workspace, input);
|
|
1153
|
-
return send(res, 201, { ...result, setup: setup(workspace) });
|
|
1169
|
+
return send(res, 201, { ...result, setup: setup(workspace, publicSetupFrom) });
|
|
1154
1170
|
}
|
|
1155
1171
|
if (url.pathname === `${LOCAL_API_PREFIX}/runs`) {
|
|
1156
1172
|
if ([...jobs.values()].some((job) => job.status === "running")) return send(res, 409, { error: "A generator run is already active." });
|
|
@@ -1174,7 +1190,8 @@ export function localApiPlugin(workspace = process.cwd()) {
|
|
|
1174
1190
|
}
|
|
1175
1191
|
return send(res, 404, { error: "Local API route not found." });
|
|
1176
1192
|
} catch (error) {
|
|
1177
|
-
|
|
1193
|
+
const status = error instanceof LocalApiError ? error.status : Number.isInteger(error?.status) ? error.status : 400;
|
|
1194
|
+
return send(res, status, {
|
|
1178
1195
|
error: error instanceof Error ? error.message : String(error),
|
|
1179
1196
|
...(error instanceof LocalApiError ? {
|
|
1180
1197
|
code: error.code,
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { afterEach, describe, expect, it } from "vitest";
|
|
5
6
|
import { rmSync } from "node:fs";
|
|
6
7
|
|
|
7
|
-
import { classifyRepositoryFailure, diffGeneratedFiles, discoverProject, forgetRepositoryCredential, inspectionRoot, planProject, readLocalSource, resolveRepositoryCommit, storeRepositoryCredential, summarizeProjectTrial, writeProject } from "./local-api.mjs";
|
|
8
|
+
import { classifyRepositoryFailure, diffGeneratedFiles, discoverProject, forgetRepositoryCredential, inspectionRoot, localApiPath, planProject, readLocalSource, resolveRepositoryCommit, storeRepositoryCredential, summarizeProjectTrial, writeProject } from "./local-api.mjs";
|
|
9
|
+
import { installDeliveryPreset, planDeliveryPreset, providerFromRemote, publicDeliveryPreset } from "./delivery-presets.mjs";
|
|
8
10
|
|
|
11
|
+
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
9
12
|
const roots = [];
|
|
10
13
|
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
|
|
11
14
|
|
|
@@ -28,7 +31,87 @@ function workspace() {
|
|
|
28
31
|
return root;
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
function gitWorkspace(provider = "github") {
|
|
35
|
+
const root = workspace();
|
|
36
|
+
execFileSync("git", ["init", "-q", "-b", "main"], { cwd: root });
|
|
37
|
+
const remote = provider === "github" ? "git@github.com:acme/shop.git" : "https://gitlab.com/acme/shop.git";
|
|
38
|
+
execFileSync("git", ["remote", "add", "origin", remote], { cwd: root });
|
|
39
|
+
return root;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("local API base path", () => {
|
|
43
|
+
it("matches control-plane routes below the configured site base", () => {
|
|
44
|
+
expect(localApiPath("/portolan/__portolan/status", "/portolan/")).toBe("/__portolan/status");
|
|
45
|
+
expect(localApiPath("/portolan/__portolan/status", "/portolan")).toBe("/__portolan/status");
|
|
46
|
+
expect(localApiPath("/__portolan/status", "/portolan/")).toBe("/__portolan/status");
|
|
47
|
+
expect(localApiPath("/another/status", "/portolan/")).toBe("/another/status");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
31
51
|
describe("local project setup", () => {
|
|
52
|
+
it("detects GitHub and GitLab remotes without treating arbitrary hosts as a forge", () => {
|
|
53
|
+
expect(providerFromRemote("git@github.com:acme/shop.git")).toBe("github");
|
|
54
|
+
expect(providerFromRemote("https://gitlab.example.com/acme/shop.git")).toBe("gitlab");
|
|
55
|
+
expect(providerFromRemote("ssh://git@example.com/acme/shop.git")).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("previews and installs GitHub delivery workflows idempotently", () => {
|
|
59
|
+
const root = gitWorkspace("github");
|
|
60
|
+
const preview = planDeliveryPreset(root);
|
|
61
|
+
expect(preview).toMatchObject({ provider: "github", detectedProvider: "github", repository: "shop", defaultBranch: "main", status: "available" });
|
|
62
|
+
expect(preview.files.map((file) => [file.path, file.status])).toEqual([
|
|
63
|
+
[".github/workflows/portolan-check.yml", "added"],
|
|
64
|
+
[".github/workflows/portolan-pages.yml", "added"],
|
|
65
|
+
]);
|
|
66
|
+
expect(publicDeliveryPreset(preview).files[0]).not.toHaveProperty("next");
|
|
67
|
+
const installed = installDeliveryPreset(root, { provider: "github", revision: preview.revision });
|
|
68
|
+
expect(installed.written).toHaveLength(2);
|
|
69
|
+
expect(installed.status).toBe("installed");
|
|
70
|
+
expect(readFileSync(join(root, ".github/workflows/portolan-check.yml"), "utf8")).toContain(`shortlink-org/portolan@${PACKAGE_VERSION}`);
|
|
71
|
+
expect(readFileSync(join(root, ".github/workflows/portolan-pages.yml"), "utf8")).toContain("base: /${{ github.event.repository.name }}/");
|
|
72
|
+
expect(installDeliveryPreset(root, { provider: "github", revision: installed.revision }).written).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("refuses to overwrite an unmanaged GitHub workflow or apply a stale preview", () => {
|
|
76
|
+
const root = gitWorkspace("github");
|
|
77
|
+
mkdirSync(join(root, ".github/workflows"), { recursive: true });
|
|
78
|
+
writeFileSync(join(root, ".github/workflows/portolan-check.yml"), "name: Mine\n");
|
|
79
|
+
const conflict = planDeliveryPreset(root, "github");
|
|
80
|
+
expect(conflict.status).toBe("conflict");
|
|
81
|
+
expect(() => installDeliveryPreset(root, { provider: "github", revision: conflict.revision })).toThrow(/was not created by Portolan/);
|
|
82
|
+
const clean = gitWorkspace("github");
|
|
83
|
+
const preview = planDeliveryPreset(clean, "github");
|
|
84
|
+
mkdirSync(join(clean, ".github/workflows"), { recursive: true });
|
|
85
|
+
writeFileSync(join(clean, ".github/workflows/portolan-check.yml"), "name: Arrived later\n");
|
|
86
|
+
expect(() => installDeliveryPreset(clean, { provider: "github", revision: preview.revision })).toThrow(/Preview it again/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("refuses a delivery target below a symlinked directory", () => {
|
|
90
|
+
const root = gitWorkspace("github");
|
|
91
|
+
const outside = mkdtempSync(join(tmpdir(), "portolan-preset-outside-"));
|
|
92
|
+
roots.push(outside);
|
|
93
|
+
symlinkSync(outside, join(root, ".github"), "dir");
|
|
94
|
+
const preview = planDeliveryPreset(root, "github");
|
|
95
|
+
expect(preview.status).toBe("conflict");
|
|
96
|
+
expect(() => installDeliveryPreset(root, { provider: "github", revision: preview.revision })).toThrow(/was not created by Portolan/);
|
|
97
|
+
expect(existsSync(join(outside, "workflows/portolan-check.yml"))).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("adds and updates one managed GitLab region while preserving the pipeline", () => {
|
|
101
|
+
const root = gitWorkspace("gitlab");
|
|
102
|
+
writeFileSync(join(root, ".gitlab-ci.yml"), "lint:\n script: echo lint\n");
|
|
103
|
+
const preview = planDeliveryPreset(root);
|
|
104
|
+
expect(preview).toMatchObject({ provider: "gitlab", detectedProvider: "gitlab", status: "available" });
|
|
105
|
+
expect(preview.files[0].diff).toContain("+\"portolan:check\":");
|
|
106
|
+
const installed = installDeliveryPreset(root, { provider: "gitlab", revision: preview.revision });
|
|
107
|
+
expect(installed.status).toBe("installed");
|
|
108
|
+
const pipeline = readFileSync(join(root, ".gitlab-ci.yml"), "utf8");
|
|
109
|
+
expect(pipeline).toContain("lint:\n script: echo lint");
|
|
110
|
+
expect(pipeline).toContain("# >>> Portolan delivery preset >>>");
|
|
111
|
+
expect(pipeline).toContain("pages:\n publish: dist");
|
|
112
|
+
expect(existsSync(join(root, ".github"))).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
32
115
|
it("classifies repository authentication, authorization, and timeout failures", () => {
|
|
33
116
|
expect(classifyRepositoryFailure(new Error("fatal: Authentication failed"), "GitHub")).toMatchObject({
|
|
34
117
|
code: "repository_auth_required", status: 401, retryable: true, provider: "GitHub",
|
package/scripts/manifest.mjs
CHANGED
|
@@ -13,7 +13,8 @@ import { normalize } from "node:path";
|
|
|
13
13
|
|
|
14
14
|
import Ajv from "ajv/dist/2020.js";
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// Read when asked, not when loaded: the CLI sets PORTOLAN_SCHEMA after its imports.
|
|
17
|
+
const schemaFile = () => process.env.PORTOLAN_SCHEMA || "schema/portolan.schema.json";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Reads the manifest and validates it.
|
|
@@ -28,7 +29,7 @@ export function loadManifest(path = "portolan.json") {
|
|
|
28
29
|
|
|
29
30
|
let schema;
|
|
30
31
|
try {
|
|
31
|
-
schema = JSON.parse(readFileSync(
|
|
32
|
+
schema = JSON.parse(readFileSync(schemaFile(), "utf8"));
|
|
32
33
|
} catch {
|
|
33
34
|
return { manifest, problems: [] };
|
|
34
35
|
}
|
|
@@ -2,18 +2,38 @@
|
|
|
2
2
|
// This catches accidental cwd coupling before the same package reaches npm.
|
|
3
3
|
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
|
-
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { resolve } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
|
|
10
10
|
const root = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
11
|
-
const cli = resolve(root, "cli/portolan.mjs");
|
|
12
11
|
const fixture = mkdtempSync(resolve(tmpdir(), "portolan-package-smoke-"));
|
|
12
|
+
const install = mkdtempSync(resolve(tmpdir(), "portolan-package-install-"));
|
|
13
13
|
|
|
14
14
|
try {
|
|
15
|
+
// Exercise the tarball from a real node_modules path. Node permits native
|
|
16
|
+
// TypeScript stripping in a checkout but rejects it below node_modules, so
|
|
17
|
+
// invoking the source-tree CLI did not catch broken published entry graphs.
|
|
18
|
+
const tarball = execFileSync("npm", ["pack", "--ignore-scripts", "--pack-destination", install], {
|
|
19
|
+
cwd: root,
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
22
|
+
}).trim().split("\n").at(-1);
|
|
23
|
+
const scope = resolve(install, "node_modules/@shortlink-org");
|
|
24
|
+
mkdirSync(scope, { recursive: true });
|
|
25
|
+
execFileSync("tar", ["-xzf", resolve(install, tarball), "-C", scope]);
|
|
26
|
+
const installed = resolve(scope, "portolan");
|
|
27
|
+
renameSync(resolve(scope, "package"), installed);
|
|
28
|
+
symlinkSync(resolve(root, "node_modules"), resolve(installed, "node_modules"), process.platform === "win32" ? "junction" : "dir");
|
|
29
|
+
const cli = resolve(installed, "cli/portolan.mjs");
|
|
30
|
+
|
|
15
31
|
writeFileSync(resolve(fixture, "package.json"), '{"name":"package-smoke","version":"1.0.0"}\n');
|
|
16
32
|
writeFileSync(resolve(fixture, "README.md"), "# Package smoke\n");
|
|
33
|
+
// A Go domain layout: init should notice it and wire the extractor without being asked.
|
|
34
|
+
writeFileSync(resolve(fixture, "go.mod"), "module example.com/smoke\n\ngo 1.24\n");
|
|
35
|
+
mkdirSync(resolve(fixture, "internal/domain/order"), { recursive: true });
|
|
36
|
+
writeFileSync(resolve(fixture, "internal/domain/order/order.go"), "package order\n\ntype Order struct{ ID string }\n");
|
|
17
37
|
run("git", ["init", "--quiet"]);
|
|
18
38
|
run("git", ["config", "user.email", "portolan@example.invalid"]);
|
|
19
39
|
run("git", ["config", "user.name", "Portolan smoke test"]);
|
|
@@ -24,12 +44,13 @@ try {
|
|
|
24
44
|
run(process.execPath, [cli, "check", "--cwd", fixture]);
|
|
25
45
|
run(process.execPath, [cli, "build", "--cwd", fixture, "--output", "dist", "--base", "/architecture/"]);
|
|
26
46
|
|
|
27
|
-
for (const path of ["portolan/project.json", "docs/README.md", "dist/index.html", "dist/404.html"]) {
|
|
47
|
+
for (const path of ["portolan/project.json", "portolan/domain.json", "docs/README.md", "dist/index.html", "dist/404.html"]) {
|
|
28
48
|
if (!existsSync(resolve(fixture, path))) throw new Error(`smoke test did not write ${path}`);
|
|
29
49
|
}
|
|
30
50
|
console.log("package smoke: init, generate, check, and build passed outside the repository");
|
|
31
51
|
} finally {
|
|
32
52
|
rmSync(fixture, { recursive: true, force: true });
|
|
53
|
+
rmSync(install, { recursive: true, force: true });
|
|
33
54
|
}
|
|
34
55
|
|
|
35
56
|
function run(command, args) {
|
package/scripts/plugin-host.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// Runs a portolan
|
|
1
|
+
// Runs a portolan plugin and returns what it wants written.
|
|
2
2
|
//
|
|
3
|
-
// A plugin never
|
|
4
|
-
// with named files on stdout; writing them - and refusing a
|
|
5
|
-
// out of the output directory - is this file's job. That split
|
|
6
|
-
//
|
|
7
|
-
// all,
|
|
8
|
-
//
|
|
3
|
+
// A plugin never writes the tree (portolan.0001). It is handed a request on
|
|
4
|
+
// stdin and answers with named files on stdout; writing them - and refusing a
|
|
5
|
+
// name that climbs out of the output directory - is this file's job. That split
|
|
6
|
+
// is what lets a generator run as a wasm module with no directory preopened at
|
|
7
|
+
// all, an extractor as one with only the workspace preopened (portolan.0006),
|
|
8
|
+
// and it is why `--check` can compare a render against disk without the plugin
|
|
9
|
+
// knowing there is a disk.
|
|
9
10
|
|
|
10
11
|
import { spawn } from "node:child_process";
|
|
11
12
|
import { createHash } from "node:crypto";
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
} from "node:fs";
|
|
19
20
|
import { mkdtemp } from "node:fs/promises";
|
|
20
21
|
import { tmpdir } from "node:os";
|
|
21
|
-
import { isAbsolute, join } from "node:path";
|
|
22
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
22
23
|
import { Worker } from "node:worker_threads";
|
|
23
24
|
|
|
24
25
|
const PORTOLAN_VERSION = "0.1.0";
|
|
@@ -35,9 +36,14 @@ const HOST_LIMITS = Object.freeze({
|
|
|
35
36
|
*
|
|
36
37
|
* @param {{name: string, wasm?: {url: string, sha256?: string}, process?: {command: string, args?: string[]}}} plugin
|
|
37
38
|
* @param {unknown} request
|
|
39
|
+
* @param {{workspace?: string}} [access] `workspace` is the directory a wasm
|
|
40
|
+
* module may read, preopened as `/` (portolan.0006). Left out, the module
|
|
41
|
+
* gets no filesystem at all, which is what a generator and a describe
|
|
42
|
+
* request should get. A process plugin is unaffected: it already has the
|
|
43
|
+
* whole machine.
|
|
38
44
|
* @returns {Promise<{files: {name: string, contents: string}[], describe?: object}>}
|
|
39
45
|
*/
|
|
40
|
-
export async function runPlugin(plugin, request, requestedLimits = {}) {
|
|
46
|
+
export async function runPlugin(plugin, request, requestedLimits = {}, access = {}) {
|
|
41
47
|
const limits = lowerLimits(requestedLimits);
|
|
42
48
|
let payload;
|
|
43
49
|
try {
|
|
@@ -52,8 +58,13 @@ export async function runPlugin(plugin, request, requestedLimits = {}) {
|
|
|
52
58
|
throw new Error(`plugin ${plugin.name}: request exceeds ${limits.requestBytes} bytes`);
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
if (plugin.host) {
|
|
62
|
+
const { response, warnings } = await runHost(plugin, request);
|
|
63
|
+
return { ...validateResponse(plugin.name, response), warnings };
|
|
64
|
+
}
|
|
65
|
+
|
|
55
66
|
const result = plugin.wasm
|
|
56
|
-
? await runWasm(plugin, payload, limits)
|
|
67
|
+
? await runWasm(plugin, payload, limits, access)
|
|
57
68
|
: await runProcess(plugin, payload, limits);
|
|
58
69
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
59
70
|
|
|
@@ -69,6 +80,31 @@ export async function runPlugin(plugin, request, requestedLimits = {}) {
|
|
|
69
80
|
return { ...validateResponse(plugin.name, response), warnings: warningsIn(result.stderr) };
|
|
70
81
|
}
|
|
71
82
|
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// host
|
|
85
|
+
//
|
|
86
|
+
// A plugin that needs what only the host has - a socket, a git binary - and
|
|
87
|
+
// so runs inside it (portolan.0008). It speaks the same protocol as any other:
|
|
88
|
+
// a request in, named files out, warnings beside them; the host writes and
|
|
89
|
+
// validates exactly as it would for a module. Only the modules shipped here
|
|
90
|
+
// can be named, so a manifest cannot point the host at arbitrary code.
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
const HOST_PLUGINS = new Set(["fetch-git", "fetch-bsr", "fetch-csr"]);
|
|
94
|
+
|
|
95
|
+
async function runHost(plugin, request) {
|
|
96
|
+
const name = String(plugin.host);
|
|
97
|
+
if (!HOST_PLUGINS.has(name)) throw new Error(`plugin ${plugin.name}: the host has no plugin named ${JSON.stringify(name)}`);
|
|
98
|
+
const module = await import(`./host-plugins/${name}.mjs`);
|
|
99
|
+
if (request?.kind === "describe") return { response: { files: [], describe: module.describe() }, warnings: [] };
|
|
100
|
+
const { files, warnings = [] } = await module.run(request);
|
|
101
|
+
// Said the way a module says it on stderr, and kept the way warningsIn
|
|
102
|
+
// keeps it, so the build report reads the same whoever ran the step.
|
|
103
|
+
const lines = warnings.map((warning) => `${warning.ref ? `${warning.ref}: ` : ""}${warning.message}`);
|
|
104
|
+
for (const line of lines) process.stderr.write(`warning: ${line}\n`);
|
|
105
|
+
return { response: { files }, warnings: lines };
|
|
106
|
+
}
|
|
107
|
+
|
|
72
108
|
/**
|
|
73
109
|
* The warnings a plugin wrote to stderr, one per `warning: ` line, without the
|
|
74
110
|
* prefix.
|
|
@@ -164,6 +200,9 @@ function validateDescriptor(pluginName, descriptor) {
|
|
|
164
200
|
if (!descriptor.options || typeof descriptor.options !== "object" || Array.isArray(descriptor.options)) {
|
|
165
201
|
throw new Error(`plugin ${pluginName}: describe.options is invalid`);
|
|
166
202
|
}
|
|
203
|
+
if (descriptor.needs !== undefined && (!Array.isArray(descriptor.needs) || descriptor.needs.some((need) => need !== "history"))) {
|
|
204
|
+
throw new Error(`plugin ${pluginName}: describe.needs may only name "history"`);
|
|
205
|
+
}
|
|
167
206
|
}
|
|
168
207
|
|
|
169
208
|
/**
|
|
@@ -191,12 +230,29 @@ export async function describePlugin(plugin) {
|
|
|
191
230
|
//
|
|
192
231
|
// Node's WASI takes file descriptors rather than pipes, so the request and the
|
|
193
232
|
// response go through a scratch directory. That is the only place the sandbox
|
|
194
|
-
// leaks, and it leaks into a temporary directory the plugin is not told about
|
|
195
|
-
//
|
|
233
|
+
// leaks, and it leaks into a temporary directory the plugin is not told about.
|
|
234
|
+
// A generator gets no preopen at all. An extract or verify step gets the
|
|
235
|
+
// workspace as `/` (portolan.0006) - read-write, because that is all WASI
|
|
236
|
+
// offers, and nothing beyond it: no network, no environment, no way to spawn.
|
|
196
237
|
// ---------------------------------------------------------------------------
|
|
197
238
|
|
|
198
|
-
|
|
239
|
+
/** Compiled modules by digest: one module answers for every built-in plugin. */
|
|
240
|
+
const compiled = new Map();
|
|
241
|
+
|
|
242
|
+
async function compileWasm(plugin, limits) {
|
|
199
243
|
const bytes = await loadWasm(plugin, limits);
|
|
244
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
245
|
+
let module = compiled.get(digest);
|
|
246
|
+
if (!module) {
|
|
247
|
+
module = await WebAssembly.compile(bytes);
|
|
248
|
+
compiled.set(digest, module);
|
|
249
|
+
}
|
|
250
|
+
return module;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function runWasm(plugin, payload, limits, access) {
|
|
254
|
+
const module = await compileWasm(plugin, limits);
|
|
255
|
+
const workspace = access.workspace ? resolve(access.workspace) : "";
|
|
200
256
|
|
|
201
257
|
const dir = await mkdtemp(join(tmpdir(), "portolan-plugin-"));
|
|
202
258
|
const inPath = join(dir, "request.json");
|
|
@@ -209,7 +265,10 @@ async function runWasm(plugin, payload, limits) {
|
|
|
209
265
|
|
|
210
266
|
try {
|
|
211
267
|
const worker = new Worker(new URL("./plugin-wasm-worker.mjs", import.meta.url), {
|
|
212
|
-
workerData: { name: plugin.name,
|
|
268
|
+
workerData: { name: plugin.name, module, workspace, inPath, outPath, errPath },
|
|
269
|
+
// node:wasi announces itself as experimental on every import; once per
|
|
270
|
+
// step, that is sixty lines of noise in a generate.
|
|
271
|
+
execArgv: [...process.execArgv, "--disable-warning=ExperimentalWarning"],
|
|
213
272
|
});
|
|
214
273
|
const code = await new Promise((resolve, reject) => {
|
|
215
274
|
let settled = false;
|
|
@@ -350,10 +409,10 @@ function verifyDigest(name, bytes, expected) {
|
|
|
350
409
|
// ---------------------------------------------------------------------------
|
|
351
410
|
// process
|
|
352
411
|
//
|
|
353
|
-
// The escape hatch for a
|
|
354
|
-
//
|
|
355
|
-
// protocol and none of the sandbox, which is the trade being
|
|
356
|
-
// reason it is not the default.
|
|
412
|
+
// The escape hatch for a plugin that needs a toolchain or a socket - the Rust
|
|
413
|
+
// extractor runs under Cargo, `adr` asks git for history, `fetch-git` clones.
|
|
414
|
+
// It gets the same protocol and none of the sandbox, which is the trade being
|
|
415
|
+
// made and the reason it is not the default.
|
|
357
416
|
// ---------------------------------------------------------------------------
|
|
358
417
|
|
|
359
418
|
function runProcess(plugin, payload, limits) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
5
6
|
|
|
6
7
|
import { runPlugin, validateResponse, warningsIn } from "./plugin-host.mjs";
|
|
7
8
|
|
|
@@ -100,6 +101,54 @@ describe("wasm plugins", () => {
|
|
|
100
101
|
});
|
|
101
102
|
});
|
|
102
103
|
|
|
104
|
+
// The built-in module, when `npm run plugins:build` has produced it. These
|
|
105
|
+
// tests read a tree through it, which is what portolan.0006 allows an extract
|
|
106
|
+
// step and denies everything else.
|
|
107
|
+
const builtinModule = fileURLToPath(new URL("../plugins/portolan-go.wasm", import.meta.url));
|
|
108
|
+
const describeBuiltin = existsSync(builtinModule) ? describe : describe.skip;
|
|
109
|
+
|
|
110
|
+
describeBuiltin("wasm preopen", () => {
|
|
111
|
+
const plugin = { name: "project", wasm: { url: `file://${builtinModule}` } };
|
|
112
|
+
const request = {
|
|
113
|
+
portolanVersion: "0.1.0",
|
|
114
|
+
input: { root: ".", output: "portolan", commit: "abc1234", generatedAt: "2026-09-08T00:00:00Z" },
|
|
115
|
+
options: { group: "fixture", component: "fixture", componentName: "Fixture", out: "project.json" },
|
|
116
|
+
};
|
|
117
|
+
const workspace = () => {
|
|
118
|
+
const dir = mkdtempSync(join(tmpdir(), "portolan-preopen-"));
|
|
119
|
+
writeFileSync(join(dir, "README.md"), "# Fixture\n");
|
|
120
|
+
writeFileSync(join(dir, "go.mod"), "module example.com/fixture\n\ngo 1.24\n");
|
|
121
|
+
return dir;
|
|
122
|
+
};
|
|
123
|
+
const serviceIn = (result) => JSON.parse(result.files[0].contents).contexts[0].services[0];
|
|
124
|
+
|
|
125
|
+
it("reads the workspace an extract step is given", async () => {
|
|
126
|
+
const dir = workspace();
|
|
127
|
+
try {
|
|
128
|
+
const service = serviceIn(await runPlugin(plugin, request, {}, { workspace: dir }));
|
|
129
|
+
expect(service.readme).toBe("# Fixture");
|
|
130
|
+
expect(service.technologies).toContain("Go");
|
|
131
|
+
expect(service.repo).toBe("example.com/fixture");
|
|
132
|
+
} finally {
|
|
133
|
+
rmSync(dir, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("sees nothing when no workspace is given, as a generator or describe never should", async () => {
|
|
138
|
+
const dir = workspace();
|
|
139
|
+
const cwd = process.cwd();
|
|
140
|
+
process.chdir(dir);
|
|
141
|
+
try {
|
|
142
|
+
const service = serviceIn(await runPlugin(plugin, request));
|
|
143
|
+
expect(service.readme).toBe("");
|
|
144
|
+
expect(service.technologies ?? []).not.toContain("Go");
|
|
145
|
+
} finally {
|
|
146
|
+
process.chdir(cwd);
|
|
147
|
+
rmSync(dir, { recursive: true, force: true });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
103
152
|
describe("warningsIn", () => {
|
|
104
153
|
it("reads only the warning lines, and nothing from an empty stderr", () => {
|
|
105
154
|
expect(warningsIn("")).toEqual([]);
|
|
@@ -11,13 +11,16 @@ async function run() {
|
|
|
11
11
|
version: "preview1",
|
|
12
12
|
args: [workerData.name],
|
|
13
13
|
env: {},
|
|
14
|
-
|
|
14
|
+
// Empty for a generator or a describe request. An extract or verify
|
|
15
|
+
// step gets the workspace as `/`, and nothing else (portolan.0006).
|
|
16
|
+
preopens: workerData.workspace ? { "/": workerData.workspace } : {},
|
|
15
17
|
stdin,
|
|
16
18
|
stdout,
|
|
17
19
|
stderr,
|
|
18
20
|
});
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
// The module was compiled once by the host and shared; a 20 MB module
|
|
22
|
+
// compiled per step would cost more than the step.
|
|
23
|
+
const instance = await WebAssembly.instantiate(workerData.module, wasi.getImportObject());
|
|
21
24
|
return wasi.start(instance);
|
|
22
25
|
} finally {
|
|
23
26
|
closeSync(stdin);
|
package/scripts/run-builtin.mjs
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
// Process adapter for plugins bundled in the npm package. It keeps the
|
|
4
4
|
// plugin's cwd in the user's workspace while resolving source and toolchain
|
|
5
|
-
// files from the installation. Go plugins
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// files from the installation. The Go plugins no longer come through here:
|
|
6
|
+
// they are one wasm module (portolan.0006), and the fetchers run inside the
|
|
7
|
+
// host (portolan.0008).
|
|
8
8
|
|
|
9
|
-
import { spawn
|
|
10
|
-
import { existsSync,
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
11
|
import { dirname, resolve } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
|
|
@@ -25,27 +25,7 @@ if (!plugin?.process) {
|
|
|
25
25
|
const command = plugin.process.command;
|
|
26
26
|
const args = [...(plugin.process.args ?? [])];
|
|
27
27
|
|
|
28
|
-
if (command === "
|
|
29
|
-
const binDir = resolve(workspace, ".portolan", "bin");
|
|
30
|
-
const suffix = process.platform === "win32" ? ".exe" : "";
|
|
31
|
-
const binary = resolve(binDir, `${name}${suffix}`);
|
|
32
|
-
mkdirSync(binDir, { recursive: true });
|
|
33
|
-
|
|
34
|
-
// Rebuilding is deliberately left to Go's build cache. Package upgrades can
|
|
35
|
-
// change shared extractor code without changing this plugin directory, so a
|
|
36
|
-
// timestamp shortcut here would make an old binary look current.
|
|
37
|
-
const built = spawnSync(
|
|
38
|
-
"go",
|
|
39
|
-
["build", "-o", binary, args[1]],
|
|
40
|
-
{ cwd: installRoot, stdio: ["ignore", "ignore", "inherit"] },
|
|
41
|
-
);
|
|
42
|
-
if (built.error) {
|
|
43
|
-
console.error(`portolan: could not start Go to build ${name}: ${built.error.message}`);
|
|
44
|
-
process.exit(1);
|
|
45
|
-
}
|
|
46
|
-
if (built.status !== 0 || !existsSync(binary)) process.exit(built.status ?? 1);
|
|
47
|
-
run(binary, []);
|
|
48
|
-
} else if (command === "cargo" && existsSync(resolve(installRoot, "plugins/extract-rust/target/release/portolan-extract-rust"))) {
|
|
28
|
+
if (command === "cargo" && existsSync(resolve(installRoot, "plugins/extract-rust/target/release/portolan-extract-rust"))) {
|
|
49
29
|
run(resolve(installRoot, "plugins/extract-rust/target/release/portolan-extract-rust"), []);
|
|
50
30
|
} else {
|
|
51
31
|
if (command === "cargo") {
|
package/scripts/schema.mjs
CHANGED
|
@@ -226,12 +226,17 @@ function compose() {
|
|
|
226
226
|
type: "object",
|
|
227
227
|
additionalProperties: false,
|
|
228
228
|
required: ["name"],
|
|
229
|
-
oneOf: [{ required: ["wasm"] }, { required: ["process"] }],
|
|
229
|
+
oneOf: [{ required: ["wasm"] }, { required: ["process"] }, { required: ["host"] }],
|
|
230
230
|
properties: {
|
|
231
231
|
name: {
|
|
232
232
|
type: "string",
|
|
233
233
|
description: "What the steps below call this plugin. It need not be the plugin's own name.",
|
|
234
234
|
},
|
|
235
|
+
host: {
|
|
236
|
+
enum: ["fetch-git", "fetch-bsr", "fetch-csr"],
|
|
237
|
+
description:
|
|
238
|
+
"A plugin the host runs inside its own process, because it needs a socket or a git binary (portolan.0008). Only the ones shipped with Portolan can be named.",
|
|
239
|
+
},
|
|
235
240
|
wasm: {
|
|
236
241
|
type: "object",
|
|
237
242
|
additionalProperties: false,
|