@reddb-io/cli 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,432 @@
1
+ /**
2
+ * RedDB JavaScript driver.
3
+ *
4
+ * Public API:
5
+ * import { connect } from '@reddb-io/sdk'
6
+ * const db = await connect('file:///data.rdb')
7
+ * const result = await db.query('SELECT * FROM users LIMIT 10')
8
+ * const inserted = await db.insert('users', { name: 'Alice' })
9
+ * await db.bulkInsert('users', [{ name: 'Bob' }, { name: 'Carol' }])
10
+ * const row = await db.get('users', '42')
11
+ * await db.delete('users', '42')
12
+ * await db.close()
13
+ *
14
+ * Connection URIs:
15
+ * - 'memory://' — ephemeral in-memory database (embedded)
16
+ * - 'file:///absolute/path' — embedded, persisted to disk
17
+ * - 'grpc://host:port' — remote server via gRPC
18
+ *
19
+ * Authentication (only meaningful for `grpc://`; embedded modes ignore
20
+ * auth options because the spawned binary inherits the caller's
21
+ * filesystem privileges):
22
+ *
23
+ * await connect('grpc://host:5051', {
24
+ * auth: { token: 'sk-...' } // raw bearer / api key
25
+ * })
26
+ * await connect('grpc://host:5051', {
27
+ * auth: { apiKey: 'ak-...' } // alias for token
28
+ * })
29
+ * await connect('grpc://host:5051', {
30
+ * auth: { username: 'admin', password: 'x' } // login flow — driver
31
+ * // calls /auth/login,
32
+ * // caches the bearer
33
+ * })
34
+ *
35
+ * Username/password requires the server to expose the `auth.login`
36
+ * JSON-RPC method (proxied through the gRPC bridge).
37
+ */
38
+
39
+ import { spawnRed } from './spawn.js'
40
+ import { resolveSdkBinary } from './binary.js'
41
+ import { RpcClient, RedDBError } from './protocol.js'
42
+ import { HttpRpcClient } from './http.js'
43
+ import { connectRedwire } from './redwire.js'
44
+ import { parseUri, deriveLoginUrl } from './url.js'
45
+ import { CacheClient } from './cache.js'
46
+ import { KvClient } from './kv.js'
47
+ import { ConfigClient } from './config.js'
48
+ import { VaultClient } from './vault.js'
49
+
50
+ export { RedDBError }
51
+ export { CacheClient } from './cache.js'
52
+ export { KvClient } from './kv.js'
53
+ export { ConfigClient } from './config.js'
54
+ export { VaultClient } from './vault.js'
55
+ export { parseUri, deriveLoginUrl } from './url.js'
56
+
57
+ /**
58
+ * Connect to a RedDB instance.
59
+ *
60
+ * @param {string} uri Connection URI. See module docstring for accepted schemes.
61
+ * @param {object} [options]
62
+ * @param {string} [options.binary] Override the path to the `red` binary.
63
+ * @param {object} [options.auth] Authentication credentials. See module docstring.
64
+ * @param {string} [options.auth.token] Bearer / API-key token.
65
+ * @param {string} [options.auth.apiKey] Alias for `token`.
66
+ * @param {string} [options.auth.username] Username for password login.
67
+ * @param {string} [options.auth.password] Password for password login.
68
+ * @returns {Promise<RedDB>}
69
+ */
70
+ export async function connect(uri, options = {}) {
71
+ const parsed = parseUri(uri)
72
+ const merged = mergeAuthFromUri(parsed, options.auth)
73
+
74
+ // Embedded modes: spawn the binary with stdio JSON-RPC. Auth is
75
+ // not applicable (caller already has filesystem privileges).
76
+ if (parsed.kind === 'embedded') {
77
+ if (merged.token || merged.username) {
78
+ throw new RedDBError(
79
+ 'AUTH_NOT_APPLICABLE',
80
+ 'auth is only meaningful for remote connections; embedded modes inherit caller privileges.',
81
+ )
82
+ }
83
+ const args = embeddedArgs(parsed)
84
+ const binary = options.binary ?? resolveSdkBinary()
85
+ const child = await spawnRed(binary, args)
86
+ const client = new RpcClient(child)
87
+ await client.call('version', {})
88
+ return new RedDB(client)
89
+ }
90
+
91
+ // HTTP / HTTPS: speak directly to the server via fetch().
92
+ if (parsed.kind === 'http' || parsed.kind === 'https') {
93
+ const baseUrl = `${parsed.kind}://${parsed.host}:${parsed.port}`
94
+ let token = merged.token
95
+ if (!token && merged.username && merged.password) {
96
+ const loginUrl = merged.loginUrl ?? `${baseUrl}/auth/login`
97
+ const session = await login(loginUrl, {
98
+ username: merged.username,
99
+ password: merged.password,
100
+ })
101
+ token = session.token
102
+ }
103
+ const client = new HttpRpcClient({ baseUrl, token })
104
+ // Sanity check before returning the handle.
105
+ await client.call('health', {})
106
+ return new RedDB(client)
107
+ }
108
+
109
+ // gRPC / gRPCs / RedWire (default for grpc-shaped URIs):
110
+ // speak the RedWire binary protocol natively via TCP. No spawn, no
111
+ // gRPC bridge. Resolves bearer auth from username/password via
112
+ // HTTP /auth/login first when needed.
113
+ //
114
+ // The server multiplexes RedWire on the same port as gRPC and HTTP
115
+ // via the service router's 0xFE detector, so pure grpc:// URLs
116
+ // still flow through RedWire because it wins on perf and parity.
117
+ if (parsed.kind === 'grpc' || parsed.kind === 'grpcs') {
118
+ let token = merged.token
119
+ if (!token && merged.username && merged.password) {
120
+ const loginUrl = merged.loginUrl ?? deriveLoginUrl(parsed)
121
+ const session = await login(loginUrl, {
122
+ username: merged.username,
123
+ password: merged.password,
124
+ })
125
+ token = session.token
126
+ }
127
+
128
+ // Honour `proto=spawn-grpc` as an escape hatch for callers that
129
+ // explicitly want the legacy stdio→gRPC bridge. Default is the
130
+ // RedWire transport.
131
+ const protoOverride = parsed.params?.get?.('proto') ?? ''
132
+ if (protoOverride === 'spawn-grpc') {
133
+ const args = grpcArgs(parsed, token)
134
+ const binary = options.binary ?? resolveSdkBinary()
135
+ const child = await spawnRed(binary, args)
136
+ const legacy = new RpcClient(child)
137
+ await legacy.call('version', {})
138
+ return new RedDB(legacy)
139
+ }
140
+
141
+ const auth = token ? { kind: 'bearer', token } : { kind: 'anonymous' }
142
+ const tls = buildTlsOpts(parsed, options.tls)
143
+ const client = await connectRedwire({
144
+ host: parsed.host,
145
+ port: parsed.port,
146
+ auth,
147
+ ...(tls ? { tls } : {}),
148
+ })
149
+ return new RedDB(client)
150
+ }
151
+
152
+ // Postgres wire: not yet wired in the driver. Document the gap
153
+ // so users get a clear actionable error instead of a silent
154
+ // unsupported transport.
155
+ if (parsed.kind === 'pg') {
156
+ throw new RedDBError(
157
+ 'PG_TRANSPORT_NOT_WIRED',
158
+ "PostgreSQL wire (proto=pg) requires a node-pg-style client; "
159
+ + "the JS driver doesn't bundle one yet. Use a separate `pg` package "
160
+ + 'against the same host:port for now, or open an issue if you want it built in.',
161
+ )
162
+ }
163
+
164
+ throw new RedDBError(
165
+ 'UNSUPPORTED_KIND',
166
+ `internal: parsed kind '${parsed.kind}' has no transport`,
167
+ )
168
+ }
169
+
170
+ function embeddedArgs(parsed) {
171
+ if (parsed.path) return ['rpc', '--stdio', '--path', parsed.path]
172
+ return ['rpc', '--stdio']
173
+ }
174
+
175
+ function grpcArgs(parsed, token) {
176
+ const scheme = parsed.kind === 'grpcs' ? 'grpcs' : 'grpc'
177
+ const url = `${scheme}://${parsed.host}:${parsed.port}${parsed.path ?? ''}`
178
+ const args = ['rpc', '--stdio', '--connect', url]
179
+ if (token) args.push('--token', token)
180
+ return args
181
+ }
182
+
183
+ /**
184
+ * Merge `options.auth` (legacy `{ token, apiKey, username, password }`
185
+ * shape) with credentials lifted from the URI itself. Explicit
186
+ * `options.auth` always wins to keep behaviour predictable.
187
+ */
188
+ /**
189
+ * Resolve TLS options for a redwire(s) connection.
190
+ *
191
+ * Sources, in priority order:
192
+ * - `options.tls` from the caller (object form), wins everything
193
+ * - `parsed.kind === 'grpcs'` (i.e. `redwires://` or `?proto=grpcs`)
194
+ * - `?tls=true` in the URL params
195
+ * - `?ca=`, `?cert=`, `?key=`, `?servername=`,
196
+ * `?rejectUnauthorized=false` URL params (paths or PEM strings)
197
+ *
198
+ * Returns `null` when TLS isn't requested.
199
+ */
200
+ function buildTlsOpts(parsed, callerTls) {
201
+ if (callerTls && typeof callerTls === 'object') {
202
+ return callerTls
203
+ }
204
+ const params = parsed.params
205
+ const wantsTls =
206
+ parsed.kind === 'grpcs'
207
+ || params?.get?.('tls') === 'true'
208
+ || params?.get?.('tls') === '1'
209
+ if (!wantsTls) return null
210
+ return {
211
+ ca: params?.get?.('ca') ?? undefined,
212
+ cert: params?.get?.('cert') ?? undefined,
213
+ key: params?.get?.('key') ?? undefined,
214
+ servername: params?.get?.('servername') ?? undefined,
215
+ rejectUnauthorized:
216
+ params?.get?.('rejectUnauthorized') === 'false' ? false : true,
217
+ }
218
+ }
219
+
220
+ function mergeAuthFromUri(parsed, optionAuth) {
221
+ const out = {
222
+ token: parsed.token ?? parsed.apiKey ?? null,
223
+ username: parsed.username ?? null,
224
+ password: parsed.password ?? null,
225
+ loginUrl: parsed.loginUrl ?? null,
226
+ }
227
+ if (optionAuth == null) return out
228
+ if (typeof optionAuth !== 'object') {
229
+ throw new TypeError('options.auth must be an object')
230
+ }
231
+ if (optionAuth.token != null) {
232
+ if (typeof optionAuth.token !== 'string' || optionAuth.token.length === 0) {
233
+ throw new TypeError('options.auth.token must be a non-empty string')
234
+ }
235
+ out.token = optionAuth.token
236
+ }
237
+ if (optionAuth.apiKey != null) {
238
+ if (typeof optionAuth.apiKey !== 'string' || optionAuth.apiKey.length === 0) {
239
+ throw new TypeError('options.auth.apiKey must be a non-empty string')
240
+ }
241
+ out.token = optionAuth.apiKey
242
+ }
243
+ if (optionAuth.username != null) {
244
+ if (typeof optionAuth.username !== 'string' || optionAuth.username.length === 0) {
245
+ throw new TypeError('options.auth.username must be a non-empty string')
246
+ }
247
+ out.username = optionAuth.username
248
+ }
249
+ if (optionAuth.password != null) {
250
+ if (typeof optionAuth.password !== 'string' || optionAuth.password.length === 0) {
251
+ throw new TypeError('options.auth.password must be a non-empty string')
252
+ }
253
+ out.password = optionAuth.password
254
+ }
255
+ if (optionAuth.loginUrl != null) {
256
+ out.loginUrl = optionAuth.loginUrl
257
+ }
258
+ return out
259
+ }
260
+
261
+ /**
262
+ * Exchange username + password for a bearer token by hitting the
263
+ * server's `POST /auth/login` HTTP endpoint, then return that token
264
+ * for use with subsequent `connect({ auth: { token } })` calls.
265
+ *
266
+ * Why a separate function: the gRPC surface does not currently
267
+ * expose `auth.login` as an RPC, so the driver can't piggyback on
268
+ * the binary spawn for password auth. The HTTP listener does
269
+ * expose it, and is the canonical login site (the same endpoint
270
+ * the dashboard uses).
271
+ *
272
+ * @param {string} loginUrl Full URL of the server's auth endpoint
273
+ * (e.g. `https://reddb.example.com/auth/login`).
274
+ * @param {{ username: string, password: string }} credentials
275
+ * @returns {Promise<{ token: string, username: string, role: string, expires_at: number }>}
276
+ */
277
+ export async function login(loginUrl, { username, password }) {
278
+ if (typeof loginUrl !== 'string' || !loginUrl.startsWith('http')) {
279
+ throw new TypeError("login() requires an http(s):// URL pointing at /auth/login")
280
+ }
281
+ if (typeof username !== 'string' || username.length === 0) {
282
+ throw new TypeError('login() requires a non-empty username')
283
+ }
284
+ if (typeof password !== 'string' || password.length === 0) {
285
+ throw new TypeError('login() requires a non-empty password')
286
+ }
287
+ const response = await fetch(loginUrl, {
288
+ method: 'POST',
289
+ headers: { 'content-type': 'application/json' },
290
+ body: JSON.stringify({ username, password }),
291
+ })
292
+ const body = await response.json().catch(() => ({}))
293
+ if (!response.ok || body.ok === false) {
294
+ const code = body.error_code || `HTTP_${response.status}`
295
+ const message = body.error || `auth/login returned ${response.status}`
296
+ throw new RedDBError(code, message, body)
297
+ }
298
+ if (typeof body.token !== 'string') {
299
+ throw new RedDBError(
300
+ 'AUTH_LOGIN_BAD_RESPONSE',
301
+ 'auth/login response missing string token',
302
+ body,
303
+ )
304
+ }
305
+ return body
306
+ }
307
+
308
+ /**
309
+ * Backwards-compatible shim: translate a URI into argv for
310
+ * `red rpc --stdio`. New code should call `parseUri` directly and
311
+ * route via `connect`. Kept exported for tests that pre-date the
312
+ * `red://` parser.
313
+ */
314
+ export function uriToArgs(uri, auth = null) {
315
+ const parsed = parseUri(uri)
316
+ if (parsed.kind === 'embedded') return embeddedArgs(parsed)
317
+ if (parsed.kind === 'grpc' || parsed.kind === 'grpcs') {
318
+ const token = auth?.kind === 'token' ? auth.token : (parsed.token ?? parsed.apiKey ?? null)
319
+ return grpcArgs(parsed, token)
320
+ }
321
+ throw new RedDBError(
322
+ 'UNSUPPORTED_SCHEME',
323
+ `uriToArgs() supports embedded + grpc kinds; for '${parsed.kind}' use connect() directly.`,
324
+ )
325
+ }
326
+
327
+
328
+ /**
329
+ * Connection handle. Methods map 1:1 to JSON-RPC methods on the binary.
330
+ */
331
+ export class RedDB {
332
+ /** @param {RpcClient} client */
333
+ constructor(client) {
334
+ this.client = client
335
+ this.cache = new CacheClient(client)
336
+ const defaultKv = new KvClient(client)
337
+ this.kv = Object.assign((collection = 'kv_default') => new KvClient(client, collection), {
338
+ put: defaultKv.put.bind(defaultKv),
339
+ invalidateTags: defaultKv.invalidateTags.bind(defaultKv),
340
+ watch: defaultKv.watch.bind(defaultKv),
341
+ watchPrefix: defaultKv.watchPrefix.bind(defaultKv),
342
+ })
343
+ this.config = (collection = 'red.config') => new ConfigClient(client, collection)
344
+ this.vault = (collection = 'red.vault') => new VaultClient(client, collection)
345
+ }
346
+
347
+ /** Execute a SQL query. Returns `{ statement, affected, columns, rows }`. */
348
+ query(sql) {
349
+ return this.client.call('query', { sql })
350
+ }
351
+
352
+ /** Insert one row. Returns `{ affected, id? }`. */
353
+ insert(collection, payload) {
354
+ return this.client.call('insert', { collection, payload })
355
+ }
356
+
357
+ /** Insert many rows in one call. Returns `{ affected }`. */
358
+ bulkInsert(collection, payloads) {
359
+ return this.client.call('bulk_insert', { collection, payloads })
360
+ }
361
+
362
+ /** Get an entity by id. Returns `{ entity }` (entity is `null` if not found). */
363
+ get(collection, id) {
364
+ return this.client.call('get', { collection, id: String(id) })
365
+ }
366
+
367
+ /** Delete an entity by id. Returns `{ affected }`. */
368
+ delete(collection, id) {
369
+ return this.client.call('delete', { collection, id: String(id) })
370
+ }
371
+
372
+ /** Probe the server. Returns `{ ok: true, version }`. */
373
+ health() {
374
+ return this.client.call('health', {})
375
+ }
376
+
377
+ /** Server version + protocol version. */
378
+ version() {
379
+ return this.client.call('version', {})
380
+ }
381
+
382
+ // ---------------------------------------------------------------
383
+ // Auth surface — these are no-ops in embedded mode because the
384
+ // bridge layer doesn't expose `auth.*` JSON-RPC methods locally.
385
+ // They forward to the server when the connection is grpc://.
386
+ // ---------------------------------------------------------------
387
+
388
+ /**
389
+ * Exchange username + password for a bearer token. Returns
390
+ * `{ token, username, role, expires_at }`. Server-side this
391
+ * routes to `POST /auth/login`.
392
+ *
393
+ * Prefer the `auth: { username, password }` form on `connect()`
394
+ * — it does the same exchange + caches the token transparently.
395
+ */
396
+ login(username, password) {
397
+ return this.client.call('auth.login', { username, password })
398
+ }
399
+
400
+ /** Identify the current caller. Returns `{ username, role }`. */
401
+ whoami() {
402
+ return this.client.call('auth.whoami', {})
403
+ }
404
+
405
+ /** Change the current caller's password. */
406
+ changePassword(currentPassword, newPassword) {
407
+ return this.client.call('auth.change_password', {
408
+ current_password: currentPassword,
409
+ new_password: newPassword,
410
+ })
411
+ }
412
+
413
+ /**
414
+ * Mint a long-lived API key for the caller (or a sub-user, when
415
+ * the caller has `Admin` role). Returns `{ key, role, created_at }`.
416
+ * Pass the returned `key` back via `auth: { apiKey: key }` on
417
+ * future `connect()` calls.
418
+ */
419
+ createApiKey({ username, role } = {}) {
420
+ return this.client.call('auth.create_api_key', { username, role })
421
+ }
422
+
423
+ /** Revoke an API key by its public id. */
424
+ revokeApiKey(key) {
425
+ return this.client.call('auth.revoke_api_key', { key })
426
+ }
427
+
428
+ /** Close the connection and wait for the binary to exit. */
429
+ close() {
430
+ return this.client.close()
431
+ }
432
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Resolve the GitHub release asset filename for a given Node
3
+ * `process.platform` / `process.arch` pair and binary base name.
4
+ *
5
+ * Mapping is the existing scheme from `drivers/js/postinstall.js`:
6
+ * linux + x64 → <bin>-linux-x86_64
7
+ * linux + arm64 → <bin>-linux-aarch64
8
+ * linux + arm/armv7l→ <bin>-linux-armv7
9
+ * darwin + x64 → <bin>-macos-x86_64
10
+ * darwin + arm64 → <bin>-macos-aarch64
11
+ * win32 + x64 → <bin>-windows-x86_64.exe
12
+ *
13
+ * Throws `UnsupportedPlatformError` for any other combination.
14
+ */
15
+
16
+ export class UnsupportedPlatformError extends Error {
17
+ constructor(platform, arch) {
18
+ super(`unsupported platform/arch combination: ${platform}/${arch}`)
19
+ this.name = 'UnsupportedPlatformError'
20
+ this.code = 'UNSUPPORTED_PLATFORM'
21
+ this.platform = platform
22
+ this.arch = arch
23
+ }
24
+ }
25
+
26
+ export function composeAssetName({ platform, arch, binName }) {
27
+ if (typeof binName !== 'string' || binName === '') {
28
+ throw new TypeError('composeAssetName: `binName` must be a non-empty string')
29
+ }
30
+ if (platform === 'linux' && arch === 'x64') return `${binName}-linux-x86_64`
31
+ if (platform === 'linux' && arch === 'arm64') return `${binName}-linux-aarch64`
32
+ if (platform === 'linux' && (arch === 'arm' || arch === 'armv7l')) return `${binName}-linux-armv7`
33
+ if (platform === 'darwin' && arch === 'x64') return `${binName}-macos-x86_64`
34
+ if (platform === 'darwin' && arch === 'arm64') return `${binName}-macos-aarch64`
35
+ if (platform === 'win32' && arch === 'x64') return `${binName}-windows-x86_64.exe`
36
+ throw new UnsupportedPlatformError(platform, arch)
37
+ }
@@ -0,0 +1,23 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ export class ChecksumMismatchError extends Error {
4
+ constructor(expected, actual) {
5
+ super(`checksum mismatch: expected sha256=${expected}, got sha256=${actual}`)
6
+ this.name = 'ChecksumMismatchError'
7
+ this.code = 'CHECKSUM_MISMATCH'
8
+ this.expected = expected
9
+ this.actual = actual
10
+ }
11
+ }
12
+
13
+ export function sha256Hex(buf) {
14
+ return createHash('sha256').update(buf).digest('hex')
15
+ }
16
+
17
+ export function verifySha256(buf, expected) {
18
+ const expectedNorm = String(expected).trim().toLowerCase()
19
+ const actual = sha256Hex(buf)
20
+ if (actual !== expectedNorm) {
21
+ throw new ChecksumMismatchError(expectedNorm, actual)
22
+ }
23
+ }
@@ -0,0 +1,89 @@
1
+ import { request as httpsRequest } from 'node:https'
2
+ import { request as httpRequest } from 'node:http'
3
+
4
+ const MAX_REDIRECTS = 5
5
+
6
+ export class AssetNotFoundError extends Error {
7
+ constructor(url) {
8
+ super(`asset not found (HTTP 404) at ${url}`)
9
+ this.name = 'AssetNotFoundError'
10
+ this.code = 'ASSET_NOT_FOUND'
11
+ this.url = url
12
+ }
13
+ }
14
+
15
+ export class HttpError extends Error {
16
+ constructor(status, url) {
17
+ super(`HTTP ${status} fetching ${url}`)
18
+ this.name = 'HttpError'
19
+ this.code = 'HTTP_ERROR'
20
+ this.status = status
21
+ this.url = url
22
+ }
23
+ }
24
+
25
+ export class TooManyRedirectsError extends Error {
26
+ constructor(url) {
27
+ super(`too many redirects (>${MAX_REDIRECTS}) starting at ${url}`)
28
+ this.name = 'TooManyRedirectsError'
29
+ this.code = 'TOO_MANY_REDIRECTS'
30
+ this.url = url
31
+ }
32
+ }
33
+
34
+ function pickRequest(url) {
35
+ return url.startsWith('http://') ? httpRequest : httpsRequest
36
+ }
37
+
38
+ function resolveLocation(currentUrl, location) {
39
+ if (/^https?:\/\//i.test(location)) return location
40
+ return new URL(location, currentUrl).toString()
41
+ }
42
+
43
+ export function downloadFollowingRedirects(url, { userAgent, originalUrl } = {}, depth = 0) {
44
+ const startUrl = originalUrl || url
45
+ if (depth > MAX_REDIRECTS) {
46
+ return Promise.reject(new TooManyRedirectsError(startUrl))
47
+ }
48
+ const request = pickRequest(url)
49
+ return new Promise((resolve, reject) => {
50
+ const req = request(
51
+ url,
52
+ {
53
+ method: 'GET',
54
+ headers: {
55
+ 'User-Agent': userAgent || 'reddb-internal-asset-fetcher',
56
+ Accept: 'application/octet-stream',
57
+ },
58
+ },
59
+ (res) => {
60
+ const status = res.statusCode || 0
61
+ if (status >= 300 && status < 400 && res.headers.location) {
62
+ res.resume()
63
+ const next = resolveLocation(url, res.headers.location)
64
+ downloadFollowingRedirects(next, { userAgent, originalUrl: startUrl }, depth + 1).then(
65
+ resolve,
66
+ reject,
67
+ )
68
+ return
69
+ }
70
+ if (status === 404) {
71
+ res.resume()
72
+ reject(new AssetNotFoundError(startUrl))
73
+ return
74
+ }
75
+ if (status < 200 || status >= 300) {
76
+ res.resume()
77
+ reject(new HttpError(status, url))
78
+ return
79
+ }
80
+ const chunks = []
81
+ res.on('data', (chunk) => chunks.push(chunk))
82
+ res.on('end', () => resolve(Buffer.concat(chunks)))
83
+ res.on('error', reject)
84
+ },
85
+ )
86
+ req.on('error', reject)
87
+ req.end()
88
+ })
89
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @reddb-io/internal-asset-fetcher — fetch a `red`/`red_client` binary
3
+ * from a GitHub release.
4
+ *
5
+ * Public surface: one function.
6
+ *
7
+ * fetchReleaseAsset({ repo, tag, platform, arch, binName, sha256? }) → Buffer
8
+ *
9
+ * Steps:
10
+ * 1. Map (platform, arch, binName) → asset filename.
11
+ * 2. Compose the GitHub download URL: `https://github.com/<repo>/releases/download/<tag>/<asset>`.
12
+ * 3. Follow up to 5 redirects, returning the final body as a Buffer.
13
+ * 4. If `sha256` was supplied, verify before returning.
14
+ *
15
+ * Errors carry distinct `.code` values so callers can differentiate:
16
+ * - UNSUPPORTED_PLATFORM — no asset for this platform/arch
17
+ * - ASSET_NOT_FOUND — HTTP 404 (release/tag/asset name wrong)
18
+ * - CHECKSUM_MISMATCH — body downloaded but sha256 mismatched
19
+ * - HTTP_ERROR — any other non-2xx status
20
+ * - TOO_MANY_REDIRECTS — redirect chain longer than 5 hops
21
+ *
22
+ * Internal modules (`./asset-name.js`, `./download.js`, `./checksum.js`)
23
+ * are not part of the public contract — only `fetchReleaseAsset` is.
24
+ * They are imported directly in tests for focused coverage.
25
+ */
26
+
27
+ import { composeAssetName } from './asset-name.js'
28
+ import { downloadFollowingRedirects } from './download.js'
29
+ import { verifySha256 } from './checksum.js'
30
+
31
+ export async function fetchReleaseAsset({ repo, tag, platform, arch, binName, sha256 } = {}) {
32
+ if (typeof repo !== 'string' || repo === '') {
33
+ throw new TypeError('fetchReleaseAsset: `repo` must be a non-empty string (e.g. "reddb-io/reddb")')
34
+ }
35
+ if (typeof tag !== 'string' || tag === '') {
36
+ throw new TypeError('fetchReleaseAsset: `tag` must be a non-empty string (e.g. "v0.2.9")')
37
+ }
38
+ if (typeof platform !== 'string' || platform === '') {
39
+ throw new TypeError('fetchReleaseAsset: `platform` must be a non-empty string')
40
+ }
41
+ if (typeof arch !== 'string' || arch === '') {
42
+ throw new TypeError('fetchReleaseAsset: `arch` must be a non-empty string')
43
+ }
44
+
45
+ const assetName = composeAssetName({ platform, arch, binName })
46
+ const url = `https://github.com/${repo}/releases/download/${tag}/${assetName}`
47
+ const body = await downloadFollowingRedirects(url)
48
+ if (sha256) {
49
+ verifySha256(body, sha256)
50
+ }
51
+ return body
52
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @reddb-io/internal-bin-resolver — runtime lookup for a pinned binary.
3
+ *
4
+ * Precedence:
5
+ * 1. `process.env[envVar]` if set and non-empty → returned verbatim.
6
+ * No existence probe: the env var is the user's "I know what I'm
7
+ * doing" override.
8
+ * 2. `<packageRoot>/bin/<name>` if it exists.
9
+ * 3. Otherwise throw an actionable error naming the env var, the
10
+ * expected local path, and a one-line `pnpm install` hint.
11
+ *
12
+ * `PATH` is deliberately never consulted. SDK/client wire formats are
13
+ * version-coupled to the binary; silent fallback to a stale `PATH`
14
+ * binary fails as misframed RPC, not "command not found". See ADR 0006.
15
+ */
16
+
17
+ import { existsSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+
20
+ /**
21
+ * @param {{ name: string, packageRoot: string, envVar: string }} opts
22
+ * @returns {string} absolute path to the binary
23
+ * @throws {Error} when neither env override nor local binary is usable
24
+ */
25
+ export function resolveBin(opts) {
26
+ if (!opts || typeof opts !== 'object') {
27
+ throw new TypeError('resolveBin: options object required')
28
+ }
29
+ const { name, packageRoot, envVar } = opts
30
+ if (typeof name !== 'string' || name === '') {
31
+ throw new TypeError('resolveBin: `name` must be a non-empty string')
32
+ }
33
+ if (typeof packageRoot !== 'string' || packageRoot === '') {
34
+ throw new TypeError('resolveBin: `packageRoot` must be a non-empty string')
35
+ }
36
+ if (typeof envVar !== 'string' || envVar === '') {
37
+ throw new TypeError('resolveBin: `envVar` must be a non-empty string')
38
+ }
39
+
40
+ const override = process.env?.[envVar]
41
+ if (typeof override === 'string' && override !== '') {
42
+ return override
43
+ }
44
+
45
+ const local = join(packageRoot, 'bin', name)
46
+ if (existsSync(local)) {
47
+ return local
48
+ }
49
+
50
+ throw new Error(
51
+ `reddb: binary "${name}" not found.\n` +
52
+ ` expected at: ${local}\n` +
53
+ ` override: set ${envVar}=/path/to/${name}\n` +
54
+ ` fix: re-run \`pnpm install\` (the postinstall script downloads it),\n` +
55
+ ` or check the postinstall log for a download error.`,
56
+ )
57
+ }