voroa-cli 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +247 -2
  3. package/package.json +41 -4
  4. package/src/agent.js +95 -0
  5. package/src/api.js +552 -0
  6. package/src/commands/db.js +99 -0
  7. package/src/commands/deploy.js +130 -0
  8. package/src/commands/domains.js +122 -0
  9. package/src/commands/env.js +143 -0
  10. package/src/commands/lifecycle.js +40 -0
  11. package/src/commands/link.js +92 -0
  12. package/src/commands/login.js +104 -0
  13. package/src/commands/logs.js +304 -0
  14. package/src/commands/services.js +72 -0
  15. package/src/commands/ssh.js +46 -0
  16. package/src/commands/status.js +50 -0
  17. package/src/commands/tokens.js +81 -0
  18. package/src/commands/workspace.js +63 -0
  19. package/src/config.js +108 -0
  20. package/src/errors.js +53 -0
  21. package/src/flags.js +32 -0
  22. package/src/index.js +212 -0
  23. package/src/interactive.js +34 -0
  24. package/src/link.js +72 -0
  25. package/src/mcp/build.js +72 -0
  26. package/src/mcp/kernel/index.js +59 -0
  27. package/src/mcp/kernel/logger.js +28 -0
  28. package/src/mcp/lib/config.js +72 -0
  29. package/src/mcp/lib/credentials.js +51 -0
  30. package/src/mcp/lib/http.js +136 -0
  31. package/src/mcp/lib/response.js +14 -0
  32. package/src/mcp/lib/reversibility.js +33 -0
  33. package/src/mcp/reversibilityMap.json +77 -0
  34. package/src/mcp/tools/_action.js +49 -0
  35. package/src/mcp/tools/_ids.js +42 -0
  36. package/src/mcp/tools/_shape.js +318 -0
  37. package/src/mcp/tools/actions.js +94 -0
  38. package/src/mcp/tools/getBuildLog.js +39 -0
  39. package/src/mcp/tools/getDeployHistory.js +42 -0
  40. package/src/mcp/tools/getRuntimeLogs.js +44 -0
  41. package/src/mcp/tools/getServiceStatus.js +41 -0
  42. package/src/mcp/tools/getUsage.js +34 -0
  43. package/src/mcp/tools/listDatabases.js +25 -0
  44. package/src/mcp/tools/listDomains.js +33 -0
  45. package/src/mcp/tools/listServices.js +20 -0
  46. package/src/mcp/tools/propose.js +124 -0
  47. package/src/mcp/tools/reads.js +756 -0
  48. package/src/mcp/tools/writes.js +632 -0
  49. package/src/mcpTools.js +292 -0
  50. package/src/output.js +145 -0
  51. package/src/prompt.js +229 -0
  52. package/src/region.js +5 -0
  53. package/src/resolve.js +64 -0
  54. package/src/session.js +270 -0
  55. package/src/status.js +199 -0
  56. package/src/stepup.js +69 -0
