@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.
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 +22 -4
  36. package/scripts/local-api.test.mjs +84 -2
  37. package/scripts/manifest.mjs +3 -2
  38. package/scripts/package-smoke.mjs +6 -2
  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 +12 -8
  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
@@ -0,0 +1,351 @@
1
+ // fetch-bsr, run by the host: a schema registry on one side, .proto files
2
+ // committed to this repository on the other.
3
+ //
4
+ // It is split off from extract-proto because it opens a socket. Extraction
5
+ // stays a pure function of the tree; fetching is the step that can fail, need
6
+ // a credential, or come back with something different than it did yesterday.
7
+ // Keeping them apart is what lets CI verify an estate whose schemas live in a
8
+ // registry it never talks to.
9
+ //
10
+ // It runs in the host (portolan.0008) because a socket and a secret are the
11
+ // host's to hold. The contract is the plugin's: the fetched protos are named,
12
+ // never written, so the host writes them, they get a manifest entry, are
13
+ // compared by `gen:check` like any other generated file, and are removed
14
+ // when the step stops naming them. Refreshing a pin then produces ONE pull
15
+ // request holding the pin bump, the proto diff, the lock diff and the
16
+ // fragment diff - which is the review worth having.
17
+ //
18
+ // In CI, or with PORTOLAN_OFFLINE=1, the step replays the committed copies,
19
+ // checks them against their locks and emits an identical file list, so a
20
+ // fork's pull request needs no secret and the registry being down cannot
21
+ // turn the tree red.
22
+ //
23
+ // No token in the manifest, ever: portolan.json is committed. The credential
24
+ // comes from BUF_TOKEN or the netrc `buf registry login` writes, and it
25
+ // changes whether the fetch succeeds, never what the fetch says.
26
+
27
+ import { createHash } from "node:crypto";
28
+ import { readFileSync } from "node:fs";
29
+ import { homedir } from "node:os";
30
+ import { join, posix } from "node:path";
31
+
32
+ import optionsSchema from "./fetch-bsr.options.json" with { type: "json" };
33
+
34
+ export const LOCK_NAME = "bsr.lock.json";
35
+ export const TOKEN_ENV = "BUF_TOKEN";
36
+ export const OFFLINE_ENV = "PORTOLAN_OFFLINE";
37
+
38
+ // The BSR's public API is Connect, and a Connect unary call is an ordinary
39
+ // HTTP POST carrying the request message as JSON. The message shapes are
40
+ // buf/registry/module/v1 with protojson's names: lowerCamelCase, bytes as
41
+ // base64. Everything the wire knows is in this file on purpose.
42
+ export const DOWNLOAD_METHOD = "/buf.registry.module.v1.DownloadService/Download";
43
+ export const COMMITS_METHOD = "/buf.registry.module.v1.CommitService/GetCommits";
44
+
45
+ export function describe() {
46
+ return {
47
+ name: "fetch-bsr",
48
+ summary: "Fetches pinned modules from a Buf Schema Registry into the tree, with a lock beside each, so the proto extractor can read a published contract.",
49
+ phases: ["extract"],
50
+ options: optionsSchema,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * The step. Reads nothing out of the tree being described; its `cache` is
56
+ * repo-relative and everything else is in the options.
57
+ *
58
+ * The rules below are the design, not error handling. The one that matters
59
+ * most is the third: a failed fetch with no cache is an ERROR, never a short
60
+ * file list, because the host deletes files a step stops naming.
61
+ *
62
+ * @param {{options?: object}} request
63
+ * @param {{env?: NodeJS.ProcessEnv, fetch?: typeof fetch}} [io]
64
+ */
65
+ export async function run(request, { env = process.env, fetch: fetchFn = globalThis.fetch } = {}) {
66
+ const options = request.options ?? {};
67
+ const modules = Array.isArray(options.modules) ? options.modules : [];
68
+ if (modules.length === 0) throw new Error("no modules to fetch: name at least one in the manifest");
69
+ if (!options.cache) {
70
+ 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");
71
+ }
72
+
73
+ const out = new Builder();
74
+ const wanted = [...modules].sort((a, b) => String(a.module).localeCompare(String(b.module)));
75
+ const skip = offline(env);
76
+
77
+ for (const want of wanted) {
78
+ const parts = splitModule(want.module);
79
+ const registry = options.registry || parts.registry;
80
+ // Every module lives in its own directory with its own lock, so a
81
+ // vendored module is self-describing and extract-proto finds the lock
82
+ // by looking beside the files it is already reading.
83
+ const dir = `${parts.owner}/${parts.module}`;
84
+ const at = join(options.cache, parts.owner, parts.module);
85
+
86
+ if (skip) {
87
+ emitCached(out, dir, at, want, "offline");
88
+ continue;
89
+ }
90
+
91
+ let fetched;
92
+ try {
93
+ fetched = await live(registry, parts, want, out, env, fetchFn);
94
+ } catch (cause) {
95
+ // Rule 2: the tree still holds a good copy, so the output is unchanged
96
+ // and `--check` stays clean.
97
+ try {
98
+ emitCached(out, dir, at, want, cause.message);
99
+ } catch (cacheCause) {
100
+ // Rule 3: nothing to fall back to.
101
+ throw new Error(`${want.module} could not be fetched (${cause.message}) and there is no usable copy in the tree (${cacheCause.message})`);
102
+ }
103
+ continue;
104
+ }
105
+ emitFetched(out, dir, want.module, fetched.commit, fetched.files);
106
+ }
107
+
108
+ return out.response();
109
+ }
110
+
111
+ export function offline(env = process.env) {
112
+ if (String(env[OFFLINE_ENV] ?? "").trim()) return true;
113
+ return !["", "0", "false"].includes(String(env.CI ?? "").trim().toLowerCase());
114
+ }
115
+
116
+ /** `buf.build/acme/shop` in its three parts. */
117
+ export function splitModule(name) {
118
+ const parts = String(name ?? "").trim().split("/");
119
+ if (parts.length !== 3 || parts.some((part) => !part)) {
120
+ throw new Error(`"${String(name ?? "").trim()}" is not a module name; expected <registry>/<owner>/<module>`);
121
+ }
122
+ return { registry: parts[0], owner: parts[1], module: parts[2] };
123
+ }
124
+
125
+ /**
126
+ * A credential for a registry, or "". None is a normal outcome: public
127
+ * modules download anonymously. The environment wins over the netrc
128
+ * `buf registry login` wrote.
129
+ */
130
+ export function token(registry, env = process.env) {
131
+ const fromEnv = String(env[TOKEN_ENV] ?? "").trim();
132
+ if (fromEnv) return fromEnv;
133
+ const home = env.HOME || homedir();
134
+ for (const name of [".netrc", "_netrc"]) {
135
+ let text;
136
+ try {
137
+ text = readFileSync(join(home, name), "utf8");
138
+ } catch {
139
+ continue;
140
+ }
141
+ const found = netrcPassword(text, registry);
142
+ if (found) return found;
143
+ }
144
+ return "";
145
+ }
146
+
147
+ /** The password belonging to one machine in a netrc: a stream of words. */
148
+ export function netrcPassword(text, machine) {
149
+ const words = text.split(/\s+/).filter(Boolean);
150
+ let current = "";
151
+ for (let index = 0; index < words.length; index += 1) {
152
+ switch (words[index]) {
153
+ case "machine":
154
+ index += 1;
155
+ if (index >= words.length) return "";
156
+ current = words[index];
157
+ break;
158
+ case "default":
159
+ current = machine;
160
+ break;
161
+ case "password":
162
+ index += 1;
163
+ if (index >= words.length) return "";
164
+ if (current === machine) return words[index];
165
+ break;
166
+ case "login":
167
+ case "account":
168
+ index += 1;
169
+ break;
170
+ default:
171
+ break;
172
+ }
173
+ }
174
+ return "";
175
+ }
176
+
177
+ /** A digest the way buf writes one: `b5:<hex>`. */
178
+ export function displayDigest(digest) {
179
+ const value = digest?.value ? Buffer.from(String(digest.value), "base64") : Buffer.alloc(0);
180
+ if (value.length === 0) return "";
181
+ let kind = String(digest.type ?? "").replace(/^DIGEST_TYPE_/, "").toLowerCase();
182
+ if (!kind || kind === "unspecified") kind = "b5";
183
+ return `${kind}:${value.toString("hex")}`;
184
+ }
185
+
186
+ /** The lock, written the way every generated file here is written. */
187
+ export function encodeLock(entry) {
188
+ const record = { module: entry.module, commit: entry.commit };
189
+ if (entry.digest) record.digest = entry.digest;
190
+ if (entry.deps?.length) record.deps = [...entry.deps].sort();
191
+ record.files = [...entry.files]
192
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
193
+ .map((file) => ({ path: file.path, sha256: file.sha256, size: file.size }));
194
+ return `${JSON.stringify({ modules: [record] }, null, 2)}\n`;
195
+ }
196
+
197
+ function digestOf(contents) {
198
+ return createHash("sha256").update(contents).digest("hex");
199
+ }
200
+
201
+ async function live(registry, parts, want, out, env, fetchFn) {
202
+ const client = new Client(registry, token(registry, env), fetchFn);
203
+ let ref = String(want.commit ?? "");
204
+ if (!ref) {
205
+ // Pin, or every run is a lottery. Resolving a label still works, but it
206
+ // is said out loud.
207
+ const label = want.ref || "main";
208
+ const resolved = await client.resolve(parts.owner, parts.module, label);
209
+ out.warn(want.module, `is not pinned; "${label}" resolved to ${resolved.id}. Pin it in portolan.json or every run is a lottery.`);
210
+ ref = resolved.id;
211
+ }
212
+ const { commit, files } = await client.download(parts.owner, parts.module, ref, want.paths ?? []);
213
+ if (files.size === 0) throw new Error(`${want.module} at ${ref} holds no .proto files`);
214
+ return { commit, files };
215
+ }
216
+
217
+ function emitFetched(out, dir, name, commit, files) {
218
+ const entry = { module: name, commit: commit.id, digest: displayDigest(commit.digest), files: [] };
219
+ for (const path of [...files.keys()].sort()) {
220
+ const contents = files.get(path);
221
+ out.file(posix.join(dir, path), contents.toString("utf8"));
222
+ entry.files.push({ path, sha256: digestOf(contents), size: contents.length });
223
+ }
224
+ out.file(posix.join(dir, LOCK_NAME), encodeLock(entry));
225
+ }
226
+
227
+ function emitCached(out, dir, at, want, why) {
228
+ if (!want.commit) throw new Error(`${want.module} is not pinned to a commit, so there is nothing to replay`);
229
+ const held = replay(at);
230
+ if (held.lock.commit !== want.commit) {
231
+ throw new Error(`${want.module} holds commit ${held.lock.commit} but the manifest pins ${want.commit}; fetch it`);
232
+ }
233
+ for (const file of held.lock.files) out.file(posix.join(dir, file.path), held.files.get(file.path).toString("utf8"));
234
+ out.file(posix.join(dir, LOCK_NAME), encodeLock(held.lock));
235
+ out.warn(want.module, `not fetched (${why}); the copy committed in this repository is used unchanged`);
236
+ }
237
+
238
+ function replay(dir) {
239
+ const lockPath = join(dir, LOCK_NAME);
240
+ let raw;
241
+ try {
242
+ raw = readFileSync(lockPath, "utf8");
243
+ } catch (cause) {
244
+ if (cause.code === "ENOENT") throw new Error(`no ${LOCK_NAME} in ${dir.split("\\").join("/")}`);
245
+ throw cause;
246
+ }
247
+ let lock;
248
+ try {
249
+ lock = JSON.parse(raw);
250
+ } catch (cause) {
251
+ throw new Error(`${lockPath}: ${cause.message}`);
252
+ }
253
+ const modules = Array.isArray(lock.modules) ? lock.modules : [];
254
+ if (modules.length !== 1) throw new Error(`${lockPath} names ${modules.length} modules; expected exactly one`);
255
+ const entry = modules[0];
256
+ const files = new Map();
257
+ for (const want of entry.files ?? []) {
258
+ let contents;
259
+ try {
260
+ contents = readFileSync(join(dir, ...String(want.path).split("/")));
261
+ } catch (cause) {
262
+ if (cause.code === "ENOENT") throw new Error(`${want.path} is in the lock but not on disk`);
263
+ throw cause;
264
+ }
265
+ if (digestOf(contents) !== want.sha256) throw new Error(`${want.path} does not match its digest; the vendored copy was edited by hand`);
266
+ files.set(want.path, contents);
267
+ }
268
+ return {
269
+ lock: { module: entry.module, commit: entry.commit, digest: entry.digest ?? "", deps: entry.deps ?? [], files: entry.files ?? [] },
270
+ files,
271
+ };
272
+ }
273
+
274
+ /** One registry, spoken to over Connect. */
275
+ class Client {
276
+ constructor(registry, credential, fetchFn) {
277
+ // buf.build is https and needs no help; a self-hosted BSR behind a plain
278
+ // http address, or a test server, says so with a scheme.
279
+ this.base = /^https?:\/\//.test(registry) ? registry.replace(/\/$/, "") : `https://${registry}`;
280
+ this.credential = credential;
281
+ this.fetch = fetchFn;
282
+ }
283
+
284
+ async download(owner, module, ref, paths) {
285
+ const body = {
286
+ values: [{
287
+ resourceRef: { name: { owner, module, ref } },
288
+ // Only .proto: the docs and licence a module also carries are not
289
+ // something portolan reads.
290
+ fileTypes: ["FILE_TYPE_PROTO"],
291
+ ...(paths.length ? { paths, pathsAllowNotExist: true } : {}),
292
+ }],
293
+ };
294
+ const answer = await this.call(DOWNLOAD_METHOD, body);
295
+ const content = answer.contents?.[0];
296
+ if (!content) throw new Error(`${owner}/${module}: the registry returned no content`);
297
+ const files = new Map();
298
+ for (const file of content.files ?? []) files.set(file.path, Buffer.from(String(file.content ?? ""), "base64"));
299
+ return { commit: content.commit ?? {}, files };
300
+ }
301
+
302
+ async resolve(owner, module, ref) {
303
+ const answer = await this.call(COMMITS_METHOD, { resourceRefs: [{ name: { owner, module, ref } }] });
304
+ const commit = answer.commits?.[0];
305
+ if (!commit) throw new Error(`${owner}/${module}: no commit for "${ref}"`);
306
+ return commit;
307
+ }
308
+
309
+ async call(method, body) {
310
+ const headers = { "Content-Type": "application/json", "Connect-Protocol-Version": "1" };
311
+ if (this.credential) headers.Authorization = `Bearer ${this.credential}`;
312
+ let response;
313
+ try {
314
+ // A timeout rather than none: a step that hangs holds up the whole run.
315
+ response = await this.fetch(`${this.base}${method}`, { method: "POST", headers, body: JSON.stringify(body), signal: AbortSignal.timeout(60_000) });
316
+ } catch (cause) {
317
+ throw new Error(`${method}: ${cause.cause?.message ?? cause.message}`);
318
+ }
319
+ const raw = await response.text();
320
+ if (!response.ok) throw new Error(`${method}: ${connectError(response.status, raw)}`);
321
+ return JSON.parse(raw);
322
+ }
323
+ }
324
+
325
+ /** The error body Connect sends, or the status when the body is not one. */
326
+ function connectError(status, raw) {
327
+ try {
328
+ const wire = JSON.parse(raw);
329
+ if (wire?.message) return wire.code ? `${wire.code}: ${wire.message}` : wire.message;
330
+ } catch {
331
+ // Not a Connect error body.
332
+ }
333
+ return `http ${status}`;
334
+ }
335
+
336
+ class Builder {
337
+ files = [];
338
+ warnings = [];
339
+
340
+ file(name, contents) {
341
+ this.files.push({ name, contents });
342
+ }
343
+
344
+ warn(ref, message) {
345
+ this.warnings.push({ severity: "warning", message, ref });
346
+ }
347
+
348
+ response() {
349
+ return { files: this.files, warnings: this.warnings };
350
+ }
351
+ }
@@ -34,6 +34,10 @@
34
34
  }
35
35
  }
