@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.
Files changed (381) hide show
  1. package/README.md +69 -205
  2. package/cli/init.mjs +285 -0
  3. package/cli/init.test.mjs +158 -0
  4. package/cli/portolan.mjs +37 -73
  5. package/index.html +19 -0
  6. package/package.json +5 -10
  7. package/plugins/README.md +66 -27
  8. package/plugins/portolan-go.wasm +0 -0
  9. package/portolan.json +52 -73
  10. package/public/landing/cats/bug-hunter.webp +0 -0
  11. package/public/landing/cats/compass-nap.webp +0 -0
  12. package/public/landing/cats/diagrammer.webp +0 -0
  13. package/public/landing/cats/docs-reader.webp +0 -0
  14. package/public/landing/cats/laptop.webp +0 -0
  15. package/public/landing/cats/map-inspector.webp +0 -0
  16. package/public/landing/cats/server-break.webp +0 -0
  17. package/public/landing/cats/star-mapper.webp +0 -0
  18. package/public/landing/cats/system-builder.webp +0 -0
  19. package/public/landing/cats/thread-tangle.webp +0 -0
  20. package/public/og.png +0 -0
  21. package/public/readme-header.webp +0 -0
  22. package/schema/portolan.schema.json +180 -0
  23. package/scripts/builtin-plugins.mjs +14 -0
  24. package/scripts/delivery-presets.mjs +353 -0
  25. package/scripts/gen.mjs +37 -4
  26. package/scripts/history.mjs +126 -0
  27. package/scripts/history.test.mjs +110 -0
  28. package/scripts/host-plugins/fetch-bsr.mjs +351 -0
  29. package/{plugins/fetch-bsr/options.schema.json → scripts/host-plugins/fetch-bsr.options.json} +4 -0
  30. package/scripts/host-plugins/fetch-bsr.test.mjs +212 -0
  31. package/scripts/host-plugins/fetch-csr.mjs +386 -0
  32. package/scripts/host-plugins/fetch-csr.test.mjs +178 -0
  33. package/scripts/host-plugins/fetch-git.mjs +379 -0
  34. package/scripts/host-plugins/fetch-git.test.mjs +210 -0
  35. package/scripts/local-api.mjs +27 -10
  36. package/scripts/local-api.test.mjs +85 -2
  37. package/scripts/manifest.mjs +3 -2
  38. package/scripts/package-smoke.mjs +24 -3
  39. package/scripts/plugin-host.mjs +77 -18
  40. package/scripts/plugin-host.test.mjs +50 -1
  41. package/scripts/plugin-wasm-worker.mjs +6 -3
  42. package/scripts/run-builtin.mjs +6 -26
  43. package/scripts/schema.mjs +6 -1
  44. package/src/app/App.tsx +24 -429
  45. package/src/app/Breadcrumbs.test.ts +7 -0
  46. package/src/app/Breadcrumbs.tsx +15 -2
  47. package/src/app/CatalogApp.tsx +436 -0
  48. package/src/app/Sidebar.tsx +0 -1
  49. package/src/chat/ChatPanel.tsx +98 -41
  50. package/src/chat/Composer.tsx +4 -2
  51. package/src/chat/Conversation.tsx +79 -15
  52. package/src/chat/Header.tsx +43 -9
  53. package/src/chat/Starter.tsx +64 -3
  54. package/src/chat/Steps.tsx +1 -1
  55. package/src/chat/flags.test.ts +51 -14
  56. package/src/chat/flags.ts +8 -2
  57. package/src/chat/page-context.test.ts +51 -0
  58. package/src/chat/page-context.ts +128 -0
  59. package/src/chat/prompt.test.ts +49 -1
  60. package/src/chat/prompt.ts +42 -1
  61. package/src/chat/tools.ts +7 -2
  62. package/src/chat/transport.ts +16 -4
  63. package/src/components/ProblemRow.tsx +182 -0
  64. package/src/data.ts +20 -5
  65. package/src/er/ErCanvas.tsx +1 -1
  66. package/src/graph/DependencyGraph.tsx +10 -1
  67. package/src/graph/FocusedEventGraph.tsx +1 -1
  68. package/src/graph/GraphToolbar.tsx +2 -4
  69. package/src/index.css +220 -2
  70. package/src/landing/DraggableReveal.tsx +168 -0
  71. package/src/landing/EstateGraph.tsx +28 -0
  72. package/src/landing/FlowPlayback.tsx +282 -0
  73. package/src/landing/HeroMap.tsx +128 -0
  74. package/src/landing/LandingPage.tsx +757 -0
  75. package/src/landing/ProductFrame.tsx +68 -0
  76. package/src/landing/ProductTour.tsx +371 -0
  77. package/src/landing/catalog.ts +25 -0
  78. package/src/landing/motion.tsx +52 -0
  79. package/src/lib/all-problems.ts +28 -0
  80. package/src/lib/local-api.ts +35 -1
  81. package/src/lib/setup-info.test.ts +8 -0
  82. package/src/lib/setup-info.ts +3 -2
  83. package/src/lib/source-code.ts +2 -1
  84. package/src/likec4/C4View.tsx +5 -0
  85. package/src/likec4/InteractiveView.tsx +5 -1
  86. package/src/map/ContextMapGraph.tsx +47 -20
  87. package/src/pages/ContextMap.tsx +5 -1
  88. package/src/pages/FlowDetail.tsx +24 -7
  89. package/src/pages/Overview.tsx +68 -12
  90. package/src/pages/Problems.tsx +6 -193
  91. package/src/pages/Settings.tsx +133 -108
  92. package/src/pages/settings/DeliverySettings.tsx +229 -0
  93. package/src/pages/settings/PreferencesSettings.tsx +101 -0
  94. package/src/routes.test.ts +20 -0
  95. package/src/routes.ts +13 -1
  96. package/vite.config.ts +17 -9
  97. package/catalog/enum_test.go +0 -46
  98. package/catalog/model.go +0 -932
  99. package/catalog/roundtrip_test.go +0 -185
  100. package/catalog/via_test.go +0 -38
  101. package/go.mod +0 -14
  102. package/go.sum +0 -14
  103. package/internal/commands/cargo.go +0 -154
  104. package/internal/commands/commands.go +0 -143
  105. package/internal/commands/commands_test.go +0 -323
  106. package/internal/commands/gradle.go +0 -77
  107. package/internal/commands/justfile.go +0 -82
  108. package/internal/commands/makefile.go +0 -106
  109. package/internal/commands/maven.go +0 -93
  110. package/internal/commands/packagejson.go +0 -159
  111. package/internal/commands/pyproject.go +0 -169
  112. package/internal/commands/taskfile.go +0 -101
  113. package/internal/commands/testdata/estate/.cargo/config.toml +0 -7
  114. package/internal/commands/testdata/estate/Makefile +0 -33
  115. package/internal/commands/testdata/estate/Taskfile.yml +0 -21
  116. package/internal/commands/testdata/estate/build.gradle.kts +0 -21
  117. package/internal/commands/testdata/estate/justfile +0 -20
  118. package/internal/commands/testdata/estate/package.json +0 -13
  119. package/internal/commands/testdata/estate/pom.xml +0 -29
  120. package/internal/commands/testdata/estate/pyproject.toml +0 -27
  121. package/internal/commands/testdata/estate/xtask/src/main.rs +0 -27
  122. package/internal/commands/testdata/golden/commands.json +0 -305
  123. package/internal/gohttp/analyze.go +0 -2500
  124. package/internal/gohttp/endpoints.go +0 -1067
  125. package/internal/gohttp/roots.go +0 -320
  126. package/internal/gohttp/typed.go +0 -143
  127. package/internal/goscan/constants.go +0 -85
  128. package/internal/goscan/goscan_test.go +0 -227
  129. package/internal/goscan/names.go +0 -52
  130. package/internal/goscan/parse_test.go +0 -11
  131. package/internal/goscan/source.go +0 -36
  132. package/internal/goscan/tree.go +0 -155
  133. package/internal/goscan/types.go +0 -99
  134. package/internal/wsdl/ids.go +0 -127
  135. package/internal/wsdl/ids_test.go +0 -21
  136. package/internal/wsdl/model.go +0 -62
  137. package/internal/wsdl/parse.go +0 -920
  138. package/internal/wsdl/parse_test.go +0 -133
  139. package/plugin/describe.go +0 -107
  140. package/plugin/describe_test.go +0 -114
  141. package/plugin/protocol.go +0 -117
  142. package/plugin/schematest/schematest.go +0 -126
  143. package/plugins/extract-adr/describe.go +0 -19
  144. package/plugins/extract-adr/describe_test.go +0 -11
  145. package/plugins/extract-adr/extract.go +0 -153
  146. package/plugins/extract-adr/extract_test.go +0 -350
  147. package/plugins/extract-adr/history.go +0 -99
  148. package/plugins/extract-adr/main.go +0 -65
  149. package/plugins/extract-adr/parse.go +0 -642
  150. package/plugins/extract-adr/parse_test.go +0 -405
  151. package/plugins/extract-asyncapi/describe.go +0 -19
  152. package/plugins/extract-asyncapi/describe_test.go +0 -11
  153. package/plugins/extract-asyncapi/extract.go +0 -315
  154. package/plugins/extract-asyncapi/extract_test.go +0 -197
  155. package/plugins/extract-asyncapi/main.go +0 -48
  156. package/plugins/extract-asyncapi/spec.go +0 -151
  157. package/plugins/extract-commands/describe.go +0 -19
  158. package/plugins/extract-commands/describe_test.go +0 -11
  159. package/plugins/extract-commands/extract.go +0 -79
  160. package/plugins/extract-commands/extract_test.go +0 -92
  161. package/plugins/extract-commands/main.go +0 -55
  162. package/plugins/extract-csr/avro.go +0 -258
  163. package/plugins/extract-csr/describe.go +0 -19
  164. package/plugins/extract-csr/describe_test.go +0 -11
  165. package/plugins/extract-csr/extract.go +0 -338
  166. package/plugins/extract-csr/extract_test.go +0 -374
  167. package/plugins/extract-csr/jsonschema.go +0 -343
  168. package/plugins/extract-csr/lock.go +0 -25
  169. package/plugins/extract-csr/main.go +0 -84
  170. package/plugins/extract-csr/subject.go +0 -102
  171. package/plugins/extract-flows/describe.go +0 -19
  172. package/plugins/extract-flows/describe_test.go +0 -11
  173. package/plugins/extract-flows/extract.go +0 -91
  174. package/plugins/extract-flows/main.go +0 -45
  175. package/plugins/extract-flows/parse.go +0 -593
  176. package/plugins/extract-flows/parse_test.go +0 -204
  177. package/plugins/extract-glossary/describe.go +0 -19
  178. package/plugins/extract-glossary/describe_test.go +0 -11
  179. package/plugins/extract-glossary/extract.go +0 -115
  180. package/plugins/extract-glossary/extract_test.go +0 -220
  181. package/plugins/extract-glossary/main.go +0 -59
  182. package/plugins/extract-glossary/parse.go +0 -214
  183. package/plugins/extract-glossary/parse_test.go +0 -203
  184. package/plugins/extract-go/aggregate.go +0 -214
  185. package/plugins/extract-go/client.go +0 -409
  186. package/plugins/extract-go/client_test.go +0 -266
  187. package/plugins/extract-go/describe.go +0 -19
  188. package/plugins/extract-go/describe_test.go +0 -11
  189. package/plugins/extract-go/enum.go +0 -195
  190. package/plugins/extract-go/enum_test.go +0 -82
  191. package/plugins/extract-go/event.go +0 -99
  192. package/plugins/extract-go/extract.go +0 -191
  193. package/plugins/extract-go/extract_test.go +0 -261
  194. package/plugins/extract-go/flow.go +0 -1441
  195. package/plugins/extract-go/flow_test.go +0 -609
  196. package/plugins/extract-go/httpclient.go +0 -174
  197. package/plugins/extract-go/httpclient_test.go +0 -296
  198. package/plugins/extract-go/ids.go +0 -92
  199. package/plugins/extract-go/layout.go +0 -225
  200. package/plugins/extract-go/layout_test.go +0 -125
  201. package/plugins/extract-go/lifecycle.go +0 -324
  202. package/plugins/extract-go/lifecycle_test.go +0 -108
  203. package/plugins/extract-go/main.go +0 -79
  204. package/plugins/extract-go/operation.go +0 -157
  205. package/plugins/extract-go/source.go +0 -316
  206. package/plugins/extract-go/transport.go +0 -321
  207. package/plugins/extract-go/transport_test.go +0 -145
  208. package/plugins/extract-go/wiring.go +0 -434
  209. package/plugins/extract-go-nats/describe.go +0 -19
  210. package/plugins/extract-go-nats/describe_test.go +0 -11
  211. package/plugins/extract-go-nats/extract.go +0 -177
  212. package/plugins/extract-go-nats/extract_test.go +0 -389
  213. package/plugins/extract-go-nats/index.go +0 -379
  214. package/plugins/extract-go-nats/main.go +0 -42
  215. package/plugins/extract-go-nats/resolve.go +0 -161
  216. package/plugins/extract-go-nats/sites.go +0 -224
  217. package/plugins/extract-graphql/describe.go +0 -19
  218. package/plugins/extract-graphql/describe_test.go +0 -11
  219. package/plugins/extract-graphql/extract.go +0 -433
  220. package/plugins/extract-graphql/extract_test.go +0 -256
  221. package/plugins/extract-graphql/ids.go +0 -49
  222. package/plugins/extract-graphql/lex.go +0 -237
  223. package/plugins/extract-graphql/main.go +0 -51
  224. package/plugins/extract-graphql/parse.go +0 -621
  225. package/plugins/extract-graphql/parse_test.go +0 -122
  226. package/plugins/extract-http-clients/describe.go +0 -19
  227. package/plugins/extract-http-clients/describe_test.go +0 -11
  228. package/plugins/extract-http-clients/extract.go +0 -705
  229. package/plugins/extract-http-clients/extract_test.go +0 -1263
  230. package/plugins/extract-http-clients/main.go +0 -42
  231. package/plugins/extract-openapi/describe.go +0 -19
  232. package/plugins/extract-openapi/describe_test.go +0 -11
  233. package/plugins/extract-openapi/discover.go +0 -243
  234. package/plugins/extract-openapi/extract.go +0 -526
  235. package/plugins/extract-openapi/extract_test.go +0 -545
  236. package/plugins/extract-openapi/main.go +0 -75
  237. package/plugins/extract-openapi/spec.go +0 -350
  238. package/plugins/extract-project/describe.go +0 -19
  239. package/plugins/extract-project/describe_test.go +0 -11
  240. package/plugins/extract-project/extract.go +0 -221
  241. package/plugins/extract-project/extract_test.go +0 -109
  242. package/plugins/extract-project/main.go +0 -41
  243. package/plugins/extract-proto/ast.go +0 -125
  244. package/plugins/extract-proto/consumes.go +0 -77
  245. package/plugins/extract-proto/describe.go +0 -19
  246. package/plugins/extract-proto/extract.go +0 -293
  247. package/plugins/extract-proto/extract_test.go +0 -459
  248. package/plugins/extract-proto/ids.go +0 -89
  249. package/plugins/extract-proto/ids_test.go +0 -57
  250. package/plugins/extract-proto/lex.go +0 -285
  251. package/plugins/extract-proto/main.go +0 -100
  252. package/plugins/extract-proto/module.go +0 -120
  253. package/plugins/extract-proto/parse.go +0 -720
  254. package/plugins/extract-proto/parse_test.go +0 -307
  255. package/plugins/extract-proto/provides.go +0 -236
  256. package/plugins/extract-proto/resolve.go +0 -222
  257. package/plugins/extract-proto/resolve_test.go +0 -119
  258. package/plugins/extract-redis/describe.go +0 -19
  259. package/plugins/extract-redis/describe_test.go +0 -11
  260. package/plugins/extract-redis/extract.go +0 -183
  261. package/plugins/extract-redis/extract_test.go +0 -168
  262. package/plugins/extract-redis/keyspaces.go +0 -469
  263. package/plugins/extract-redis/main.go +0 -44
  264. package/plugins/extract-river/describe.go +0 -19
  265. package/plugins/extract-river/describe_test.go +0 -11
  266. package/plugins/extract-river/extract.go +0 -507
  267. package/plugins/extract-river/extract_test.go +0 -132
  268. package/plugins/extract-river/main.go +0 -42
  269. package/plugins/extract-sql/ddl.go +0 -893
  270. package/plugins/extract-sql/ddl_test.go +0 -401
  271. package/plugins/extract-sql/describe.go +0 -19
  272. package/plugins/extract-sql/describe_test.go +0 -11
  273. package/plugins/extract-sql/layout.go +0 -221
  274. package/plugins/extract-sql/layout_test.go +0 -55
  275. package/plugins/extract-sql/lineage.go +0 -107
  276. package/plugins/extract-sql/main.go +0 -141
  277. package/plugins/extract-sql/maps.go +0 -564
  278. package/plugins/extract-sql/maps_java.go +0 -117
  279. package/plugins/extract-sql/maps_rust.go +0 -333
  280. package/plugins/extract-sql/maps_rust_test.go +0 -70
  281. package/plugins/extract-sql/maps_test.go +0 -204
  282. package/plugins/extract-sql/maps_ts.go +0 -398
  283. package/plugins/extract-sql/maps_ts_test.go +0 -136
  284. package/plugins/extract-sql/projection.go +0 -70
  285. package/plugins/extract-sql/projection_test.go +0 -50
  286. package/plugins/extract-sql/store.go +0 -420
  287. package/plugins/extract-sql/store_test.go +0 -233
  288. package/plugins/extract-sql/view.go +0 -295
  289. package/plugins/extract-watermill/describe.go +0 -19
  290. package/plugins/extract-watermill/describe_test.go +0 -11
  291. package/plugins/extract-watermill/extract.go +0 -1228
  292. package/plugins/extract-watermill/extract_test.go +0 -234
  293. package/plugins/extract-watermill/main.go +0 -41
  294. package/plugins/extract-wsdl/describe.go +0 -19
  295. package/plugins/extract-wsdl/describe_test.go +0 -11
  296. package/plugins/extract-wsdl/extract.go +0 -148
  297. package/plugins/extract-wsdl/extract_test.go +0 -45
  298. package/plugins/extract-wsdl/main.go +0 -53
  299. package/plugins/fetch-bsr/auth.go +0 -114
  300. package/plugins/fetch-bsr/bsr.go +0 -240
  301. package/plugins/fetch-bsr/cache.go +0 -66
  302. package/plugins/fetch-bsr/describe.go +0 -19
  303. package/plugins/fetch-bsr/fetch.go +0 -192
  304. package/plugins/fetch-bsr/fetch_test.go +0 -465
  305. package/plugins/fetch-bsr/lock.go +0 -71
  306. package/plugins/fetch-bsr/main.go +0 -117
  307. package/plugins/fetch-csr/auth.go +0 -95
  308. package/plugins/fetch-csr/cache.go +0 -69
  309. package/plugins/fetch-csr/describe.go +0 -19
  310. package/plugins/fetch-csr/describe_test.go +0 -11
  311. package/plugins/fetch-csr/fetch.go +0 -254
  312. package/plugins/fetch-csr/fetch_test.go +0 -494
  313. package/plugins/fetch-csr/lock.go +0 -95
  314. package/plugins/fetch-csr/main.go +0 -122
  315. package/plugins/fetch-csr/registry.go +0 -209
  316. package/plugins/fetch-git/cache.go +0 -65
  317. package/plugins/fetch-git/describe.go +0 -19
  318. package/plugins/fetch-git/describe_test.go +0 -11
  319. package/plugins/fetch-git/fetch.go +0 -181
  320. package/plugins/fetch-git/fetch_test.go +0 -332
  321. package/plugins/fetch-git/git.go +0 -169
  322. package/plugins/fetch-git/lock.go +0 -72
  323. package/plugins/fetch-git/main.go +0 -127
  324. package/plugins/fetch-git/offline.go +0 -39
  325. package/plugins/fetch-git/pin.go +0 -90
  326. package/plugins/fetch-git/pin_test.go +0 -74
  327. package/plugins/gen-backstage/describe.go +0 -17
  328. package/plugins/gen-backstage/main.go +0 -22
  329. package/plugins/gen-backstage/plugin.go +0 -473
  330. package/plugins/gen-backstage/plugin_test.go +0 -145
  331. package/plugins/gen-backstage.wasm +0 -0
  332. package/plugins/gen-markdown/adr.go +0 -162
  333. package/plugins/gen-markdown/aggregate.go +0 -362
  334. package/plugins/gen-markdown/canonical.go +0 -93
  335. package/plugins/gen-markdown/context.go +0 -90
  336. package/plugins/gen-markdown/coverage_test.go +0 -89
  337. package/plugins/gen-markdown/describe.go +0 -19
  338. package/plugins/gen-markdown/describe_test.go +0 -11
  339. package/plugins/gen-markdown/external.go +0 -71
  340. package/plugins/gen-markdown/flow.go +0 -277
  341. package/plugins/gen-markdown/glossary.go +0 -76
  342. package/plugins/gen-markdown/glossary_test.go +0 -148
  343. package/plugins/gen-markdown/llms.go +0 -302
  344. package/plugins/gen-markdown/main.go +0 -22
  345. package/plugins/gen-markdown/markdown.go +0 -254
  346. package/plugins/gen-markdown/markdown_test.go +0 -100
  347. package/plugins/gen-markdown/module.go +0 -107
  348. package/plugins/gen-markdown/plugin.go +0 -39
  349. package/plugins/gen-markdown/quality_test.go +0 -176
  350. package/plugins/gen-markdown/redis_test.go +0 -38
  351. package/plugins/gen-markdown/render.go +0 -344
  352. package/plugins/gen-markdown/render_test.go +0 -186
  353. package/plugins/gen-markdown/service.go +0 -456
  354. package/plugins/gen-markdown/source.go +0 -177
  355. package/plugins/gen-markdown/store.go +0 -201
  356. package/plugins/gen-markdown.wasm +0 -0
  357. package/plugins/gen-mermaid/describe.go +0 -17
  358. package/plugins/gen-mermaid/main.go +0 -22
  359. package/plugins/gen-mermaid/plugin.go +0 -104
  360. package/plugins/gen-mermaid/plugin_test.go +0 -33
  361. package/plugins/gen-mermaid.wasm +0 -0
  362. package/plugins/openapi/ids.go +0 -261
  363. package/plugins/openapi/ids_test.go +0 -98
  364. package/plugins/verify-codeowners/describe.go +0 -19
  365. package/plugins/verify-codeowners/describe_test.go +0 -11
  366. package/plugins/verify-codeowners/main.go +0 -75
  367. package/plugins/verify-codeowners/match.go +0 -85
  368. package/plugins/verify-codeowners/match_test.go +0 -47
  369. package/plugins/verify-codeowners/owners.go +0 -164
  370. package/plugins/verify-codeowners/owners_test.go +0 -225
  371. package/plugins/verify-codeowners/parse.go +0 -90
  372. package/plugins/verify-codeowners/parse_test.go +0 -62
  373. package/plugins/verify-otel/describe.go +0 -19
  374. package/plugins/verify-otel/describe_test.go +0 -11
  375. package/plugins/verify-otel/main.go +0 -53
  376. package/plugins/verify-otel/match.go +0 -336
  377. package/plugins/verify-otel/otlp.go +0 -200
  378. package/plugins/verify-otel/verify.go +0 -734
  379. package/plugins/verify-otel/verify_test.go +0 -460
  380. /package/{plugins/fetch-csr/options.schema.json → scripts/host-plugins/fetch-csr.options.json} +0 -0
  381. /package/{plugins/fetch-git/options.schema.json → scripts/host-plugins/fetch-git.options.json} +0 -0
