@celsian/vura-cli 0.5.2 → 0.5.4

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
  }
@@ -263,7 +263,7 @@ export async function startStandaloneServer(manifest, opts) {
263
263
  catch { /* not installed — keep default */ }
264
264
  const result = await esbuild({
265
265
  stdin: {
266
- contents: generateClientPageEntry(`./${basename(absPath)}`, page.mode),
266
+ contents: generateClientPageEntry(`./${basename(absPath)}`, page.mode, { dev: true }),
267
267
  resolveDir: dirname(absPath),
268
268
  sourcefile: '__vura-client-entry__.js',
269
269
  loader: 'js',
@@ -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
@@ -1,5 +1,5 @@
1
1
  import type { ApiRoute, PageRoute, RouteManifest } from '@celsian/vura-core';
2
- export type RuntimeProfile = 'static' | 'cold' | 'hot' | 'task-cold' | 'cron-cold' | 'task-hot' | 'cron-hot';
2
+ export type RuntimeProfile = 'static' | 'cold' | 'hot' | 'streaming-hot' | 'task-cold' | 'cron-cold' | 'task-hot' | 'cron-hot';
3
3
  export interface RuntimeRouteInspection {
4
4
  type: 'api' | 'page';
5
5
  pattern: string;
@@ -6,19 +6,24 @@ function configNumber(config, key) {
6
6
  const value = config[key];
7
7
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
8
8
  }
9
+ function prefersHotTask(config) {
10
+ return ['runtime', 'placement', 'target'].some((key) => configString(config, key) === 'hot')
11
+ || config.hot === true;
12
+ }
9
13
  export function taskNameFromPattern(urlPattern) {
10
14
  return urlPattern.replace(/^\/api\//, '').replace(/\//g, '.');
11
15
  }
12
16
  export function profileForApiRoute(route) {
13
17
  if (route.kind === 'hot')
14
- return 'hot';
18
+ return route.hasWebsocket ? 'streaming-hot' : 'hot';
15
19
  if (route.kind === 'task')
16
- return 'task-cold';
20
+ return prefersHotTask(route.config) ? 'task-hot' : 'task-cold';
17
21
  return 'cold';
18
22
  }
19
23
  export function cronProfileForApiRoute(route) {
20
- if (route.kind === 'task' && configString(route.config, 'schedule'))
21
- return 'cron-cold';
24
+ if (route.kind === 'task' && configString(route.config, 'schedule')) {
25
+ return profileForApiRoute(route) === 'task-hot' ? 'cron-hot' : 'cron-cold';
26
+ }
22
27
  return null;
23
28
  }
24
29
  export function profileForPageRoute(page) {
@@ -27,9 +32,12 @@ export function profileForPageRoute(page) {
27
32
  return 'static';
28
33
  }
29
34
  function backingTargetForApiRoute(route) {
30
- if (route.kind === 'hot')
35
+ const profile = profileForApiRoute(route);
36
+ if (profile === 'hot' || profile === 'streaming-hot')
31
37
  return 'hot server';
32
- if (route.kind === 'task')
38
+ if (profile === 'task-hot')
39
+ return 'hot task runtime';
40
+ if (profile === 'task-cold')
33
41
  return 'serverless task function';
34
42
  return 'serverless function';
35
43
  }
@@ -71,7 +79,9 @@ function inspectApiRoute(route) {
71
79
  filePath: route.filePath,
72
80
  sourceIntent: `schedule:${schedule}`,
73
81
  effectiveProfile: cronProfile,
74
- backingTarget: 'control-plane scheduler to task function',
82
+ backingTarget: cronProfile === 'cron-hot'
83
+ ? 'control-plane scheduler to hot task runtime'
84
+ : 'control-plane scheduler to task function',
75
85
  methods: route.methods,
76
86
  schedule,
77
87
  hasWebsocket: false,
@@ -100,6 +110,7 @@ export function inspectRuntime(manifest) {
100
110
  static: 0,
101
111
  cold: 0,
102
112
  hot: 0,
113
+ 'streaming-hot': 0,
103
114
  'task-cold': 0,
104
115
  'cron-cold': 0,
105
116
  'task-hot': 0,
@@ -134,37 +145,43 @@ export function adviseRuntime(manifest) {
134
145
  pattern: route.urlPattern,
135
146
  type: 'api',
136
147
  currentProfile,
137
- recommendation: 'hot',
148
+ recommendation: 'streaming-hot',
138
149
  severity: route.kind === 'hot' ? 'info' : 'warn',
139
150
  reason: route.kind === 'hot'
140
- ? 'WebSocket route is correctly placed on hot runtime.'
141
- : 'WebSocket exports require hot runtime to handle upgrades.',
151
+ ? 'WebSocket route is correctly placed on streaming-hot runtime.'
152
+ : 'WebSocket exports require streaming-hot runtime to handle upgrades.',
142
153
  });
143
154
  }
144
155
  if (route.kind === 'task') {
156
+ const taskProfile = profileForApiRoute(route);
145
157
  advice.push({
146
158
  pattern: route.urlPattern,
147
159
  type: 'api',
148
160
  currentProfile,
149
- recommendation: 'task-cold',
161
+ recommendation: taskProfile,
150
162
  severity: 'info',
151
- reason: schedule
152
- ? 'Scheduled task is a task-cold worker with cron-cold dispatch.'
153
- : 'Task route is a task-cold worker for manual or API-triggered jobs.',
163
+ reason: taskProfile === 'task-hot'
164
+ ? 'Task route is pinned to hot task runtime for long-running or stateful work.'
165
+ : schedule
166
+ ? 'Scheduled task is a task-cold worker with cron-cold dispatch.'
167
+ : 'Task route is a task-cold worker for manual or API-triggered jobs.',
154
168
  nextCommand: `vura tasks run ${taskNameFromPattern(route.urlPattern)}`,
155
169
  });
156
170
  if (schedule) {
171
+ const cronProfile = cronProfileForApiRoute(route) ?? 'cron-cold';
157
172
  advice.push({
158
173
  pattern: route.urlPattern,
159
174
  type: 'api',
160
- currentProfile: 'cron-cold',
161
- recommendation: 'cron-cold',
175
+ currentProfile: cronProfile,
176
+ recommendation: cronProfile,
162
177
  severity: 'info',
163
- reason: `Cron schedule ${schedule} dispatches to the task-cold target.`,
178
+ reason: cronProfile === 'cron-hot'
179
+ ? `Cron schedule ${schedule} dispatches to the hot task target.`
180
+ : `Cron schedule ${schedule} dispatches to the task-cold target.`,
164
181
  nextCommand: 'vura tasks list',
165
182
  });
166
183
  }
167
- if (typeof timeout === 'number' && timeout > 60_000) {
184
+ if (typeof timeout === 'number' && timeout > 60_000 && taskProfile !== 'task-hot') {
168
185
  advice.push({
169
186
  pattern: route.urlPattern,
170
187
  type: 'api',
@@ -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.2",
3
+ "version": "0.5.4",
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.2",
18
+ "@celsian/vura-core": "0.5.4",
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.2",
23
+ "@celsian/vura-adapter-vura": "0.5.4",
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.2",
35
+ "@celsian/vura-adapter-vura": "0.5.4",
36
36
  "@types/ws": "^8.18.1",
37
37
  "ws": "^8.21.0"
38
38
  },