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