36
36
  },
37
+ "registry": {
38
+ "type": "string",
39
+ "description": "Overrides the registry in every module name, for a self-hosted BSR: a host, or a URL when it is not https."
40
+ },
37
41
  "cache": {
38
42
  "type": "string",
39
43
  "description": "Where the last run's files were written, repo-relative: the same path as the step's out. An offline run replays what is there and checks it against the lock beside it."
@@ -0,0 +1,212 @@
1
+ // The fetcher, against a recorded registry. NEVER against a live one: a test
2
+ // that reaches buf.build would fail on a train, and the whole point of this
3
+ // plugin's design is that the build does not depend on a registry being up.
4
+ import { createHash } from "node:crypto";
5
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { createServer } from "node:http";
7
+ import { tmpdir } from "node:os";
8
+ import { dirname, join } from "node:path";
9
+ import { afterEach, describe, expect, it } from "vitest";
10
+
11
+ import { runPlugin } from "../plugin-host.mjs";
12
+ import { COMMITS_METHOD, DOWNLOAD_METHOD, OFFLINE_ENV, TOKEN_ENV, displayDigest, encodeLock, netrcPassword, run, splitModule, token } from "./fetch-bsr.mjs";
13
+
14
+ const MODULE = "buf.build/acme/shop";
15
+ const PINNED = "c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6";
16
+ const PROTO = 'syntax = "proto3";\n\npackage shop.v1;\n\nservice Orders {\n rpc PlaceOrder(PlaceOrderRequest) returns (PlaceOrderResponse);\n}\n\nmessage PlaceOrderRequest { string customer_id = 1; }\nmessage PlaceOrderResponse { string order_id = 1; }\n';
17
+ const DIGEST = { type: "DIGEST_TYPE_B5", value: Buffer.from([0xde, 0xad, 0xbe, 0xef]).toString("base64") };
18
+
19
+ const cleanups = [];
20
+ afterEach(async () => {
21
+ for (const cleanup of cleanups.splice(0)) await cleanup();
22
+ });
23
+
24
+ /** Serves the two calls the plugin makes, from recorded shapes. */
25
+ async function registry(handle = null) {
26
+ const seen = [];
27
+ const requests = [];
28
+ const server = createServer((request, response) => {
29
+ let body = "";
30
+ request.on("data", (chunk) => { body += chunk; });
31
+ request.on("end", () => {
32
+ seen.push(request.headers.authorization ?? "");
33
+ if (handle) return handle(request, response, body);
34
+ response.setHeader("Content-Type", "application/json");
35
+ if (request.url === DOWNLOAD_METHOD) {
36
+ requests.push(JSON.parse(body));
37
+ response.end(JSON.stringify({ contents: [{ commit: { id: PINNED, digest: DIGEST }, files: [{ path: "shop/v1/orders.proto", content: Buffer.from(PROTO).toString("base64") }] }] }));
38
+ } else if (request.url === COMMITS_METHOD) {
39
+ response.end(JSON.stringify({ commits: [{ id: PINNED, digest: DIGEST }] }));
40
+ } else {
41
+ response.statusCode = 404;
42
+ response.end(JSON.stringify({ code: "not_found", message: "no such method" }));
43
+ }
44
+ });
45
+ });
46
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
47
+ cleanups.push(() => new Promise((resolve) => server.close(resolve)));
48
+ return { url: `http://127.0.0.1:${server.address().port}`, seen, requests };
49
+ }
50
+
51
+ function cache() {
52
+ const dir = mkdtempSync(join(tmpdir(), "portolan-fetch-bsr-"));
53
+ cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
54
+ return dir;
55
+ }
56
+
57
+ // A test must never inherit a real credential or a real offline setting.
58
+ const isolated = { [TOKEN_ENV]: "", [OFFLINE_ENV]: "", CI: "", HOME: "/nonexistent" };
59
+ const options = (base, cacheDir, extra = {}) => ({ registry: base, cache: cacheDir, modules: [{ module: MODULE, commit: PINNED, ...extra }] });
60
+ const fetch = (opts, env = {}) => run({ options: opts }, { env: { ...isolated, ...env } });
61
+ const names = (response) => response.files.map((file) => file.name).sort();
62
+ const contentsOf = (response, name) => response.files.find((file) => file.name === name)?.contents ?? "";
63
+ const write = (dir, response) => {
64
+ for (const file of response.files) {
65
+ mkdirSync(dirname(join(dir, file.name)), { recursive: true });
66
+ writeFileSync(join(dir, file.name), file.contents);
67
+ }
68
+ };
69
+
70
+ describe("fetch-bsr", () => {
71
+ it("writes the protos and a lock beside them", async () => {
72
+ const server = await registry();
73
+ const response = await fetch(options(server.url, cache()));
74
+ // Each module in its own directory with its own lock, so extract-proto
75
+ // finds the lock beside the files it is already reading.
76
+ expect(names(response)).toEqual(["acme/shop/bsr.lock.json", "acme/shop/shop/v1/orders.proto"]);
77
+ expect(contentsOf(response, "acme/shop/shop/v1/orders.proto")).toBe(PROTO);
78
+ const lock = JSON.parse(contentsOf(response, "acme/shop/bsr.lock.json"));
79
+ expect(lock.modules).toHaveLength(1);
80
+ expect(lock.modules[0]).toEqual({
81
+ module: MODULE,
82
+ commit: PINNED,
83
+ digest: "b5:deadbeef",
84
+ files: [{ path: "shop/v1/orders.proto", sha256: createHash("sha256").update(PROTO).digest("hex"), size: PROTO.length }],
85
+ });
86
+ expect(response.warnings).toEqual([]);
87
+ // The wire shape, asserted rather than assumed.
88
+ expect(server.requests[0].values[0]).toEqual({ resourceRef: { name: { owner: "acme", module: "shop", ref: PINNED } }, fileTypes: ["FILE_TYPE_PROTO"] });
89
+ });
90
+
91
+ it("replays the committed copy offline, byte for byte, and in CI", async () => {
92
+ const server = await registry();
93
+ const cacheDir = cache();
94
+ const online = await fetch(options(server.url, cacheDir));
95
+ write(cacheDir, online);
96
+
97
+ const replayed = await fetch(options(server.url, cacheDir), { [OFFLINE_ENV]: "1" });
98
+ expect(names(replayed)).toEqual(names(online));
99
+ for (const file of online.files) expect(contentsOf(replayed, file.name)).toBe(file.contents);
100
+ expect(replayed.warnings).toHaveLength(1);
101
+ expect(replayed.warnings[0].message).toContain("not fetched");
102
+
103
+ const inCi = await fetch(options(server.url, cacheDir), { CI: "true" });
104
+ expect(names(inCi)).toEqual(names(online));
105
+ });
106
+
107
+ it("names an edited vendored file, and refuses a copy at another commit", async () => {
108
+ const server = await registry();
109
+ const cacheDir = cache();
110
+ write(cacheDir, await fetch(options(server.url, cacheDir)));
111
+
112
+ await expect(fetch(options(server.url, cacheDir, { commit: "0000000000000000000000000000000f" }), { [OFFLINE_ENV]: "1" })).rejects.toThrow(/holds commit .* but the manifest pins/);
113
+
114
+ writeFileSync(join(cacheDir, "acme/shop/shop/v1/orders.proto"), "// edited by hand\n");
115
+ await expect(fetch(options(server.url, cacheDir), { [OFFLINE_ENV]: "1" })).rejects.toThrow(/shop\/v1\/orders\.proto.*edited by hand/);
116
+ });
117
+
118
+ it("fails rather than emitting nothing when there is no copy to fall back to", async () => {
119
+ await expect(fetch(options("http://127.0.0.1:1", cache()), { [OFFLINE_ENV]: "1" })).rejects.toThrow(/no bsr\.lock\.json/);
120
+
121
+ const broken = await registry((request, response) => {
122
+ response.statusCode = 500;
123
+ response.end(JSON.stringify({ code: "internal", message: "the registry is having a day" }));
124
+ });
125
+ await expect(fetch(options(broken.url, cache()))).rejects.toThrow(/the registry is having a day.*no usable copy/);
126
+ });
127
+
128
+ it("falls back to the committed copy when the registry fails", async () => {
129
+ const server = await registry();
130
+ const cacheDir = cache();
131
+ const online = await fetch(options(server.url, cacheDir));
132
+ write(cacheDir, online);
133
+
134
+ const broken = await registry((request, response) => {
135
+ response.statusCode = 502;
136
+ response.end();
137
+ });
138
+ const fallback = await fetch(options(broken.url, cacheDir));
139
+ expect(names(fallback)).toEqual(names(online));
140
+ expect(fallback.warnings[0].message).toMatch(/not fetched \(.*http 502\)/);
141
+ });
142
+
143
+ it("resolves an unpinned module online with a warning, and refuses it offline", async () => {
144
+ const server = await registry();
145
+ const response = await fetch(options(server.url, cache(), { commit: "" }));
146
+ expect(response.warnings).toHaveLength(1);
147
+ expect(response.warnings[0].message).toContain("not pinned");
148
+ expect(response.warnings[0].message).toContain(PINNED);
149
+
150
+ await expect(fetch(options("http://127.0.0.1:1", cache(), { commit: "" }), { [OFFLINE_ENV]: "1" })).rejects.toThrow(MODULE);
151
+ });
152
+
153
+ it("sends the token and never lets it reach the output", async () => {
154
+ const server = await registry();
155
+ const anonymous = await fetch(options(server.url, cache()));
156
+ const authorised = await fetch(options(server.url, cache()), { [TOKEN_ENV]: "a-real-looking-secret" });
157
+ for (const file of anonymous.files) {
158
+ expect(contentsOf(authorised, file.name)).toBe(file.contents);
159
+ expect(contentsOf(authorised, file.name)).not.toContain("a-real-looking-secret");
160
+ }
161
+ expect(server.seen[0]).toBe("");
162
+ expect(server.seen.at(-1)).toBe("Bearer a-real-looking-secret");
163
+ });
164
+
165
+ it("refuses a missing cache and a module name without three parts", async () => {
166
+ await expect(fetch(options("http://127.0.0.1:1", ""))).rejects.toThrow(/cache/);
167
+ await expect(fetch(options("http://127.0.0.1:1", cache(), { module: "acme/shop" }))).rejects.toThrow(/module name/);
168
+ expect(splitModule(MODULE)).toEqual({ registry: "buf.build", owner: "acme", module: "shop" });
169
+ });
170
+
171
+ it("runs through the host like any plugin", async () => {
172
+ const server = await registry();
173
+ const cacheDir = cache();
174
+ write(cacheDir, await fetch(options(server.url, cacheDir)));
175
+ const saved = { CI: process.env.CI, [OFFLINE_ENV]: process.env[OFFLINE_ENV] };
176
+ process.env.CI = "";
177
+ process.env[OFFLINE_ENV] = "1";
178
+ try {
179
+ const result = await runPlugin({ name: "bsr", host: "fetch-bsr" }, { portolanVersion: "0.1.0", options: options(server.url, cacheDir) });
180
+ expect(result.files).toHaveLength(2);
181
+ expect(result.warnings).toEqual([`${MODULE}: not fetched (offline); the copy committed in this repository is used unchanged`]);
182
+ const described = await runPlugin({ name: "bsr", host: "fetch-bsr" }, { portolanVersion: "0.1.0", kind: "describe" });
183
+ expect(described.describe).toMatchObject({ name: "fetch-bsr", phases: ["extract"] });
184
+ } finally {
185
+ for (const [key, value] of Object.entries(saved)) {
186
+ if (value === undefined) delete process.env[key];
187
+ else process.env[key] = value;
188
+ }
189
+ }
190
+ });
191
+ });
192
+
193
+ describe("credentials and locks", () => {
194
+ it("takes the environment over the netrc, and the netrc over nothing", () => {
195
+ const home = cache();
196
+ writeFileSync(join(home, ".netrc"), "machine buf.build login someone password from-netrc\nmachine other.example login x password other\n");
197
+ expect(token("buf.build", { HOME: home })).toBe("from-netrc");
198
+ expect(token("elsewhere.example", { HOME: home })).toBe("");
199
+ expect(token("buf.build", { HOME: home, [TOKEN_ENV]: "from-env" })).toBe("from-env");
200
+ expect(netrcPassword("default login a password d\n", "any.host")).toBe("d");
201
+ expect(netrcPassword("machine buf.build login", "buf.build")).toBe("");
202
+ });
203
+
204
+ it("writes a digest the way buf does and a lock the way every generated file is written", () => {
205
+ expect(displayDigest(DIGEST)).toBe("b5:deadbeef");
206
+ expect(displayDigest({ type: "DIGEST_TYPE_UNSPECIFIED", value: DIGEST.value })).toBe("b5:deadbeef");
207
+ expect(displayDigest({})).toBe("");
208
+ const lock = encodeLock({ module: MODULE, commit: PINNED, digest: "", deps: ["b", "a"], files: [{ path: "z", sha256: "2", size: 2 }, { path: "a", sha256: "1", size: 1 }] });
209
+ expect(JSON.parse(lock)).toEqual({ modules: [{ module: MODULE, commit: PINNED, deps: ["a", "b"], files: [{ path: "a", sha256: "1", size: 1 }, { path: "z", sha256: "2", size: 2 }] }] });
210
+ expect(lock.endsWith("\n")).toBe(true);
211
+ });
212
+ });