@supatype/cli 0.1.0-alpha.6 → 0.1.0-alpha.7

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 (309) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +203 -1
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/app-config.d.ts +7 -0
  5. package/dist/app-config.d.ts.map +1 -0
  6. package/dist/app-config.js +113 -0
  7. package/dist/app-config.js.map +1 -0
  8. package/dist/augmentation-generator.d.ts +2 -0
  9. package/dist/augmentation-generator.d.ts.map +1 -0
  10. package/dist/augmentation-generator.js +111 -0
  11. package/dist/augmentation-generator.js.map +1 -0
  12. package/dist/binary-cache.d.ts +89 -0
  13. package/dist/binary-cache.d.ts.map +1 -0
  14. package/dist/binary-cache.js +656 -0
  15. package/dist/binary-cache.js.map +1 -0
  16. package/dist/cli.d.ts.map +1 -1
  17. package/dist/cli.js +13 -7
  18. package/dist/cli.js.map +1 -1
  19. package/dist/commands/admin.d.ts.map +1 -1
  20. package/dist/commands/admin.js +4 -3
  21. package/dist/commands/admin.js.map +1 -1
  22. package/dist/commands/app.d.ts.map +1 -1
  23. package/dist/commands/app.js +56 -209
  24. package/dist/commands/app.js.map +1 -1
  25. package/dist/commands/cache.d.ts +6 -0
  26. package/dist/commands/cache.d.ts.map +1 -0
  27. package/dist/commands/cache.js +105 -0
  28. package/dist/commands/cache.js.map +1 -0
  29. package/dist/commands/cloud.d.ts +12 -0
  30. package/dist/commands/cloud.d.ts.map +1 -1
  31. package/dist/commands/cloud.js +36 -46
  32. package/dist/commands/cloud.js.map +1 -1
  33. package/dist/commands/db.d.ts.map +1 -1
  34. package/dist/commands/db.js +47 -54
  35. package/dist/commands/db.js.map +1 -1
  36. package/dist/commands/deploy.d.ts +2 -1
  37. package/dist/commands/deploy.d.ts.map +1 -1
  38. package/dist/commands/deploy.js +92 -51
  39. package/dist/commands/deploy.js.map +1 -1
  40. package/dist/commands/dev.d.ts +11 -0
  41. package/dist/commands/dev.d.ts.map +1 -1
  42. package/dist/commands/dev.js +751 -384
  43. package/dist/commands/dev.js.map +1 -1
  44. package/dist/commands/diff.d.ts.map +1 -1
  45. package/dist/commands/diff.js +20 -15
  46. package/dist/commands/diff.js.map +1 -1
  47. package/dist/commands/engine.d.ts +1 -3
  48. package/dist/commands/engine.d.ts.map +1 -1
  49. package/dist/commands/engine.js +13 -85
  50. package/dist/commands/engine.js.map +1 -1
  51. package/dist/commands/functions.d.ts.map +1 -1
  52. package/dist/commands/functions.js +92 -105
  53. package/dist/commands/functions.js.map +1 -1
  54. package/dist/commands/generate.d.ts.map +1 -1
  55. package/dist/commands/generate.js +22 -12
  56. package/dist/commands/generate.js.map +1 -1
  57. package/dist/commands/init.d.ts +1 -1
  58. package/dist/commands/init.d.ts.map +1 -1
  59. package/dist/commands/init.js +124 -410
  60. package/dist/commands/init.js.map +1 -1
  61. package/dist/commands/migrate-from-v1.d.ts +5 -0
  62. package/dist/commands/migrate-from-v1.d.ts.map +1 -0
  63. package/dist/commands/migrate-from-v1.js +125 -0
  64. package/dist/commands/migrate-from-v1.js.map +1 -0
  65. package/dist/commands/migrate.d.ts.map +1 -1
  66. package/dist/commands/migrate.js +27 -23
  67. package/dist/commands/migrate.js.map +1 -1
  68. package/dist/commands/pg.d.ts +8 -0
  69. package/dist/commands/pg.d.ts.map +1 -0
  70. package/dist/commands/pg.js +102 -0
  71. package/dist/commands/pg.js.map +1 -0
  72. package/dist/commands/pull.d.ts.map +1 -1
  73. package/dist/commands/pull.js +5 -66
  74. package/dist/commands/pull.js.map +1 -1
  75. package/dist/commands/push.d.ts.map +1 -1
  76. package/dist/commands/push.js +99 -39
  77. package/dist/commands/push.js.map +1 -1
  78. package/dist/commands/seed.d.ts +2 -0
  79. package/dist/commands/seed.d.ts.map +1 -1
  80. package/dist/commands/seed.js +44 -11
  81. package/dist/commands/seed.js.map +1 -1
  82. package/dist/commands/self-host.d.ts +7 -1
  83. package/dist/commands/self-host.d.ts.map +1 -1
  84. package/dist/commands/self-host.js +272 -758
  85. package/dist/commands/self-host.js.map +1 -1
  86. package/dist/commands/self-update.d.ts +9 -0
  87. package/dist/commands/self-update.d.ts.map +1 -0
  88. package/dist/commands/self-update.js +33 -0
  89. package/dist/commands/self-update.js.map +1 -0
  90. package/dist/commands/status.d.ts.map +1 -1
  91. package/dist/commands/status.js +4 -3
  92. package/dist/commands/status.js.map +1 -1
  93. package/dist/commands/types.d.ts +3 -0
  94. package/dist/commands/types.d.ts.map +1 -0
  95. package/dist/commands/types.js +62 -0
  96. package/dist/commands/types.js.map +1 -0
  97. package/dist/commands/update.d.ts +7 -0
  98. package/dist/commands/update.d.ts.map +1 -0
  99. package/dist/commands/update.js +77 -0
  100. package/dist/commands/update.js.map +1 -0
  101. package/dist/components.d.ts +5 -0
  102. package/dist/components.d.ts.map +1 -0
  103. package/dist/components.js +3 -0
  104. package/dist/components.js.map +1 -0
  105. package/dist/config.d.ts +10 -51
  106. package/dist/config.d.ts.map +1 -1
  107. package/dist/config.js +101 -33
  108. package/dist/config.js.map +1 -1
  109. package/dist/docker-postgres.d.ts +39 -0
  110. package/dist/docker-postgres.d.ts.map +1 -0
  111. package/dist/docker-postgres.js +96 -0
  112. package/dist/docker-postgres.js.map +1 -0
  113. package/dist/engine-client.d.ts +67 -0
  114. package/dist/engine-client.d.ts.map +1 -0
  115. package/dist/engine-client.js +156 -0
  116. package/dist/engine-client.js.map +1 -0
  117. package/dist/ensure-binary.d.ts +7 -0
  118. package/dist/ensure-binary.d.ts.map +1 -0
  119. package/dist/ensure-binary.js +17 -0
  120. package/dist/ensure-binary.js.map +1 -0
  121. package/dist/functions-router-gen.d.ts +14 -0
  122. package/dist/functions-router-gen.d.ts.map +1 -0
  123. package/dist/functions-router-gen.js +199 -0
  124. package/dist/functions-router-gen.js.map +1 -0
  125. package/dist/index.d.ts +4 -5
  126. package/dist/index.d.ts.map +1 -1
  127. package/dist/index.js +2 -3
  128. package/dist/index.js.map +1 -1
  129. package/dist/kong-config.d.ts +21 -0
  130. package/dist/kong-config.d.ts.map +1 -0
  131. package/dist/kong-config.js +60 -0
  132. package/dist/kong-config.js.map +1 -0
  133. package/dist/local-gateway.d.ts +7 -0
  134. package/dist/local-gateway.d.ts.map +1 -0
  135. package/dist/local-gateway.js +9 -0
  136. package/dist/local-gateway.js.map +1 -0
  137. package/dist/local-storage.d.ts +8 -0
  138. package/dist/local-storage.d.ts.map +1 -0
  139. package/dist/local-storage.js +14 -0
  140. package/dist/local-storage.js.map +1 -0
  141. package/dist/pgbouncer-userlist.d.ts +5 -0
  142. package/dist/pgbouncer-userlist.d.ts.map +1 -0
  143. package/dist/pgbouncer-userlist.js +14 -0
  144. package/dist/pgbouncer-userlist.js.map +1 -0
  145. package/dist/postgres-ctl.d.ts +44 -0
  146. package/dist/postgres-ctl.d.ts.map +1 -0
  147. package/dist/postgres-ctl.js +137 -0
  148. package/dist/postgres-ctl.js.map +1 -0
  149. package/dist/process-manager.d.ts +41 -0
  150. package/dist/process-manager.d.ts.map +1 -0
  151. package/dist/process-manager.js +120 -0
  152. package/dist/process-manager.js.map +1 -0
  153. package/dist/project-config.d.ts +215 -0
  154. package/dist/project-config.d.ts.map +1 -0
  155. package/dist/project-config.js +145 -0
  156. package/dist/project-config.js.map +1 -0
  157. package/dist/pull-utils.d.ts +15 -0
  158. package/dist/pull-utils.d.ts.map +1 -1
  159. package/dist/pull-utils.js +12 -0
  160. package/dist/pull-utils.js.map +1 -1
  161. package/dist/release-pins.d.ts +7 -0
  162. package/dist/release-pins.d.ts.map +1 -0
  163. package/dist/release-pins.js +27 -0
  164. package/dist/release-pins.js.map +1 -0
  165. package/dist/release-public-key.d.ts +8 -0
  166. package/dist/release-public-key.d.ts.map +1 -0
  167. package/dist/release-public-key.js +13 -0
  168. package/dist/release-public-key.js.map +1 -0
  169. package/dist/runtime-routes.d.ts +25 -0
  170. package/dist/runtime-routes.d.ts.map +1 -0
  171. package/dist/runtime-routes.js +189 -0
  172. package/dist/runtime-routes.js.map +1 -0
  173. package/dist/scripts/postinstall.d.ts +5 -6
  174. package/dist/scripts/postinstall.d.ts.map +1 -1
  175. package/dist/scripts/postinstall.js +36 -20
  176. package/dist/scripts/postinstall.js.map +1 -1
  177. package/dist/self-host-compose.d.ts +14 -0
  178. package/dist/self-host-compose.d.ts.map +1 -0
  179. package/dist/self-host-compose.js +236 -0
  180. package/dist/self-host-compose.js.map +1 -0
  181. package/dist/storage-provision.d.ts +24 -0
  182. package/dist/storage-provision.d.ts.map +1 -0
  183. package/dist/storage-provision.js +44 -0
  184. package/dist/storage-provision.js.map +1 -0
  185. package/dist/systemd.d.ts +26 -0
  186. package/dist/systemd.d.ts.map +1 -0
  187. package/dist/systemd.js +102 -0
  188. package/dist/systemd.js.map +1 -0
  189. package/dist/tsx-runner.d.ts.map +1 -1
  190. package/dist/tsx-runner.js +9 -2
  191. package/dist/tsx-runner.js.map +1 -1
  192. package/dist/type-extractor.d.ts +31 -0
  193. package/dist/type-extractor.d.ts.map +1 -0
  194. package/dist/type-extractor.js +876 -0
  195. package/dist/type-extractor.js.map +1 -0
  196. package/package.json +4 -3
  197. package/releases/deno/VERSION +1 -0
  198. package/scripts/mirror-deno-release.sh +76 -0
  199. package/src/app-config.ts +128 -0
  200. package/src/augmentation-generator.ts +126 -0
  201. package/src/binary-cache.ts +802 -0
  202. package/src/cli.ts +13 -8
  203. package/src/commands/admin.ts +4 -3
  204. package/src/commands/app.ts +67 -231
  205. package/src/commands/cache.ts +117 -0
  206. package/src/commands/cloud.ts +46 -57
  207. package/src/commands/db.ts +54 -63
  208. package/src/commands/deploy.ts +110 -61
  209. package/src/commands/dev.ts +930 -405
  210. package/src/commands/diff.ts +21 -29
  211. package/src/commands/engine.ts +13 -116
  212. package/src/commands/functions.ts +97 -115
  213. package/src/commands/generate.ts +23 -10
  214. package/src/commands/init.ts +136 -414
  215. package/src/commands/migrate-from-v1.ts +131 -0
  216. package/src/commands/migrate.ts +27 -23
  217. package/src/commands/pg.ts +133 -0
  218. package/src/commands/pull.ts +6 -85
  219. package/src/commands/push.ts +128 -59
  220. package/src/commands/seed.ts +54 -12
  221. package/src/commands/self-host.ts +312 -880
  222. package/src/commands/self-update.ts +45 -0
  223. package/src/commands/status.ts +4 -3
  224. package/src/commands/types.ts +76 -0
  225. package/src/commands/update.ts +92 -0
  226. package/src/components.ts +6 -0
  227. package/src/config.ts +127 -94
  228. package/src/docker-postgres.ts +138 -0
  229. package/src/engine-client.ts +231 -0
  230. package/src/ensure-binary.ts +28 -0
  231. package/src/functions-router-gen.ts +224 -0
  232. package/src/index.ts +4 -12
  233. package/src/kong-config.ts +78 -0
  234. package/src/local-gateway.ts +9 -0
  235. package/src/local-storage.ts +14 -0
  236. package/src/pgbouncer-userlist.ts +15 -0
  237. package/src/postgres-ctl.ts +171 -0
  238. package/src/process-manager.ts +151 -0
  239. package/src/project-config.ts +353 -0
  240. package/src/pull-utils.ts +24 -0
  241. package/src/release-pins.ts +31 -0
  242. package/src/release-public-key.ts +12 -0
  243. package/src/runtime-routes.ts +216 -0
  244. package/src/scripts/postinstall.ts +36 -25
  245. package/src/self-host-compose.ts +257 -0
  246. package/src/storage-provision.ts +58 -0
  247. package/src/systemd.ts +137 -0
  248. package/src/tsx-runner.ts +11 -1
  249. package/src/type-extractor.ts +1016 -0
  250. package/tests/app-command.test.ts +54 -0
  251. package/tests/augmentation-generator.test.ts +59 -0
  252. package/tests/binary-cache-cloud-overrides.test.ts +123 -0
  253. package/tests/cached-artifact-format.test.ts +84 -0
  254. package/tests/cli-help.test.ts +40 -14
  255. package/tests/config.test.ts +140 -37
  256. package/tests/engine-distribution.test.ts +3 -3
  257. package/tests/ensure-binary.test.ts +59 -0
  258. package/tests/init.test.ts +28 -86
  259. package/tests/migrate-from-v1.test.ts +29 -0
  260. package/tests/pg-spawn-env.test.ts +18 -0
  261. package/tests/postgres-archive-tag.test.ts +9 -0
  262. package/tests/pull-utils.test.ts +36 -1
  263. package/tests/release-pins.test.ts +28 -0
  264. package/tests/runtime-contract.test.ts +236 -0
  265. package/tests/seed-discover.test.ts +31 -0
  266. package/tests/tsconfig.json +9 -0
  267. package/tests/type-extractor.test.ts +401 -0
  268. package/tsconfig.tsbuildinfo +1 -1
  269. package/vitest.config.ts +12 -0
  270. package/dist/engine/cache.d.ts +0 -37
  271. package/dist/engine/cache.d.ts.map +0 -1
  272. package/dist/engine/cache.js +0 -121
  273. package/dist/engine/cache.js.map +0 -1
  274. package/dist/engine/download.d.ts +0 -19
  275. package/dist/engine/download.d.ts.map +0 -1
  276. package/dist/engine/download.js +0 -108
  277. package/dist/engine/download.js.map +0 -1
  278. package/dist/engine/platform.d.ts +0 -24
  279. package/dist/engine/platform.d.ts.map +0 -1
  280. package/dist/engine/platform.js +0 -50
  281. package/dist/engine/platform.js.map +0 -1
  282. package/dist/engine/resolve.d.ts +0 -37
  283. package/dist/engine/resolve.d.ts.map +0 -1
  284. package/dist/engine/resolve.js +0 -133
  285. package/dist/engine/resolve.js.map +0 -1
  286. package/dist/engine/update-notify.d.ts +0 -11
  287. package/dist/engine/update-notify.d.ts.map +0 -1
  288. package/dist/engine/update-notify.js +0 -43
  289. package/dist/engine/update-notify.js.map +0 -1
  290. package/dist/engine/verify.d.ts +0 -50
  291. package/dist/engine/verify.d.ts.map +0 -1
  292. package/dist/engine/verify.js +0 -161
  293. package/dist/engine/verify.js.map +0 -1
  294. package/dist/engine-version.d.ts +0 -35
  295. package/dist/engine-version.d.ts.map +0 -1
  296. package/dist/engine-version.js +0 -35
  297. package/dist/engine-version.js.map +0 -1
  298. package/dist/engine.d.ts +0 -34
  299. package/dist/engine.d.ts.map +0 -1
  300. package/dist/engine.js +0 -76
  301. package/dist/engine.js.map +0 -1
  302. package/src/engine/cache.ts +0 -135
  303. package/src/engine/download.ts +0 -143
  304. package/src/engine/platform.ts +0 -66
  305. package/src/engine/resolve.ts +0 -197
  306. package/src/engine/update-notify.ts +0 -50
  307. package/src/engine/verify.ts +0 -206
  308. package/src/engine-version.ts +0 -39
  309. package/src/engine.ts +0 -99
