@shortlink-org/portolan 0.1.0 → 0.2.0
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 +22 -4
- package/scripts/local-api.test.mjs +84 -2
- package/scripts/manifest.mjs +3 -2
- package/scripts/package-smoke.mjs +6 -2
- 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 +12 -8
- 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
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// fetch-git, run by the host: a git repository on one side, a narrowed copy of
|
|
2
|
+
// it committed to this repository on the other.
|
|
3
|
+
//
|
|
4
|
+
// It exists for the estate whose services live in repositories of their own.
|
|
5
|
+
// An extractor reads one tree, and reads it as a pure function of what is on
|
|
6
|
+
// disk; fetching is the step that can fail, need a credential, or come back
|
|
7
|
+
// with something different than it did yesterday. Keeping the two apart is
|
|
8
|
+
// what lets CI verify an estate it never clones.
|
|
9
|
+
//
|
|
10
|
+
// It runs in the host rather than as a plugin because it needs a socket and a
|
|
11
|
+
// git binary, which no sandboxed module has, and both are the host's to hold
|
|
12
|
+
// (portolan.0008). The contract is the plugin's all the same: the fetched
|
|
13
|
+
// files are named, never written, so the host writes them, they get a manifest
|
|
14
|
+
// entry, are compared by `gen:check` like any other generated file, and are
|
|
15
|
+
// removed when the step stops naming them. Paths inside the copy are the
|
|
16
|
+
// repository's own, so the extract step that follows points its `in` at the
|
|
17
|
+
// vendored service and reads it exactly as it would read the service's own
|
|
18
|
+
// checkout.
|
|
19
|
+
//
|
|
20
|
+
// Two files land beside each copy. `git.lock.json` is for the next run of this
|
|
21
|
+
// step - the commit and every file's digest, which is what makes replaying it
|
|
22
|
+
// equivalent to fetching again. `git.repo.json` is for the estate: a catalog
|
|
23
|
+
// fragment naming the repository and the commit, which is the only way that
|
|
24
|
+
// fact reaches a page.
|
|
25
|
+
//
|
|
26
|
+
// In CI, or with PORTOLAN_OFFLINE=1, the step replays the committed copies,
|
|
27
|
+
// checks them against their locks and emits an identical file list, so a
|
|
28
|
+
// fork's pull request needs no credential and a forge being down cannot turn
|
|
29
|
+
// the tree red.
|
|
30
|
+
//
|
|
31
|
+
// No credential, ever. portolan.json is committed; git reaches the forge with
|
|
32
|
+
// whatever it is configured with - a credential helper, a netrc entry, an ssh
|
|
33
|
+
// agent - and nothing here reads or passes any of it.
|
|
34
|
+
|
|
35
|
+
import { execFileSync } from "node:child_process";
|
|
36
|
+
import { createHash } from "node:crypto";
|
|
37
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
38
|
+
import { tmpdir } from "node:os";
|
|
39
|
+
import { join, posix } from "node:path";
|
|
40
|
+
|
|
41
|
+
import optionsSchema from "./fetch-git.options.json" with { type: "json" };
|
|
42
|
+
|
|
43
|
+
export const LOCK_NAME = "git.lock.json";
|
|
44
|
+
export const PIN_NAME = "git.repo.json";
|
|
45
|
+
export const OFFLINE_ENV = "PORTOLAN_OFFLINE";
|
|
46
|
+
|
|
47
|
+
export function describe() {
|
|
48
|
+
return {
|
|
49
|
+
name: "fetch-git",
|
|
50
|
+
summary: "Fetches pinned directories of another git repository into the tree, with a lock, so extractors can read a service that lives elsewhere.",
|
|
51
|
+
phases: ["extract"],
|
|
52
|
+
options: optionsSchema,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The step. Reads nothing out of the tree being described: its `cache` is
|
|
58
|
+
* repo-relative, like every other path in the manifest, and everything else
|
|
59
|
+
* it needs is in the options.
|
|
60
|
+
*
|
|
61
|
+
* The rules below are the design, not error handling. The one that matters
|
|
62
|
+
* most is the third: a failed fetch with no cache is an ERROR, never a short
|
|
63
|
+
* file list. The host deletes files a step stops naming, and dropping a
|
|
64
|
+
* vendored service because a laptop went offline is worse than a red build.
|
|
65
|
+
*
|
|
66
|
+
* @param {{options?: object}} request
|
|
67
|
+
* @param {{env?: NodeJS.ProcessEnv}} [io]
|
|
68
|
+
* @returns {{files: {name: string, contents: string}[], warnings: {severity: string, message: string, ref?: string}[]}}
|
|
69
|
+
*/
|
|
70
|
+
export function run(request, { env = process.env } = {}) {
|
|
71
|
+
const options = request.options ?? {};
|
|
72
|
+
const repos = Array.isArray(options.repos) ? options.repos : [];
|
|
73
|
+
if (repos.length === 0) throw new Error("no repositories to fetch: name at least one in the manifest");
|
|
74
|
+
if (!options.cache) {
|
|
75
|
+
throw new Error("no cache directory: set `cache` to the same path as the step's `out`, so an offline run can replay what the last online one wrote");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const out = new Builder();
|
|
79
|
+
// Sorted, so the file list and the diagnostics come out the same way every
|
|
80
|
+
// run whatever order the manifest happens to list them in.
|
|
81
|
+
const wanted = [...repos].sort((a, b) => String(a.repo).localeCompare(String(b.repo)));
|
|
82
|
+
const skip = offline(env);
|
|
83
|
+
|
|
84
|
+
for (const want of wanted) {
|
|
85
|
+
const { url, dir } = splitRepo(want.repo);
|
|
86
|
+
const at = join(options.cache, ...dir.split("/"));
|
|
87
|
+
|
|
88
|
+
if (skip) {
|
|
89
|
+
emitCached(out, dir, at, want, "offline");
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let fetched;
|
|
94
|
+
try {
|
|
95
|
+
fetched = live(url, want, out, env);
|
|
96
|
+
} catch (cause) {
|
|
97
|
+
// Rule 2: the tree still holds a good copy, so the output is unchanged
|
|
98
|
+
// and `--check` stays clean.
|
|
99
|
+
try {
|
|
100
|
+
emitCached(out, dir, at, want, cause.message);
|
|
101
|
+
} catch (cacheCause) {
|
|
102
|
+
// Rule 3: nothing to fall back to.
|
|
103
|
+
throw new Error(`${want.repo} could not be fetched (${cause.message}) and there is no usable copy in the tree (${cacheCause.message})`);
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
emitFetched(out, dir, want, fetched.commit, fetched.files);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return out.response();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether to open a socket. Explicit, and CI verifies what was committed. */
|
|
114
|
+
export function offline(env = process.env) {
|
|
115
|
+
if (String(env[OFFLINE_ENV] ?? "").trim()) return true;
|
|
116
|
+
return !["", "0", "false"].includes(String(env.CI ?? "").trim().toLowerCase());
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The name the manifest uses, turned into a URL git can fetch and the
|
|
121
|
+
* directory the copy lives in: owner/name, the same shape as fetch-bsr.
|
|
122
|
+
*/
|
|
123
|
+
export function splitRepo(name) {
|
|
124
|
+
const value = String(name ?? "").trim();
|
|
125
|
+
if (!value) throw new Error("a repo entry names no repository");
|
|
126
|
+
let url;
|
|
127
|
+
let where;
|
|
128
|
+
if (value.startsWith("git@")) {
|
|
129
|
+
url = value;
|
|
130
|
+
where = value.slice(value.indexOf(":") + 1);
|
|
131
|
+
} else if (value.includes("://")) {
|
|
132
|
+
url = value;
|
|
133
|
+
where = value.slice(value.indexOf("://") + 3);
|
|
134
|
+
const slash = where.indexOf("/");
|
|
135
|
+
where = slash >= 0 ? where.slice(slash + 1) : "";
|
|
136
|
+
} else {
|
|
137
|
+
url = `https://${value}`;
|
|
138
|
+
const slash = value.indexOf("/");
|
|
139
|
+
where = slash >= 0 ? value.slice(slash + 1) : "";
|
|
140
|
+
}
|
|
141
|
+
const segments = where.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "").split("/").filter(Boolean);
|
|
142
|
+
if (segments.length < 2) throw new Error(`"${value}" does not name an owner and a repository`);
|
|
143
|
+
return { url, dir: `${segments.at(-2)}/${segments.at(-1)}` };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The repository as `Service.repo` spells it - host/owner/name, the way go.mod
|
|
148
|
+
* writes it - whatever spelling the manifest used. The lock keeps what the
|
|
149
|
+
* manifest said, because a lock is about the fetch; the fragment is matched
|
|
150
|
+
* against a service's `repo`, which knows nothing of ssh URLs.
|
|
151
|
+
*/
|
|
152
|
+
export function webRepo(name) {
|
|
153
|
+
let value = String(name ?? "").trim();
|
|
154
|
+
if (value.startsWith("git@")) {
|
|
155
|
+
const rest = value.slice("git@".length);
|
|
156
|
+
const colon = rest.indexOf(":");
|
|
157
|
+
value = colon >= 0 ? `${rest.slice(0, colon)}/${rest.slice(colon + 1)}` : rest;
|
|
158
|
+
} else if (value.includes("://")) {
|
|
159
|
+
value = value.slice(value.indexOf("://") + 3);
|
|
160
|
+
const at = value.indexOf("@");
|
|
161
|
+
if (at >= 0) value = value.slice(at + 1);
|
|
162
|
+
}
|
|
163
|
+
return value.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The fragment for one repository at one commit: a catalog with one pin. */
|
|
167
|
+
export function pin(repo, commit) {
|
|
168
|
+
return `${JSON.stringify({ contexts: [], defs: {}, flows: [], adrs: [], repos: [{ repo: webRepo(repo), commit }] }, null, 2)}\n`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The lock, written the way every generated file here is written. */
|
|
172
|
+
export function encodeLock(entry) {
|
|
173
|
+
const files = [...entry.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
174
|
+
const record = { repo: entry.repo, commit: entry.commit };
|
|
175
|
+
if (entry.paths?.length) record.paths = [...entry.paths].sort();
|
|
176
|
+
record.files = files.map((file) => ({ path: file.path, sha256: file.sha256, size: file.size }));
|
|
177
|
+
return `${JSON.stringify({ repos: [record] }, null, 2)}\n`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function digestOf(contents) {
|
|
181
|
+
return createHash("sha256").update(contents).digest("hex");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function live(url, want, out, env) {
|
|
185
|
+
let commit = String(want.commit ?? "");
|
|
186
|
+
if (!commit) {
|
|
187
|
+
// Pin, or every run is a lottery. Resolving a ref still works, but it is
|
|
188
|
+
// said out loud, because two runs a day apart would then produce two
|
|
189
|
+
// different trees from one manifest.
|
|
190
|
+
commit = resolve(url, want.ref, env);
|
|
191
|
+
out.warn(want.repo, `is not pinned; "${want.ref || "HEAD"}" resolved to ${commit}. Pin it in portolan.json or every run is a lottery.`);
|
|
192
|
+
}
|
|
193
|
+
const files = download(url, commit, want.paths ?? [], env);
|
|
194
|
+
if (files.size === 0) throw new Error(`${want.repo} at ${commit} holds no files under ${(want.paths ?? []).join(", ")}`);
|
|
195
|
+
return { commit, files };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function emitFetched(out, dir, want, commit, files) {
|
|
199
|
+
const entry = { repo: want.repo, commit, paths: want.paths ?? [], files: [] };
|
|
200
|
+
for (const path of [...files.keys()].sort()) {
|
|
201
|
+
const contents = files.get(path);
|
|
202
|
+
out.file(posix.join(dir, path), contents.toString("utf8"));
|
|
203
|
+
entry.files.push({ path, sha256: digestOf(contents), size: contents.length });
|
|
204
|
+
}
|
|
205
|
+
out.file(posix.join(dir, LOCK_NAME), encodeLock(entry));
|
|
206
|
+
out.file(posix.join(dir, PIN_NAME), pin(want.repo, commit));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Re-emits the committed copy unchanged: byte-identical to what the last
|
|
211
|
+
* online run wrote, so `gen:check` sees no drift and CI can verify an estate
|
|
212
|
+
* whose services live in repositories it never clones.
|
|
213
|
+
*/
|
|
214
|
+
function emitCached(out, dir, at, want, why) {
|
|
215
|
+
if (!want.commit) throw new Error(`${want.repo} is not pinned to a commit, so there is nothing to replay`);
|
|
216
|
+
const held = replay(at);
|
|
217
|
+
if (held.lock.commit !== want.commit) {
|
|
218
|
+
throw new Error(`${want.repo} holds commit ${held.lock.commit} but the manifest pins ${want.commit}; fetch it`);
|
|
219
|
+
}
|
|
220
|
+
for (const file of held.lock.files) out.file(posix.join(dir, file.path), held.files.get(file.path).toString("utf8"));
|
|
221
|
+
out.file(posix.join(dir, LOCK_NAME), encodeLock(held.lock));
|
|
222
|
+
// From the LOCK's commit, not the manifest's. They are equal by the check
|
|
223
|
+
// above, and taking it from the copy is what keeps the fragment describing
|
|
224
|
+
// what is actually on disk rather than what was asked for.
|
|
225
|
+
out.file(posix.join(dir, PIN_NAME), pin(want.repo, held.lock.commit));
|
|
226
|
+
out.warn(want.repo, `not fetched (${why}); the copy committed in this repository is used unchanged`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* A repository's committed copy, read back and checked against its lock. An
|
|
231
|
+
* incomplete or edited cache is reported rather than patched over: nothing on
|
|
232
|
+
* disk means there is nothing to fall back to, and bytes that no longer match
|
|
233
|
+
* their digest mean somebody edited a vendored copy.
|
|
234
|
+
*/
|
|
235
|
+
function replay(dir) {
|
|
236
|
+
const lockPath = join(dir, LOCK_NAME);
|
|
237
|
+
let raw;
|
|
238
|
+
try {
|
|
239
|
+
raw = readFileSync(lockPath, "utf8");
|
|
240
|
+
} catch (cause) {
|
|
241
|
+
if (cause.code === "ENOENT") throw new Error(`no ${LOCK_NAME} in ${dir.split("\\").join("/")}`);
|
|
242
|
+
throw cause;
|
|
243
|
+
}
|
|
244
|
+
let lock;
|
|
245
|
+
try {
|
|
246
|
+
lock = JSON.parse(raw);
|
|
247
|
+
} catch (cause) {
|
|
248
|
+
throw new Error(`${lockPath}: ${cause.message}`);
|
|
249
|
+
}
|
|
250
|
+
const repos = Array.isArray(lock.repos) ? lock.repos : [];
|
|
251
|
+
if (repos.length !== 1) throw new Error(`${lockPath} names ${repos.length} repositories; expected exactly one`);
|
|
252
|
+
const entry = repos[0];
|
|
253
|
+
const files = new Map();
|
|
254
|
+
for (const want of entry.files ?? []) {
|
|
255
|
+
let contents;
|
|
256
|
+
try {
|
|
257
|
+
contents = readFileSync(join(dir, ...String(want.path).split("/")));
|
|
258
|
+
} catch (cause) {
|
|
259
|
+
if (cause.code === "ENOENT") throw new Error(`${want.path} is in the lock but not on disk`);
|
|
260
|
+
throw cause;
|
|
261
|
+
}
|
|
262
|
+
if (digestOf(contents) !== want.sha256) throw new Error(`${want.path} does not match its digest; the vendored copy was edited by hand`);
|
|
263
|
+
files.set(want.path, contents);
|
|
264
|
+
}
|
|
265
|
+
return { lock: { repo: entry.repo, commit: entry.commit, paths: entry.paths ?? [], files: entry.files ?? [] }, files };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// --- git --------------------------------------------------------------------
|
|
269
|
+
//
|
|
270
|
+
// The host already needs a git binary to stamp fragments, so this leans on
|
|
271
|
+
// the same one rather than on a library: whatever git is configured to do
|
|
272
|
+
// about credentials and hosts, it does here too. Prompts are off: a fetch
|
|
273
|
+
// that needs a credential git does not have is a failure to report, not a
|
|
274
|
+
// question to hang on.
|
|
275
|
+
|
|
276
|
+
function git(cwd, args, env, { binary = false } = {}) {
|
|
277
|
+
try {
|
|
278
|
+
return execFileSync("git", args, {
|
|
279
|
+
cwd: cwd || undefined,
|
|
280
|
+
env: { ...env, GIT_TERMINAL_PROMPT: "0" },
|
|
281
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
282
|
+
maxBuffer: 512 * 1024 * 1024,
|
|
283
|
+
...(binary ? {} : { encoding: "utf8" }),
|
|
284
|
+
});
|
|
285
|
+
} catch (cause) {
|
|
286
|
+
const stderr = String(cause.stderr ?? "").trim();
|
|
287
|
+
throw new Error(`git ${args[0]}: ${(stderr || cause.message).split("\n")[0]}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** What the remote says a ref points at right now. */
|
|
292
|
+
function resolve(url, ref, env) {
|
|
293
|
+
const out = git("", ["ls-remote", "--quiet", url, ref || "HEAD"], env);
|
|
294
|
+
for (const line of out.trim().split("\n")) {
|
|
295
|
+
const [sha] = line.trim().split(/\s+/);
|
|
296
|
+
if (sha && sha.length === 40) return sha;
|
|
297
|
+
}
|
|
298
|
+
throw new Error(`${url} has no ref "${ref || "HEAD"}"`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* One commit, fetched, and the wanted paths read out of it. The commit is
|
|
303
|
+
* fetched by name where the forge allows it - GitHub and GitLab do - and by
|
|
304
|
+
* its branches where it does not, which is the case for a plain file://
|
|
305
|
+
* remote; either way what is read is verified to be that commit.
|
|
306
|
+
*
|
|
307
|
+
* @returns {Map<string, Buffer>} path → contents
|
|
308
|
+
*/
|
|
309
|
+
function download(url, commit, paths, env) {
|
|
310
|
+
const tmp = mkdtempSync(join(tmpdir(), "portolan-fetch-git-"));
|
|
311
|
+
try {
|
|
312
|
+
git(tmp, ["init", "--quiet"], env);
|
|
313
|
+
try {
|
|
314
|
+
git(tmp, ["fetch", "--quiet", "--depth", "1", url, commit], env);
|
|
315
|
+
} catch (cause) {
|
|
316
|
+
try {
|
|
317
|
+
git(tmp, ["fetch", "--quiet", "--depth", "1", url, "+refs/heads/*:refs/remotes/src/*"], env);
|
|
318
|
+
} catch {
|
|
319
|
+
throw cause;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
git(tmp, ["cat-file", "-e", `${commit}^{commit}`], env);
|
|
324
|
+
} catch {
|
|
325
|
+
throw new Error(`${commit} is not a commit ${url} has, or not one reachable from a branch`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const listing = git(tmp, ["ls-tree", "-r", "-z", "--format=%(objectname) %(objecttype) %(path)", commit, "--", ...paths], env);
|
|
329
|
+
const blobs = [];
|
|
330
|
+
for (const line of listing.split("\0")) {
|
|
331
|
+
if (!line) continue;
|
|
332
|
+
const [sha, type, ...rest] = line.split(" ");
|
|
333
|
+
if (type !== "blob") continue;
|
|
334
|
+
blobs.push({ sha, path: posix.normalize(rest.join(" ")) });
|
|
335
|
+
}
|
|
336
|
+
const files = new Map();
|
|
337
|
+
if (blobs.length === 0) return files;
|
|
338
|
+
|
|
339
|
+
// One process for every blob: `cat-file --batch` answers each sha with a
|
|
340
|
+
// header line and the bytes.
|
|
341
|
+
const batch = execFileSync("git", ["cat-file", "--batch"], {
|
|
342
|
+
cwd: tmp,
|
|
343
|
+
env: { ...env, GIT_TERMINAL_PROMPT: "0" },
|
|
344
|
+
input: `${blobs.map((blob) => blob.sha).join("\n")}\n`,
|
|
345
|
+
maxBuffer: 512 * 1024 * 1024,
|
|
346
|
+
});
|
|
347
|
+
let offset = 0;
|
|
348
|
+
for (const blob of blobs) {
|
|
349
|
+
const newline = batch.indexOf(0x0a, offset);
|
|
350
|
+
const header = batch.subarray(offset, newline).toString("utf8").split(" ");
|
|
351
|
+
if (header[1] === "missing") throw new Error(`${blob.path} is missing from ${commit}`);
|
|
352
|
+
const size = Number(header[2]);
|
|
353
|
+
const start = newline + 1;
|
|
354
|
+
files.set(blob.path, Buffer.from(batch.subarray(start, start + size)));
|
|
355
|
+
offset = start + size + 1;
|
|
356
|
+
}
|
|
357
|
+
return files;
|
|
358
|
+
} finally {
|
|
359
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Accumulates a response the way plugin.Builder does. */
|
|
364
|
+
class Builder {
|
|
365
|
+
files = [];
|
|
366
|
+
warnings = [];
|
|
367
|
+
|
|
368
|
+
file(name, contents) {
|
|
369
|
+
this.files.push({ name, contents });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
warn(ref, message) {
|
|
373
|
+
this.warnings.push({ severity: "warning", message, ref });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
response() {
|
|
377
|
+
return { files: this.files, warnings: this.warnings };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { runPlugin } from "../plugin-host.mjs";
|
|
8
|
+
import { OFFLINE_ENV, encodeLock, pin, run, splitRepo, webRepo } from "./fetch-git.mjs";
|
|
9
|
+
|
|
10
|
+
const created = [];
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
for (const path of created.splice(0)) rmSync(path, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// A repository to fetch from, made on the spot: git itself is the fake, and
|
|
16
|
+
// what it serves is what a forge would serve.
|
|
17
|
+
function repository() {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), "portolan-fetch-git-remote-"));
|
|
19
|
+
created.push(dir);
|
|
20
|
+
const git = (args) => execFileSync("git", [
|
|
21
|
+
"-c", "user.name=test", "-c", "user.email=test@example.com",
|
|
22
|
+
"-c", "commit.gpgsign=false", "-c", "init.defaultBranch=main",
|
|
23
|
+
...args,
|
|
24
|
+
], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
25
|
+
const write = (rel, contents) => {
|
|
26
|
+
mkdirSync(dirname(join(dir, rel)), { recursive: true });
|
|
27
|
+
writeFileSync(join(dir, rel), contents);
|
|
28
|
+
};
|
|
29
|
+
git(["init", "--quiet"]);
|
|
30
|
+
write("services/oms/internal/domain/order/order.go", "package order\n");
|
|
31
|
+
write("services/oms/README.md", "# OMS\n");
|
|
32
|
+
write("proto/shop/v1/orders.proto", 'syntax = "proto3";\n');
|
|
33
|
+
write("README.md", "# shop\n");
|
|
34
|
+
git(["add", "."]);
|
|
35
|
+
git(["commit", "--quiet", "-m", "the estate"]);
|
|
36
|
+
// A forge lets a client fetch a commit by name; a plain file remote does
|
|
37
|
+
// not unless told to, and the fallback to branches is tested separately.
|
|
38
|
+
git(["config", "uploadpack.allowAnySHA1InWant", "true"]);
|
|
39
|
+
return { dir, url: `file://${dir}`, commit: git(["rev-parse", "HEAD"]), git, copyDir: `${basename(dirname(dir))}/${basename(dir)}` };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function cache() {
|
|
43
|
+
const dir = mkdtempSync(join(tmpdir(), "portolan-fetch-git-cache-"));
|
|
44
|
+
created.push(dir);
|
|
45
|
+
return dir;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const online = { CI: "", [OFFLINE_ENV]: "" };
|
|
49
|
+
const offline = { CI: "", [OFFLINE_ENV]: "1" };
|
|
50
|
+
const options = (remote, cacheDir, paths) => ({ cache: cacheDir, repos: [{ repo: remote.url, commit: remote.commit, ...(paths ? { paths } : {}) }] });
|
|
51
|
+
const fetch = (opts, env) => run({ options: opts }, { env: { ...process.env, ...env } });
|
|
52
|
+
const names = (response) => response.files.map((file) => file.name).sort();
|
|
53
|
+
const contentsOf = (response, name) => response.files.find((file) => file.name === name)?.contents ?? "";
|
|
54
|
+
// What the host does with a response.
|
|
55
|
+
const write = (dir, response) => {
|
|
56
|
+
for (const file of response.files) {
|
|
57
|
+
mkdirSync(dirname(join(dir, file.name)), { recursive: true });
|
|
58
|
+
writeFileSync(join(dir, file.name), file.contents);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
describe("fetch-git", () => {
|
|
63
|
+
it("writes the narrowed copy, a lock and a pin", () => {
|
|
64
|
+
const remote = repository();
|
|
65
|
+
const response = fetch(options(remote, cache(), ["services/oms", "proto"]), online);
|
|
66
|
+
// The directory is owner/name, read off the URL; the paths inside are
|
|
67
|
+
// the repository's own, so an extractor can read the copy as a checkout.
|
|
68
|
+
const dir = remote.copyDir;
|
|
69
|
+
expect(names(response)).toEqual([
|
|
70
|
+
`${dir}/git.lock.json`,
|
|
71
|
+
`${dir}/git.repo.json`,
|
|
72
|
+
`${dir}/proto/shop/v1/orders.proto`,
|
|
73
|
+
`${dir}/services/oms/README.md`,
|
|
74
|
+
`${dir}/services/oms/internal/domain/order/order.go`,
|
|
75
|
+
]);
|
|
76
|
+
const lock = JSON.parse(contentsOf(response, `${dir}/git.lock.json`));
|
|
77
|
+
expect(lock.repos[0]).toMatchObject({ repo: remote.url, commit: remote.commit, paths: ["proto", "services/oms"] });
|
|
78
|
+
expect(lock.repos[0].files.map((file) => file.path)).toEqual(["proto/shop/v1/orders.proto", "services/oms/README.md", "services/oms/internal/domain/order/order.go"]);
|
|
79
|
+
expect(lock.repos[0].files[0]).toMatchObject({ size: 19 });
|
|
80
|
+
expect(lock.repos[0].files[0].sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
81
|
+
expect(response.warnings).toEqual([]);
|
|
82
|
+
|
|
83
|
+
// The fragment is the lock's commit said in the shape the catalog reads.
|
|
84
|
+
const fragment = contentsOf(response, `${dir}/git.repo.json`);
|
|
85
|
+
expect(JSON.parse(fragment)).toEqual({ contexts: [], defs: {}, flows: [], adrs: [], repos: [{ repo: remote.dir.slice(1), commit: remote.commit }] });
|
|
86
|
+
expect(fragment.endsWith("\n")).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("replays the committed copy offline, byte for byte, and says so", () => {
|
|
90
|
+
const remote = repository();
|
|
91
|
+
const cacheDir = cache();
|
|
92
|
+
const first = fetch(options(remote, cacheDir, ["services/oms"]), online);
|
|
93
|
+
write(cacheDir, first);
|
|
94
|
+
|
|
95
|
+
const replayed = fetch(options(remote, cacheDir, ["services/oms"]), offline);
|
|
96
|
+
expect(names(replayed)).toEqual(names(first));
|
|
97
|
+
for (const file of first.files) expect(contentsOf(replayed, file.name)).toBe(file.contents);
|
|
98
|
+
expect(replayed.warnings).toHaveLength(1);
|
|
99
|
+
expect(replayed.warnings[0].message).toContain("offline");
|
|
100
|
+
|
|
101
|
+
// CI verifies what was committed and never opens a socket.
|
|
102
|
+
const inCi = fetch(options(remote, cacheDir, ["services/oms"]), { CI: "true", [OFFLINE_ENV]: "" });
|
|
103
|
+
expect(inCi.warnings).toHaveLength(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("names the edited file when a vendored copy no longer matches its lock", () => {
|
|
107
|
+
const remote = repository();
|
|
108
|
+
const cacheDir = cache();
|
|
109
|
+
const first = fetch(options(remote, cacheDir, ["services/oms"]), online);
|
|
110
|
+
write(cacheDir, first);
|
|
111
|
+
const edited = names(first).find((name) => name.endsWith("order.go"));
|
|
112
|
+
writeFileSync(join(cacheDir, edited), "package order // edited\n");
|
|
113
|
+
|
|
114
|
+
expect(() => fetch(options(remote, cacheDir, ["services/oms"]), offline)).toThrow(/order\.go.*edited by hand/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("fails rather than emitting nothing when there is no copy to fall back to", () => {
|
|
118
|
+
const nowhere = { url: "file:///nowhere/acme/shop", commit: "0".repeat(40) };
|
|
119
|
+
expect(() => fetch(options(nowhere, cache()), offline)).toThrow(/no git\.lock\.json/);
|
|
120
|
+
const gone = { url: `file://${join(cache(), "acme", "gone")}`, commit: "0".repeat(40) };
|
|
121
|
+
expect(() => fetch(options(gone, cache()), online)).toThrow(/no usable copy/);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("falls back to the committed copy when the forge is gone", () => {
|
|
125
|
+
const remote = repository();
|
|
126
|
+
const cacheDir = cache();
|
|
127
|
+
const first = fetch(options(remote, cacheDir, ["proto"]), online);
|
|
128
|
+
write(cacheDir, first);
|
|
129
|
+
rmSync(remote.dir, { recursive: true, force: true });
|
|
130
|
+
|
|
131
|
+
const fallback = fetch(options(remote, cacheDir, ["proto"]), online);
|
|
132
|
+
expect(names(fallback)).toEqual(names(first));
|
|
133
|
+
expect(fallback.warnings).toHaveLength(1);
|
|
134
|
+
expect(fallback.warnings[0].message).toContain("not fetched");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("resolves an unpinned repository online with a warning, and refuses it offline", () => {
|
|
138
|
+
const remote = repository();
|
|
139
|
+
const opts = { cache: cache(), repos: [{ repo: remote.url, ref: "main", paths: ["proto"] }] };
|
|
140
|
+
const response = fetch(opts, online);
|
|
141
|
+
expect(response.warnings).toHaveLength(1);
|
|
142
|
+
expect(response.warnings[0].message).toContain("not pinned");
|
|
143
|
+
expect(response.warnings[0].message).toContain(remote.commit);
|
|
144
|
+
|
|
145
|
+
expect(() => fetch(opts, offline)).toThrow(/not pinned/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("reaches a commit through its branch when the remote refuses it by name", () => {
|
|
149
|
+
const remote = repository();
|
|
150
|
+
remote.git(["config", "--unset", "uploadpack.allowAnySHA1InWant"]);
|
|
151
|
+
const response = fetch(options(remote, cache(), ["README.md"]), online);
|
|
152
|
+
// The file, the lock, and the fragment naming the commit.
|
|
153
|
+
expect(names(response)).toHaveLength(3);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("runs through the host like any plugin, warnings kept beside the files", async () => {
|
|
157
|
+
const remote = repository();
|
|
158
|
+
const cacheDir = cache();
|
|
159
|
+
write(cacheDir, fetch(options(remote, cacheDir, ["proto"]), online));
|
|
160
|
+
const saved = { CI: process.env.CI, [OFFLINE_ENV]: process.env[OFFLINE_ENV] };
|
|
161
|
+
process.env.CI = "";
|
|
162
|
+
process.env[OFFLINE_ENV] = "1";
|
|
163
|
+
try {
|
|
164
|
+
const result = await runPlugin({ name: "git", host: "fetch-git" }, { portolanVersion: "0.1.0", options: options(remote, cacheDir, ["proto"]) });
|
|
165
|
+
expect(result.files.map((file) => file.name)).toHaveLength(3);
|
|
166
|
+
expect(result.warnings).toEqual([`${remote.url}: not fetched (offline); the copy committed in this repository is used unchanged`]);
|
|
167
|
+
const described = await runPlugin({ name: "git", host: "fetch-git" }, { portolanVersion: "0.1.0", kind: "describe" });
|
|
168
|
+
expect(described.describe).toMatchObject({ name: "fetch-git", phases: ["extract"] });
|
|
169
|
+
expect(described.describe.options.required).toEqual(["repos", "cache"]);
|
|
170
|
+
} finally {
|
|
171
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
172
|
+
if (value === undefined) delete process.env[key];
|
|
173
|
+
else process.env[key] = value;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
await expect(runPlugin({ name: "x", host: "../etc" }, {})).rejects.toThrow("no plugin named");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("names", () => {
|
|
181
|
+
it("splits what the manifest wrote into a URL and a copy directory", () => {
|
|
182
|
+
const cases = {
|
|
183
|
+
"github.com/acme/shop": ["https://github.com/acme/shop", "acme/shop"],
|
|
184
|
+
"https://gitlab.com/acme/shop.git": ["https://gitlab.com/acme/shop.git", "acme/shop"],
|
|
185
|
+
"git@github.com:acme/shop.git": ["git@github.com:acme/shop.git", "acme/shop"],
|
|
186
|
+
"ssh://git@github.com/acme/shop": ["ssh://git@github.com/acme/shop", "acme/shop"],
|
|
187
|
+
"https://gitlab.com/org/group/shop": ["https://gitlab.com/org/group/shop", "group/shop"],
|
|
188
|
+
};
|
|
189
|
+
for (const [name, [url, dir]] of Object.entries(cases)) expect(splitRepo(name)).toEqual({ url, dir });
|
|
190
|
+
expect(() => splitRepo("shop")).toThrow("owner and a repository");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("spells a pin the way go.mod spells the repository", () => {
|
|
194
|
+
for (const spelling of [
|
|
195
|
+
"github.com/acme/shop", "github.com/acme/shop/", "github.com/acme/shop.git",
|
|
196
|
+
"https://github.com/acme/shop", "https://github.com/acme/shop.git",
|
|
197
|
+
"ssh://git@github.com/acme/shop.git", "git@github.com:acme/shop.git", " github.com/acme/shop ",
|
|
198
|
+
]) expect(webRepo(spelling)).toBe("github.com/acme/shop");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("writes a pin and a lock the way every generated file is written", () => {
|
|
202
|
+
const fragment = pin("git@github.com:acme/shop.git", "c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0");
|
|
203
|
+
expect(fragment).toBe(`${JSON.stringify({ contexts: [], defs: {}, flows: [], adrs: [], repos: [{ repo: "github.com/acme/shop", commit: "c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0" }] }, null, 2)}\n`);
|
|
204
|
+
expect(pin("github.com/acme/shop", "c1d2e3f")).toBe(pin("github.com/acme/shop", "c1d2e3f"));
|
|
205
|
+
|
|
206
|
+
const lock = encodeLock({ repo: "github.com/acme/shop", commit: "abc", paths: ["services/oms", "proto"], files: [{ path: "b", sha256: "2", size: 1 }, { path: "a", sha256: "1", size: 1 }] });
|
|
207
|
+
expect(JSON.parse(lock)).toEqual({ repos: [{ repo: "github.com/acme/shop", commit: "abc", paths: ["proto", "services/oms"], files: [{ path: "a", sha256: "1", size: 1 }, { path: "b", sha256: "2", size: 1 }] }] });
|
|
208
|
+
expect(JSON.parse(encodeLock({ repo: "r", commit: "c", paths: [], files: [] })).repos[0]).not.toHaveProperty("paths");
|
|
209
|
+
});
|
|
210
|
+
});
|
package/scripts/local-api.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { basename, dirname, join, posix, relative, resolve, sep } from "node:pat
|
|
|
21
21
|
import { publicSetupFrom } from "../src/lib/setup-info.ts";
|
|
22
22
|
import { loadManifest } from "./manifest.mjs";
|
|
23
23
|
import { builtinPluginNames } from "./builtin-plugins.mjs";
|
|
24
|
+
import { installDeliveryPreset, planDeliveryPreset, publicDeliveryPreset } from "./delivery-presets.mjs";
|
|
24
25
|
|
|
25
26
|
export const LOCAL_API_PREFIX = "/__portolan";
|
|
26
27
|
export const GENERATOR_EVENT_PREFIX = "::portolan-event::";
|
|
@@ -34,6 +35,15 @@ 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;
|
|
36
37
|
|
|
38
|
+
/** Remove Vite's configured base before matching a local control-plane route. */
|
|
39
|
+
export function localApiPath(pathname, base = "/") {
|
|
40
|
+
const root = base.endsWith("/") ? base : `${base}/`;
|
|
41
|
+
if (root !== "/" && pathname.startsWith(root)) {
|
|
42
|
+
return `/${pathname.slice(root.length)}`;
|
|
43
|
+
}
|
|
44
|
+
return pathname;
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
function safeRoot(workspace, input) {
|
|
38
48
|
if (typeof input !== "string" || !input.trim()) throw new Error("Project path is required.");
|
|
39
49
|
const clean = input.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
@@ -651,7 +661,7 @@ export function planProject(workspace, manifest, request) {
|
|
|
651
661
|
...(String(request.componentKind ?? "").trim() ? { componentKind: String(request.componentKind).trim() } : {}),
|
|
652
662
|
...(String(request.repository ?? "").trim() ? { repository: String(request.repository).trim() } : {}),
|
|
653
663
|
};
|
|
654
|
-
const out =
|
|
664
|
+
const out = posix.join(finalRoot, "portolan");
|
|
655
665
|
const detectionByPlugin = new Map(discovery.detections.map((item) => [item.plugin, item]));
|
|
656
666
|
const hasDomainModel = plugins.some((plugin) => ["go-domain", "ts-domain", "rust-domain", "java-domain", "django-domain"].includes(plugin));
|
|
657
667
|
const steps = plugins.map((plugin) => ({
|
|
@@ -671,7 +681,7 @@ export function planProject(workspace, manifest, request) {
|
|
|
671
681
|
return { project, plugins, steps, source, discovery, fetch };
|
|
672
682
|
}
|
|
673
683
|
|
|
674
|
-
function manifestWithProject(manifest, plan, { isolated = false } = {}) {
|
|
684
|
+
export function manifestWithProject(manifest, plan, { isolated = false } = {}) {
|
|
675
685
|
const fetchIndex = (manifest.extract ?? []).findIndex((step) => step.plugin === "git");
|
|
676
686
|
const extract = isolated ? [] : [...(manifest.extract ?? [])];
|
|
677
687
|
if (plan.fetch) {
|
|
@@ -695,7 +705,7 @@ function manifestWithProject(manifest, plan, { isolated = false } = {}) {
|
|
|
695
705
|
};
|
|
696
706
|
}
|
|
697
707
|
|
|
698
|
-
function writeManifest(path, manifest) {
|
|
708
|
+
export function writeManifest(path, manifest) {
|
|
699
709
|
const staging = mkdtempSync(join(dirname(path), ".portolan-manifest-"));
|
|
700
710
|
const temp = join(staging, "portolan.json");
|
|
701
711
|
try {
|
|
@@ -1092,6 +1102,7 @@ export function localApiPlugin(workspace = process.cwd()) {
|
|
|
1092
1102
|
configureServer(server) {
|
|
1093
1103
|
server.middlewares.use(async (req, res, next) => {
|
|
1094
1104
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1105
|
+
url.pathname = localApiPath(url.pathname, server.config.base);
|
|
1095
1106
|
if (!url.pathname.startsWith(LOCAL_API_PREFIX)) return next();
|
|
1096
1107
|
if (!localRequest(req)) return send(res, 403, { error: "The local API is available only through localhost." });
|
|
1097
1108
|
try {
|
|
@@ -1099,6 +1110,9 @@ export function localApiPlugin(workspace = process.cwd()) {
|
|
|
1099
1110
|
const active = [...jobs.values()].find((job) => job.status === "running");
|
|
1100
1111
|
return send(res, 200, { local: true, workspace: realpathSync(workspace), setup: setup(workspace), activeRun: active ? { id: active.id, mode: active.mode } : null });
|
|
1101
1112
|
}
|
|
1113
|
+
if (req.method === "GET" && url.pathname === `${LOCAL_API_PREFIX}/delivery-presets`) {
|
|
1114
|
+
return send(res, 200, publicDeliveryPreset(planDeliveryPreset(workspace, url.searchParams.get("provider"))));
|
|
1115
|
+
}
|
|
1102
1116
|
const eventMatch = url.pathname.match(/^\/__portolan\/runs\/([^/]+)\/events$/);
|
|
1103
1117
|
if (req.method === "GET" && eventMatch) {
|
|
1104
1118
|
const job = jobs.get(eventMatch[1]);
|
|
@@ -1113,6 +1127,9 @@ export function localApiPlugin(workspace = process.cwd()) {
|
|
|
1113
1127
|
return send(res, 405, { error: "Use a local JSON request." });
|
|
1114
1128
|
}
|
|
1115
1129
|
const input = await body(req);
|
|
1130
|
+
if (url.pathname === `${LOCAL_API_PREFIX}/delivery-presets/install`) {
|
|
1131
|
+
return send(res, 201, installDeliveryPreset(workspace, input));
|
|
1132
|
+
}
|
|
1116
1133
|
if (url.pathname === `${LOCAL_API_PREFIX}/source`) return send(res, 200, readLocalSource(workspace, input.path));
|
|
1117
1134
|
if (url.pathname === `${LOCAL_API_PREFIX}/repositories/credentials`) return send(res, 201, storeRepositoryCredential(input));
|
|
1118
1135
|
if (url.pathname === `${LOCAL_API_PREFIX}/repositories/credentials/forget`) return send(res, 200, forgetRepositoryCredential(input));
|
|
@@ -1174,7 +1191,8 @@ export function localApiPlugin(workspace = process.cwd()) {
|
|
|
1174
1191
|
}
|
|
1175
1192
|
return send(res, 404, { error: "Local API route not found." });
|
|
1176
1193
|
} catch (error) {
|
|
1177
|
-
|
|
1194
|
+
const status = error instanceof LocalApiError ? error.status : Number.isInteger(error?.status) ? error.status : 400;
|
|
1195
|
+
return send(res, status, {
|
|
1178
1196
|
error: error instanceof Error ? error.message : String(error),
|
|
1179
1197
|
...(error instanceof LocalApiError ? {
|
|
1180
1198
|
code: error.code,
|