@@ -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 = `${finalRoot}/portolan`;
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
- return send(res, error instanceof LocalApiError ? error.status : 400, {
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",
@@ -13,7 +13,8 @@ import { normalize } from "node:path";
13
13
 
14
14
  import Ajv from "ajv/dist/2020.js";
15
15
 
16
- const SCHEMA = process.env.PORTOLAN_SCHEMA || "schema/portolan.schema.json";
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(SCHEMA, "utf8"));
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) {
@@ -1,11 +1,12 @@
1
- // Runs a portolan generator plugin and returns what it wants written.
1
+ // Runs a portolan plugin and returns what it wants written.
2
2
  //
3
- // A plugin never touches the tree (portolan.0001). It is handed a request on stdin and answers
4
- // with named files on stdout; writing them - and refusing a name that climbs
5
- // out of the output directory - is this file's job. That split is what lets the
6
- // same plugin run as a sandboxed wasm module with no directory preopened at
7
- // all, and it is why `--check` can compare a render against disk without the
8
- // plugin knowing there is a disk.
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
- // `preopens` stays empty, so the module cannot open a path even if it tries.
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
- async function runWasm(plugin, payload, limits) {
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, bytes, inPath, outPath, errPath },
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 generator that needs a toolchain - one reading Go
354
- // source has to run `go list`, which no wasm module can. It gets the same
355
- // protocol and none of the sandbox, which is the trade being made and the
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
- preopens: {},
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
- const module = await WebAssembly.compile(workerData.bytes);
20
- const instance = await WebAssembly.instantiate(module, wasi.getImportObject());
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);
@@ -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 are compiled into the ignored
6
- // `.portolan/bin` directory because `go run` changes the child cwd to the
7
- // module containing the plugin.
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, spawnSync } from "node:child_process";
10
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
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 === "go" && args[0] === "run" && args[1]) {
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") {
@@ -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,