@@ -0,0 +1,802 @@
1
+ /**
2
+ * Binary cache — manages supatype component binaries.
3
+ *
4
+ * Components: engine, server, postgres, deno.
5
+ * Cache root: ~/.supatype/cache/{component}/{version}/
6
+ * Override path: config.overrides?.{component} (local build path).
7
+ *
8
+ * Security model:
9
+ * 1. Download checksums.sha256 + checksums.sha256.minisig from CDN.
10
+ * 2. Verify Ed25519 minisign signature on the checksum file using the
11
+ * embedded public key (SUPATYPE_RELEASE_PUBLIC_KEY).
12
+ * 3. Verify SHA256 of the downloaded binary against the signed checksum.
13
+ * Both checks are mandatory when SUPATYPE_RELEASE_PUBLIC_KEY is set.
14
+ */
15
+
16
+ import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto"
17
+ import {
18
+ closeSync,
19
+ copyFileSync,
20
+ createWriteStream,
21
+ existsSync,
22
+ mkdirSync,
23
+ openSync,
24
+ readFileSync,
25
+ readSync,
26
+ statSync,
27
+ unlinkSync,
28
+ writeFileSync,
29
+ } from "node:fs"
30
+ import { chmod } from "node:fs/promises"
31
+ import { homedir } from "node:os"
32
+ import { basename, join, resolve, isAbsolute } from "node:path"
33
+ import type { SupatypeProjectConfig } from "./project-config.js"
34
+ import { releasePublicKey } from "./release-public-key.js"
35
+
36
+ /**
37
+ * Set `versions.{engine|server|postgres|deno}: VERSION_PIN_LOCAL` to mean “use `overrides.*` only”
38
+ * without duplicating the path string (Phase 10.7). Requires the matching `overrides` entry.
39
+ */
40
+ export const VERSION_PIN_LOCAL = "local"
41
+
42
+ /** True if `overrides` contains any non-empty string path (contributor local builds). */
43
+ export function hasMeaningfulOverrides(config: SupatypeProjectConfig): boolean {
44
+ const o = config.overrides
45
+ if (!o) return false
46
+ for (const v of Object.values(o)) {
47
+ if (typeof v === "string" && v.trim() !== "") return true
48
+ }
49
+ return false
50
+ }
51
+
52
+ /** Lines for a startup banner — non-empty override paths only. */
53
+ export function describeActiveOverrides(config: SupatypeProjectConfig): string[] {
54
+ const o = config.overrides
55
+ if (!o) return []
56
+ const lines: string[] = []
57
+ const add = (label: string, v: string | undefined) => {
58
+ if (typeof v === "string" && v.trim() !== "") {
59
+ lines.push(` ${label.padEnd(12)} → ${v.trim()}`)
60
+ }
61
+ }
62
+ add("engine", o.engine)
63
+ add("server", o.server)
64
+ add("postgres_dir", o.postgres_dir)
65
+ add("deno", o.deno)
66
+ add("studio", o.studio)
67
+ add("postgrest", o.postgrest)
68
+ return lines
69
+ }
70
+
71
+ /**
72
+ * True when this working tree is associated with a remote Supatype Cloud project:
73
+ * `project.ref`, `.supatype/cloud.json` (schema deploy link), or `.supatype/linked.json` (functions link).
74
+ */
75
+ export function isLinkedToCloudProject(cwd: string, config: SupatypeProjectConfig): boolean {
76
+ const ref = config.project.ref
77
+ if (typeof ref === "string" && ref.trim() !== "") return true
78
+
79
+ const linkedPath = join(cwd, ".supatype", "linked.json")
80
+ if (existsSync(linkedPath)) {
81
+ try {
82
+ const data = JSON.parse(readFileSync(linkedPath, "utf8")) as Record<string, unknown>
83
+ if (typeof data["ref"] === "string" && (data["ref"] as string).trim() !== "") return true
84
+ } catch { /* ignore */ }
85
+ }
86
+
87
+ const cloudPath = join(cwd, ".supatype", "cloud.json")
88
+ if (existsSync(cloudPath)) {
89
+ try {
90
+ const data = JSON.parse(readFileSync(cloudPath, "utf8")) as { projectSlug?: string }
91
+ if (typeof data.projectSlug === "string" && data.projectSlug.trim() !== "") return true
92
+ } catch { /* ignore */ }
93
+ }
94
+
95
+ return false
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Types
100
+ // ---------------------------------------------------------------------------
101
+
102
+ export type { Component, ComponentVersions } from "./components.js"
103
+ export { BINARY_COMPONENTS } from "./components.js"
104
+ import { BINARY_COMPONENTS, type Component } from "./components.js"
105
+
106
+ export interface PlatformId {
107
+ os: "linux" | "darwin" | "windows"
108
+ arch: "amd64" | "arm64"
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // CDN base URL + release signing public key
113
+ // ---------------------------------------------------------------------------
114
+
115
+ const CDN_BASE = "https://releases.supatype.com"
116
+
117
+ /** Postgres CDN archives use PG major in the basename (17.2 → `supatype-pg-17-…`). */
118
+ export function postgresArchiveTag(version: string): string {
119
+ return version.split(".")[0]!
120
+ }
121
+
122
+ /**
123
+ * Supatype release signing public key (minisign format).
124
+ * Generated with: minisign -G
125
+ * Rotate by: generating a new pair, updating this constant, and updating
126
+ * the MINISIGN_PRIVATE_KEY GitHub Actions secret.
127
+ *
128
+ * ⚠ PLACEHOLDER — replace with actual public key before first release.
129
+ * When empty, minisign verification is skipped with a warning (SHA256 only).
130
+ */
131
+ const SUPATYPE_RELEASE_PUBLIC_KEY = ""
132
+
133
+ // CDN path templates per component.
134
+ const CDN_PATHS: Record<Component, (version: string, platform: PlatformId) => string> = {
135
+ engine: (v, p) => `/engine/v${v}/supatype-engine-${p.os}-${p.arch}${p.os === "windows" ? ".exe" : ""}`,
136
+ server: (v, p) => `/server/v${v}/supatype-server-${p.os}-${p.arch}${p.os === "windows" ? ".exe" : ""}`,
137
+ postgres: (v, p) => `/postgres/v${v}/supatype-pg-${postgresArchiveTag(v)}-${p.os}-${p.arch}${p.os === "windows" ? ".zip" : ".tar.gz"}`,
138
+ deno: (v, p) => `/deno/v${v}/deno-${p.os}-${p.arch}${p.os === "windows" ? ".exe" : ""}`,
139
+ }
140
+
141
+ // Checksums file path (one per version directory, covers all platform binaries).
142
+ const checksumsDirPath = (component: Component, version: string): string =>
143
+ `/${component}/v${version}/checksums.sha256`
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Cache paths
147
+ // ---------------------------------------------------------------------------
148
+
149
+ export function cacheRoot(): string {
150
+ return join(homedir(), ".supatype", "cache")
151
+ }
152
+
153
+ export function cachePath(component: Component, version: string): string {
154
+ return join(cacheRoot(), component, version)
155
+ }
156
+
157
+ export function cachedBinaryPath(component: Component, version: string, platform: PlatformId): string {
158
+ return join(cachePath(component, version), binaryName(component, version, platform))
159
+ }
160
+
161
+ function binaryName(component: Component, version: string, platform: PlatformId): string {
162
+ const win = platform.os === "windows"
163
+ switch (component) {
164
+ case "engine": return `supatype-engine-${platform.os}-${platform.arch}${win ? ".exe" : ""}`
165
+ case "server": return `supatype-server-${platform.os}-${platform.arch}${win ? ".exe" : ""}`
166
+ case "postgres": return `supatype-pg-${postgresArchiveTag(version)}-${platform.os}-${platform.arch}${win ? ".zip" : ".tar.gz"}`
167
+ case "deno": return `deno-${platform.os}-${platform.arch}${win ? ".exe" : ""}`
168
+ }
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Platform detection
173
+ // ---------------------------------------------------------------------------
174
+
175
+ export function currentPlatform(): PlatformId {
176
+ let os: PlatformId["os"]
177
+ if (process.platform === "darwin") os = "darwin"
178
+ else if (process.platform === "win32") os = "windows"
179
+ else os = "linux"
180
+
181
+ const rawArch = process.arch
182
+ let arch: PlatformId["arch"]
183
+ if (rawArch === "arm64") arch = "arm64"
184
+ else if (rawArch === "x64") arch = "amd64"
185
+ else throw new Error(`Unsupported architecture: ${rawArch}`)
186
+ return { os, arch }
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Override validation
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /**
194
+ * Resolve the binary path for a component.
195
+ *
196
+ * Resolution order:
197
+ * 1. config.overrides?.[component] — local build path (must exist)
198
+ * 2. Cached binary at ~/.supatype/cache/{component}/{version}/
199
+ * 3. Throws — caller should call download() first.
200
+ *
201
+ * Hard error if any meaningful `overrides` entry is set while the project is linked to cloud
202
+ * (`project.ref`, `.supatype/cloud.json`, or `.supatype/linked.json`).
203
+ */
204
+ export async function resolveBinary(
205
+ component: Component,
206
+ config: SupatypeProjectConfig,
207
+ ): Promise<string> {
208
+ const cwd = process.cwd()
209
+ if (hasMeaningfulOverrides(config) && isLinkedToCloudProject(cwd, config)) {
210
+ throw new Error(
211
+ "[overrides] cannot be used while this project is linked to Supatype Cloud " +
212
+ "(project.ref, .supatype/cloud.json, or .supatype/linked.json).\n" +
213
+ "Remove overrides from supatype.config.ts / supatype.local.config.ts, or remove the cloud link files / clear project.ref.",
214
+ )
215
+ }
216
+
217
+ const overridePath = config.overrides?.[component === "postgres" ? "postgres_dir" : component]
218
+ const version = versionFor(component, config)
219
+
220
+ if (version === VERSION_PIN_LOCAL && !overridePath) {
221
+ const key = component === "postgres" ? "postgres_dir" : component
222
+ throw new Error(
223
+ `[versions] versions.${component} is "${VERSION_PIN_LOCAL}" but overrides.${key} is not set. ` +
224
+ `Set overrides.${key} to your local build path, or pin a semver in versions.${component}.`,
225
+ )
226
+ }
227
+
228
+ if (overridePath) {
229
+ const normalised = normalisePlatformPath(overridePath)
230
+ let resolvedOverride = isAbsolute(normalised)
231
+ ? normalised
232
+ : resolve(process.cwd(), normalised)
233
+
234
+ if (process.platform === "win32" && !/\.\w+$/.test(resolvedOverride) && !existsSync(resolvedOverride)) {
235
+ const withExe = resolvedOverride + ".exe"
236
+ if (existsSync(withExe)) resolvedOverride = withExe
237
+ }
238
+
239
+ // On Windows, CreateProcess automatically appends .exe to extensionless paths.
240
+ // If the override binary exists without .exe, copy it to path.exe so it
241
+ // spawns correctly (and takes precedence over any stale .exe at that path).
242
+ if (process.platform === "win32" && !/\.\w+$/.test(resolvedOverride) && existsSync(resolvedOverride)) {
243
+ const withExe = resolvedOverride + ".exe"
244
+ const srcStat = statSync(resolvedOverride)
245
+ const dstStat = existsSync(withExe) ? statSync(withExe) : null
246
+ if (!dstStat || dstStat.size !== srcStat.size || dstStat.mtimeMs < srcStat.mtimeMs) {
247
+ copyFileSync(resolvedOverride, withExe)
248
+ }
249
+ resolvedOverride = withExe
250
+ }
251
+
252
+ if (!existsSync(resolvedOverride)) {
253
+ throw new Error(`[overrides] ${component} path does not exist: ${resolvedOverride}`)
254
+ }
255
+
256
+ const stat = statSync(resolvedOverride)
257
+ if (!stat.isFile() && !stat.isDirectory()) {
258
+ throw new Error(`[overrides] ${component} path is not a file or directory: ${resolvedOverride}`)
259
+ }
260
+
261
+ return resolvedOverride
262
+ }
263
+
264
+ const platform = currentPlatform()
265
+ const binPath = cachedBinaryPath(component, version, platform)
266
+
267
+ if (existsSync(binPath)) return binPath
268
+
269
+ throw new Error(`${component} v${version} not found in cache. Run: supatype update`)
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Download + verify
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /**
277
+ * Download a component binary to the cache.
278
+ *
279
+ * Verification order:
280
+ * 1. Fetch checksums.sha256 + checksums.sha256.minisig from CDN.
281
+ * 2. If SUPATYPE_RELEASE_PUBLIC_KEY is set: verify minisign signature.
282
+ * 3. Verify SHA256 of downloaded binary against signed checksum.
283
+ */
284
+ export async function download(
285
+ component: Component,
286
+ version: string,
287
+ platform: PlatformId,
288
+ ): Promise<string> {
289
+ if (version === VERSION_PIN_LOCAL) {
290
+ throw new Error(
291
+ `cannot download CDN binary when version is "${VERSION_PIN_LOCAL}" — set overrides.${component === "postgres" ? "postgres_dir" : component} or pin a semver`,
292
+ )
293
+ }
294
+
295
+ const dir = cachePath(component, version)
296
+ mkdirSync(dir, { recursive: true })
297
+
298
+ const name = binaryName(component, version, platform)
299
+ const destPath = join(dir, name)
300
+
301
+ if (existsSync(destPath)) {
302
+ if (cachedArtifactLooksValid(component, destPath)) {
303
+ console.log(`[supatype] ${component} v${version} already cached.`)
304
+ return destPath
305
+ }
306
+ console.warn(
307
+ `[supatype] ${component} v${version} cache invalid — re-downloading (${destPath}).`,
308
+ )
309
+ unlinkSync(destPath)
310
+ }
311
+
312
+ const binaryUrl = `${CDN_BASE}${CDN_PATHS[component](version, platform)}`
313
+ const checksumsUrl = `${CDN_BASE}${checksumsDirPath(component, version)}`
314
+ const minisigUrl = `${checksumsUrl}.minisig`
315
+
316
+ console.log(`[supatype] Downloading ${component} v${version} (${platform.os}/${platform.arch})...`)
317
+
318
+ // ── Fetch checksums + optional minisig ────────────────────────────────────
319
+ const expectedChecksum = await withRetry(() =>
320
+ fetchChecksums(checksumsUrl, minisigUrl, name),
321
+ )
322
+
323
+ // ── Stream-download binary with progress ─────────────────────────────────
324
+ const tmpPath = destPath + ".tmp"
325
+ try {
326
+ await withRetry(() => streamToFileWithProgress(binaryUrl, tmpPath))
327
+
328
+ // ── Verify SHA256 ────────────────────────────────────────────────────────
329
+ await verifyChecksum(tmpPath, expectedChecksum, component)
330
+
331
+ writeFileSync(destPath, readFileSync(tmpPath))
332
+
333
+ assertArtifactFormat(component, destPath, platform)
334
+ if (process.platform !== "win32" && EXECUTABLE_COMPONENTS.has(component)) {
335
+ await chmod(destPath, 0o755)
336
+ }
337
+ } finally {
338
+ if (existsSync(tmpPath)) {
339
+ try { require("node:fs").unlinkSync(tmpPath) } catch { /* ignore */ }
340
+ }
341
+ }
342
+
343
+ return destPath
344
+ }
345
+
346
+ /**
347
+ * Fetch checksums.sha256, optionally verify its minisign signature, and
348
+ * return the expected SHA256 for `binaryFilename`.
349
+ */
350
+ async function fetchChecksums(
351
+ checksumsUrl: string,
352
+ minisigUrl: string,
353
+ binaryFilename: string,
354
+ ): Promise<string> {
355
+ const csResp = await fetch(checksumsUrl)
356
+ if (!csResp.ok) {
357
+ throw new Error(`Failed to fetch checksums from ${checksumsUrl}: HTTP ${csResp.status}`)
358
+ }
359
+ const checksumsText = await csResp.text()
360
+
361
+ const pubKey = releasePublicKey()
362
+ if (pubKey) {
363
+ // Minisign signature is required when a public key is embedded.
364
+ const sigResp = await fetch(minisigUrl)
365
+ if (!sigResp.ok) {
366
+ throw new Error(
367
+ `Failed to fetch checksum signature from ${minisigUrl}: HTTP ${sigResp.status}\n` +
368
+ "Cannot verify release integrity. Aborting download.",
369
+ )
370
+ }
371
+ const sigText = await sigResp.text()
372
+ verifyMinisign(Buffer.from(checksumsText, "utf8"), sigText, pubKey)
373
+ } else {
374
+ console.warn(
375
+ "[supatype] \u26a0 Minisign public key not configured — " +
376
+ "skipping signature verification (SHA256 only).",
377
+ )
378
+ }
379
+
380
+ return extractChecksum(checksumsText, binaryFilename)
381
+ }
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // Minisign signature verification (pure Node.js, no external deps)
385
+ // ---------------------------------------------------------------------------
386
+
387
+ /**
388
+ * Ed25519 SPKI DER prefix — wraps a raw 32-byte public key into the
389
+ * SubjectPublicKeyInfo structure that Node.js crypto.createPublicKey expects.
390
+ *
391
+ * Breakdown:
392
+ * 30 2a SEQUENCE (42 bytes)
393
+ * 30 05 SEQUENCE (5 bytes)
394
+ * 06 03 OID (3 bytes)
395
+ * 2b 65 70 OID value: 1.3.101.112 (id-Ed25519)
396
+ * 03 21 BIT STRING (33 bytes)
397
+ * 00 0 unused bits
398
+ * <32 bytes Ed25519 public key>
399
+ */
400
+ const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex")
401
+
402
+ /**
403
+ * Verify a minisign signature (Ed25519 legacy mode, algorithm bytes "Ed").
404
+ * Throws if verification fails.
405
+ */
406
+ function verifyMinisign(fileBytes: Buffer, sigFileContent: string, pubKeyStr: string): void {
407
+ // Parse public key: [2 algo][8 keyId][32 ed25519 key]
408
+ const pkLines = pubKeyStr.trim().split("\n")
409
+ const pkBytes = Buffer.from(pkLines[pkLines.length - 1]!.trim(), "base64")
410
+ if (pkBytes.length < 42) throw new Error("Invalid minisign public key")
411
+ const pkKeyId = pkBytes.subarray(2, 10)
412
+ const pkEd25519 = pkBytes.subarray(10, 42)
413
+
414
+ // Parse signature file:
415
+ // line 0: untrusted comment
416
+ // line 1: base64 sig bytes — [2 algo][8 keyId][64 Ed25519 sig]
417
+ // line 2: trusted comment
418
+ // line 3: base64 global sig (over sig bytes + trusted comment)
419
+ const sigLines = sigFileContent.trim().split("\n")
420
+ if (sigLines.length < 4) throw new Error("Malformed minisign signature file")
421
+ const sigBytes = Buffer.from(sigLines[1]!.trim(), "base64")
422
+ if (sigBytes.length < 74) throw new Error("Invalid minisign signature length")
423
+
424
+ const algo = sigBytes.subarray(0, 2)
425
+ const sigKeyId = sigBytes.subarray(2, 10)
426
+ const signature = sigBytes.subarray(10, 74)
427
+
428
+ // Only Ed25519 legacy mode ("Ed" = 0x45, 0x64) is supported.
429
+ // Hashed mode ("ED") requires BLAKE2b prehashing — not implemented.
430
+ if (algo[0] !== 0x45 || algo[1] !== 0x64) {
431
+ throw new Error(
432
+ "Unsupported minisign algorithm — only Ed25519 legacy mode supported.\n" +
433
+ `Got: 0x${algo[0]?.toString(16)}${algo[1]?.toString(16)}`,
434
+ )
435
+ }
436
+
437
+ if (!sigKeyId.equals(pkKeyId)) {
438
+ throw new Error(
439
+ "Minisign key ID mismatch — signature was produced with a different key.\n" +
440
+ "This could indicate a compromised release. Do not proceed.",
441
+ )
442
+ }
443
+
444
+ const spkiDer = Buffer.concat([ED25519_SPKI_PREFIX, pkEd25519])
445
+ const keyObject = createPublicKey({ key: spkiDer, format: "der", type: "spki" })
446
+
447
+ const valid = cryptoVerify(null, fileBytes, keyObject, signature)
448
+ if (!valid) {
449
+ throw new Error(
450
+ "Minisign signature verification FAILED — the checksum file may have been tampered with.\n" +
451
+ "This could indicate a supply chain attack. Aborting download.",
452
+ )
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Extract the SHA256 hash for `filename` from a checksums.sha256 file.
458
+ * Format: `<hash> <filename>` (sha256sum output, two spaces).
459
+ */
460
+ function extractChecksum(checksumsText: string, filename: string): string {
461
+ const target = basename(filename)
462
+ for (const line of checksumsText.split("\n")) {
463
+ const parts = line.trim().split(/\s+/)
464
+ if (parts.length >= 2 && parts[1] === target) {
465
+ return parts[0]!
466
+ }
467
+ }
468
+ throw new Error(
469
+ `Checksum not found for "${target}" in checksums.sha256.\n` +
470
+ "The checksums file may be from a different release.",
471
+ )
472
+ }
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // Streaming download with progress bar
476
+ // ---------------------------------------------------------------------------
477
+
478
+ async function streamToFileWithProgress(url: string, destPath: string): Promise<void> {
479
+ const resp = await fetch(url)
480
+ if (!resp.ok) throw new Error(`Failed to download from ${url}: HTTP ${resp.status}`)
481
+ if (!resp.body) throw new Error("Response body is null")
482
+
483
+ const totalStr = resp.headers.get("content-length")
484
+ const total = totalStr ? parseInt(totalStr, 10) : null
485
+ let downloaded = 0
486
+
487
+ const file = createWriteStream(destPath)
488
+ const reader = resp.body.getReader()
489
+
490
+ try {
491
+ while (true) {
492
+ const { done, value } = await reader.read()
493
+ if (done) break
494
+
495
+ await new Promise<void>((res, rej) => {
496
+ file.write(value, (err) => (err ? rej(err) : res()))
497
+ })
498
+ downloaded += value.length
499
+
500
+ if (total && process.stdout.isTTY) {
501
+ const pct = Math.min(100, Math.floor((downloaded / total) * 100))
502
+ const filled = Math.floor(pct / 5)
503
+ const bar = "=".repeat(filled).padEnd(20)
504
+ process.stdout.write(
505
+ `\r [${bar}] ${pct}% ${(downloaded / 1_000_000).toFixed(1)} / ${(total / 1_000_000).toFixed(1)} MB`,
506
+ )
507
+ }
508
+ }
509
+
510
+ if (total && process.stdout.isTTY) process.stdout.write("\n")
511
+
512
+ await new Promise<void>((res, rej) => {
513
+ file.end((err?: Error | null) => (err ? rej(err) : res()))
514
+ })
515
+ } catch (err) {
516
+ file.destroy()
517
+ throw err
518
+ }
519
+ }
520
+
521
+ // ---------------------------------------------------------------------------
522
+ // SHA256 verification
523
+ // ---------------------------------------------------------------------------
524
+
525
+ const EXECUTABLE_COMPONENTS = new Set<Component>(["engine", "server", "deno"])
526
+
527
+ /** True when a cached file matches expected format for the current platform. */
528
+ function cachedArtifactLooksValid(component: Component, filePath: string): boolean {
529
+ try {
530
+ const st = statSync(filePath)
531
+ if (!st.isFile() || st.size < 64) return false
532
+ assertArtifactFormat(component, filePath, currentPlatform())
533
+ return true
534
+ } catch {
535
+ return false
536
+ }
537
+ }
538
+
539
+ /** Confirm a downloaded/cached artifact matches the expected CDN format (tests, CI). */
540
+ export function validateArtifactFormat(
541
+ component: Component,
542
+ filePath: string,
543
+ platform: PlatformId,
544
+ ): void {
545
+ assertArtifactFormat(component, filePath, platform)
546
+ }
547
+
548
+ /**
549
+ * Per-component CDN artifact shapes:
550
+ * engine, server, deno — native executable (ELF / Mach-O / PE)
551
+ * postgres (unix) — .tar.gz (gzip)
552
+ * postgres (windows) — .zip
553
+ */
554
+ function assertArtifactFormat(
555
+ component: Component,
556
+ filePath: string,
557
+ platform: PlatformId,
558
+ ): void {
559
+ if (component === "postgres") {
560
+ if (platform.os === "windows") assertZipArchive(filePath)
561
+ else assertGzipArchive(filePath)
562
+ return
563
+ }
564
+ if (EXECUTABLE_COMPONENTS.has(component)) {
565
+ assertNativeExecutable(filePath, component, platform)
566
+ return
567
+ }
568
+ }
569
+
570
+ /** Reject HTML/error pages or corrupt postgres .tar.gz on CDN. */
571
+ function assertGzipArchive(filePath: string): void {
572
+ const fd = openSync(filePath, "r")
573
+ try {
574
+ const magic = Buffer.alloc(2)
575
+ readSync(fd, magic, 0, 2, 0)
576
+ if (magic[0] !== 0x1f || magic[1] !== 0x8b) {
577
+ throw new Error(
578
+ "Downloaded postgres file is not a gzip archive (bad magic bytes). " +
579
+ "The CDN object may be corrupt or cached HTML — delete ~/.supatype/cache and retry.",
580
+ )
581
+ }
582
+ } finally {
583
+ closeSync(fd)
584
+ }
585
+ }
586
+
587
+ /** Reject corrupt postgres .zip on CDN (Windows bundles). */
588
+ function assertZipArchive(filePath: string): void {
589
+ const fd = openSync(filePath, "r")
590
+ try {
591
+ const magic = Buffer.alloc(4)
592
+ readSync(fd, magic, 0, 4, 0)
593
+ if (magic[0] !== 0x50 || magic[1] !== 0x4b) {
594
+ throw new Error(
595
+ "Downloaded postgres file is not a zip archive (bad magic bytes). " +
596
+ "The CDN object may be corrupt or cached HTML — delete ~/.supatype/cache and retry.",
597
+ )
598
+ }
599
+ } finally {
600
+ closeSync(fd)
601
+ }
602
+ }
603
+
604
+ /** Reject HTML/error pages, Go c-archives, or wrong-OS executables on CDN. */
605
+ function assertNativeExecutable(
606
+ filePath: string,
607
+ component: Component,
608
+ platform: PlatformId,
609
+ ): void {
610
+ const fd = openSync(filePath, "r")
611
+ try {
612
+ const magic = Buffer.alloc(4)
613
+ readSync(fd, magic, 0, 4, 0)
614
+ const goCArchive =
615
+ magic[0] === 0x21 && magic[1] === 0x3c && magic[2] === 0x61 && magic[3] === 0x72
616
+ if (goCArchive) {
617
+ throw new Error(
618
+ `Downloaded ${component} file is a Go static archive (c-archive), not an executable. ` +
619
+ "The CDN object may be from a bad release build — delete ~/.supatype/cache and retry.",
620
+ )
621
+ }
622
+ if (platform.os === "windows") {
623
+ if (magic[0] !== 0x4d || magic[1] !== 0x5a) {
624
+ throw new Error(
625
+ `Downloaded ${component} file is not a Windows PE executable (bad magic bytes). ` +
626
+ "The CDN object may be corrupt or cached HTML — delete ~/.supatype/cache and retry.",
627
+ )
628
+ }
629
+ return
630
+ }
631
+ if (platform.os === "linux") {
632
+ const elf =
633
+ magic[0] === 0x7f && magic[1] === 0x45 && magic[2] === 0x4c && magic[3] === 0x46
634
+ if (!elf) {
635
+ throw new Error(
636
+ `Downloaded ${component} file is not an ELF executable (bad magic bytes). ` +
637
+ "The CDN object may be corrupt or cached HTML — delete ~/.supatype/cache and retry.",
638
+ )
639
+ }
640
+ return
641
+ }
642
+ const macho =
643
+ magic.readUInt32BE(0) === 0xfe_ed_fa_ce ||
644
+ magic.readUInt32BE(0) === 0xfe_ed_fa_cf ||
645
+ magic.readUInt32LE(0) === 0xfe_ed_fa_ce ||
646
+ magic.readUInt32LE(0) === 0xfe_ed_fa_cf ||
647
+ magic.readUInt32BE(0) === 0xca_fe_ba_be
648
+ if (!macho) {
649
+ throw new Error(
650
+ `Downloaded ${component} file is not a Mach-O executable (bad magic bytes). ` +
651
+ "The CDN object may be corrupt or cached HTML — delete ~/.supatype/cache and retry.",
652
+ )
653
+ }
654
+ } finally {
655
+ closeSync(fd)
656
+ }
657
+ }
658
+
659
+ async function verifyChecksum(filePath: string, expected: string, component: Component): Promise<void> {
660
+ const data = readFileSync(filePath)
661
+ const actual = createHash("sha256").update(data).digest("hex")
662
+ if (actual !== expected) {
663
+ throw new Error(
664
+ `Checksum mismatch for ${component}.\n` +
665
+ ` Expected: ${expected}\n` +
666
+ ` Got: ${actual}`,
667
+ )
668
+ }
669
+ }
670
+
671
+ // ---------------------------------------------------------------------------
672
+ // Retry with exponential backoff
673
+ // ---------------------------------------------------------------------------
674
+
675
+ async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
676
+ for (let i = 1; i <= attempts; i++) {
677
+ try {
678
+ return await fn()
679
+ } catch (err) {
680
+ if (i === attempts) throw err
681
+ const delay = Math.pow(3, i - 1) * 1_000 // 1 s, 3 s, 9 s
682
+ console.error(
683
+ `[supatype] Download attempt ${i}/${attempts} failed: ${(err as Error).message}. ` +
684
+ `Retrying in ${delay / 1_000}s...`,
685
+ )
686
+ await new Promise((r) => setTimeout(r, delay))
687
+ }
688
+ }
689
+ throw new Error("unreachable")
690
+ }
691
+
692
+ // ---------------------------------------------------------------------------
693
+ // Helpers
694
+ // ---------------------------------------------------------------------------
695
+
696
+ /**
697
+ * On Windows, Git Bash represents paths as /c/Users/... — convert to C:\Users\...
698
+ */
699
+ export function normalisePlatformPath(p: string): string {
700
+ let result = p
701
+ if (process.platform === "win32" && /^\/[a-zA-Z]\//.test(result)) {
702
+ result = result
703
+ .replace(/^\/([a-zA-Z])\//, (_, drive: string) => `${drive.toUpperCase()}:\\`)
704
+ .replace(/\//g, "\\")
705
+ }
706
+ if (process.platform === "win32" && !/\.\w+$/.test(result) && !existsSync(result)) {
707
+ const withExe = result + ".exe"
708
+ if (existsSync(withExe)) return withExe
709
+ }
710
+ return result
711
+ }
712
+
713
+ export function versionFor(component: Component, config: SupatypeProjectConfig): string {
714
+ const version = config.versions[component]
715
+ if (typeof version !== "string" || version.trim() === "") {
716
+ throw new Error(`[supatype] versions.${component} must be set in supatype.config.ts`)
717
+ }
718
+ return version
719
+ }
720
+
721
+ // ---------------------------------------------------------------------------
722
+ // Latest version resolution from CDN
723
+ // ---------------------------------------------------------------------------
724
+
725
+ /**
726
+ * Fetch the latest available version for a component.
727
+ * Each component directory on the CDN exposes `latest.json` → `{"version":"x.y.z"}`.
728
+ */
729
+ export async function fetchLatestVersion(component: Component): Promise<string> {
730
+ const url = `${CDN_BASE}/${component}/latest.json`
731
+ const resp = await fetch(url)
732
+ if (!resp.ok) {
733
+ throw new Error(`Failed to fetch latest version for ${component} from ${url}: HTTP ${resp.status}`)
734
+ }
735
+ const data = await resp.json() as { version?: unknown }
736
+ if (typeof data.version !== "string" || data.version.trim() === "") {
737
+ throw new Error(`Invalid latest.json for ${component}: missing "version" field`)
738
+ }
739
+ return data.version.trim()
740
+ }
741
+
742
+ /** Fetch the latest version for all components concurrently. */
743
+ export async function fetchAllLatestVersions(): Promise<Record<Component, string>> {
744
+ const results = await Promise.all(
745
+ BINARY_COMPONENTS.map(async (c) => [c, await fetchLatestVersion(c)] as const),
746
+ )
747
+ return Object.fromEntries(results) as Record<Component, string>
748
+ }
749
+
750
+ // ---------------------------------------------------------------------------
751
+ // Download all components (used by postinstall + supatype update)
752
+ // ---------------------------------------------------------------------------
753
+
754
+ /**
755
+ * Download all component binaries for the current platform.
756
+ * Skips components that are already cached.
757
+ * Fails gracefully when graceful=true (suitable for postinstall).
758
+ */
759
+ /**
760
+ * Verify all cached binaries for the current platform (used by integration CI).
761
+ * Throws if any cached component is missing or fails format checks.
762
+ */
763
+ export function verifyCachedBinaries(versions: SupatypeProjectConfig["versions"]): void {
764
+ const platform = currentPlatform()
765
+ for (const component of BINARY_COMPONENTS) {
766
+ const version = versions[component]
767
+ if (typeof version !== "string" || version.trim() === "") {
768
+ throw new Error(`[supatype] versions.${component} must be set`)
769
+ }
770
+ const destPath = join(cachePath(component, version), binaryName(component, version, platform))
771
+ if (!cachedArtifactLooksValid(component, destPath)) {
772
+ throw new Error(
773
+ `[supatype] Cached ${component} v${version} is missing or invalid at ${destPath}. ` +
774
+ "Run: supatype update (or delete ~/.supatype/cache and retry).",
775
+ )
776
+ }
777
+ }
778
+ }
779
+
780
+ export async function downloadAll(
781
+ versions: SupatypeProjectConfig["versions"],
782
+ graceful = false,
783
+ ): Promise<void> {
784
+ const platform = currentPlatform()
785
+ const components: Component[] = [...BINARY_COMPONENTS]
786
+ const fakeConfig = { versions } as SupatypeProjectConfig
787
+
788
+ for (const component of components) {
789
+ const version = versionFor(component, fakeConfig)
790
+ if (version === VERSION_PIN_LOCAL) continue
791
+ try {
792
+ await download(component, version, platform)
793
+ } catch (err) {
794
+ const msg = `[supatype] Failed to download ${component}: ${(err as Error).message}`
795
+ if (graceful) {
796
+ console.error(msg)
797
+ } else {
798
+ throw new Error(msg)
799
+ }
800
+ }
801
+ }
802
+ }