@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,177 @@
1
+ /**
2
+ * Runtime-agnostic subprocess spawn.
3
+ *
4
+ * Returns a uniform handle exposing:
5
+ * - `stdin` WritableStream-like (has `write(buf)`, `end()`)
6
+ * - `stdout` AsyncIterable<Uint8Array> for line-buffered reading
7
+ * - `kill()` terminate the process
8
+ * - `wait()` resolve when the process exits
9
+ *
10
+ * Detects Bun and Deno first because they ship native, Node-incompatible
11
+ * spawn APIs that are faster than their `node:child_process` shims.
12
+ */
13
+
14
+ const isBun = typeof globalThis.Bun !== 'undefined' && typeof globalThis.Bun.spawn === 'function'
15
+ const isDeno = typeof globalThis.Deno !== 'undefined' && typeof globalThis.Deno.Command === 'function'
16
+
17
+ /** @returns {Promise<RedProcess>} */
18
+ export async function spawnRed(binary, args) {
19
+ if (isBun) return spawnBun(binary, args)
20
+ if (isDeno) return spawnDeno(binary, args)
21
+ return spawnNode(binary, args)
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Node
26
+ // ---------------------------------------------------------------------------
27
+
28
+ async function spawnNode(binary, args) {
29
+ const { spawn } = await import('node:child_process')
30
+ const child = spawn(binary, args, { stdio: ['pipe', 'pipe', 'inherit'] })
31
+
32
+ return {
33
+ runtime: 'node',
34
+ stdin: {
35
+ write(buf) {
36
+ return new Promise((resolve, reject) => {
37
+ child.stdin.write(buf, (err) => (err ? reject(err) : resolve()))
38
+ })
39
+ },
40
+ end() {
41
+ child.stdin.end()
42
+ },
43
+ },
44
+ stdout: child.stdout, // already AsyncIterable<Buffer>
45
+ kill() {
46
+ child.kill('SIGTERM')
47
+ },
48
+ wait() {
49
+ return new Promise((resolve) => {
50
+ child.on('exit', (code) => resolve(code ?? 0))
51
+ })
52
+ },
53
+ }
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Bun
58
+ // ---------------------------------------------------------------------------
59
+
60
+ function spawnBun(binary, args) {
61
+ const child = globalThis.Bun.spawn({
62
+ cmd: [binary, ...args],
63
+ stdin: 'pipe',
64
+ stdout: 'pipe',
65
+ stderr: 'inherit',
66
+ })
67
+
68
+ const writer = child.stdin.getWriter ? child.stdin.getWriter() : null
69
+
70
+ return {
71
+ runtime: 'bun',
72
+ stdin: {
73
+ async write(buf) {
74
+ if (writer) {
75
+ await writer.write(buf)
76
+ } else {
77
+ // Older Bun: stdin is a FileSink
78
+ child.stdin.write(buf)
79
+ await child.stdin.flush()
80
+ }
81
+ },
82
+ end() {
83
+ if (writer) {
84
+ writer.close()
85
+ } else {
86
+ child.stdin.end()
87
+ }
88
+ },
89
+ },
90
+ stdout: bunStdoutToAsyncIterable(child.stdout),
91
+ kill() {
92
+ child.kill()
93
+ },
94
+ wait() {
95
+ return child.exited
96
+ },
97
+ }
98
+ }
99
+
100
+ async function* bunStdoutToAsyncIterable(stream) {
101
+ const reader = stream.getReader()
102
+ try {
103
+ while (true) {
104
+ const { value, done } = await reader.read()
105
+ if (done) return
106
+ yield value
107
+ }
108
+ } finally {
109
+ reader.releaseLock()
110
+ }
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Deno
115
+ // ---------------------------------------------------------------------------
116
+
117
+ async function spawnDeno(binary, args) {
118
+ const cmd = new globalThis.Deno.Command(binary, {
119
+ args,
120
+ stdin: 'piped',
121
+ stdout: 'piped',
122
+ stderr: 'inherit',
123
+ })
124
+ const child = cmd.spawn()
125
+ const writer = child.stdin.getWriter()
126
+
127
+ return {
128
+ runtime: 'deno',
129
+ stdin: {
130
+ async write(buf) {
131
+ await writer.write(buf)
132
+ },
133
+ end() {
134
+ try {
135
+ writer.close()
136
+ } catch {
137
+ // already closed
138
+ }
139
+ },
140
+ },
141
+ stdout: denoStdoutToAsyncIterable(child.stdout),
142
+ kill() {
143
+ try {
144
+ child.kill('SIGTERM')
145
+ } catch {
146
+ // process may already be gone
147
+ }
148
+ },
149
+ async wait() {
150
+ const status = await child.status
151
+ return status.code ?? 0
152
+ },
153
+ }
154
+ }
155
+
156
+ async function* denoStdoutToAsyncIterable(stream) {
157
+ const reader = stream.getReader()
158
+ try {
159
+ while (true) {
160
+ const { value, done } = await reader.read()
161
+ if (done) return
162
+ yield value
163
+ }
164
+ } finally {
165
+ reader.releaseLock()
166
+ }
167
+ }
168
+
169
+ /**
170
+ * @typedef {{
171
+ * runtime: 'node' | 'bun' | 'deno',
172
+ * stdin: { write(buf: Uint8Array): Promise<void>, end(): void },
173
+ * stdout: AsyncIterable<Uint8Array>,
174
+ * kill(): void,
175
+ * wait(): Promise<number>,
176
+ * }} RedProcess
177
+ */
@@ -0,0 +1,271 @@
1
+ /**
2
+ * `red://` connection-string parser.
3
+ *
4
+ * One URL covers every transport RedDB speaks:
5
+ *
6
+ * red:// embedded in-memory
7
+ * red:///abs/path/data.rdb embedded persistent
8
+ * red://user:pass@host:5050 remote, default proto=red (wire)
9
+ * red://host:8080?proto=https remote HTTPS
10
+ * red://host:5432?proto=pg PostgreSQL wire
11
+ * red://host:5055?proto=grpc&token=sk-abc remote gRPC w/ bearer
12
+ * red://host:8080?proto=https&apiKey=ak-xyz remote HTTPS w/ api key
13
+ *
14
+ * Backwards-compat: legacy `memory://`, `file://`, `grpc://` URLs
15
+ * still work via `parseLegacyUrl`. New code should prefer `red://`
16
+ * because it carries auth + protocol selection in one place.
17
+ */
18
+
19
+ import { RedDBError } from './protocol.js'
20
+
21
+ /**
22
+ * @typedef {object} ParsedUri
23
+ * @property {'embedded' | 'http' | 'https' | 'red' | 'reds' | 'grpc' | 'grpcs' | 'pg'} kind
24
+ * @property {string} [host]
25
+ * @property {number} [port]
26
+ * @property {string} [path] // for embedded `file://`-equivalent
27
+ * @property {string} [username]
28
+ * @property {string} [password]
29
+ * @property {string} [token]
30
+ * @property {string} [apiKey]
31
+ * @property {string} [loginUrl] // explicit override for login flow
32
+ * @property {URLSearchParams} [params] // remaining query params
33
+ * @property {string} originalUri
34
+ */
35
+
36
+ /**
37
+ * Parse any URI string into a normalised `ParsedUri`.
38
+ * Accepts `red://`, `memory://`, `file://`, `grpc://` (the latter
39
+ * three for backwards compat).
40
+ *
41
+ * @param {string} uri
42
+ * @returns {ParsedUri}
43
+ */
44
+ export function parseUri(uri) {
45
+ if (typeof uri !== 'string' || uri.length === 0) {
46
+ throw new TypeError(
47
+ "connect() requires a URI string (e.g. 'red://localhost:5050' or 'red:///data.rdb')",
48
+ )
49
+ }
50
+ if (uri.startsWith('red://') || uri === 'red:' || uri === 'red:/') {
51
+ return parseRedUrl(uri)
52
+ }
53
+ return parseLegacyUrl(uri)
54
+ }
55
+
56
+ /**
57
+ * Parse a `red://` URL.
58
+ *
59
+ * Authority shape: `[user[:pass]@]host[:port]`
60
+ * Path: optional, used as filesystem path when `host` is absent or
61
+ * is the special token `localhost-embedded` (rare).
62
+ * Query: `proto`, `token`, `apiKey`, `loginUrl`.
63
+ */
64
+ export function parseRedUrl(uri) {
65
+ // The host might be missing (`red:///path`), the URL constructor
66
+ // requires *something* there. Re-write to a parse-friendly shape:
67
+ // - `red:///x` → `red://embedded.local/x` (embedded with path)
68
+ // - `red://memory` → `red://embedded.local` (embedded in-memory)
69
+ // - `red://` → `red://embedded.local` (embedded in-memory)
70
+ let normalised = uri
71
+ if (uri === 'red:' || uri === 'red:/' || uri === 'red://') {
72
+ normalised = 'red://embedded.local'
73
+ } else if (uri.startsWith('red:///')) {
74
+ normalised = `red://embedded.local${uri.slice('red://'.length)}`
75
+ } else if (
76
+ uri === 'red://memory'
77
+ || uri === 'red://memory/'
78
+ || uri === 'red://:memory'
79
+ || uri === 'red://:memory:' // SQLite-style ":memory:" alias
80
+ ) {
81
+ normalised = 'red://embedded.local'
82
+ }
83
+
84
+ let parsed
85
+ try {
86
+ parsed = new URL(normalised)
87
+ } catch (err) {
88
+ throw new RedDBError('UNPARSEABLE_URI', `failed to parse '${uri}': ${err.message}`)
89
+ }
90
+
91
+ const params = parsed.searchParams
92
+ const proto = (params.get('proto') || '').toLowerCase()
93
+ const path = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname : ''
94
+
95
+ // Embedded: special host, OR `proto=embedded`, OR no proto + has path
96
+ // and the user clearly meant a file path (red:///abs/path).
97
+ if (parsed.hostname === 'embedded.local') {
98
+ if (path) {
99
+ return {
100
+ kind: 'embedded',
101
+ path,
102
+ params,
103
+ originalUri: uri,
104
+ }
105
+ }
106
+ return {
107
+ kind: 'embedded',
108
+ params,
109
+ originalUri: uri,
110
+ }
111
+ }
112
+
113
+ // Remote — default proto is red (wire).
114
+ const kind = resolveKind(proto)
115
+ const port = parsed.port ? Number(parsed.port) : defaultPortFor(kind)
116
+ const username = parsed.username ? decodeURIComponent(parsed.username) : undefined
117
+ const password = parsed.password ? decodeURIComponent(parsed.password) : undefined
118
+
119
+ return {
120
+ kind,
121
+ host: parsed.hostname,
122
+ port,
123
+ path: path || undefined,
124
+ username,
125
+ password,
126
+ token: params.get('token') ?? undefined,
127
+ apiKey: params.get('apiKey') ?? params.get('api_key') ?? undefined,
128
+ loginUrl: params.get('loginUrl') ?? params.get('login_url') ?? undefined,
129
+ params,
130
+ originalUri: uri,
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Backwards-compat parser for the legacy URL shapes the driver
136
+ * accepted before `red://` existed. Returns the same `ParsedUri`
137
+ * shape so downstream code is uniform.
138
+ */
139
+ export function parseLegacyUrl(uri) {
140
+ if (uri === 'memory://' || uri === 'memory:') {
141
+ return { kind: 'embedded', originalUri: uri }
142
+ }
143
+ if (uri.startsWith('file://')) {
144
+ const path = uri.slice('file://'.length)
145
+ if (!path) {
146
+ throw new TypeError(`invalid file:// URI: missing path in '${uri}'`)
147
+ }
148
+ return { kind: 'embedded', path, originalUri: uri }
149
+ }
150
+ if (
151
+ uri.startsWith('grpc://')
152
+ || uri.startsWith('grpcs://')
153
+ || uri.startsWith('reds://')
154
+ ) {
155
+ const scheme = uri.split('://', 1)[0]
156
+ const stripped = uri.slice(`${scheme}://`.length)
157
+ const [hostPort] = stripped.split(/[/?]/, 1)
158
+ const [host, portStr] = hostPort.split(':')
159
+ if (!host) {
160
+ throw new TypeError(`invalid ${scheme}:// URI: missing host in '${uri}'`)
161
+ }
162
+ const legacyKind = scheme === 'reds' ? 'reds' : scheme === 'grpcs' ? 'grpcs' : scheme === 'grpc' ? 'grpc' : 'red'
163
+ return {
164
+ kind: legacyKind,
165
+ host,
166
+ port: portStr ? Number(portStr) : defaultPortFor(legacyKind),
167
+ originalUri: uri,
168
+ }
169
+ }
170
+ if (uri.startsWith('http://') || uri.startsWith('https://')) {
171
+ let parsed
172
+ try {
173
+ parsed = new URL(uri)
174
+ } catch (err) {
175
+ throw new RedDBError('UNPARSEABLE_URI', `failed to parse '${uri}': ${err.message}`)
176
+ }
177
+ return {
178
+ kind: parsed.protocol === 'https:' ? 'https' : 'http',
179
+ host: parsed.hostname,
180
+ port: parsed.port ? Number(parsed.port) : defaultPortFor(parsed.protocol === 'https:' ? 'https' : 'http'),
181
+ path: parsed.pathname !== '/' ? parsed.pathname : undefined,
182
+ username: parsed.username ? decodeURIComponent(parsed.username) : undefined,
183
+ password: parsed.password ? decodeURIComponent(parsed.password) : undefined,
184
+ token: parsed.searchParams.get('token') ?? undefined,
185
+ apiKey: parsed.searchParams.get('apiKey') ?? undefined,
186
+ params: parsed.searchParams,
187
+ originalUri: uri,
188
+ }
189
+ }
190
+ throw new RedDBError(
191
+ 'UNSUPPORTED_SCHEME',
192
+ `unsupported URI: '${uri}'. Use 'red://...' or one of memory://, file://, grpc://, http(s)://`,
193
+ )
194
+ }
195
+
196
+ function resolveKind(protoQueryParam) {
197
+ switch (protoQueryParam) {
198
+ case '':
199
+ case 'red':
200
+ return 'red'
201
+ case 'reds':
202
+ return 'reds'
203
+ case 'grpc':
204
+ return 'grpc'
205
+ case 'grpcs':
206
+ return 'grpcs'
207
+ case 'http':
208
+ return 'http'
209
+ case 'https':
210
+ return 'https'
211
+ case 'pg':
212
+ case 'postgres':
213
+ case 'postgresql':
214
+ return 'pg'
215
+ default:
216
+ throw new RedDBError(
217
+ 'UNSUPPORTED_PROTO',
218
+ `unknown proto='${protoQueryParam}'. Supported: red | reds | grpc | grpcs | http | https | pg`,
219
+ )
220
+ }
221
+ }
222
+
223
+ function defaultPortFor(kind) {
224
+ switch (kind) {
225
+ case 'http':
226
+ return 8080
227
+ case 'https':
228
+ return 8443
229
+ case 'red':
230
+ case 'reds':
231
+ case 'redwire':
232
+ return 5050
233
+ case 'grpc':
234
+ return 5055
235
+ case 'grpcs':
236
+ return 5056
237
+ case 'pg':
238
+ case 'postgres':
239
+ case 'postgresql':
240
+ return 5432
241
+ default:
242
+ return undefined
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Derive the HTTP login URL (`/auth/login`) from a parsed URI.
248
+ * Used by the auto-login flow when the user supplies `username:password@`
249
+ * but not an explicit `loginUrl`.
250
+ *
251
+ * Strategy: if proto is already http/https, just append `/auth/login`.
252
+ * For grpc/grpcs/pg, default to https://host:443 — operators that
253
+ * don't want that should pass `loginUrl=` explicitly.
254
+ */
255
+ export function deriveLoginUrl(parsed) {
256
+ if (parsed.loginUrl) return parsed.loginUrl
257
+ if (!parsed.host) {
258
+ throw new RedDBError(
259
+ 'AUTH_LOGIN_NEEDS_HOST',
260
+ 'cannot derive loginUrl without a host; pass it explicitly via loginUrl=...',
261
+ )
262
+ }
263
+ if (parsed.kind === 'http' || parsed.kind === 'https') {
264
+ const scheme = parsed.kind
265
+ const port = parsed.port ?? defaultPortFor(parsed.kind)
266
+ return `${scheme}://${parsed.host}:${port}/auth/login`
267
+ }
268
+ // Non-HTTP transports — default to HTTPS on 443 unless the user
269
+ // tells us otherwise.
270
+ return `https://${parsed.host}/auth/login`
271
+ }
@@ -0,0 +1,58 @@
1
+ export class VaultClient {
2
+ constructor(client, collection = 'red.vault') {
3
+ this.client = client
4
+ this.collection = collection
5
+ }
6
+
7
+ put(key, value, options = {}) {
8
+ rejectVolatileOptions(options, 'vault')
9
+ const collection = options.collection ?? this.collection
10
+ const tags = Array.isArray(options.tags) && options.tags.length > 0
11
+ ? ` TAGS [${options.tags.map(keyedStringLiteral).join(', ')}]`
12
+ : ''
13
+ return this.client.call('query', {
14
+ sql: `VAULT PUT ${keyedIdentifier(collection)}.${keyedIdentifier(key)} = ${keyedValueLiteral(value)}${tags}`,
15
+ })
16
+ }
17
+
18
+ get(key, options = {}) {
19
+ const collection = options.collection ?? this.collection
20
+ return this.client.call('query', {
21
+ sql: `VAULT GET ${keyedIdentifier(collection)}.${keyedIdentifier(key)}`,
22
+ })
23
+ }
24
+
25
+ unseal(key, options = {}) {
26
+ const collection = options.collection ?? this.collection
27
+ return this.client.call('query', {
28
+ sql: `UNSEAL VAULT ${keyedIdentifier(collection)}.${keyedIdentifier(key)}`,
29
+ })
30
+ }
31
+ }
32
+
33
+ function rejectVolatileOptions(options, domain) {
34
+ for (const field of ['ttl', 'ttlMs', 'ttl_ms', 'expireMs', 'expire_ms', 'expiresAt']) {
35
+ if (options[field] != null) {
36
+ throw new TypeError(`${domain} does not support TTL or expiration options`)
37
+ }
38
+ }
39
+ }
40
+
41
+ function keyedIdentifier(value) {
42
+ const out = String(value)
43
+ if (!/^[A-Za-z0-9_.]+$/.test(out)) {
44
+ throw new TypeError('keyed collection and key names must use letters, numbers, underscores, or dots')
45
+ }
46
+ return out
47
+ }
48
+
49
+ function keyedValueLiteral(value) {
50
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
51
+ if (value == null) return 'NULL'
52
+ if (Array.isArray(value) || typeof value === 'object') return JSON.stringify(value)
53
+ return keyedStringLiteral(value)
54
+ }
55
+
56
+ function keyedStringLiteral(value) {
57
+ return `'${String(value).replace(/'/g, "''")}'`
58
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@reddb-io/cli",
3
+ "version": "1.0.1",
4
+ "description": "CLI launcher for RedDB. The JS/TS app driver is published as @reddb-io/sdk.",
5
+ "type": "commonjs",
6
+ "bin": {
7
+ "reddb-cli": "./drivers/js/src/cli.js"
8
+ },
9
+ "files": [
10
+ "drivers/js/src/",
11
+ "drivers/js/cli-postinstall.js",
12
+ "drivers/js/package.json"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "packageManager": "pnpm@9.15.0",
18
+ "scripts": {
19
+ "postinstall": "node drivers/js/cli-postinstall.js",
20
+ "test": "node drivers/js/test/smoke.test.mjs",
21
+ "version": "node scripts/sync-version.js"
22
+ },
23
+ "keywords": [
24
+ "database",
25
+ "multi-model",
26
+ "vector-search",
27
+ "graph",
28
+ "document-store",
29
+ "key-value",
30
+ "rust",
31
+ "embedded"
32
+ ],
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/reddb-io/reddb"
37
+ },
38
+ "homepage": "https://github.com/reddb-io/reddb"
39
+ }