@@ -0,0 +1,292 @@
1
+ // Every customer-MCP tool as a CLI command (plan docs/plans/2026-11-11-cli-mcp-parity.md).
2
+ //
3
+ // The CLI does NOT re-implement the tools. It runs the MCP's own tool kernel (customer-mcp/src/build.js),
4
+ // so the argument checks, the response projection and the white-label shaping have ONE home (rule #10,
5
+ // golden rule 6), and a tool added to the MCP is a CLI command on the next release with no code here.
6
+ // That kernel imports only `node:` builtins - the MCP SDK lives in its server.js, which is never loaded -
7
+ // so the zero-dependency decision in the README header still holds.
8
+ //
9
+ // TWO PLACES THE KERNEL CAN SIT. The published tarball carries a copy at src/mcp/ (scripts/pack-mcp.mjs,
10
+ // run by `prepack`); a repo checkout has the original next door. The packaged copy is tried FIRST on
11
+ // purpose: after `npm install -g`, the repo-relative path would resolve to a sibling package called
12
+ // `customer-mcp` in the same node_modules, and loading code from a name we do not own is not a fallback.
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { CliError } from './errors.js';
16
+ import { contextFrom, isApiToken } from './api.js';
17
+ import { resolveService } from './resolve.js';
18
+ import { out, json, renderTable } from './output.js';
19
+ import { ownsStdin } from './prompt.js';
20
+
21
+ const KERNEL_HOMES = [new URL('./mcp/', import.meta.url), new URL('../../customer-mcp/src/', import.meta.url)];
22
+
23
+ // The kernel logs one line per HTTP call to stderr - right for a long-lived MCP server, noise on a
24
+ // terminal. Errors are not lost: they are thrown, and index.js prints the message.
25
+ const SILENT = { info() {}, warn() {}, error() {} };
26
+
27
+ export const NO_API_TOKEN =
28
+ 'This command needs an API token. Run `voroa tokens create --name cli --scope services:read,services:act,services:write,services:admin --save`, or set VOROA_TOKEN to one from the dashboard.';
29
+
30
+ // A CliError that keeps what caused it (golden rule 14). The MESSAGE is what a person reads and is written
31
+ // here, never taken from the cause: a JSON parse error quotes its input back, and that input can be a
32
+ // secret value on its way to being set.
33
+ const because = (message, cause) => Object.assign(new CliError(message), { cause });
34
+
35
+ export async function loadKernel({ apiUrl = '', token = '', fetchImpl } = {}) {
36
+ for (const home of KERNEL_HOMES) {
37
+ const file = new URL('build.js', home);
38
+ if (!existsSync(fileURLToPath(file))) continue;
39
+ const { buildKernel } = await import(file.href);
40
+ // Both values are always passed, so the kernel's own config never falls back to a credentials file
41
+ // on disk - a stored token is bound to the endpoint that issued it (config.js) and must not follow
42
+ // `--api-url` somewhere else.
43
+ return buildKernel({ env: { VOROA_MCP_URL: apiUrl, VOROA_MCP_TOKEN: token }, fetchImpl, logger: SILENT });
44
+ }
45
+ throw new CliError('This install of voroa-cli is incomplete. Reinstall it with `npm install -g voroa-cli`.');
46
+ }
47
+
48
+ export const commandName = (toolName) => toolName.replace(/_/g, '-');
49
+ const flagName = (prop) => `--${prop.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
50
+ const squash = (s) => String(s).replace(/^--?/, '').replace(/[-_]/g, '').toLowerCase();
51
+
52
+ export function findTool(kernel, name) {
53
+ const wanted = String(name || '').replace(/-/g, '_');
54
+ return kernel.listTools().find((t) => t.name === wanted) || null;
55
+ }
56
+
57
+ function typeOf(schema) {
58
+ if (Array.isArray(schema.type)) return schema.type.find((t) => t !== 'null') || 'string';
59
+ return schema.type || 'string';
60
+ }
61
+
62
+ function coerce(flag, raw, schema) {
63
+ const type = typeOf(schema);
64
+ if (type === 'boolean') {
65
+ if (raw === true || raw === 'true') return true;
66
+ if (raw === 'false') return false;
67
+ throw new CliError(`${flag} is true or false. Got "${raw}".`);
68
+ }
69
+ if (raw === true) throw new CliError(`${flag} needs a value.`);
70
+ if (type === 'integer' || type === 'number') {
71
+ const n = Number(String(raw).trim());
72
+ if (!Number.isFinite(n) || (type === 'integer' && !Number.isInteger(n))) {
73
+ throw new CliError(`${flag} takes a ${type === 'integer' ? 'whole number' : 'number'}. Got "${raw}".`);
74
+ }
75
+ return n;
76
+ }
77
+ if (type === 'object' || type === 'array') {
78
+ try {
79
+ return JSON.parse(raw);
80
+ } catch (e) {
81
+ throw because(`${flag} takes JSON (${type === 'array' ? 'a list' : 'an object'}).`, e);
82
+ }
83
+ }
84
+ return String(raw);
85
+ }
86
+
87
+ function readJsonObject(text, what) {
88
+ let parsed;
89
+ try {
90
+ parsed = JSON.parse(text);
91
+ } catch (e) {
92
+ throw because(`${what} is not valid JSON.`, e);
93
+ }
94
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new CliError(`${what} must be a JSON object.`);
95
+ return parsed;
96
+ }
97
+
98
+ // argv AFTER the tool name -> { args, help, asJson }. Flags are matched against the tool's own
99
+ // schema, so `--service-id`, `--serviceId` and `--service_id` are the same flag and an unknown one is
100
+ // refused by name rather than silently dropped. Bare words fill the REQUIRED arguments in schema order:
101
+ // `voroa get-database <id>`.
102
+ export function parseToolArgv(tool, argv) {
103
+ const props = (tool.inputSchema && tool.inputSchema.properties) || {};
104
+ const bySquashed = new Map(Object.keys(props).map((p) => [squash(p), p]));
105
+ const result = { args: {}, help: false, asJson: false };
106
+ const bare = [];
107
+
108
+ for (let i = 0; i < argv.length; i++) {
109
+ const token = argv[i];
110
+ if (!token.startsWith('--')) {
111
+ bare.push(token);
112
+ continue;
113
+ }
114
+ const eq = token.indexOf('=');
115
+ const flag = eq === -1 ? token : token.slice(0, eq);
116
+ const inline = eq === -1 ? undefined : token.slice(eq + 1);
117
+ const takeValue = () => {
118
+ if (inline !== undefined) return inline;
119
+ if (i + 1 >= argv.length) throw new CliError(`${flag} needs a value.`);
120
+ return argv[++i];
121
+ };
122
+
123
+ // A tool's own argument wins over a CLI-wide flag of the same name (`propose` has an `args`).
124
+ const prop = bySquashed.get(squash(flag));
125
+ if (prop) {
126
+ const isBool = typeOf(props[prop]) === 'boolean';
127
+ const next = argv[i + 1];
128
+ // `--enabled` alone is true; `--enabled false` and `--enabled=false` are both false.
129
+ const raw = !isBool ? takeValue() : inline !== undefined ? inline : next === 'true' || next === 'false' ? argv[++i] : true;
130
+ result.args[prop] = coerce(flag, raw, props[prop]);
131
+ } else if (flag === '--help') result.help = true;
132
+ else if (flag === '--json') result.asJson = true;
133
+ // Already applied by index.js when it built the settings; consumed here so they are not "unknown".
134
+ else if (flag === '--api-url' || flag === '--workspace') takeValue();
135
+ else if (flag === '--no-input') result.noInput = true;
136
+ else if (flag === '--args') result.args = { ...readJsonObject(takeValue(), '--args'), ...result.args };
137
+ else if (flag === '--args-file') {
138
+ // `-` is stdin. A value on the command line lands in shell history and in `ps`; a secret (an
139
+ // environment variable's value, a secret file's contents) should arrive by pipe or by file.
140
+ const file = takeValue();
141
+ if (file === '-' && ownsStdin()) throw new CliError('Inside a session, standard input belongs to the session. Give --args-file a real file.');
142
+ let text;
143
+ try {
144
+ text = readFileSync(file === '-' ? 0 : file, 'utf8');
145
+ } catch (e) {
146
+ throw because(file === '-' ? 'Nothing arrived on standard input.' : `Cannot read ${file}.`, e);
147
+ }
148
+ result.args = { ...readJsonObject(text, file), ...result.args };
149
+ } else {
150
+ throw new CliError(`${commandName(tool.name)} has no ${flag} argument. Run \`voroa ${commandName(tool.name)} --help\`.`);
151
+ }
152
+ }
153
+
154
+ const open = ((tool.inputSchema && tool.inputSchema.required) || []).filter((p) => !(p in result.args));
155
+ if (bare.length > open.length) throw new CliError(`Unexpected "${bare[open.length]}". Run \`voroa ${commandName(tool.name)} --help\`.`);
156
+ bare.forEach((value, n) => {
157
+ result.args[open[n]] = coerce(flagName(open[n]), value, props[open[n]]);
158
+ });
159
+ return result;
160
+ }
161
+
162
+ const firstSentence = (text) => {
163
+ const s = String(text || '').replace(/\s+/g, ' ').trim();
164
+ const end = s.search(/\.(\s|$)/);
165
+ return end === -1 ? s : s.slice(0, end + 1);
166
+ };
167
+
168
+ // Tool descriptions were written for the connector, where a tool is `list_buckets`. Here it is
169
+ // `list-buckets`, and a description that names a command the reader cannot type is a small lie.
170
+ function forCli(text, names) {
171
+ let said = String(text || '');
172
+ // Longest first, so `list_bucket_files` is not half-replaced by `list_bucket`.
173
+ for (const name of [...names].sort((a, b) => b.length - a.length)) {
174
+ if (name.includes('_')) said = said.split(name).join(commandName(name));
175
+ }
176
+ return said;
177
+ }
178
+
179
+ // What `voroa tools` lists: optionally only those whose name or description contains every word given.
180
+ // `changes` is the tool's own declared kind - a read never changes anything.
181
+ export function listForCli(kernel, words = []) {
182
+ const wanted = words.map((w) => w.toLowerCase());
183
+ return kernel
184
+ .listTools()
185
+ .map((t) => ({ command: commandName(t.name), changes: kernel.getTool(t.name).kind !== 'read', description: forCli(t.description, kernel.toolNames()), inputSchema: t.inputSchema }))
186
+ .filter((t) => wanted.every((w) => t.command.includes(w) || t.description.toLowerCase().includes(w)));
187
+ }
188
+
189
+ export function toolsList(kernel, words = []) {
190
+ const rows = listForCli(kernel, words);
191
+ if (!rows.length) return `No command matches "${words.join(' ')}". Run \`voroa tools\` for the full list.`;
192
+ return renderTable(['COMMAND', 'CHANGES', 'WHAT IT DOES'], rows.map((t) => [t.command, t.changes ? 'yes' : 'no', firstSentence(t.description)]));
193
+ }
194
+
195
+ export function toolHelp(tool, { explicit = false, toolNames = [] } = {}) {
196
+ const props = (tool.inputSchema && tool.inputSchema.properties) || {};
197
+ const required = new Set((tool.inputSchema && tool.inputSchema.required) || []);
198
+ const lines = [`voroa ${explicit ? 'tool ' : ''}${commandName(tool.name)}`, '', forCli(tool.description, toolNames).replace(/\s+/g, ' ').trim()];
199
+ const names = Object.keys(props);
200
+ if (names.length) {
201
+ lines.push('', 'Arguments');
202
+ lines.push(
203
+ renderTable(
204
+ ['FLAG', 'TYPE', 'REQUIRED', 'MEANING'],
205
+ names.map((p) => [
206
+ flagName(p),
207
+ props[p].enum ? 'one of' : typeOf(props[p]),
208
+ required.has(p) ? 'yes' : 'no',
209
+ props[p].enum ? `${props[p].enum.join(', ')}` : forCli(props[p].description, toolNames).replace(/\s+/g, ' ').trim(),
210
+ ])
211
+ )
212
+ );
213
+ }
214
+ lines.push('', 'Also: --args \'<json>\' or --args-file <path> (- for standard input) to pass every argument as one JSON object; --json for raw output.');
215
+ return lines.join('\n');
216
+ }
217
+
218
+ // A list of flat records reads better as a table; anything else is printed as the JSON it is. The
219
+ // payload is whatever the tool projected - nothing is added or widened here.
220
+ function printResult(payload, asJson) {
221
+ if (asJson) return json(payload);
222
+ const lists = Array.isArray(payload)
223
+ ? [payload]
224
+ : payload && typeof payload === 'object'
225
+ ? Object.values(payload).filter((v) => Array.isArray(v))
226
+ : [];
227
+ const rows = lists.length === 1 ? lists[0] : null;
228
+ const flat = (v) => v === null || ['string', 'number', 'boolean'].includes(typeof v);
229
+ if (rows && rows.length && rows.every((r) => r && typeof r === 'object' && !Array.isArray(r))) {
230
+ const cols = Object.keys(rows[0]).filter((k) => rows.every((r) => flat(r[k] ?? null)));
231
+ const scalars = Array.isArray(payload) ? [] : Object.entries(payload).filter(([, v]) => flat(v));
232
+ const nested = Array.isArray(payload) ? 0 : Object.keys(payload).length - scalars.length - 1;
233
+ if (cols.length && cols.length === Object.keys(rows[0]).length && nested === 0) {
234
+ out(renderTable(cols.map((c) => c.toUpperCase()), rows.map((r) => cols.map((c) => r[c]))));
235
+ for (const [k, v] of scalars) out(`${k}: ${v}`);
236
+ return undefined;
237
+ }
238
+ }
239
+ return json(payload);
240
+ }
241
+
242
+ // `voroa tools`, `voroa tool <name> ...` and the shorthand `voroa <tool-name> ...`.
243
+ export async function runTool(settings, name, argv, { fetchImpl, asJson = false, explicit = false } = {}) {
244
+ const token = isApiToken(settings.token) ? settings.token : settings.apiToken || '';
245
+ // Listing and help need no credential: both come from the tool definitions. Only a call does.
246
+ const kernel = await loadKernel({ apiUrl: settings.apiUrl, token, fetchImpl });
247
+ if (name === 'tools') {
248
+ const words = argv.filter((a) => !a.startsWith('--'));
249
+ if (asJson || argv.includes('--json')) json(listForCli(kernel, words));
250
+ else out(toolsList(kernel, words));
251
+ return 0;
252
+ }
253
+ const tool = findTool(kernel, name);
254
+ if (!tool) throw new CliError(`Unknown command "${name}". Run \`voroa --help\`, or \`voroa tools\` for the full list.`, 1, 'BAD_INPUT');
255
+
256
+ const parsed = parseToolArgv(tool, argv);
257
+ if (parsed.help) {
258
+ out(toolHelp(tool, { explicit, toolNames: kernel.toolNames() }));
259
+ return 0;
260
+ }
261
+ if (!token) throw new CliError(NO_API_TOKEN, 1, 'NOT_SIGNED_IN');
262
+
263
+ // A service can be named, or left out in a linked folder - the same resolver the hand-written commands
264
+ // use, so the answer still comes from the server's own workspace-scoped list (resolve.js).
265
+ const props = tool.inputSchema.properties || {};
266
+ if ('serviceId' in props && tool.name !== 'list_services') {
267
+ const required = (tool.inputSchema.required || []).includes('serviceId');
268
+ if (parsed.args.serviceId || required) {
269
+ const ctx = contextFrom({ ...settings, token });
270
+ parsed.args.serviceId = (await resolveService(ctx, parsed.args.serviceId)).uuid;
271
+ }
272
+ }
273
+
274
+ let result;
275
+ try {
276
+ result = await kernel.callTool(tool.name, parsed.args);
277
+ } catch (e) {
278
+ // The kernel's messages are generic and secret-free by contract (customer-mcp/src/lib/http.js).
279
+ throw because(e && e.message ? e.message : 'That did not work. Try again.', e);
280
+ }
281
+ const text = result && result.content && result.content[0] && result.content[0].text;
282
+ let payload;
283
+ try {
284
+ payload = JSON.parse(text);
285
+ } catch (e) {
286
+ // Every tool answers in JSON (customer-mcp/src/lib/response.js). Anything else is a kernel this
287
+ // build does not understand - say so rather than print something half-read.
288
+ throw because('Voroa answered in a form this version cannot read. Update with `npm install -g voroa-cli`.', e);
289
+ }
290
+ printResult(payload, parsed.asJson || asJson);
291
+ return result && result.isError ? 1 : 0;
292
+ }
package/src/output.js ADDED
@@ -0,0 +1,145 @@
1
+ // Terminal output helpers. Plain text: a CLI's output gets piped, redirected and pasted into tickets,
2
+ // and ANSI codes survive all three as garbage. The state is carried by the WORD ("Crash looping"),
3
+ // never by a colour a pipe would strip anyway.
4
+ //
5
+ // The ONE exception is everything gated by `fancy()` below: colour, a status mark, a spinner. All of
6
+ // it is decoration ON TOP of the words and switches itself off unless the stream is a real terminal,
7
+ // NO_COLOR is unset and TERM is not "dumb" - so everything above still holds for every pipe, file and
8
+ // CI log, and an AI agent reading the output never sees an escape code.
9
+ import { errorCode } from './errors.js';
10
+
11
+ export function fancy(stream = process.stdout) {
12
+ return !!(stream && stream.isTTY) && !process.env.NO_COLOR && process.env.TERM !== 'dumb';
13
+ }
14
+
15
+ const OFF_COLOUR = '\x1b[39m';
16
+ const STYLES = {
17
+ bold: ['\x1b[1m', '\x1b[22m'],
18
+ dim: ['\x1b[2m', '\x1b[22m'],
19
+ green: ['\x1b[32m', OFF_COLOUR],
20
+ amber: ['\x1b[33m', OFF_COLOUR],
21
+ red: ['\x1b[31m', OFF_COLOUR],
22
+ gray: ['\x1b[90m', OFF_COLOUR],
23
+ cyan: ['\x1b[36m', OFF_COLOUR],
24
+ violet: ['\x1b[38;5;141m', OFF_COLOUR], // the brand accent, from the 256-colour table every VT terminal has
25
+ };
26
+
27
+ export function style(kind, text, stream = process.stdout) {
28
+ const codes = STYLES[kind];
29
+ if (!codes || !fancy(stream)) return text;
30
+ return `${codes[0]}${text}${codes[1]}`;
31
+ }
32
+
33
+ // Marks. The old Windows console host draws several of these as boxes, so it gets ASCII; Windows
34
+ // Terminal and editor terminals announce themselves and get the real ones.
35
+ const UNICODE = { run: '\u25cf', idle: '\u25cb', busy: '\u25d0', fail: '\u25cf', ok: '\u2714', bad: '\u2716', prompt: '\u203a', bar: '\u258c', arrow: '\u2192', logo: '\u25b2', dot: '\u00b7', spin: ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'] };
36
+ const ASCII = { run: '*', idle: 'o', busy: '~', fail: 'x', ok: 'ok', bad: 'x', prompt: '>', bar: '|', arrow: '->', logo: '^', dot: '-', spin: ['-', '\\', '|', '/'] };
37
+
38
+ export function glyphs(env = process.env, platform = process.platform) {
39
+ return platform !== 'win32' || env.WT_SESSION || env.TERM_PROGRAM ? UNICODE : ASCII;
40
+ }
41
+
42
+ const TONE = { green: ['green', 'run'], gray: ['gray', 'idle'], amber: ['amber', 'busy'], red: ['red', 'fail'] };
43
+
44
+ // A status for a table cell. The WORD is always there; on a terminal it gains a mark and a colour.
45
+ // Takes what `classify` returned - this file never looks at a raw status itself.
46
+ export function statusCell(state, stream = process.stdout) {
47
+ if (!fancy(stream) || !TONE[state.tone]) return state.label;
48
+ const [colour, mark] = TONE[state.tone];
49
+ return style(colour, `${glyphs()[mark]} ${state.label}`, stream);
50
+ }
51
+
52
+ export function elapsed(ms) {
53
+ const s = Math.max(0, Math.round(ms / 1000));
54
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
55
+ }
56
+
57
+ // "Still working" for a wait with nothing to print. Off a terminal it is ONE plain line, exactly what
58
+ // the command printed before spinners existed, so a log file gets a sentence and not 400 frames.
59
+ export function spinner(text, { stream = process.stdout, intervalMs = 90 } = {}) {
60
+ if (!fancy(stream)) {
61
+ stream.write(`${text}\n`);
62
+ return { stop() {} };
63
+ }
64
+ const frames = glyphs().spin;
65
+ const started = Date.now();
66
+ let i = 0;
67
+ const draw = () => stream.write(`\r\x1b[2K ${style('cyan', frames[i++ % frames.length], stream)} ${text} ${style('dim', elapsed(Date.now() - started), stream)}`);
68
+ draw();
69
+ const timer = setInterval(draw, intervalMs);
70
+ return {
71
+ stop() {
72
+ clearInterval(timer);
73
+ stream.write('\r\x1b[2K');
74
+ },
75
+ };
76
+ }
77
+
78
+ // Escape codes take no room on screen, so a column is measured by what is SEEN.
79
+ const ANSI = /\x1b\[[0-9;]*m/g;
80
+ export function visibleLength(text) {
81
+ return String(text).replace(ANSI, '').length;
82
+ }
83
+ const padTo = (text, width) => text + ' '.repeat(Math.max(0, width - visibleLength(text)));
84
+
85
+ // Column-aligned table. Values are stringified defensively - a null cell renders as the em dash, so
86
+ // an empty column never reads as an empty VALUE.
87
+ export const EMPTY_CELL = '-';
88
+
89
+ function cell(value) {
90
+ if (value === null || value === undefined || value === '') return EMPTY_CELL;
91
+ return String(value);
92
+ }
93
+
94
+ export function renderTable(headers, rows) {
95
+ const body = rows.map((r) => r.map(cell));
96
+ const widths = headers.map((h, i) => Math.max(String(h).length, ...body.map((r) => visibleLength(r[i] || '')), 0));
97
+ const line = (cells) => cells.map((c, i) => (i === cells.length - 1 ? c : padTo(c, widths[i]))).join(' ').trimEnd();
98
+ return [line(headers.map(String)), ...body.map(line)].join('\n');
99
+ }
100
+
101
+ // Key/value block for a single record.
102
+ export function renderFields(pairs) {
103
+ const width = Math.max(...pairs.map(([k]) => k.length), 0);
104
+ return pairs.map(([k, v]) => `${k.padEnd(width)} ${cell(v)}`).join('\n');
105
+ }
106
+
107
+ // Relative time from epoch SECONDS (what the API sends). Absolute dates are avoided because the
108
+ // server speaks UTC and the reader thinks in their own zone; "3 hours ago" is unambiguous in both.
109
+ export function timeAgo(epochSeconds) {
110
+ const secs = Number(epochSeconds);
111
+ if (!Number.isFinite(secs) || secs <= 0) return null;
112
+ const diff = Math.floor(Date.now() / 1000) - secs;
113
+ if (diff < 0) return 'just now'; // clock skew - never render a negative age
114
+ if (diff < 60) return 'just now';
115
+ const mins = Math.floor(diff / 60);
116
+ if (mins < 60) return `${mins}m ago`;
117
+ const hours = Math.floor(mins / 60);
118
+ if (hours < 24) return `${hours}h ago`;
119
+ const days = Math.floor(hours / 24);
120
+ if (days < 30) return `${days}d ago`;
121
+ const months = Math.floor(days / 30);
122
+ if (months < 12) return `${months}mo ago`;
123
+ return `${Math.floor(months / 12)}y ago`;
124
+ }
125
+
126
+ export function out(text = '') {
127
+ process.stdout.write(`${text}\n`);
128
+ }
129
+
130
+ export function err(text = '') {
131
+ process.stderr.write(`${text}\n`);
132
+ }
133
+
134
+ // A failure, for whoever is reading. With --json it is one JSON object on stderr - the same message a
135
+ // person gets, plus a code from the fixed list in errors.js. Never the error object itself: that is the
136
+ // last place a token could surface (golden rule 13).
137
+ export function reportError(e, { asJson = false } = {}) {
138
+ const message = e && e.message ? e.message : 'Something went wrong.';
139
+ if (asJson) process.stderr.write(`${JSON.stringify({ error: message, code: errorCode(e) })}\n`);
140
+ else err(message);
141
+ }
142
+
143
+ export function json(value) {
144
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
145
+ }
package/src/prompt.js ADDED
@@ -0,0 +1,229 @@
1
+ // Interactive prompts on node:readline (no dependency - see the README header).
2
+ //
3
+ // ONE readline interface for the whole process, deliberately. A fresh interface per question looks
4
+ // tidier and is wrong: readline reads stdin in CHUNKS and buffers what it has not handed out yet,
5
+ // so closing it after the first answer throws away everything else that had already arrived. Piped
6
+ // input ("email\ncode\n") arrives as a single chunk, which means the second prompt would wait
7
+ // forever for a line that was read and discarded a moment earlier.
8
+ //
9
+ // Two things here are security-relevant, not cosmetic:
10
+ // * `askHidden` never echoes. A password typed into a shared terminal, a screen share or a
11
+ // recorded session is a leaked password (golden rule 13's spirit: a credential must not become
12
+ // visible anywhere).
13
+ // * every prompt can carry a timeout. A CLI blocked forever on stdin inside CI is a hung job with
14
+ // no explanation; timing out with a sentence beats hanging with none.
15
+ import readline from 'node:readline';
16
+ import { CliError } from './errors.js';
17
+
18
+ class PromptTimeout extends Error {
19
+ constructor() {
20
+ super('prompt timed out');
21
+ this.name = 'PromptTimeout';
22
+ this.timedOut = true;
23
+ }
24
+ }
25
+
26
+ let rl = null;
27
+ let closed = false;
28
+ const waiting = []; // resolvers for prompts that have been asked but not yet answered
29
+ const buffered = []; // lines that arrived before anything asked for them
30
+
31
+ // THE INTERACTIVE SESSION (session.js) rides on this same interface - it must, for the reason above -
32
+ // and needs three things a one-shot command does not. They are set here, once, so that an interface
33
+ // RE-created later (a prompt timeout closes it) still has them:
34
+ // completer Tab completion.
35
+ // onSigint In terminal mode readline SWALLOWS Ctrl+C: the process never receives SIGINT, so the
36
+ // stop handlers `logs --follow` and `deploy --follow` register on `process` would never
37
+ // fire and a tail could not be stopped. The session decides where the keypress goes.
38
+ // history Kept in this module, so it survives a re-created interface.
39
+ let session = null;
40
+ let history = [];
41
+
42
+ export function configureSession(options) {
43
+ session = options || null;
44
+ if (!session) history = [];
45
+ }
46
+
47
+ // True while a session owns standard input - `--args-file -` cannot read it then (mcpTools.js).
48
+ export function ownsStdin() {
49
+ return !!session;
50
+ }
51
+
52
+ // Readline records EVERY line it reads, and Up arrow replays them. The answer to a prompt is not a
53
+ // command: it can be a password, a one-time code, or an environment variable's value (golden rule 13).
54
+ // Only a line the session asked for as a command stays.
55
+ function forget(line) {
56
+ if (rl && Array.isArray(rl.history) && rl.history[0] === line) rl.history.shift();
57
+ }
58
+
59
+ function iface() {
60
+ if (!rl) {
61
+ rl = readline.createInterface({
62
+ input: process.stdin,
63
+ // stderr, not stdout: a prompt is for the person, and stdout is for what a script captures
64
+ // (`--json`, `db connect --show-url`). A prompt mixed into it corrupts exactly that output.
65
+ output: process.stderr,
66
+ terminal: !!process.stdin.isTTY,
67
+ ...(session ? { completer: (line) => session.completer(line), history, historySize: 200 } : {}),
68
+ });
69
+ closed = false;
70
+ // We consume 'line' rather than rl.question(), and we QUEUE what nobody has asked for yet. On a
71
+ // pipe, readline emits a 'line' for every line the moment the chunk arrives - so a line that
72
+ // shows up while the CLI is mid-request (between "email" and "code" there is a network call)
73
+ // would otherwise be emitted to nobody and lost, and the next prompt would wait forever for
74
+ // input that had already been read.
75
+ rl.on('line', (line) => {
76
+ const next = waiting.shift();
77
+ if (!next || !next.isCommand) forget(line);
78
+ if (next) next(line);
79
+ else buffered.push(line);
80
+ });
81
+ rl.on('close', () => {
82
+ closed = true;
83
+ if (rl && Array.isArray(rl.history)) history = rl.history;
84
+ while (waiting.length) waiting.shift()(''); // EOF - unblock anything still asking
85
+ });
86
+ // Registered ONLY for a session: with no listener readline keeps its default for one-shot commands.
87
+ if (session) rl.on('SIGINT', () => session.onSigint());
88
+ }
89
+ return rl;
90
+ }
91
+
92
+ // Standard input has ended (Ctrl+D, or the end of a pipe) and every line that arrived has been used.
93
+ export function promptsEnded() {
94
+ return closed && buffered.length === 0;
95
+ }
96
+
97
+ // What was typed while a command was running is thrown away, not run: a stray `y` typed during a log
98
+ // tail must not become the next command. Terminal only - on a pipe every line "arrives early", and
99
+ // dropping there would discard the script.
100
+ export function dropTypeAhead() {
101
+ if (process.stdin.isTTY) buffered.length = 0;
102
+ }
103
+
104
+ // Hand the terminal to another program (psql, ssh) and take it back. Readline holds the terminal in raw
105
+ // mode and reads every key; a child that inherits it would fight for each keystroke.
106
+ export function suspendPrompts() {
107
+ if (!rl) return;
108
+ rl.pause();
109
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') process.stdin.setRawMode(false);
110
+ }
111
+
112
+ export function resumePrompts() {
113
+ if (!rl) return;
114
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') process.stdin.setRawMode(true);
115
+ rl.resume();
116
+ }
117
+
118
+ // The session's own prompt. Unlike `ask`, the text is given to readline AS its prompt, so recalling a
119
+ // line with Up arrow redraws it instead of erasing it - and the line it returns is kept in history.
120
+ export function askCommand(text) {
121
+ const r = iface();
122
+ if (buffered.length) return Promise.resolve(String(buffered.shift()));
123
+ if (closed) return Promise.resolve('');
124
+ r.setPrompt(text);
125
+ r.prompt();
126
+ return new Promise((resolve) => {
127
+ const take = (line) => {
128
+ r.setPrompt('');
129
+ resolve(String(line || ''));
130
+ };
131
+ take.isCommand = true;
132
+ waiting.push(take);
133
+ });
134
+ }
135
+
136
+ // Draw the session prompt again after something was printed over it.
137
+ export function reprompt() {
138
+ if (rl) rl.prompt();
139
+ }
140
+
141
+ // Clear whatever is typed on the current line (Ctrl+C at a prompt, the way a shell does it).
142
+ export function clearTypedLine() {
143
+ if (!rl || !rl.line) return false;
144
+ rl.write(null, { ctrl: true, name: 'u' });
145
+ return true;
146
+ }
147
+
148
+ // An open readline interface holds the event loop open, so the process would never exit. Every
149
+ // command path closes prompts when it finishes (index.js does it centrally).
150
+ export function closePrompts() {
151
+ if (rl) {
152
+ rl.close();
153
+ rl = null;
154
+ }
155
+ }
156
+
157
+ function withTimeout(promise, timeoutMs) {
158
+ if (!timeoutMs) return promise;
159
+ return new Promise((resolve, reject) => {
160
+ const timer = setTimeout(() => {
161
+ closePrompts();
162
+ reject(new PromptTimeout());
163
+ }, timeoutMs);
164
+ promise.then(
165
+ (v) => {
166
+ clearTimeout(timer);
167
+ resolve(v);
168
+ },
169
+ (e) => {
170
+ clearTimeout(timer);
171
+ reject(e);
172
+ }
173
+ );
174
+ });
175
+ }
176
+
177
+ // NO PERSON IS HERE (--no-input, VOROA_NO_INPUT). An agent or a pipeline that reaches a question would
178
+ // otherwise sit on it until something upstream gives up, with nothing to tell it why. It gets a code
179
+ // it can act on instead. The session's own prompt is not a question and is not affected.
180
+ let noInput = false;
181
+ export function setNoInput(value) {
182
+ noInput = !!value;
183
+ }
184
+
185
+ function question(text, { hide = false } = {}) {
186
+ if (noInput) {
187
+ const asked = String(text).replace(/[:\s]+$/, '');
188
+ throw new CliError(`This step needs a person - it asks for "${asked}". Run it yourself in a terminal, or set VOROA_TOKEN to an API token.`, 1, 'NEEDS_HUMAN');
189
+ }
190
+ const r = iface();
191
+ process.stderr.write(text);
192
+
193
+ // Already have the line (it arrived while we were busy) - answer immediately.
194
+ if (buffered.length) return Promise.resolve(String(buffered.shift()));
195
+ if (closed) return Promise.resolve(''); // stdin ended - the caller validates the empty answer
196
+
197
+ const hiding = hide && !!process.stdin.isTTY;
198
+ const original = r._writeToOutput;
199
+ if (hiding) {
200
+ // Suppress the echo readline would write for each keystroke. There is nothing to suppress on a
201
+ // pipe, which is why this is TTY-only.
202
+ r._writeToOutput = function writeToOutput() {};
203
+ }
204
+ return new Promise((resolve) => {
205
+ waiting.push((line) => {
206
+ if (hiding) {
207
+ r._writeToOutput = original;
208
+ r.output.write('\n'); // the newline Enter would have echoed
209
+ }
210
+ resolve(String(line || ''));
211
+ });
212
+ });
213
+ }
214
+
215
+ export function ask(text, { timeoutMs } = {}) {
216
+ return withTimeout(
217
+ question(text).then((v) => v.trim()),
218
+ timeoutMs
219
+ );
220
+ }
221
+
222
+ // Hidden input. `terminal: true` is what gives readline control of the echo; overriding
223
+ // `_writeToOutput` suppresses it. On a non-TTY stdin (a pipe, a CI step) there is no echo to
224
+ // suppress, so the line is simply read.
225
+ export function askHidden(text, { timeoutMs } = {}) {
226
+ return withTimeout(question(text, { hide: true }), timeoutMs);
227
+ }
228
+
229
+ export { PromptTimeout };
package/src/region.js ADDED
@@ -0,0 +1,5 @@
1
+ // White-label region label - CLI ka ek ghar (rule #10, golden rule 6). Customer ko HAMESHA
2
+ // "Mumbai (IN)" dikhta hai, kabhi raw infra region (ap-south-1). Peers: frontend/src/lib/region.js
3
+ // aur backend/src/lib/region.js. Teeno ko backend/tests/centralizationGuard.test.js pin karta hai -
4
+ // yahan se import karo, kahin aur ye string mat likho.
5
+ export const REGION_LABEL = 'Mumbai (IN)';