@celsian/vura-cli 0.5.3 → 0.5.5

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.
@@ -14,9 +14,8 @@
14
14
  */
15
15
  import { readFile } from 'node:fs/promises';
16
16
  import { existsSync } from 'node:fs';
17
- import { homedir } from 'node:os';
18
17
  import { join } from 'node:path';
19
- const DEFAULT_API_URL = 'https://api.vura.io';
18
+ import { resolveApiUrl, resolveProjectId, resolveToken } from '../vura-client.js';
20
19
  function parseFlags(args) {
21
20
  const flags = { production: false };
22
21
  for (let i = 0; i < args.length; i++) {
@@ -42,38 +41,6 @@ function parseFlags(args) {
42
41
  }
43
42
  return flags;
44
43
  }
45
- /** Home directory, honoring HOME/USERPROFILE overrides (e.g. in tests). */
46
- function resolveHome() {
47
- return process.env.HOME || process.env.USERPROFILE || homedir();
48
- }
49
- async function resolveToken(flag) {
50
- if (flag)
51
- return flag;
52
- if (process.env.VURA_TOKEN)
53
- return process.env.VURA_TOKEN;
54
- try {
55
- const raw = await readFile(join(resolveHome(), '.vura', 'credentials'), 'utf-8');
56
- const creds = JSON.parse(raw);
57
- return creds.token ?? null;
58
- }
59
- catch {
60
- return null;
61
- }
62
- }
63
- async function resolveProjectId(flag, projectRoot) {
64
- if (flag)
65
- return flag;
66
- if (process.env.VURA_PROJECT_ID)
67
- return process.env.VURA_PROJECT_ID;
68
- try {
69
- const raw = await readFile(join(projectRoot, '.vura', 'project.json'), 'utf-8');
70
- const link = JSON.parse(raw);
71
- return link.projectId ?? null;
72
- }
73
- catch {
74
- return null;
75
- }
76
- }
77
44
  export async function deployCommand(args) {
78
45
  const projectRoot = process.cwd();
79
46
  const flags = parseFlags(args);
@@ -81,7 +48,7 @@ export async function deployCommand(args) {
81
48
  // 1. Resolve authentication.
82
49
  const token = await resolveToken(flags.token);
83
50
  if (!token) {
84
- console.error(' Not authenticated. Set VURA_TOKEN, pass --token <token>, or sign in so ~/.vura/credentials exists.');
51
+ console.error(' Not authenticated. Run `vura login`, set VURA_TOKEN, or pass --token <token>.');
85
52
  process.exitCode = 1;
86
53
  return;
87
54
  }
@@ -89,6 +56,7 @@ export async function deployCommand(args) {
89
56
  const projectId = await resolveProjectId(flags.projectId, projectRoot);
90
57
  if (!projectId) {
91
58
  console.error(' Project not linked. Set VURA_PROJECT_ID, pass --project-id <id>, or create .vura/project.json.');
59
+ console.error(' Run `vura teams list` to find a team, then `vura projects create <name> --team <id-or-slug>`.');
92
60
  process.exitCode = 1;
93
61
  return;
94
62
  }
@@ -100,7 +68,7 @@ export async function deployCommand(args) {
100
68
  process.exitCode = 1;
101
69
  return;
102
70
  }
103
- const apiUrl = flags.apiUrl || process.env.VURA_API_URL || DEFAULT_API_URL;
71
+ const apiUrl = resolveApiUrl(flags.apiUrl);
104
72
  // Attach the built manifest so the platform can classify routes without
105
73
  // re-scanning the artifact.
106
74
  let manifest;
@@ -117,7 +85,7 @@ export async function deployCommand(args) {
117
85
  }
118
86
  catch {
119
87
  console.error(' Managed Vura deploy support is not installed in this CLI package.');
120
- console.error(' The Vura Platform adapter is closed-alpha; use the Vura Platform CLI bundle or self-host adapters until access is granted.');
88
+ console.error(' Install it with: npm install @celsian/vura-adapter-vura');
121
89
  process.exitCode = 1;
122
90
  return;
123
91
  }
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { renderToString as builtinRenderToString } from 'what-framework/server';
16
16
  import { importRouteModule } from './shared.js';
17
- import { buildManifest, compilePageRoutes, matchPageRoute, compileRoutes, matchApiPath, getLogger, wrapDocument, escapeHtml, parseNodeBody, reportError, getMimeType, createApiApp, createWsUpgradeHandler, createNoServerWebSocketServer, GLOBAL_HOOKS_FILENAMES, nodeToWebRequest, writeWebResponse, generateClientPageEntry, } from '@celsian/vura-core';
17
+ import { buildManifest, compilePageRoutes, matchPageRoute, compileRoutes, matchApiPath, getLogger, wrapDocument, escapeHtml, parseNodeBody, reportError, getMimeType, runTaskOnce, buildTaskEnvelope, createApiApp, createWsUpgradeHandler, createNoServerWebSocketServer, GLOBAL_HOOKS_FILENAMES, nodeToWebRequest, writeWebResponse, generateClientPageEntry, } from '@celsian/vura-core';
18
18
  export function parseDevOptions(args, projectRoot = process.cwd()) {
19
19
  const portArg = args.find((_, i) => args[i - 1] === '--port');
20
20
  const hostArg = args.find((_, i) => args[i - 1] === '--host');
@@ -375,14 +375,39 @@ export async function startStandaloneServer(manifest, opts) {
375
375
  }
376
376
  try {
377
377
  const mod = await loadHandler(taskRoute.filePath);
378
+ if (typeof mod.POST !== 'function') {
379
+ res.writeHead(400, { 'Content-Type': 'application/json' });
380
+ res.end(JSON.stringify({ error: 'Task must export POST handler' }));
381
+ return;
382
+ }
378
383
  const body = await parseNodeBody(req);
379
- const result = await mod.POST({
380
- taskId: String(Date.now()),
381
- input: body?.input,
382
- attempt: 1,
383
- });
384
+ // Accept both `{ input: ... }` (legacy admin convention + the platform's
385
+ // `{ taskId, input, attempt }` wrapper) and a raw payload posted directly
386
+ // as the body (enqueue()'s local fallback).
387
+ const input = body && typeof body === 'object' && 'input' in body
388
+ ? body.input
389
+ : body;
390
+ // Platform cron/synthetic dispatches (X-Vura-Cron: true) skip input
391
+ // validation, mirroring the standalone server + in-process cron.
392
+ const isCronDispatch = String(req.headers['x-vura-cron'] ?? '').toLowerCase() === 'true';
393
+ const runResult = await runTaskOnce({
394
+ name: taskName,
395
+ config: {
396
+ retries: typeof taskRoute.config.retries === 'number' ? taskRoute.config.retries : 0,
397
+ timeout: typeof taskRoute.config.timeout === 'number' ? taskRoute.config.timeout : 30_000,
398
+ },
399
+ handler: mod.POST,
400
+ inputSchema: isCronDispatch ? undefined : mod.input,
401
+ }, { input });
402
+ if (runResult.validationError) {
403
+ res.writeHead(runResult.validationError.statusCode, { 'Content-Type': 'application/json' });
404
+ res.end(JSON.stringify(runResult.validationError.body));
405
+ return;
406
+ }
407
+ // Additive envelope + legacy status/result for backward compatibility.
408
+ const envelope = buildTaskEnvelope(taskName, runResult);
384
409
  res.writeHead(200, { 'Content-Type': 'application/json' });
385
- res.end(JSON.stringify({ status: 'completed', result }));
410
+ res.end(JSON.stringify({ ...envelope, status: runResult.status, ...(runResult.error !== undefined ? { error: runResult.error } : {}) }));
386
411
  }
387
412
  catch (err) {
388
413
  reportError(err instanceof Error ? err : new Error(String(err.message)), { method, path: url.pathname, requestId: reqCtx.requestId }, logger);
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `vura login` — authenticate the CLI against the Vura Platform.
3
+ *
4
+ * Two modes:
5
+ * vura login Interactive email/password prompt (POST /v1/auth/login)
6
+ * vura login --token <t> Paste an existing token directly ("paste-token" mode)
7
+ *
8
+ * Either way, credentials land in ~/.vura/credentials at mode 0600 — the same
9
+ * file `vura deploy` and `@celsian/vura-adapter-vura` already read (see
10
+ * `vura-client.ts`), so a successful `vura login` is immediately usable by
11
+ * every other command without extra configuration.
12
+ *
13
+ * Flags:
14
+ * --token <t> Store this token directly instead of prompting for email/password.
15
+ * The token is verified against GET /v1/auth/me before being saved.
16
+ * --api-url <url> API base URL (else VURA_API_URL, else https://api.vura.io)
17
+ */
18
+ interface LoginPrompts {
19
+ /** Prompt for plain text input (echoed as typed). */
20
+ prompt: (question: string) => Promise<string>;
21
+ /** Prompt for a password (not echoed to the terminal). */
22
+ promptPassword: (question: string) => Promise<string>;
23
+ }
24
+ /**
25
+ * @param io Override the interactive prompt functions for testing. Defaults
26
+ * to real stdin/stdout prompts.
27
+ */
28
+ export declare function loginCommand(args: string[], io?: LoginPrompts): Promise<void>;
29
+ export {};
30
+ //# sourceMappingURL=login.d.ts.map
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `vura login` — authenticate the CLI against the Vura Platform.
3
+ *
4
+ * Two modes:
5
+ * vura login Interactive email/password prompt (POST /v1/auth/login)
6
+ * vura login --token <t> Paste an existing token directly ("paste-token" mode)
7
+ *
8
+ * Either way, credentials land in ~/.vura/credentials at mode 0600 — the same
9
+ * file `vura deploy` and `@celsian/vura-adapter-vura` already read (see
10
+ * `vura-client.ts`), so a successful `vura login` is immediately usable by
11
+ * every other command without extra configuration.
12
+ *
13
+ * Flags:
14
+ * --token <t> Store this token directly instead of prompting for email/password.
15
+ * The token is verified against GET /v1/auth/me before being saved.
16
+ * --api-url <url> API base URL (else VURA_API_URL, else https://api.vura.io)
17
+ */
18
+ import * as readline from 'node:readline';
19
+ import { formatApiError, resolveApiUrl, vuraApiRequest, writeCredentials } from '../vura-client.js';
20
+ const CTRL_C = String.fromCharCode(3);
21
+ const CTRL_D = String.fromCharCode(4);
22
+ const BACKSPACE = String.fromCharCode(127);
23
+ function parseFlags(args) {
24
+ const flags = {};
25
+ for (let i = 0; i < args.length; i++) {
26
+ switch (args[i]) {
27
+ case '--token':
28
+ flags.token = args[++i];
29
+ break;
30
+ case '--api-url':
31
+ flags.apiUrl = args[++i];
32
+ break;
33
+ default:
34
+ // ignore unknown args (keeps house style: lenient flag parsing)
35
+ break;
36
+ }
37
+ }
38
+ return flags;
39
+ }
40
+ function prompt(question) {
41
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
42
+ return new Promise((resolve) => {
43
+ rl.question(question, (answer) => {
44
+ rl.close();
45
+ resolve(answer.trim());
46
+ });
47
+ });
48
+ }
49
+ /**
50
+ * Prompt for a password without echoing it to the terminal. Node has no
51
+ * built-in masked-input prompt, so this puts stdin in raw mode and renders
52
+ * `*` per keystroke itself, handling backspace and Ctrl-C/Ctrl-D directly.
53
+ * Requires a real TTY — callers must gate on `process.stdin.isTTY` first.
54
+ */
55
+ function promptPassword(question) {
56
+ return new Promise((resolve) => {
57
+ const stdin = process.stdin;
58
+ process.stdout.write(question);
59
+ stdin.resume();
60
+ stdin.setRawMode?.(true);
61
+ stdin.setEncoding('utf8');
62
+ let password = '';
63
+ const onData = (chunk) => {
64
+ switch (chunk) {
65
+ case '\n':
66
+ case '\r':
67
+ case CTRL_D:
68
+ stdin.setRawMode?.(false);
69
+ stdin.pause();
70
+ stdin.removeListener('data', onData);
71
+ process.stdout.write('\n');
72
+ resolve(password);
73
+ break;
74
+ case CTRL_C:
75
+ process.stdout.write('\n');
76
+ process.exit(1);
77
+ break;
78
+ case BACKSPACE:
79
+ case '\b':
80
+ if (password.length > 0) {
81
+ password = password.slice(0, -1);
82
+ process.stdout.write('\b \b');
83
+ }
84
+ break;
85
+ default:
86
+ password += chunk;
87
+ process.stdout.write('*'.repeat(chunk.length));
88
+ break;
89
+ }
90
+ };
91
+ stdin.on('data', onData);
92
+ });
93
+ }
94
+ const DEFAULT_PROMPTS = { prompt, promptPassword };
95
+ /**
96
+ * @param io Override the interactive prompt functions for testing. Defaults
97
+ * to real stdin/stdout prompts.
98
+ */
99
+ export async function loginCommand(args, io = DEFAULT_PROMPTS) {
100
+ const flags = parseFlags(args);
101
+ const apiUrl = resolveApiUrl(flags.apiUrl);
102
+ console.log('\n vura login\n');
103
+ if (flags.token) {
104
+ console.log(` Verifying token against ${apiUrl}...`);
105
+ let email;
106
+ try {
107
+ const body = (await vuraApiRequest(apiUrl, '/v1/auth/me', { token: flags.token }));
108
+ email = body?.user?.email;
109
+ }
110
+ catch (err) {
111
+ console.error(` ${formatApiError(err)}`);
112
+ process.exitCode = 1;
113
+ return;
114
+ }
115
+ await writeCredentials({ token: flags.token, email });
116
+ console.log(` Logged in${email ? ` as ${email}` : ''}.`);
117
+ return;
118
+ }
119
+ if (!process.stdin.isTTY) {
120
+ console.error(' Interactive login requires a terminal. Use `vura login --token <token>` in CI or non-interactive environments.');
121
+ process.exitCode = 1;
122
+ return;
123
+ }
124
+ const email = await io.prompt(' Email: ');
125
+ if (!email) {
126
+ console.error(' Email is required.');
127
+ process.exitCode = 1;
128
+ return;
129
+ }
130
+ const password = await io.promptPassword(' Password: ');
131
+ if (!password) {
132
+ console.error(' Password is required.');
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+ let result;
137
+ try {
138
+ result = (await vuraApiRequest(apiUrl, '/v1/auth/login', {
139
+ method: 'POST',
140
+ body: { email, password },
141
+ }));
142
+ }
143
+ catch (err) {
144
+ console.error(` ${formatApiError(err)}`);
145
+ process.exitCode = 1;
146
+ return;
147
+ }
148
+ await writeCredentials({
149
+ token: result.token,
150
+ email: result.user?.email ?? email,
151
+ refreshToken: result.refreshToken,
152
+ });
153
+ console.log(` Logged in as ${result.user?.email ?? email}.`);
154
+ }
155
+ //# sourceMappingURL=login.js.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `vura projects list|create <name>` — manage Vura Platform projects.
3
+ *
4
+ * list GET /v1/projects?teamId=<id> — projects in a team.
5
+ * create POST /v1/projects { name, teamId } — create a project.
6
+ *
7
+ * Both need a team. `--team <id-or-slug>` resolves it (see
8
+ * `team-resolution.ts`); if omitted, the caller's only team is used when
9
+ * they have exactly one (true for every freshly registered account).
10
+ *
11
+ * `create` also links the current directory to the new project by writing
12
+ * `.vura/project.json` (the same file `vura deploy` reads via
13
+ * `vura-client.ts`'s `readProjectLink`) — but only when this looks like a
14
+ * Vura project root (a `vura.config.*` is present) that isn't already linked,
15
+ * so it never silently overwrites an existing link.
16
+ *
17
+ * Flags:
18
+ * --token <t> API token (else VURA_TOKEN, else ~/.vura/credentials)
19
+ * --api-url <url> API base URL (else VURA_API_URL, else https://api.vura.io)
20
+ * --team <id|slug> Team to operate in (else the caller's only team)
21
+ */
22
+ export declare function projectsCommand(args: string[], projectRoot?: string): Promise<void>;
23
+ //# sourceMappingURL=projects.d.ts.map
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `vura projects list|create <name>` — manage Vura Platform projects.
3
+ *
4
+ * list GET /v1/projects?teamId=<id> — projects in a team.
5
+ * create POST /v1/projects { name, teamId } — create a project.
6
+ *
7
+ * Both need a team. `--team <id-or-slug>` resolves it (see
8
+ * `team-resolution.ts`); if omitted, the caller's only team is used when
9
+ * they have exactly one (true for every freshly registered account).
10
+ *
11
+ * `create` also links the current directory to the new project by writing
12
+ * `.vura/project.json` (the same file `vura deploy` reads via
13
+ * `vura-client.ts`'s `readProjectLink`) — but only when this looks like a
14
+ * Vura project root (a `vura.config.*` is present) that isn't already linked,
15
+ * so it never silently overwrites an existing link.
16
+ *
17
+ * Flags:
18
+ * --token <t> API token (else VURA_TOKEN, else ~/.vura/credentials)
19
+ * --api-url <url> API base URL (else VURA_API_URL, else https://api.vura.io)
20
+ * --team <id|slug> Team to operate in (else the caller's only team)
21
+ */
22
+ import { existsSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { formatApiError, readProjectLink, resolveApiUrl, resolveToken, vuraApiRequest, writeProjectLink } from '../vura-client.js';
25
+ import { resolveTeam, TeamResolutionError } from './team-resolution.js';
26
+ const CONFIG_FILES = ['vura.config.ts', 'vura.config.js', 'vura.config.mjs'];
27
+ function parseFlags(args) {
28
+ const flags = {};
29
+ const positionals = [];
30
+ for (let i = 0; i < args.length; i++) {
31
+ const arg = args[i];
32
+ switch (arg) {
33
+ case '--token':
34
+ flags.token = args[++i];
35
+ break;
36
+ case '--api-url':
37
+ flags.apiUrl = args[++i];
38
+ break;
39
+ case '--team':
40
+ flags.team = args[++i];
41
+ break;
42
+ default:
43
+ if (!arg.startsWith('--'))
44
+ positionals.push(arg);
45
+ break;
46
+ }
47
+ }
48
+ return { flags, positionals };
49
+ }
50
+ function looksLikeProjectRoot(root) {
51
+ return CONFIG_FILES.some((f) => existsSync(join(root, f)));
52
+ }
53
+ function printProjectsTable(projects) {
54
+ if (projects.length === 0) {
55
+ console.log(' No projects found. Run `vura projects create <name>` to create one.');
56
+ return;
57
+ }
58
+ console.log('\n Projects:\n');
59
+ const slugWidth = Math.max(4, ...projects.map((p) => p.slug.length));
60
+ const nameWidth = Math.max(4, ...projects.map((p) => p.name.length));
61
+ for (const p of projects) {
62
+ console.log(` ${p.slug.padEnd(slugWidth)} ${p.name.padEnd(nameWidth)} ${p.id}`);
63
+ }
64
+ console.log();
65
+ }
66
+ async function listCommand(apiUrl, token, teamFlag) {
67
+ const team = await resolveTeam(apiUrl, token, teamFlag);
68
+ const body = (await vuraApiRequest(apiUrl, `/v1/projects?teamId=${encodeURIComponent(team.id)}`, { token }));
69
+ printProjectsTable(body?.data ?? []);
70
+ }
71
+ async function createCommand(apiUrl, token, name, teamFlag, projectRoot) {
72
+ const team = await resolveTeam(apiUrl, token, teamFlag);
73
+ const body = (await vuraApiRequest(apiUrl, '/v1/projects', {
74
+ method: 'POST',
75
+ token,
76
+ body: { name, teamId: team.id },
77
+ }));
78
+ const project = body?.data;
79
+ if (!project) {
80
+ console.error(' Project creation succeeded but the response was missing project data.');
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+ console.log(`\n Created project "${project.name}" (${project.slug}) in team ${team.slug}.`);
85
+ console.log(` Project id: ${project.id}`);
86
+ if (looksLikeProjectRoot(projectRoot) && !(await readProjectLink(projectRoot))) {
87
+ await writeProjectLink(projectRoot, { projectId: project.id, teamId: team.id, teamSlug: team.slug });
88
+ console.log(` Linked this directory to ${project.slug} (.vura/project.json).\n`);
89
+ }
90
+ else {
91
+ console.log('');
92
+ }
93
+ }
94
+ export async function projectsCommand(args, projectRoot = process.cwd()) {
95
+ const subcommand = args[0];
96
+ const { flags, positionals } = parseFlags(args.slice(1));
97
+ const token = await resolveToken(flags.token);
98
+ if (!token) {
99
+ console.error(' Not authenticated. Run `vura login`, set VURA_TOKEN, or pass --token <token>.');
100
+ process.exitCode = 1;
101
+ return;
102
+ }
103
+ const apiUrl = resolveApiUrl(flags.apiUrl);
104
+ try {
105
+ if (subcommand === 'list') {
106
+ await listCommand(apiUrl, token, flags.team);
107
+ return;
108
+ }
109
+ if (subcommand === 'create') {
110
+ const name = positionals[0];
111
+ if (!name) {
112
+ console.error(' Usage: vura projects create <name> [--team <id-or-slug>]');
113
+ process.exitCode = 1;
114
+ return;
115
+ }
116
+ await createCommand(apiUrl, token, name, flags.team, projectRoot);
117
+ return;
118
+ }
119
+ }
120
+ catch (err) {
121
+ if (err instanceof TeamResolutionError) {
122
+ console.error(` ${err.message}`);
123
+ }
124
+ else {
125
+ console.error(` ${formatApiError(err)}`);
126
+ }
127
+ process.exitCode = 1;
128
+ return;
129
+ }
130
+ console.error(` Unknown subcommand: vura projects ${subcommand ?? ''}`);
131
+ console.error(' Usage:');
132
+ console.error(' vura projects list [--team <id-or-slug>]');
133
+ console.error(' vura projects create <name> [--team <id-or-slug>]');
134
+ process.exitCode = 1;
135
+ }
136
+ //# sourceMappingURL=projects.js.map
@@ -10,7 +10,7 @@
10
10
  * process.exit() directly. This lets vitest keep running while still signalling
11
11
  * the right exit code to the shell via the CLI entry point.
12
12
  */
13
- import { buildManifest, runTaskOnce } from '@celsian/vura-core';
13
+ import { buildManifest, runTaskOnce, buildTaskEnvelope } from '@celsian/vura-core';
14
14
  import { importRouteModule } from './shared.js';
15
15
  // ─── Name helpers ────────────────────────────────────────────────────────────
16
16
  /**
@@ -82,8 +82,23 @@ async function runTask(projectRoot, taskName, rawInput) {
82
82
  timeout: typeof route.config.timeout === 'number' ? route.config.timeout : 30_000,
83
83
  },
84
84
  handler: mod.POST,
85
+ // Phase 1: validate --input against the task's optional `input` schema.
86
+ inputSchema: mod.input,
85
87
  }, { input });
86
- console.log(JSON.stringify(result, null, 2));
88
+ // A schema validation failure never ran the handler — print the standard
89
+ // validation error body and exit 1.
90
+ if (result.validationError) {
91
+ console.error(JSON.stringify(result.validationError.body, null, 2));
92
+ process.exitCode = 1;
93
+ return;
94
+ }
95
+ // Print the additive run envelope ({ ok, taskName, attempts, result? }) plus
96
+ // the legacy status/error fields so existing consumers keep working.
97
+ const envelope = buildTaskEnvelope(taskName, result);
98
+ const output = { ...envelope, status: result.status };
99
+ if (result.error !== undefined)
100
+ output.error = result.error;
101
+ console.log(JSON.stringify(output, null, 2));
87
102
  if (result.status === 'failed') {
88
103
  process.exitCode = 1;
89
104
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared "which team am I talking about" logic for `vura teams` and
3
+ * `vura projects`. Both commands accept a `--team <id-or-slug>` flag, and
4
+ * both need the same fallback when it's omitted: use the caller's only team
5
+ * if they have exactly one (true for every freshly registered account, which
6
+ * gets a personal team automatically), otherwise ask them to disambiguate.
7
+ */
8
+ export interface ResolvedTeam {
9
+ id: string;
10
+ slug: string;
11
+ name: string;
12
+ }
13
+ interface TeamSummary {
14
+ id: string;
15
+ name: string;
16
+ slug: string;
17
+ role?: string;
18
+ plan?: string;
19
+ }
20
+ /** Thrown for team-resolution failures the caller should print and exit(1) on. */
21
+ export declare class TeamResolutionError extends Error {
22
+ }
23
+ /**
24
+ * List the caller's teams via GET /v1/teams.
25
+ */
26
+ export declare function listTeams(apiUrl: string, token: string): Promise<TeamSummary[]>;
27
+ /**
28
+ * Resolve a `--team` flag value (or its absence) to a concrete team id.
29
+ *
30
+ * - UUID-shaped input is used directly as the team id (no round trip).
31
+ * - Non-UUID input is treated as a slug and looked up via GET /v1/teams/:slug.
32
+ * - No input at all falls back to the caller's only team, if they have
33
+ * exactly one; otherwise throws {@link TeamResolutionError} listing the
34
+ * choices so the CLI can print a helpful "pick one of: a, b, c" error.
35
+ */
36
+ export declare function resolveTeam(apiUrl: string, token: string, teamFlag: string | undefined): Promise<ResolvedTeam>;
37
+ export {};
38
+ //# sourceMappingURL=team-resolution.d.ts.map
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Shared "which team am I talking about" logic for `vura teams` and
3
+ * `vura projects`. Both commands accept a `--team <id-or-slug>` flag, and
4
+ * both need the same fallback when it's omitted: use the caller's only team
5
+ * if they have exactly one (true for every freshly registered account, which
6
+ * gets a personal team automatically), otherwise ask them to disambiguate.
7
+ */
8
+ import { vuraApiRequest } from '../vura-client.js';
9
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
+ /** Thrown for team-resolution failures the caller should print and exit(1) on. */
11
+ export class TeamResolutionError extends Error {
12
+ }
13
+ /**
14
+ * List the caller's teams via GET /v1/teams.
15
+ */
16
+ export async function listTeams(apiUrl, token) {
17
+ const body = (await vuraApiRequest(apiUrl, '/v1/teams', { token }));
18
+ return body?.data ?? [];
19
+ }
20
+ /**
21
+ * Resolve a `--team` flag value (or its absence) to a concrete team id.
22
+ *
23
+ * - UUID-shaped input is used directly as the team id (no round trip).
24
+ * - Non-UUID input is treated as a slug and looked up via GET /v1/teams/:slug.
25
+ * - No input at all falls back to the caller's only team, if they have
26
+ * exactly one; otherwise throws {@link TeamResolutionError} listing the
27
+ * choices so the CLI can print a helpful "pick one of: a, b, c" error.
28
+ */
29
+ export async function resolveTeam(apiUrl, token, teamFlag) {
30
+ if (teamFlag && UUID_RE.test(teamFlag)) {
31
+ return { id: teamFlag, slug: teamFlag, name: teamFlag };
32
+ }
33
+ if (teamFlag) {
34
+ const body = (await vuraApiRequest(apiUrl, `/v1/teams/${encodeURIComponent(teamFlag)}`, { token }));
35
+ if (!body?.data) {
36
+ throw new TeamResolutionError(`Team "${teamFlag}" not found.`);
37
+ }
38
+ return { id: body.data.id, slug: body.data.slug, name: body.data.name };
39
+ }
40
+ const teams = await listTeams(apiUrl, token);
41
+ if (teams.length === 1) {
42
+ return { id: teams[0].id, slug: teams[0].slug, name: teams[0].name };
43
+ }
44
+ if (teams.length === 0) {
45
+ throw new TeamResolutionError('No teams found. Run `vura teams create <name>` first.');
46
+ }
47
+ const choices = teams.map((t) => t.slug).join(', ');
48
+ throw new TeamResolutionError(`Multiple teams found — pass --team <id-or-slug>. Choices: ${choices}`);
49
+ }
50
+ //# sourceMappingURL=team-resolution.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `vura teams list|create <name>` — manage Vura Platform teams.
3
+ *
4
+ * list GET /v1/teams — teams the authenticated user belongs to.
5
+ * create POST /v1/teams — create a team. The API requires a slug, so one is
6
+ * derived from the name (matching `vura-client.ts`'s `slugify`,
7
+ * which mirrors how the API itself derives slugs) unless --slug is given.
8
+ *
9
+ * Flags:
10
+ * --token <t> API token (else VURA_TOKEN, else ~/.vura/credentials)
11
+ * --api-url <url> API base URL (else VURA_API_URL, else https://api.vura.io)
12
+ * --slug <slug> (create only) Explicit slug instead of deriving one from <name>.
13
+ */
14
+ export declare function teamsCommand(args: string[]): Promise<void>;
15
+ //# sourceMappingURL=teams.d.ts.map
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `vura teams list|create <name>` — manage Vura Platform teams.
3
+ *
4
+ * list GET /v1/teams — teams the authenticated user belongs to.
5
+ * create POST /v1/teams — create a team. The API requires a slug, so one is
6
+ * derived from the name (matching `vura-client.ts`'s `slugify`,
7
+ * which mirrors how the API itself derives slugs) unless --slug is given.
8
+ *
9
+ * Flags:
10
+ * --token <t> API token (else VURA_TOKEN, else ~/.vura/credentials)
11
+ * --api-url <url> API base URL (else VURA_API_URL, else https://api.vura.io)
12
+ * --slug <slug> (create only) Explicit slug instead of deriving one from <name>.
13
+ */
14
+ import { formatApiError, resolveApiUrl, resolveToken, slugify, vuraApiRequest } from '../vura-client.js';
15
+ import { listTeams } from './team-resolution.js';
16
+ function parseFlags(args) {
17
+ const flags = {};
18
+ const positionals = [];
19
+ for (let i = 0; i < args.length; i++) {
20
+ const arg = args[i];
21
+ switch (arg) {
22
+ case '--token':
23
+ flags.token = args[++i];
24
+ break;
25
+ case '--api-url':
26
+ flags.apiUrl = args[++i];
27
+ break;
28
+ case '--slug':
29
+ flags.slug = args[++i];
30
+ break;
31
+ default:
32
+ if (!arg.startsWith('--'))
33
+ positionals.push(arg);
34
+ break;
35
+ }
36
+ }
37
+ return { flags, positionals };
38
+ }
39
+ function printTeamsTable(teams) {
40
+ if (teams.length === 0) {
41
+ console.log(' No teams found. Run `vura teams create <name>` to create one.');
42
+ return;
43
+ }
44
+ console.log('\n Teams:\n');
45
+ const slugWidth = Math.max(4, ...teams.map((t) => t.slug.length));
46
+ const nameWidth = Math.max(4, ...teams.map((t) => t.name.length));
47
+ for (const t of teams) {
48
+ console.log(` ${t.slug.padEnd(slugWidth)} ${t.name.padEnd(nameWidth)} ${(t.role ?? '').padEnd(8)} ${t.plan ?? ''}`);
49
+ }
50
+ console.log();
51
+ }
52
+ async function listCommand(apiUrl, token) {
53
+ const teams = await listTeams(apiUrl, token);
54
+ printTeamsTable(teams);
55
+ }
56
+ async function createCommand(apiUrl, token, name, slugFlag) {
57
+ const slug = slugFlag || slugify(name);
58
+ const body = (await vuraApiRequest(apiUrl, '/v1/teams', {
59
+ method: 'POST',
60
+ token,
61
+ body: { name, slug },
62
+ }));
63
+ const team = body?.data;
64
+ if (!team) {
65
+ console.error(' Team creation succeeded but the response was missing team data.');
66
+ process.exitCode = 1;
67
+ return;
68
+ }
69
+ console.log(`\n Created team "${team.name}" (${team.slug}).`);
70
+ console.log(` Team id: ${team.id}\n`);
71
+ }
72
+ export async function teamsCommand(args) {
73
+ const subcommand = args[0];
74
+ const { flags, positionals } = parseFlags(args.slice(1));
75
+ const token = await resolveToken(flags.token);
76
+ if (!token) {
77
+ console.error(' Not authenticated. Run `vura login`, set VURA_TOKEN, or pass --token <token>.');
78
+ process.exitCode = 1;
79
+ return;
80
+ }
81
+ const apiUrl = resolveApiUrl(flags.apiUrl);
82
+ try {
83
+ if (subcommand === 'list') {
84
+ await listCommand(apiUrl, token);
85
+ return;
86
+ }
87
+ if (subcommand === 'create') {
88
+ const name = positionals[0];
89
+ if (!name) {
90
+ console.error(' Usage: vura teams create <name> [--slug <slug>]');
91
+ process.exitCode = 1;
92
+ return;
93
+ }
94
+ await createCommand(apiUrl, token, name, flags.slug);
95
+ return;
96
+ }
97
+ }
98
+ catch (err) {
99
+ console.error(` ${formatApiError(err)}`);
100
+ process.exitCode = 1;
101
+ return;
102
+ }
103
+ console.error(` Unknown subcommand: vura teams ${subcommand ?? ''}`);
104
+ console.error(' Usage:');
105
+ console.error(' vura teams list');
106
+ console.error(' vura teams create <name> [--slug <slug>]');
107
+ process.exitCode = 1;
108
+ }
109
+ //# sourceMappingURL=teams.js.map
package/dist/index.js CHANGED
@@ -12,6 +12,9 @@ import { adminCommand } from './commands/admin.js';
12
12
  import { tasksCommand } from './commands/tasks.js';
13
13
  import { routesCommand } from './commands/routes.js';
14
14
  import { runtimeCommand } from './commands/runtime.js';
15
+ import { loginCommand } from './commands/login.js';
16
+ import { teamsCommand } from './commands/teams.js';
17
+ import { projectsCommand } from './commands/projects.js';
15
18
  const COMMANDS = {
16
19
  build: buildCommand,
17
20
  manifest: manifestCommand,
@@ -21,6 +24,9 @@ const COMMANDS = {
21
24
  tasks: tasksCommand,
22
25
  routes: routesCommand,
23
26
  runtime: runtimeCommand,
27
+ login: loginCommand,
28
+ teams: teamsCommand,
29
+ projects: projectsCommand,
24
30
  };
25
31
  export async function run(args) {
26
32
  const command = args[0];
@@ -43,7 +49,15 @@ function printHelp() {
43
49
  Commands:
44
50
  dev Start local development server
45
51
  build Build the project for deployment
46
- deploy Deploy the built project to the Vura platform
52
+ login Authenticate against the Vura Platform
53
+ vura login [--token <t>]
54
+ teams Manage Vura Platform teams
55
+ vura teams list
56
+ vura teams create <name> [--slug <slug>]
57
+ projects Manage Vura Platform projects
58
+ vura projects list [--team <id-or-slug>]
59
+ vura projects create <name> [--team <id-or-slug>]
60
+ deploy Deploy the built project to the Vura platform (beta)
47
61
  vura deploy [--prod] [--token <t>] [--api-url <u>]
48
62
  admin Launch the admin dashboard
49
63
  manifest Print the route manifest (debug)
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Shared credential/config resolution and API client for commands that talk
3
+ * to the Vura Platform (`vura login`, `vura teams`, `vura projects`,
4
+ * `vura deploy`).
5
+ *
6
+ * Conventions (do not change without updating every command that relies on
7
+ * them — `deploy.ts` and `@celsian/vura-adapter-vura` read the same files):
8
+ * token : --token flag > VURA_TOKEN env > ~/.vura/credentials `token`
9
+ * api url : --api-url flag > VURA_API_URL env > https://api.vura.io
10
+ * project id : --project-id flag > VURA_PROJECT_ID env > <root>/.vura/project.json `projectId`
11
+ */
12
+ export declare const DEFAULT_API_URL = "https://api.vura.io";
13
+ export interface VuraCredentials {
14
+ token: string;
15
+ email?: string;
16
+ refreshToken?: string;
17
+ }
18
+ export interface VuraProjectLink {
19
+ projectId: string;
20
+ teamId?: string;
21
+ teamSlug?: string;
22
+ }
23
+ /** Home directory, honoring HOME/USERPROFILE overrides (e.g. in tests). */
24
+ export declare function resolveHome(): string;
25
+ /** Read ~/.vura/credentials. Returns null if absent, malformed, or tokenless. */
26
+ export declare function readCredentials(): Promise<VuraCredentials | null>;
27
+ /** Write ~/.vura/credentials at mode 0600 (owner read/write only). */
28
+ export declare function writeCredentials(creds: VuraCredentials): Promise<void>;
29
+ /** Resolve the auth token: --token flag > VURA_TOKEN env > ~/.vura/credentials. */
30
+ export declare function resolveToken(flag?: string): Promise<string | null>;
31
+ /** Resolve the API base URL: --api-url flag > VURA_API_URL env > default. */
32
+ export declare function resolveApiUrl(flag?: string): string;
33
+ /** Read <projectRoot>/.vura/project.json. Returns null if absent/malformed. */
34
+ export declare function readProjectLink(projectRoot: string): Promise<VuraProjectLink | null>;
35
+ /** Write <projectRoot>/.vura/project.json, linking the project for `vura deploy`. */
36
+ export declare function writeProjectLink(projectRoot: string, link: VuraProjectLink): Promise<void>;
37
+ /** Resolve the project id: --project-id flag > VURA_PROJECT_ID env > .vura/project.json. */
38
+ export declare function resolveProjectId(flag: string | undefined, projectRoot: string): Promise<string | null>;
39
+ /** An error response from the Vura API, carrying the HTTP status for callers to branch on (e.g. 401 → suggest `vura login`). */
40
+ export declare class VuraApiError extends Error {
41
+ readonly status: number;
42
+ readonly code?: string;
43
+ constructor(message: string, status: number, code?: string);
44
+ }
45
+ /**
46
+ * Call the Vura API and return the parsed JSON body. Throws {@link VuraApiError}
47
+ * on a non-2xx response, using the server's `{ error: { message } }` shape
48
+ * when present.
49
+ */
50
+ export declare function vuraApiRequest(apiUrl: string, path: string, init?: {
51
+ method?: string;
52
+ token?: string;
53
+ body?: unknown;
54
+ }): Promise<unknown>;
55
+ /** Format a caught error for CLI display, adding a `vura login` hint on 401s. */
56
+ export declare function formatApiError(err: unknown): string;
57
+ /** Slugify a name the same way the Vura API derives team/project slugs. */
58
+ export declare function slugify(name: string): string;
59
+ //# sourceMappingURL=vura-client.d.ts.map
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Shared credential/config resolution and API client for commands that talk
3
+ * to the Vura Platform (`vura login`, `vura teams`, `vura projects`,
4
+ * `vura deploy`).
5
+ *
6
+ * Conventions (do not change without updating every command that relies on
7
+ * them — `deploy.ts` and `@celsian/vura-adapter-vura` read the same files):
8
+ * token : --token flag > VURA_TOKEN env > ~/.vura/credentials `token`
9
+ * api url : --api-url flag > VURA_API_URL env > https://api.vura.io
10
+ * project id : --project-id flag > VURA_PROJECT_ID env > <root>/.vura/project.json `projectId`
11
+ */
12
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
13
+ import { homedir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ export const DEFAULT_API_URL = 'https://api.vura.io';
16
+ /** Home directory, honoring HOME/USERPROFILE overrides (e.g. in tests). */
17
+ export function resolveHome() {
18
+ return process.env.HOME || process.env.USERPROFILE || homedir();
19
+ }
20
+ function credentialsPath() {
21
+ return join(resolveHome(), '.vura', 'credentials');
22
+ }
23
+ /** Read ~/.vura/credentials. Returns null if absent, malformed, or tokenless. */
24
+ export async function readCredentials() {
25
+ try {
26
+ const raw = await readFile(credentialsPath(), 'utf-8');
27
+ const parsed = JSON.parse(raw);
28
+ if (parsed && typeof parsed.token === 'string' && parsed.token.length > 0) {
29
+ return parsed;
30
+ }
31
+ return null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ /** Write ~/.vura/credentials at mode 0600 (owner read/write only). */
38
+ export async function writeCredentials(creds) {
39
+ const dir = join(resolveHome(), '.vura');
40
+ await mkdir(dir, { recursive: true });
41
+ const path = credentialsPath();
42
+ await writeFile(path, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
43
+ // `writeFile`'s `mode` only applies when CREATING the file — a pre-existing
44
+ // credentials file (left at a looser mode by an older CLI or a manual edit)
45
+ // would keep that mode while now holding a token. Re-tighten explicitly.
46
+ await chmod(path, 0o600);
47
+ }
48
+ /** Resolve the auth token: --token flag > VURA_TOKEN env > ~/.vura/credentials. */
49
+ export async function resolveToken(flag) {
50
+ if (flag)
51
+ return flag;
52
+ if (process.env.VURA_TOKEN)
53
+ return process.env.VURA_TOKEN;
54
+ const creds = await readCredentials();
55
+ return creds?.token ?? null;
56
+ }
57
+ /** Resolve the API base URL: --api-url flag > VURA_API_URL env > default. */
58
+ export function resolveApiUrl(flag) {
59
+ return flag || process.env.VURA_API_URL || DEFAULT_API_URL;
60
+ }
61
+ /** Read <projectRoot>/.vura/project.json. Returns null if absent/malformed. */
62
+ export async function readProjectLink(projectRoot) {
63
+ try {
64
+ const raw = await readFile(join(projectRoot, '.vura', 'project.json'), 'utf-8');
65
+ const parsed = JSON.parse(raw);
66
+ if (parsed && typeof parsed.projectId === 'string' && parsed.projectId.length > 0) {
67
+ return parsed;
68
+ }
69
+ return null;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ /** Write <projectRoot>/.vura/project.json, linking the project for `vura deploy`. */
76
+ export async function writeProjectLink(projectRoot, link) {
77
+ const dir = join(projectRoot, '.vura');
78
+ await mkdir(dir, { recursive: true });
79
+ await writeFile(join(dir, 'project.json'), `${JSON.stringify(link, null, 2)}\n`);
80
+ }
81
+ /** Resolve the project id: --project-id flag > VURA_PROJECT_ID env > .vura/project.json. */
82
+ export async function resolveProjectId(flag, projectRoot) {
83
+ if (flag)
84
+ return flag;
85
+ if (process.env.VURA_PROJECT_ID)
86
+ return process.env.VURA_PROJECT_ID;
87
+ const link = await readProjectLink(projectRoot);
88
+ return link?.projectId ?? null;
89
+ }
90
+ /** An error response from the Vura API, carrying the HTTP status for callers to branch on (e.g. 401 → suggest `vura login`). */
91
+ export class VuraApiError extends Error {
92
+ status;
93
+ code;
94
+ constructor(message, status, code) {
95
+ super(message);
96
+ this.name = 'VuraApiError';
97
+ this.status = status;
98
+ this.code = code;
99
+ }
100
+ }
101
+ /**
102
+ * Call the Vura API and return the parsed JSON body. Throws {@link VuraApiError}
103
+ * on a non-2xx response, using the server's `{ error: { message } }` shape
104
+ * when present.
105
+ */
106
+ export async function vuraApiRequest(apiUrl, path, init = {}) {
107
+ const headers = { 'Content-Type': 'application/json' };
108
+ if (init.token)
109
+ headers.Authorization = `Bearer ${init.token}`;
110
+ const res = await fetch(`${apiUrl}${path}`, {
111
+ method: init.method ?? 'GET',
112
+ headers,
113
+ body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
114
+ });
115
+ const text = await res.text();
116
+ let body = null;
117
+ try {
118
+ body = text ? JSON.parse(text) : null;
119
+ }
120
+ catch {
121
+ body = text;
122
+ }
123
+ if (!res.ok) {
124
+ const errBody = body;
125
+ const message = errBody?.error?.message || `Request failed with HTTP ${res.status}`;
126
+ throw new VuraApiError(message, res.status, errBody?.error?.code);
127
+ }
128
+ return body;
129
+ }
130
+ /** Format a caught error for CLI display, adding a `vura login` hint on 401s. */
131
+ export function formatApiError(err) {
132
+ if (err instanceof VuraApiError) {
133
+ if (err.status === 401)
134
+ return `${err.message}. Run \`vura login\` and try again.`;
135
+ return err.message;
136
+ }
137
+ return err instanceof Error ? err.message : String(err);
138
+ }
139
+ /** Slugify a name the same way the Vura API derives team/project slugs. */
140
+ export function slugify(name) {
141
+ return name
142
+ .toLowerCase()
143
+ .replace(/[^a-z0-9]+/g, '-')
144
+ .replace(/^-|-$/g, '')
145
+ .slice(0, 64);
146
+ }
147
+ //# sourceMappingURL=vura-client.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celsian/vura-cli",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "description": "Vura CLI — build and deploy full-stack apps",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,12 +15,12 @@
15
15
  "!dist/**/*.map"
16
16
  ],
17
17
  "dependencies": {
18
- "@celsian/vura-core": "0.5.3",
18
+ "@celsian/vura-core": "0.5.5",
19
19
  "esbuild": "^0.28.1",
20
20
  "what-framework": "^0.11.1"
21
21
  },
22
22
  "peerDependencies": {
23
- "@celsian/vura-adapter-vura": "0.5.3",
23
+ "@celsian/vura-adapter-vura": "0.5.5",
24
24
  "ws": "^8.0.0"
25
25
  },
26
26
  "peerDependenciesMeta": {
@@ -32,7 +32,7 @@
32
32
  }
33
33
  },
34
34
  "devDependencies": {
35
- "@celsian/vura-adapter-vura": "0.5.3",
35
+ "@celsian/vura-adapter-vura": "0.5.5",
36
36
  "@types/ws": "^8.18.1",
37
37
  "ws": "^8.21.0"
38
38
  },