@vtriv/cli 0.1.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 (3) hide show
  1. package/README.md +68 -0
  2. package/index.ts +937 -0
  3. package/package.json +42 -0
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # vtriv CLI
2
+
3
+ Command-line interface for vtriv backend services.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ # Install dependencies
9
+ bun install
10
+
11
+ # Run directly
12
+ bun run index.ts --help
13
+
14
+ # Or link globally
15
+ bun link
16
+ vtriv --help
17
+ ```
18
+
19
+ ## Setup
20
+
21
+ ```bash
22
+ # Initialize configuration
23
+ vtriv init
24
+ ```
25
+
26
+ This creates `~/.vtrivrc` with your API gateway URL and bootstrap key.
27
+
28
+ ## Quick Start
29
+
30
+ ```bash
31
+ # List projects
32
+ vtriv auth project:list
33
+
34
+ # Create a project
35
+ vtriv auth project:create my-app
36
+
37
+ # Create a user
38
+ vtriv auth user:create my-app admin@example.com secretpass
39
+
40
+ # List documents
41
+ vtriv db ls my-app posts
42
+
43
+ # Create a document
44
+ echo '{"title":"Hello"}' | vtriv db put my-app posts
45
+
46
+ # Search
47
+ vtriv search query my-app posts "hello world"
48
+ ```
49
+
50
+ ## Commands
51
+
52
+ - `vtriv init` - Setup configuration
53
+ - `vtriv auth` - Auth service (projects, users, tokens, API keys)
54
+ - `vtriv db` - Database service (ls, get, put, rm, schema)
55
+ - `vtriv blob` - Blob storage (put, get, rm)
56
+ - `vtriv cron` - Cron jobs (ls, runs, trigger, script:cat)
57
+ - `vtriv ai` - AI service (stats, chat)
58
+ - `vtriv search` - Search service (config, query)
59
+
60
+ ## Options
61
+
62
+ - `--json` - Output raw JSON for scripts/agents
63
+ - `--profile <name>` - Use a specific profile
64
+ - `--debug` - Show HTTP curl equivalents
65
+
66
+ ## Documentation
67
+
68
+ See [~/grit/skills/vtriv/cli.md](../../grit/skills/vtriv/cli.md) for full documentation.
package/index.ts ADDED
@@ -0,0 +1,937 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * vtriv-cli - Command-line interface for vtriv services
4
+ *
5
+ * Gateway-first: single baseUrl with service routing by path prefix
6
+ * Auto-mints JWTs for non-auth services using root key
7
+ */
8
+
9
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { Command } from "commander";
13
+ import chalk from "chalk";
14
+
15
+ // =============================================================================
16
+ // Types
17
+ // =============================================================================
18
+
19
+ interface Profile {
20
+ baseUrl: string;
21
+ rootKey: string;
22
+ }
23
+
24
+ interface Config {
25
+ default: string;
26
+ profiles: Record<string, Profile>;
27
+ apiKeys?: Record<string, string>;
28
+ }
29
+
30
+ interface GlobalOptions {
31
+ json?: boolean;
32
+ profile?: string;
33
+ debug?: boolean;
34
+ }
35
+
36
+ // =============================================================================
37
+ // Configuration
38
+ // =============================================================================
39
+
40
+ const CONFIG_PATH = join(homedir(), ".vtrivrc");
41
+
42
+ function loadConfig(): Config | null {
43
+ if (!existsSync(CONFIG_PATH)) return null;
44
+ try {
45
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function saveConfig(config: Config): void {
52
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
53
+ }
54
+
55
+ function getProfile(profileName?: string): Profile {
56
+ const config = loadConfig();
57
+ if (!config) {
58
+ console.error(chalk.red("No config found. Run 'vtriv init' first."));
59
+ process.exit(1);
60
+ }
61
+ const name = profileName || config.default;
62
+ const profile = config.profiles[name];
63
+ if (!profile) {
64
+ console.error(chalk.red(`Profile '${name}' not found.`));
65
+ process.exit(1);
66
+ }
67
+ return profile;
68
+ }
69
+
70
+ // =============================================================================
71
+ // Output Helpers
72
+ // =============================================================================
73
+
74
+ function output(data: unknown, opts: GlobalOptions): void {
75
+ if (opts.json) {
76
+ console.log(JSON.stringify(data, null, 2));
77
+ } else if (Array.isArray(data)) {
78
+ printTable(data);
79
+ } else if (typeof data === "object" && data !== null) {
80
+ for (const [key, value] of Object.entries(data)) {
81
+ console.log(`${chalk.cyan(key)}: ${JSON.stringify(value)}`);
82
+ }
83
+ } else {
84
+ console.log(data);
85
+ }
86
+ }
87
+
88
+ function printTable(rows: Record<string, unknown>[]): void {
89
+ if (rows.length === 0) {
90
+ console.log(chalk.dim("(no results)"));
91
+ return;
92
+ }
93
+ const keys = Object.keys(rows[0] ?? {});
94
+ const widths = keys.map((k) =>
95
+ Math.max(k.length, ...rows.map((r) => String(r[k] ?? "").length))
96
+ );
97
+
98
+ // Header
99
+ const header = keys.map((k, i) => k.padEnd(widths[i] ?? 0)).join(" ");
100
+ console.log(chalk.bold(header));
101
+ console.log(chalk.dim("-".repeat(header.length)));
102
+
103
+ // Rows
104
+ for (const row of rows) {
105
+ const line = keys.map((k, i) => String(row[k] ?? "").padEnd(widths[i] ?? 0)).join(" ");
106
+ console.log(line);
107
+ }
108
+ }
109
+
110
+ function error(msg: string, opts: GlobalOptions): void {
111
+ if (opts.json) {
112
+ console.error(JSON.stringify({ error: msg }));
113
+ } else {
114
+ console.error(chalk.red(`Error: ${msg}`));
115
+ }
116
+ process.exit(1);
117
+ }
118
+
119
+ // =============================================================================
120
+ // HTTP Request Helper
121
+ // =============================================================================
122
+
123
+ // Cache for minted tokens per project
124
+ const tokenCache = new Map<string, { token: string; expires: number }>();
125
+
126
+ async function request(
127
+ service: string,
128
+ path: string,
129
+ options: {
130
+ method?: string;
131
+ body?: unknown;
132
+ project?: string;
133
+ },
134
+ opts: GlobalOptions
135
+ ): Promise<unknown> {
136
+ const profile = getProfile(opts.profile);
137
+ const url = `${profile.baseUrl}/${service}${path}`;
138
+ const method = options.method || "GET";
139
+
140
+ const headers: Record<string, string> = {
141
+ "Content-Type": "application/json",
142
+ };
143
+
144
+ // Auth service uses root key via Bearer header
145
+ if (service === "auth") {
146
+ headers["Authorization"] = `Bearer ${profile.rootKey}`;
147
+ } else if (options.project) {
148
+ // Other services need a JWT minted via auth
149
+ const token = await getProjectToken(options.project, opts);
150
+ headers["Authorization"] = `Bearer ${token}`;
151
+ }
152
+
153
+ if (opts.debug) {
154
+ const bodyArg = options.body ? `-d '${JSON.stringify(options.body)}'` : "";
155
+ const authArg = headers["Authorization"]
156
+ ? `-H "Authorization: ${headers["Authorization"]}"`
157
+ : "";
158
+ console.error(chalk.dim(`curl -X ${method} "${url}" ${authArg} ${bodyArg}`));
159
+ }
160
+
161
+ const res = await fetch(url, {
162
+ method,
163
+ headers,
164
+ body: options.body ? JSON.stringify(options.body) : undefined,
165
+ });
166
+
167
+ const text = await res.text();
168
+ let data: unknown;
169
+ try {
170
+ data = JSON.parse(text);
171
+ } catch {
172
+ data = text;
173
+ }
174
+
175
+ if (!res.ok) {
176
+ const errMsg = typeof data === "object" && data !== null && "error" in data
177
+ ? (data as { error: string }).error
178
+ : text;
179
+ error(errMsg, opts);
180
+ }
181
+
182
+ return data;
183
+ }
184
+
185
+ async function getProjectToken(project: string, opts: GlobalOptions): Promise<string> {
186
+ const cached = tokenCache.get(project);
187
+ if (cached && cached.expires > Date.now()) {
188
+ return cached.token;
189
+ }
190
+
191
+ const profile = getProfile(opts.profile);
192
+
193
+ // Get or create a CLI API key
194
+ let cliApiKey = getCachedApiKey(project);
195
+
196
+ if (!cliApiKey) {
197
+ // Create a new API key for CLI use
198
+ const createKeyRes = await fetch(`${profile.baseUrl}/auth/${project}/api-keys`, {
199
+ method: "POST",
200
+ headers: {
201
+ "Authorization": `Bearer ${profile.rootKey}`,
202
+ "Content-Type": "application/json",
203
+ },
204
+ body: JSON.stringify({ name: "vtriv-cli" }),
205
+ });
206
+
207
+ if (!createKeyRes.ok) {
208
+ const err = await createKeyRes.text();
209
+ error(`Failed to create CLI API key: ${err}`, opts);
210
+ }
211
+
212
+ const keyData = (await createKeyRes.json()) as { api_key: string };
213
+ cliApiKey = keyData.api_key;
214
+ cacheApiKey(project, cliApiKey);
215
+ }
216
+
217
+ // Now mint a token using the API key
218
+ const mintRes = await fetch(`${profile.baseUrl}/auth/${project}/token`, {
219
+ method: "POST",
220
+ headers: {
221
+ "Authorization": `Bearer ${cliApiKey}`,
222
+ "Content-Type": "application/json",
223
+ },
224
+ body: JSON.stringify({ expires_in: 3600 }),
225
+ });
226
+
227
+ if (!mintRes.ok) {
228
+ // API key might be invalid/deleted, clear cache and retry
229
+ clearCachedApiKey(project);
230
+ error(`Failed to mint token. Try again.`, opts);
231
+ }
232
+
233
+ const tokenData = (await mintRes.json()) as { token: string };
234
+ const token = tokenData.token;
235
+
236
+ // Cache for 55 minutes (token valid for 60)
237
+ tokenCache.set(project, { token, expires: Date.now() + 55 * 60 * 1000 });
238
+
239
+ return token;
240
+ }
241
+
242
+ // API key cache (stored in config)
243
+ function getCachedApiKey(project: string): string | null {
244
+ const config = loadConfig();
245
+ return config?.apiKeys?.[project] || null;
246
+ }
247
+
248
+ function cacheApiKey(project: string, apiKey: string): void {
249
+ const config = loadConfig();
250
+ if (!config) return;
251
+ config.apiKeys = config.apiKeys || {};
252
+ config.apiKeys[project] = apiKey;
253
+ saveConfig(config);
254
+ }
255
+
256
+ function clearCachedApiKey(project: string): void {
257
+ const config = loadConfig();
258
+ if (!config) return;
259
+ if (config.apiKeys) {
260
+ delete config.apiKeys[project];
261
+ saveConfig(config);
262
+ }
263
+ }
264
+
265
+ // =============================================================================
266
+ // Program
267
+ // =============================================================================
268
+
269
+ const program = new Command();
270
+
271
+ program
272
+ .name("vtriv")
273
+ .description("CLI for vtriv backend services")
274
+ .version("0.1.0")
275
+ .option("--json", "Output raw JSON")
276
+ .option("--profile <name>", "Use specific profile")
277
+ .option("--debug", "Show HTTP requests");
278
+
279
+ // =============================================================================
280
+ // Init Command
281
+ // =============================================================================
282
+
283
+ program
284
+ .command("init")
285
+ .description("Initialize CLI configuration")
286
+ .action(async () => {
287
+ const existing = loadConfig();
288
+
289
+ console.log(chalk.bold("vtriv CLI Setup\n"));
290
+
291
+ const readline = await import("node:readline");
292
+ const rl = readline.createInterface({
293
+ input: process.stdin,
294
+ output: process.stdout,
295
+ });
296
+
297
+ const question = (q: string): Promise<string> =>
298
+ new Promise((resolve) => rl.question(q, resolve));
299
+
300
+ const baseUrl = await question(
301
+ `Base URL [${existing?.profiles?.prod?.baseUrl || "https://api.vtriv.com"}]: `
302
+ ) || existing?.profiles?.prod?.baseUrl || "https://api.vtriv.com";
303
+
304
+ const rootKey = await question("Root Key (vtriv_rk_...): ");
305
+
306
+ if (!rootKey) {
307
+ console.error(chalk.red("Root key is required."));
308
+ rl.close();
309
+ process.exit(1);
310
+ }
311
+
312
+ if (!rootKey.startsWith("vtriv_rk_")) {
313
+ console.error(chalk.red("Root key must start with 'vtriv_rk_'."));
314
+ rl.close();
315
+ process.exit(1);
316
+ }
317
+
318
+ const config: Config = {
319
+ default: "prod",
320
+ profiles: {
321
+ prod: { baseUrl, rootKey },
322
+ },
323
+ };
324
+
325
+ saveConfig(config);
326
+ console.log(chalk.green(`\nConfig saved to ${CONFIG_PATH}`));
327
+ rl.close();
328
+ });
329
+
330
+ // =============================================================================
331
+ // Auth Commands
332
+ // =============================================================================
333
+
334
+ const auth = program.command("auth").description("Auth service commands");
335
+
336
+ // Account commands (root key only)
337
+ auth
338
+ .command("account:list")
339
+ .description("List all accounts")
340
+ .action(async () => {
341
+ const opts = program.opts<GlobalOptions>();
342
+ const data = (await request("auth", "/accounts", {}, opts)) as { accounts: unknown[] };
343
+ output(data.accounts, opts);
344
+ });
345
+
346
+ auth
347
+ .command("account:create")
348
+ .description("Create a new account")
349
+ .option("--name <name>", "Account name")
350
+ .action(async (cmdOpts: { name?: string }) => {
351
+ const opts = program.opts<GlobalOptions>();
352
+ const body: Record<string, unknown> = {};
353
+ if (cmdOpts.name) body.name = cmdOpts.name;
354
+ const data = await request("auth", "/accounts", { method: "POST", body }, opts);
355
+ output(data, opts);
356
+ if (!opts.json) {
357
+ console.log(chalk.yellow("\nIMPORTANT: Save the account_key above - it cannot be retrieved later!"));
358
+ }
359
+ });
360
+
361
+ auth
362
+ .command("account:get")
363
+ .description("Get account details")
364
+ .argument("<id>", "Account ID")
365
+ .action(async (id: string) => {
366
+ const opts = program.opts<GlobalOptions>();
367
+ const data = await request("auth", `/accounts/${id}`, {}, opts);
368
+ output(data, opts);
369
+ });
370
+
371
+ auth
372
+ .command("account:delete")
373
+ .description("Delete an account")
374
+ .argument("<id>", "Account ID")
375
+ .action(async (id: string) => {
376
+ const opts = program.opts<GlobalOptions>();
377
+ const data = await request("auth", `/accounts/${id}`, { method: "DELETE" }, opts);
378
+ output(data, opts);
379
+ });
380
+
381
+ // Project commands
382
+ auth
383
+ .command("project:list")
384
+ .description("List all projects")
385
+ .action(async () => {
386
+ const opts = program.opts<GlobalOptions>();
387
+ const data = (await request("auth", "/projects", {}, opts)) as { projects: unknown[] };
388
+ output(data.projects, opts);
389
+ });
390
+
391
+ auth
392
+ .command("project:create")
393
+ .description("Create a new project")
394
+ .argument("<name>", "Project name")
395
+ .action(async (name: string) => {
396
+ const opts = program.opts<GlobalOptions>();
397
+ const data = await request("auth", "/projects", { method: "POST", body: { name } }, opts);
398
+ output(data, opts);
399
+ });
400
+
401
+ auth
402
+ .command("project:get")
403
+ .description("Get project details")
404
+ .argument("<name>", "Project name")
405
+ .action(async (name: string) => {
406
+ const opts = program.opts<GlobalOptions>();
407
+ const data = await request("auth", `/projects/${name}`, {}, opts);
408
+ output(data, opts);
409
+ });
410
+
411
+ auth
412
+ .command("project:delete")
413
+ .description("Delete a project")
414
+ .argument("<name>", "Project name")
415
+ .action(async (name: string) => {
416
+ const opts = program.opts<GlobalOptions>();
417
+ const data = await request("auth", `/projects/${name}`, { method: "DELETE" }, opts);
418
+ output(data, opts);
419
+ });
420
+
421
+ auth
422
+ .command("user:list")
423
+ .description("List users in a project")
424
+ .argument("<project>", "Project name")
425
+ .action(async (project: string) => {
426
+ const opts = program.opts<GlobalOptions>();
427
+ const data = (await request("auth", `/${project}/users`, {}, opts)) as { users: unknown[] };
428
+ output(data.users, opts);
429
+ });
430
+
431
+ auth
432
+ .command("user:create")
433
+ .description("Create a user")
434
+ .argument("<project>", "Project name")
435
+ .argument("<email>", "User email")
436
+ .argument("[password]", "User password")
437
+ .action(async (project: string, email: string, password?: string) => {
438
+ const opts = program.opts<GlobalOptions>();
439
+ const body: Record<string, unknown> = { email };
440
+ if (password) body.password = password;
441
+ const data = await request("auth", `/${project}/users`, { method: "POST", body }, opts);
442
+ output(data, opts);
443
+ });
444
+
445
+ auth
446
+ .command("user:delete")
447
+ .description("Delete a user")
448
+ .argument("<project>", "Project name")
449
+ .argument("<id>", "User ID")
450
+ .action(async (project: string, id: string) => {
451
+ const opts = program.opts<GlobalOptions>();
452
+ const data = await request("auth", `/${project}/users/${id}`, { method: "DELETE" }, opts);
453
+ output(data, opts);
454
+ });
455
+
456
+ auth
457
+ .command("token:mint")
458
+ .description("Mint a JWT token for a project")
459
+ .argument("<project>", "Project name")
460
+ .action(async (project: string) => {
461
+ const opts = program.opts<GlobalOptions>();
462
+ const token = await getProjectToken(project, opts);
463
+ if (opts.json) {
464
+ console.log(JSON.stringify({ token }));
465
+ } else {
466
+ console.log(token);
467
+ }
468
+ });
469
+
470
+ auth
471
+ .command("apikey:list")
472
+ .description("List API keys for a project")
473
+ .argument("<project>", "Project name")
474
+ .action(async (project: string) => {
475
+ const opts = program.opts<GlobalOptions>();
476
+ const data = (await request("auth", `/${project}/api-keys`, {}, opts)) as { api_keys: unknown[] };
477
+ output(data.api_keys, opts);
478
+ });
479
+
480
+ auth
481
+ .command("apikey:create")
482
+ .description("Create an API key")
483
+ .argument("<project>", "Project name")
484
+ .option("--name <name>", "Key name")
485
+ .action(async (project: string, cmdOpts: { name?: string }) => {
486
+ const opts = program.opts<GlobalOptions>();
487
+ const body: Record<string, unknown> = {};
488
+ if (cmdOpts.name) body.name = cmdOpts.name;
489
+ const data = await request("auth", `/${project}/api-keys`, { method: "POST", body }, opts);
490
+ output(data, opts);
491
+ });
492
+
493
+ auth
494
+ .command("apikey:delete")
495
+ .description("Delete an API key")
496
+ .argument("<project>", "Project name")
497
+ .argument("<id>", "API key ID")
498
+ .action(async (project: string, id: string) => {
499
+ const opts = program.opts<GlobalOptions>();
500
+ const data = await request("auth", `/${project}/api-keys/${id}`, { method: "DELETE" }, opts);
501
+ output(data, opts);
502
+ });
503
+
504
+ // =============================================================================
505
+ // DB Commands
506
+ // =============================================================================
507
+
508
+ const db = program.command("db").description("Database service commands");
509
+
510
+ db
511
+ .command("ls")
512
+ .description("List documents")
513
+ .argument("<project>", "Project name")
514
+ .argument("<collection>", "Collection name")
515
+ .option("-f, --filter <json>", "Filter criteria (JSON)")
516
+ .option("-l, --limit <n>", "Limit results")
517
+ .option("-s, --sort <field>", "Sort field (prefix with - for desc)")
518
+ .action(async (project: string, collection: string, cmdOpts: { filter?: string; limit?: string; sort?: string }) => {
519
+ const opts = program.opts<GlobalOptions>();
520
+ const params = new URLSearchParams();
521
+ if (cmdOpts.filter) {
522
+ const filter = JSON.parse(cmdOpts.filter);
523
+ for (const [k, v] of Object.entries(filter)) {
524
+ params.set(k, String(v));
525
+ }
526
+ }
527
+ if (cmdOpts.limit) params.set("_limit", cmdOpts.limit);
528
+ if (cmdOpts.sort) params.set("_sort", cmdOpts.sort);
529
+ const query = params.toString() ? `?${params.toString()}` : "";
530
+ const data = (await request("db", `/${project}/${collection}${query}`, { project }, opts)) as { data: unknown[] };
531
+ output(data.data, opts);
532
+ });
533
+
534
+ db
535
+ .command("get")
536
+ .description("Get a document")
537
+ .argument("<project>", "Project name")
538
+ .argument("<collection>", "Collection name")
539
+ .argument("<id>", "Document ID")
540
+ .action(async (project: string, collection: string, id: string) => {
541
+ const opts = program.opts<GlobalOptions>();
542
+ const data = await request("db", `/${project}/${collection}/${id}`, { project }, opts);
543
+ output(data, opts);
544
+ });
545
+
546
+ db
547
+ .command("put")
548
+ .description("Create or update a document (reads JSON from stdin)")
549
+ .argument("<project>", "Project name")
550
+ .argument("<collection>", "Collection name")
551
+ .argument("[id]", "Document ID (for update)")
552
+ .action(async (project: string, collection: string, id?: string) => {
553
+ const opts = program.opts<GlobalOptions>();
554
+
555
+ // Read from stdin
556
+ const chunks: Buffer[] = [];
557
+ for await (const chunk of Bun.stdin.stream()) {
558
+ chunks.push(Buffer.from(chunk));
559
+ }
560
+ const input = Buffer.concat(chunks).toString("utf-8").trim();
561
+
562
+ if (!input) {
563
+ error("No JSON provided on stdin", opts);
564
+ }
565
+
566
+ let body: unknown;
567
+ try {
568
+ body = JSON.parse(input);
569
+ } catch {
570
+ error("Invalid JSON on stdin", opts);
571
+ }
572
+
573
+ if (id) {
574
+ // Update (PUT)
575
+ const data = await request("db", `/${project}/${collection}/${id}`, { method: "PUT", body, project }, opts);
576
+ output(data, opts);
577
+ } else {
578
+ // Create (POST)
579
+ const data = await request("db", `/${project}/${collection}`, { method: "POST", body, project }, opts);
580
+ output(data, opts);
581
+ }
582
+ });
583
+
584
+ db
585
+ .command("rm")
586
+ .description("Delete a document")
587
+ .argument("<project>", "Project name")
588
+ .argument("<collection>", "Collection name")
589
+ .argument("<id>", "Document ID")
590
+ .action(async (project: string, collection: string, id: string) => {
591
+ const opts = program.opts<GlobalOptions>();
592
+ const data = await request("db", `/${project}/${collection}/${id}`, { method: "DELETE", project }, opts);
593
+ output(data, opts);
594
+ });
595
+
596
+ db
597
+ .command("schema")
598
+ .description("Get or set collection schema")
599
+ .argument("<project>", "Project name")
600
+ .argument("<collection>", "Collection name")
601
+ .option("--set", "Set schema (reads JSON from stdin)")
602
+ .action(async (project: string, collection: string, cmdOpts: { set?: boolean }) => {
603
+ const opts = program.opts<GlobalOptions>();
604
+
605
+ if (cmdOpts.set) {
606
+ const chunks: Buffer[] = [];
607
+ for await (const chunk of Bun.stdin.stream()) {
608
+ chunks.push(Buffer.from(chunk));
609
+ }
610
+ const input = Buffer.concat(chunks).toString("utf-8").trim();
611
+ const body = JSON.parse(input);
612
+ const data = await request("db", `/${project}/${collection}/_schema`, { method: "PUT", body, project }, opts);
613
+ output(data, opts);
614
+ } else {
615
+ const data = await request("db", `/${project}/${collection}/_schema`, { project }, opts);
616
+ output(data, opts);
617
+ }
618
+ });
619
+
620
+ // =============================================================================
621
+ // Blob Commands
622
+ // =============================================================================
623
+
624
+ const blob = program.command("blob").description("Blob storage commands");
625
+
626
+ blob
627
+ .command("put")
628
+ .description("Upload a file")
629
+ .argument("<project>", "Project name")
630
+ .argument("<path>", "Remote path")
631
+ .argument("<file>", "Local file path")
632
+ .action(async (project: string, remotePath: string, localFile: string) => {
633
+ const opts = program.opts<GlobalOptions>();
634
+ const profile = getProfile(opts.profile);
635
+ const token = await getProjectToken(project, opts);
636
+
637
+ const file = Bun.file(localFile);
638
+ if (!(await file.exists())) {
639
+ error(`File not found: ${localFile}`, opts);
640
+ }
641
+
642
+ const url = `${profile.baseUrl}/blob/${project}/${remotePath}`;
643
+
644
+ if (opts.debug) {
645
+ console.error(chalk.dim(`curl -X PUT "${url}" -T "${localFile}"`));
646
+ }
647
+
648
+ const res = await fetch(url, {
649
+ method: "PUT",
650
+ headers: {
651
+ "Authorization": `Bearer ${token}`,
652
+ "Content-Type": file.type || "application/octet-stream",
653
+ },
654
+ body: file,
655
+ });
656
+
657
+ const data = await res.json();
658
+ if (!res.ok) {
659
+ error((data as { error?: string }).error || "Upload failed", opts);
660
+ }
661
+ output(data, opts);
662
+ });
663
+
664
+ blob
665
+ .command("get")
666
+ .description("Download a file (outputs to stdout)")
667
+ .argument("<project>", "Project name")
668
+ .argument("<path>", "Remote path")
669
+ .action(async (project: string, remotePath: string) => {
670
+ const opts = program.opts<GlobalOptions>();
671
+ const profile = getProfile(opts.profile);
672
+ const token = await getProjectToken(project, opts);
673
+
674
+ const url = `${profile.baseUrl}/blob/${project}/${remotePath}`;
675
+
676
+ if (opts.debug) {
677
+ console.error(chalk.dim(`curl "${url}"`));
678
+ }
679
+
680
+ const res = await fetch(url, {
681
+ headers: { "Authorization": `Bearer ${token}` },
682
+ });
683
+
684
+ if (!res.ok) {
685
+ const data = await res.json();
686
+ error((data as { error?: string }).error || "Download failed", opts);
687
+ }
688
+
689
+ // Stream to stdout
690
+ const writer = Bun.stdout.writer();
691
+ const reader = res.body?.getReader();
692
+ if (reader) {
693
+ while (true) {
694
+ const { done, value } = await reader.read();
695
+ if (done) break;
696
+ writer.write(value);
697
+ }
698
+ await writer.flush();
699
+ }
700
+ });
701
+
702
+ blob
703
+ .command("rm")
704
+ .description("Delete a file")
705
+ .argument("<project>", "Project name")
706
+ .argument("<path>", "Remote path")
707
+ .action(async (project: string, remotePath: string) => {
708
+ const opts = program.opts<GlobalOptions>();
709
+ const profile = getProfile(opts.profile);
710
+ const token = await getProjectToken(project, opts);
711
+
712
+ const url = `${profile.baseUrl}/blob/${project}/${remotePath}`;
713
+
714
+ const res = await fetch(url, {
715
+ method: "DELETE",
716
+ headers: { "Authorization": `Bearer ${token}` },
717
+ });
718
+
719
+ const data = await res.json();
720
+ if (!res.ok) {
721
+ error((data as { error?: string }).error || "Delete failed", opts);
722
+ }
723
+ output(data, opts);
724
+ });
725
+
726
+ // =============================================================================
727
+ // Cron Commands
728
+ // =============================================================================
729
+
730
+ const cron = program.command("cron").description("Cron service commands");
731
+
732
+ cron
733
+ .command("ls")
734
+ .description("List jobs")
735
+ .argument("<project>", "Project name")
736
+ .action(async (project: string) => {
737
+ const opts = program.opts<GlobalOptions>();
738
+ const data = (await request("cron", `/${project}/jobs`, { project }, opts)) as { jobs: unknown[] };
739
+ output(data.jobs, opts);
740
+ });
741
+
742
+ cron
743
+ .command("runs")
744
+ .description("List run history")
745
+ .argument("<project>", "Project name")
746
+ .option("-l, --limit <n>", "Limit results", "100")
747
+ .option("--job <id>", "Filter by job ID")
748
+ .action(async (project: string, cmdOpts: { limit: string; job?: string }) => {
749
+ const opts = program.opts<GlobalOptions>();
750
+ const params = new URLSearchParams({ limit: cmdOpts.limit });
751
+ if (cmdOpts.job) params.set("job_id", cmdOpts.job);
752
+ const data = (await request("cron", `/${project}/runs?${params}`, { project }, opts)) as { runs: unknown[] };
753
+ output(data.runs, opts);
754
+ });
755
+
756
+ cron
757
+ .command("trigger")
758
+ .description("Manually trigger a job")
759
+ .argument("<project>", "Project name")
760
+ .argument("<job_id>", "Job ID")
761
+ .action(async (project: string, jobId: string) => {
762
+ const opts = program.opts<GlobalOptions>();
763
+ const data = await request("cron", `/${project}/jobs/${jobId}/trigger`, { method: "POST", project }, opts);
764
+ output(data, opts);
765
+ });
766
+
767
+ cron
768
+ .command("script:cat")
769
+ .description("Output script content")
770
+ .argument("<project>", "Project name")
771
+ .argument("<script>", "Script name")
772
+ .action(async (project: string, script: string) => {
773
+ const opts = program.opts<GlobalOptions>();
774
+ const profile = getProfile(opts.profile);
775
+ const token = await getProjectToken(project, opts);
776
+
777
+ const url = `${profile.baseUrl}/cron/${project}/scripts/${script}`;
778
+ const res = await fetch(url, {
779
+ headers: { "Authorization": `Bearer ${token}` },
780
+ });
781
+
782
+ if (!res.ok) {
783
+ const data = await res.json();
784
+ error((data as { error?: string }).error || "Failed to get script", opts);
785
+ }
786
+
787
+ console.log(await res.text());
788
+ });
789
+
790
+ // =============================================================================
791
+ // AI Commands
792
+ // =============================================================================
793
+
794
+ const ai = program.command("ai").description("AI service commands");
795
+
796
+ ai
797
+ .command("stats")
798
+ .description("View usage statistics")
799
+ .argument("<project>", "Project name")
800
+ .option("-p, --period <days>", "Period in days", "30")
801
+ .action(async (project: string, cmdOpts: { period: string }) => {
802
+ const opts = program.opts<GlobalOptions>();
803
+ const data = await request("ai", `/usage/stats?period=${cmdOpts.period}d`, { project }, opts);
804
+ output(data, opts);
805
+ });
806
+
807
+ ai
808
+ .command("chat")
809
+ .description("Send a chat completion request")
810
+ .argument("<project>", "Project name")
811
+ .argument("<prompt>", "Chat prompt")
812
+ .option("-m, --model <model>", "Model to use (defaults to project config)")
813
+ .action(async (project: string, prompt: string, cmdOpts: { model?: string }) => {
814
+ const opts = program.opts<GlobalOptions>();
815
+ const profile = getProfile(opts.profile);
816
+ const token = await getProjectToken(project, opts);
817
+
818
+ // Get default model from project config if not specified
819
+ let model = cmdOpts.model;
820
+ if (!model) {
821
+ const configRes = await fetch(`${profile.baseUrl}/ai/config`, {
822
+ headers: { "Authorization": `Bearer ${token}` },
823
+ });
824
+ if (configRes.ok) {
825
+ const config = await configRes.json() as { default_model?: string };
826
+ model = config.default_model || "openai/gpt-4o-mini";
827
+ } else {
828
+ model = "openai/gpt-4o-mini";
829
+ }
830
+ }
831
+
832
+ const url = `${profile.baseUrl}/ai/v1/chat/completions`;
833
+
834
+ if (opts.debug) {
835
+ console.error(chalk.dim(`curl -X POST "${url}" ...`));
836
+ }
837
+
838
+ const res = await fetch(url, {
839
+ method: "POST",
840
+ headers: {
841
+ "Authorization": `Bearer ${token}`,
842
+ "Content-Type": "application/json",
843
+ },
844
+ body: JSON.stringify({
845
+ model,
846
+ messages: [{ role: "user", content: prompt }],
847
+ }),
848
+ });
849
+
850
+ const data = await res.json();
851
+ if (!res.ok) {
852
+ error((data as { error?: string }).error || "Chat failed", opts);
853
+ }
854
+
855
+ if (opts.json) {
856
+ console.log(JSON.stringify(data, null, 2));
857
+ } else {
858
+ const content = (data as { choices: Array<{ message: { content: string } }> }).choices?.[0]?.message?.content;
859
+ console.log(content || "(no response)");
860
+ }
861
+ });
862
+
863
+ ai
864
+ .command("config")
865
+ .description("Get or set AI config for a project")
866
+ .argument("<project>", "Project name")
867
+ .option("--model <model>", "Set default model")
868
+ .option("--rate-limit <usd>", "Set rate limit in USD")
869
+ .option("--key <key>", "Set custom OpenRouter API key")
870
+ .action(async (project: string, cmdOpts: { model?: string; rateLimit?: string; key?: string }) => {
871
+ const opts = program.opts<GlobalOptions>();
872
+
873
+ // If any options provided, update config
874
+ if (cmdOpts.model || cmdOpts.rateLimit || cmdOpts.key) {
875
+ const body: Record<string, unknown> = {};
876
+ if (cmdOpts.model) body.default_model = cmdOpts.model;
877
+ if (cmdOpts.rateLimit) body.rate_limit_usd = Number.parseFloat(cmdOpts.rateLimit);
878
+ if (cmdOpts.key) body.key = cmdOpts.key;
879
+
880
+ await request("ai", "/config", { method: "PUT", body, project }, opts);
881
+ console.log("Config updated");
882
+ }
883
+
884
+ // Always show current config
885
+ const data = await request("ai", "/config", { project }, opts);
886
+ output(data, opts);
887
+ });
888
+
889
+ // =============================================================================
890
+ // Search Commands
891
+ // =============================================================================
892
+
893
+ const search = program.command("search").description("Search service commands");
894
+
895
+ search
896
+ .command("config")
897
+ .description("Get or set index configuration")
898
+ .argument("<project>", "Project name")
899
+ .argument("<index>", "Index name")
900
+ .option("--set", "Set config (reads JSON from stdin)")
901
+ .action(async (project: string, index: string, cmdOpts: { set?: boolean }) => {
902
+ const opts = program.opts<GlobalOptions>();
903
+
904
+ if (cmdOpts.set) {
905
+ const chunks: Buffer[] = [];
906
+ for await (const chunk of Bun.stdin.stream()) {
907
+ chunks.push(Buffer.from(chunk));
908
+ }
909
+ const input = Buffer.concat(chunks).toString("utf-8").trim();
910
+ const body = JSON.parse(input);
911
+ const data = await request("search", `/${project}/${index}/_config`, { method: "PUT", body, project }, opts);
912
+ output(data, opts);
913
+ } else {
914
+ const data = await request("search", `/${project}/${index}/_config`, { project }, opts);
915
+ output(data, opts);
916
+ }
917
+ });
918
+
919
+ search
920
+ .command("query")
921
+ .description("Search an index")
922
+ .argument("<project>", "Project name")
923
+ .argument("<index>", "Index name")
924
+ .argument("<q>", "Search query")
925
+ .option("-l, --limit <n>", "Limit results", "10")
926
+ .action(async (project: string, index: string, q: string, cmdOpts: { limit: string }) => {
927
+ const opts = program.opts<GlobalOptions>();
928
+ const params = new URLSearchParams({ q, limit: cmdOpts.limit });
929
+ const data = (await request("search", `/${project}/${index}/search?${params}`, { project }, opts)) as { results: unknown[] };
930
+ output(data.results, opts);
931
+ });
932
+
933
+ // =============================================================================
934
+ // Parse & Run
935
+ // =============================================================================
936
+
937
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@vtriv/cli",
3
+ "version": "0.1.0",
4
+ "module": "index.ts",
5
+ "type": "module",
6
+ "description": "CLI for vtriv backend services - auth, db, blob, search, ai, cron",
7
+ "bin": {
8
+ "vtriv": "./index.ts"
9
+ },
10
+ "files": [
11
+ "index.ts"
12
+ ],
13
+ "scripts": {
14
+ "cli": "bun run index.ts"
15
+ },
16
+ "engines": {
17
+ "bun": ">=1.0.0"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/vtriv/vtriv"
22
+ },
23
+ "keywords": [
24
+ "vtriv",
25
+ "cli",
26
+ "bun",
27
+ "auth",
28
+ "database",
29
+ "blob",
30
+ "search",
31
+ "ai"
32
+ ],
33
+ "author": "vtriv",
34
+ "license": "MIT",
35
+ "devDependencies": {
36
+ "@types/bun": "latest"
37
+ },
38
+ "dependencies": {
39
+ "chalk": "^5.6.2",
40
+ "commander": "^14.0.2"
41
+ }
42
+ }