@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,386 @@
1
+ // fetch-csr, run by the host: a Confluent Schema Registry on one side, schema
2
+ // files committed to this repository on the other.
3
+ //
4
+ // Shaped like fetch-bsr on purpose and living by the same rules: a pinned
5
+ // version is immutable, so a fetch is byte-reproducible; the committed copy
6
+ // replays offline and in CI; a failed fetch falls back to the tree and, with
7
+ // nothing to fall back to, is an error rather than a short file list. A
8
+ // subject a fetched schema references is fetched too, pinned by the reference
9
+ // itself, so following one adds no lottery.
10
+ //
11
+ // It runs in the host (portolan.0008) because it needs a socket and a
12
+ // credential. The credential comes from the environment and is never in the
13
+ // manifest; the registry URL is a fact about the estate and is.
14
+
15
+ import { createHash } from "node:crypto";
16
+ import { readFileSync } from "node:fs";
17
+ import { join, posix } from "node:path";
18
+
19
+ import optionsSchema from "./fetch-csr.options.json" with { type: "json" };
20
+
21
+ export const LOCK_NAME = "csr.lock.json";
22
+ export const OFFLINE_ENV = "PORTOLAN_OFFLINE";
23
+ export const KEY_ENV = "CONFLUENT_SCHEMA_REGISTRY_API_KEY";
24
+ export const SECRET_ENV = "CONFLUENT_SCHEMA_REGISTRY_API_SECRET";
25
+ export const SHORT_KEY_ENV = "CSR_API_KEY";
26
+ export const SHORT_SECRET_ENV = "CSR_API_SECRET";
27
+ export const TOKEN_ENV = "CSR_TOKEN";
28
+
29
+ // The registry's own media type first and plain JSON second, so the same
30
+ // client works against Confluent's registry and the API-compatible ones.
31
+ const ACCEPT = "application/vnd.schemaregistry.v1+json, application/json";
32
+
33
+ export function describe() {
34
+ return {
35
+ name: "fetch-csr",
36
+ summary: "Fetches pinned subject versions from a Confluent Schema Registry into the tree, with a lock beside each, so a schema published to a registry can be read without talking to it.",
37
+ phases: ["extract"],
38
+ options: optionsSchema,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * @param {{options?: object}} request
44
+ * @param {{env?: NodeJS.ProcessEnv, fetch?: typeof fetch}} [io]
45
+ */
46
+ export async function run(request, { env = process.env, fetch: fetchFn = globalThis.fetch } = {}) {
47
+ const options = request.options ?? {};
48
+ const registry = String(options.registry ?? "").trim();
49
+ if (!registry) throw new Error("no registry: name the schema registry's base URL in the manifest");
50
+ const subjects = Array.isArray(options.subjects) ? options.subjects : [];
51
+ if (subjects.length === 0) throw new Error("no subjects to fetch: name at least one in the manifest");
52
+ if (!options.cache) {
53
+ 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");
54
+ }
55
+
56
+ const out = new Builder();
57
+ const queue = [...subjects].map((want) => ({ subject: String(want.subject ?? ""), version: Number(want.version ?? 0) || 0 }))
58
+ .sort((a, b) => (a.subject < b.subject ? -1 : a.subject > b.subject ? 1 : 0));
59
+ const client = new Client(registry, authorization(env), fetchFn);
60
+ const skip = offline(env);
61
+
62
+ // Two subjects that slug to the same directory would overwrite each other,
63
+ // and one subject wanted at two versions cannot be held in a tree that
64
+ // keeps one version per subject. Both are refused by name.
65
+ const claimed = new Map();
66
+ const done = new Set();
67
+
68
+ while (queue.length) {
69
+ const want = queue.shift();
70
+ if (!want.subject) throw new Error("a subject entry names no subject");
71
+ if (claimed.has(want.subject)) {
72
+ const held = claimed.get(want.subject);
73
+ if (held.version !== want.version) {
74
+ throw new Error(`${want.subject} is wanted at version ${pinOf(held.version)} and at version ${pinOf(want.version)}; the tree keeps one version per subject, so pin one`);
75
+ }
76
+ continue;
77
+ }
78
+ const dir = slugOf(want.subject);
79
+ if (done.has(dir)) throw new Error(`${want.subject} and another subject both vendor into ${dir}; the tree cannot hold both`);
80
+ claimed.set(want.subject, want);
81
+ done.add(dir);
82
+ const at = join(options.cache, dir);
83
+
84
+ let references;
85
+ if (skip) {
86
+ references = emitCached(out, registry, dir, at, want, "offline");
87
+ } else {
88
+ try {
89
+ references = await live(client, out, registry, dir, want);
90
+ } catch (cause) {
91
+ try {
92
+ references = emitCached(out, registry, dir, at, want, cause.message);
93
+ } catch (cacheCause) {
94
+ throw new Error(`${want.subject} could not be fetched (${cause.message}) and there is no usable copy in the tree (${cacheCause.message})`);
95
+ }
96
+ }
97
+ }
98
+
99
+ // A referenced subject is pinned by the schema that references it, so
100
+ // following one needs no permission from the manifest.
101
+ const following = references
102
+ .filter((ref) => ref.subject && !claimed.has(ref.subject))
103
+ .map((ref) => ({ subject: ref.subject, version: Number(ref.version) || 0 }))
104
+ .sort((a, b) => (a.subject < b.subject ? -1 : a.subject > b.subject ? 1 : 0));
105
+ queue.push(...following);
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
+ /** The Authorization value, or "" for an anonymous registry. */
117
+ export function authorization(env = process.env) {
118
+ const read = (name) => String(env[name] ?? "").trim();
119
+ const raw = read(TOKEN_ENV);
120
+ if (raw) {
121
+ // Passed through when it already names its scheme, so the same variable
122
+ // serves "Bearer ..." and an opaque token a proxy expects bare.
123
+ return raw.includes(" ") ? raw : `Bearer ${raw}`;
124
+ }
125
+ const key = read(KEY_ENV) || read(SHORT_KEY_ENV);
126
+ const secret = read(SECRET_ENV) || read(SHORT_SECRET_ENV);
127
+ if (!key || !secret) return "";
128
+ return `Basic ${Buffer.from(`${key}:${secret}`).toString("base64")}`;
129
+ }
130
+
131
+ /**
132
+ * The directory a subject vendors into. A subject name is whatever the
133
+ * producer registered; everything outside a safe set becomes an underscore,
134
+ * and the true subject is written in the lock beside the file.
135
+ */
136
+ export function slugOf(subject) {
137
+ const slug = [...String(subject)].map((char) => (/[A-Za-z0-9._-]/.test(char) ? char : "_")).join("").replace(/^\.+|\.+$/g, "");
138
+ return slug || "_";
139
+ }
140
+
141
+ /** The empty answer is AVRO by the registry's own rule. */
142
+ export function schemaType(declared) {
143
+ const clean = String(declared ?? "").trim();
144
+ return clean ? clean.toUpperCase() : "AVRO";
145
+ }
146
+
147
+ export function extensionFor(kind) {
148
+ switch (schemaType(kind)) {
149
+ case "AVRO": return ".avsc";
150
+ case "PROTOBUF": return ".proto";
151
+ case "JSON": return ".json";
152
+ default: throw new Error(`unknown schema type "${kind}"`);
153
+ }
154
+ }
155
+
156
+ /**
157
+ * The schema as it should be written. AVRO and JSON arrive minified onto one
158
+ * line; they are indented without reordering or rewriting a single token, so
159
+ * the file still says what the registry said. PROTOBUF is left as it is.
160
+ */
161
+ export function body(kind, schema) {
162
+ if (schemaType(kind) === "PROTOBUF") return withNewline(schema);
163
+ try {
164
+ JSON.parse(schema);
165
+ } catch {
166
+ // A registry that answered with something that is not JSON has told us
167
+ // something, and the honest thing is to keep it so the reader can see.
168
+ return withNewline(schema);
169
+ }
170
+ return withNewline(indentJson(schema));
171
+ }
172
+
173
+ /** json.Indent: re-spaced, never re-parsed - every token stays as it came. */
174
+ export function indentJson(text) {
175
+ let out = "";
176
+ let depth = 0;
177
+ let index = 0;
178
+ const skipSpace = () => { while (index < text.length && /\s/.test(text[index])) index += 1; };
179
+ while (index < text.length) {
180
+ const char = text[index];
181
+ if (/\s/.test(char)) { index += 1; continue; }
182
+ if (char === '"') {
183
+ let end = index + 1;
184
+ while (end < text.length && text[end] !== '"') end += text[end] === "\\" ? 2 : 1;
185
+ out += text.slice(index, end + 1);
186
+ index = end + 1;
187
+ continue;
188
+ }
189
+ if (char === "{" || char === "[") {
190
+ const closer = char === "{" ? "}" : "]";
191
+ index += 1;
192
+ skipSpace();
193
+ if (text[index] === closer) { out += char + closer; index += 1; continue; }
194
+ depth += 1;
195
+ out += `${char}\n${" ".repeat(depth)}`;
196
+ continue;
197
+ }
198
+ if (char === "}" || char === "]") {
199
+ depth -= 1;
200
+ out += `\n${" ".repeat(depth)}${char}`;
201
+ index += 1;
202
+ continue;
203
+ }
204
+ if (char === ",") { out += `,\n${" ".repeat(depth)}`; index += 1; continue; }
205
+ if (char === ":") { out += ": "; index += 1; continue; }
206
+ let end = index;
207
+ while (end < text.length && !/[\s,\]}:]/.test(text[end])) end += 1;
208
+ out += text.slice(index, end);
209
+ index = end;
210
+ }
211
+ return out;
212
+ }
213
+
214
+ function withNewline(text) {
215
+ return text.endsWith("\n") ? text : `${text}\n`;
216
+ }
217
+
218
+ /** The lock, one subject per directory, references sorted by name. */
219
+ export function encodeLock(registry, entry) {
220
+ const record = { subject: entry.subject, version: entry.version, id: entry.id };
221
+ if (entry.guid) record.guid = entry.guid;
222
+ record.schemaType = entry.schemaType;
223
+ if (entry.references?.length) {
224
+ record.references = [...entry.references]
225
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
226
+ .map((ref) => ({ name: ref.name, subject: ref.subject, version: ref.version }));
227
+ }
228
+ record.files = [...entry.files]
229
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
230
+ .map((file) => ({ path: file.path, sha256: file.sha256, size: file.size }));
231
+ return `${JSON.stringify({ registry, subjects: [record] }, null, 2)}\n`;
232
+ }
233
+
234
+ function digestOf(contents) {
235
+ return createHash("sha256").update(contents).digest("hex");
236
+ }
237
+
238
+ function pinOf(version) {
239
+ return version === 0 ? "latest" : String(version);
240
+ }
241
+
242
+ async function live(client, out, registry, dir, want) {
243
+ const got = await client.version(want.subject, want.version);
244
+ if (want.version === 0) {
245
+ out.warn(want.subject, `is not pinned; "latest" resolved to version ${got.version}. Pin it in portolan.json or every run is a lottery.`);
246
+ }
247
+ let extension;
248
+ try {
249
+ extension = extensionFor(got.schemaType);
250
+ } catch (cause) {
251
+ throw new Error(`${want.subject} at version ${got.version}: ${cause.message}`);
252
+ }
253
+ const name = `v${got.version}${extension}`;
254
+ const contents = body(got.schemaType, got.schema);
255
+ const entry = {
256
+ subject: want.subject,
257
+ version: got.version,
258
+ id: got.id ?? 0,
259
+ guid: got.guid ?? "",
260
+ schemaType: schemaType(got.schemaType),
261
+ references: got.references ?? [],
262
+ files: [{ path: name, sha256: digestOf(contents), size: Buffer.byteLength(contents) }],
263
+ };
264
+ out.file(posix.join(dir, name), contents);
265
+ out.file(posix.join(dir, LOCK_NAME), encodeLock(registry, entry));
266
+ return entry.references;
267
+ }
268
+
269
+ function emitCached(out, registry, dir, at, want, why) {
270
+ if (want.version === 0) throw new Error(`${want.subject} is not pinned to a version, so there is nothing to replay`);
271
+ const held = replay(at);
272
+ if (held.lock.version !== want.version) throw new Error(`${want.subject} holds version ${held.lock.version} but the manifest pins ${want.version}; fetch it`);
273
+ if (held.lock.subject !== want.subject) throw new Error(`${want.subject} vendors into ${at.split("\\").join("/")}, which holds ${held.lock.subject}`);
274
+ if (held.registry && held.registry !== registry) throw new Error(`${want.subject} was fetched from ${held.registry} but the manifest names ${registry}; fetch it again`);
275
+ for (const file of held.lock.files) out.file(posix.join(dir, file.path), held.files.get(file.path).toString("utf8"));
276
+ out.file(posix.join(dir, LOCK_NAME), encodeLock(registry, held.lock));
277
+ out.warn(want.subject, `not fetched (${why}); the copy committed in this repository is used unchanged`);
278
+ return held.lock.references ?? [];
279
+ }
280
+
281
+ function replay(dir) {
282
+ const lockPath = join(dir, LOCK_NAME);
283
+ let raw;
284
+ try {
285
+ raw = readFileSync(lockPath, "utf8");
286
+ } catch (cause) {
287
+ if (cause.code === "ENOENT") throw new Error(`no ${LOCK_NAME} in ${dir.split("\\").join("/")}`);
288
+ throw cause;
289
+ }
290
+ let lock;
291
+ try {
292
+ lock = JSON.parse(raw);
293
+ } catch (cause) {
294
+ throw new Error(`${lockPath}: ${cause.message}`);
295
+ }
296
+ const subjects = Array.isArray(lock.subjects) ? lock.subjects : [];
297
+ if (subjects.length !== 1) throw new Error(`${lockPath} names ${subjects.length} subjects; expected exactly one`);
298
+ const entry = subjects[0];
299
+ const files = new Map();
300
+ for (const want of entry.files ?? []) {
301
+ let contents;
302
+ try {
303
+ contents = readFileSync(join(dir, ...String(want.path).split("/")));
304
+ } catch (cause) {
305
+ if (cause.code === "ENOENT") throw new Error(`${want.path} is in the lock but not on disk`);
306
+ throw cause;
307
+ }
308
+ if (digestOf(contents) !== want.sha256) throw new Error(`${want.path} does not match its digest; the vendored copy was edited by hand`);
309
+ files.set(want.path, contents);
310
+ }
311
+ return {
312
+ registry: String(lock.registry ?? ""),
313
+ lock: {
314
+ subject: entry.subject, version: Number(entry.version) || 0, id: entry.id ?? 0, guid: entry.guid ?? "",
315
+ schemaType: entry.schemaType ?? "", references: entry.references ?? [], files: entry.files ?? [],
316
+ },
317
+ files,
318
+ };
319
+ }
320
+
321
+ /** The registry's REST API, and only the one call this plugin makes. */
322
+ class Client {
323
+ constructor(base, auth, fetchFn) {
324
+ this.base = base.replace(/\/+$/, "");
325
+ this.auth = auth;
326
+ this.fetch = fetchFn;
327
+ }
328
+
329
+ /** One registration. A zero version asks for `latest`. */
330
+ async version(subject, version) {
331
+ const which = version > 0 ? String(version) : "latest";
332
+ const at = `${this.base}/subjects/${encodeURIComponent(subject)}/versions/${which}`;
333
+ let got;
334
+ try {
335
+ got = await this.get(at);
336
+ } catch (cause) {
337
+ throw new Error(`${subject} at version ${which}: ${cause.message}`);
338
+ }
339
+ if (!String(got.schema ?? "").trim()) throw new Error(`${subject} at version ${which}: the registry answered with no schema`);
340
+ if (!(Number(got.version) > 0)) throw new Error(`${subject} at version ${which}: the registry did not say which version it answered with`);
341
+ return { ...got, version: Number(got.version) };
342
+ }
343
+
344
+ async get(at) {
345
+ const headers = { Accept: ACCEPT };
346
+ if (this.auth) headers.Authorization = this.auth;
347
+ let response;
348
+ try {
349
+ response = await this.fetch(at, { headers, signal: AbortSignal.timeout(30_000) });
350
+ } catch (cause) {
351
+ throw new Error(cause.cause?.message ?? cause.message);
352
+ }
353
+ const raw = await response.text();
354
+ if (!response.ok) {
355
+ try {
356
+ const fault = JSON.parse(raw);
357
+ if (fault?.message) throw new Error(`the registry answered ${response.status} ${response.statusText}: ${fault.message} (error_code ${fault.error_code ?? 0})`);
358
+ } catch (cause) {
359
+ if (cause.message.startsWith("the registry answered")) throw cause;
360
+ }
361
+ throw new Error(`the registry answered ${response.status} ${response.statusText}`);
362
+ }
363
+ try {
364
+ return JSON.parse(raw);
365
+ } catch (cause) {
366
+ throw new Error(`the registry's answer is not the shape this plugin reads: ${cause.message}`);
367
+ }
368
+ }
369
+ }
370
+
371
+ class Builder {
372
+ files = [];
373
+ warnings = [];
374
+
375
+ file(name, contents) {
376
+ this.files.push({ name, contents });
377
+ }
378
+
379
+ warn(ref, message) {
380
+ this.warnings.push({ severity: "warning", message, ref });
381
+ }
382
+
383
+ response() {
384
+ return { files: this.files, warnings: this.warnings };
385
+ }
386
+ }
@@ -0,0 +1,178 @@
1
+ // The fetcher, against a recorded registry. NEVER against a live one.
2
+ import { createHash } from "node:crypto";
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { createServer } from "node:http";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ import { afterEach, describe, expect, it } from "vitest";
8
+
9
+ import { runPlugin } from "../plugin-host.mjs";
10
+ import { KEY_ENV, OFFLINE_ENV, SECRET_ENV, TOKEN_ENV, authorization, body, indentJson, run, slugOf } from "./fetch-csr.mjs";
11
+
12
+ const VALUE = "shop.oms.order-value";
13
+ const PARTY = "shop.oms.Party";
14
+ // The value schema references the party one, which is how a registry says a
15
+ // record is shared between subjects.
16
+ const ORDER = '{"type":"record","namespace":"shop.oms","name":"OrderPlaced","fields":[{"name":"order_id","type":"string"},{"name":"buyer","type":"shop.oms.Party"}]}';
17
+ const PARTY_SCHEMA = '{"type":"record","namespace":"shop.oms","name":"Party","fields":[{"name":"id","type":"string"}]}';
18
+
19
+ const cleanups = [];
20
+ afterEach(async () => {
21
+ for (const cleanup of cleanups.splice(0)) await cleanup();
22
+ });
23
+
24
+ async function registry(answers = recorded()) {
25
+ const seen = [];
26
+ const server = createServer((request, response) => {
27
+ seen.push(request.headers.authorization ?? "");
28
+ response.setHeader("Content-Type", "application/json");
29
+ const answer = answers[decodeURIComponent(request.url)];
30
+ if (!answer) {
31
+ response.statusCode = 404;
32
+ response.end(JSON.stringify({ error_code: 40401, message: `Subject '${request.url}' not found.` }));
33
+ return;
34
+ }
35
+ if (typeof answer === "function") return answer(response);
36
+ response.end(JSON.stringify(answer));
37
+ });
38
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
39
+ cleanups.push(() => new Promise((resolve) => server.close(resolve)));
40
+ return { url: `http://127.0.0.1:${server.address().port}`, seen };
41
+ }
42
+
43
+ function recorded() {
44
+ const order = { subject: VALUE, version: 3, id: 100021, guid: "8f0d", schemaType: "AVRO", schema: ORDER, references: [{ name: "shop.oms.Party", subject: PARTY, version: 1 }] };
45
+ return {
46
+ "/subjects/shop.oms.order-value/versions/3": order,
47
+ "/subjects/shop.oms.order-value/versions/latest": order,
48
+ "/subjects/shop.oms.Party/versions/1": { subject: PARTY, version: 1, id: 100014, schemaType: "AVRO", schema: PARTY_SCHEMA },
49
+ };
50
+ }
51
+
52
+ function cache() {
53
+ const dir = mkdtempSync(join(tmpdir(), "portolan-fetch-csr-"));
54
+ cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
55
+ return dir;
56
+ }
57
+
58
+ const isolated = { [OFFLINE_ENV]: "", CI: "", [TOKEN_ENV]: "", [KEY_ENV]: "", [SECRET_ENV]: "", CSR_API_KEY: "", CSR_API_SECRET: "" };
59
+ const options = (base, cacheDir, ...subjects) => ({ registry: base, cache: cacheDir, subjects });
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-csr", () => {
71
+ it("writes the schema, its lock, and follows a reference the schema pinned", async () => {
72
+ const server = await registry();
73
+ const response = await fetch(options(server.url, cache(), { subject: VALUE, version: 3 }));
74
+ expect(names(response)).toEqual(["shop.oms.Party/csr.lock.json", "shop.oms.Party/v1.avsc", "shop.oms.order-value/csr.lock.json", "shop.oms.order-value/v3.avsc"]);
75
+ expect(contentsOf(response, "shop.oms.Party/v1.avsc")).toContain('"name": "Party"');
76
+
77
+ // Indented rather than the one line the registry answers with, without
78
+ // reordering a key.
79
+ const schema = contentsOf(response, "shop.oms.order-value/v3.avsc");
80
+ expect(schema).toContain('\n "type": "record"');
81
+ expect(schema.endsWith("\n")).toBe(true);
82
+ expect(schema.indexOf('"namespace"')).toBeLessThan(schema.indexOf('"name"'));
83
+ expect(JSON.parse(schema)).toEqual(JSON.parse(ORDER));
84
+
85
+ const lock = JSON.parse(contentsOf(response, "shop.oms.order-value/csr.lock.json"));
86
+ expect(lock).toEqual({
87
+ registry: server.url,
88
+ subjects: [{
89
+ subject: VALUE, version: 3, id: 100021, guid: "8f0d", schemaType: "AVRO",
90
+ references: [{ name: "shop.oms.Party", subject: PARTY, version: 1 }],
91
+ files: [{ path: "v3.avsc", sha256: createHash("sha256").update(schema).digest("hex"), size: schema.length }],
92
+ }],
93
+ });
94
+ expect(JSON.parse(contentsOf(response, "shop.oms.Party/csr.lock.json")).subjects[0]).not.toHaveProperty("guid");
95
+ expect(response.warnings).toEqual([]);
96
+ });
97
+
98
+ it("reads an absent schema type as AVRO and resolves latest with a warning", async () => {
99
+ const answers = recorded();
100
+ delete answers["/subjects/shop.oms.Party/versions/1"].schemaType;
101
+ const server = await registry(answers);
102
+ const response = await fetch(options(server.url, cache(), { subject: VALUE }));
103
+ expect(JSON.parse(contentsOf(response, "shop.oms.Party/csr.lock.json")).subjects[0].schemaType).toBe("AVRO");
104
+ expect(response.warnings).toHaveLength(1);
105
+ expect(response.warnings[0].message).toContain('"latest" resolved to version 3');
106
+
107
+ await expect(fetch(options("http://127.0.0.1:1", cache(), { subject: VALUE }), { [OFFLINE_ENV]: "1" })).rejects.toThrow(/not pinned/);
108
+ });
109
+
110
+ it("replays the committed copies offline, references included, byte for byte", async () => {
111
+ const server = await registry();
112
+ const cacheDir = cache();
113
+ const online = await fetch(options(server.url, cacheDir, { subject: VALUE, version: 3 }));
114
+ write(cacheDir, online);
115
+ const replayed = await fetch(options(server.url, cacheDir, { subject: VALUE, version: 3 }), { [OFFLINE_ENV]: "1" });
116
+ expect(names(replayed)).toEqual(names(online));
117
+ for (const file of online.files) expect(contentsOf(replayed, file.name)).toBe(file.contents);
118
+ expect(replayed.warnings.map((warning) => warning.ref)).toEqual([VALUE, PARTY]);
119
+ });
120
+
121
+ it("falls back to the tree when the registry fails, and is an error without one", async () => {
122
+ const server = await registry();
123
+ const cacheDir = cache();
124
+ write(cacheDir, await fetch(options(server.url, cacheDir, { subject: VALUE, version: 3 })));
125
+ const broken = await registry({ "/subjects/shop.oms.order-value/versions/3": (response) => { response.statusCode = 502; response.end(); } });
126
+ // The lock names the registry it came from; a copy from another one is
127
+ // not this one's to replay.
128
+ await expect(fetch(options(broken.url, cacheDir, { subject: VALUE, version: 3 }))).rejects.toThrow(/was fetched from .* but the manifest names/);
129
+
130
+ await expect(fetch(options("http://127.0.0.1:1", cache(), { subject: VALUE, version: 3 }))).rejects.toThrow(/no usable copy/);
131
+ const missing = await registry({});
132
+ await expect(fetch(options(missing.url, cache(), { subject: VALUE, version: 3 }))).rejects.toThrow(/Subject .* not found\. \(error_code 40401\)/);
133
+ });
134
+
135
+ it("reports an edited copy, a moved pin, and one subject at two versions", async () => {
136
+ const server = await registry();
137
+ const cacheDir = cache();
138
+ write(cacheDir, await fetch(options(server.url, cacheDir, { subject: VALUE, version: 3 })));
139
+ await expect(fetch(options(server.url, cacheDir, { subject: VALUE, version: 2 }), { [OFFLINE_ENV]: "1" })).rejects.toThrow(/holds version 3 but the manifest pins 2/);
140
+ writeFileSync(join(cacheDir, "shop.oms.order-value/v3.avsc"), "{}\n");
141
+ await expect(fetch(options(server.url, cacheDir, { subject: VALUE, version: 3 }), { [OFFLINE_ENV]: "1" })).rejects.toThrow(/v3\.avsc does not match its digest/);
142
+ await expect(fetch(options(server.url, cache(), { subject: VALUE, version: 3 }, { subject: VALUE, version: 2 }))).rejects.toThrow(/one version per subject/);
143
+ });
144
+
145
+ it("sends the credential from the environment and gives a bare token a scheme", async () => {
146
+ const server = await registry();
147
+ await fetch(options(server.url, cache(), { subject: PARTY, version: 1 }));
148
+ await fetch(options(server.url, cache(), { subject: PARTY, version: 1 }), { [KEY_ENV]: "key", [SECRET_ENV]: "secret" });
149
+ expect(server.seen).toEqual(["", `Basic ${Buffer.from("key:secret").toString("base64")}`]);
150
+ expect(authorization({ [TOKEN_ENV]: "abc" })).toBe("Bearer abc");
151
+ expect(authorization({ [TOKEN_ENV]: "Token abc" })).toBe("Token abc");
152
+ expect(authorization({ CSR_API_KEY: "k", CSR_API_SECRET: "s" })).toBe(`Basic ${Buffer.from("k:s").toString("base64")}`);
153
+ expect(authorization({ CSR_API_KEY: "k" })).toBe("");
154
+ });
155
+
156
+ it("runs through the host like any plugin", async () => {
157
+ const server = await registry();
158
+ const result = await runPlugin({ name: "csr", host: "fetch-csr" }, { portolanVersion: "0.1.0", options: options(server.url, cache(), { subject: PARTY, version: 1 }) });
159
+ expect(result.files.map((file) => file.name)).toEqual(["shop.oms.Party/v1.avsc", "shop.oms.Party/csr.lock.json"]);
160
+ const described = await runPlugin({ name: "csr", host: "fetch-csr" }, { portolanVersion: "0.1.0", kind: "describe" });
161
+ expect(described.describe).toMatchObject({ name: "fetch-csr", phases: ["extract"] });
162
+ });
163
+ });
164
+
165
+ describe("shapes", () => {
166
+ it("keeps a subject readable as a directory", () => {
167
+ expect(slugOf("shop.oms.order-value")).toBe("shop.oms.order-value");
168
+ expect(slugOf("shop.oms.OrderPlaced")).toBe("shop.oms.OrderPlaced");
169
+ expect(slugOf("a/b c")).toBe("a_b_c");
170
+ expect(slugOf("...")).toBe("_");
171
+ });
172
+
173
+ it("indents JSON without touching a token, and leaves protobuf alone", () => {
174
+ expect(indentJson('{"a":[1,2.50,"x\\"y",{"b":null}],"c":{},"d":[],"e":true}')).toBe('{\n "a": [\n 1,\n 2.50,\n "x\\"y",\n {\n "b": null\n }\n ],\n "c": {},\n "d": [],\n "e": true\n}');
175
+ expect(body("PROTOBUF", 'syntax = "proto3";')).toBe('syntax = "proto3";\n');
176
+ expect(body("JSON", "not json")).toBe("not json\n");
177
+ });
178
+ });