voroa-cli 0.0.0-stage → 0.2.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.
- package/LICENSE +21 -0
- package/README.md +193 -2
- package/package.json +41 -4
- package/src/api.js +552 -0
- package/src/commands/db.js +99 -0
- package/src/commands/deploy.js +104 -0
- package/src/commands/domains.js +122 -0
- package/src/commands/env.js +143 -0
- package/src/commands/lifecycle.js +40 -0
- package/src/commands/link.js +92 -0
- package/src/commands/login.js +104 -0
- package/src/commands/logs.js +285 -0
- package/src/commands/services.js +72 -0
- package/src/commands/ssh.js +46 -0
- package/src/commands/status.js +36 -0
- package/src/commands/tokens.js +81 -0
- package/src/commands/workspace.js +63 -0
- package/src/config.js +108 -0
- package/src/errors.js +10 -0
- package/src/flags.js +32 -0
- package/src/index.js +190 -0
- package/src/interactive.js +28 -0
- package/src/link.js +72 -0
- package/src/mcp/build.js +72 -0
- package/src/mcp/kernel/index.js +59 -0
- package/src/mcp/kernel/logger.js +28 -0
- package/src/mcp/lib/config.js +72 -0
- package/src/mcp/lib/credentials.js +51 -0
- package/src/mcp/lib/http.js +136 -0
- package/src/mcp/lib/response.js +14 -0
- package/src/mcp/lib/reversibility.js +33 -0
- package/src/mcp/reversibilityMap.json +77 -0
- package/src/mcp/tools/_action.js +49 -0
- package/src/mcp/tools/_ids.js +42 -0
- package/src/mcp/tools/_shape.js +318 -0
- package/src/mcp/tools/actions.js +94 -0
- package/src/mcp/tools/getBuildLog.js +39 -0
- package/src/mcp/tools/getDeployHistory.js +42 -0
- package/src/mcp/tools/getRuntimeLogs.js +44 -0
- package/src/mcp/tools/getServiceStatus.js +41 -0
- package/src/mcp/tools/getUsage.js +34 -0
- package/src/mcp/tools/listDatabases.js +25 -0
- package/src/mcp/tools/listDomains.js +33 -0
- package/src/mcp/tools/listServices.js +20 -0
- package/src/mcp/tools/propose.js +124 -0
- package/src/mcp/tools/reads.js +756 -0
- package/src/mcp/tools/writes.js +632 -0
- package/src/mcpTools.js +265 -0
- package/src/output.js +56 -0
- package/src/prompt.js +127 -0
- package/src/region.js +5 -0
- package/src/resolve.js +64 -0
- package/src/status.js +178 -0
- package/src/stepup.js +69 -0
package/src/mcpTools.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
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
|
+
|
|
20
|
+
const KERNEL_HOMES = [new URL('./mcp/', import.meta.url), new URL('../../customer-mcp/src/', import.meta.url)];
|
|
21
|
+
|
|
22
|
+
// The kernel logs one line per HTTP call to stderr - right for a long-lived MCP server, noise on a
|
|
23
|
+
// terminal. Errors are not lost: they are thrown, and index.js prints the message.
|
|
24
|
+
const SILENT = { info() {}, warn() {}, error() {} };
|
|
25
|
+
|
|
26
|
+
export const NO_API_TOKEN =
|
|
27
|
+
'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.';
|
|
28
|
+
|
|
29
|
+
// A CliError that keeps what caused it (golden rule 14). The MESSAGE is what a person reads and is written
|
|
30
|
+
// here, never taken from the cause: a JSON parse error quotes its input back, and that input can be a
|
|
31
|
+
// secret value on its way to being set.
|
|
32
|
+
const because = (message, cause) => Object.assign(new CliError(message), { cause });
|
|
33
|
+
|
|
34
|
+
export async function loadKernel({ apiUrl = '', token = '', fetchImpl } = {}) {
|
|
35
|
+
for (const home of KERNEL_HOMES) {
|
|
36
|
+
const file = new URL('build.js', home);
|
|
37
|
+
if (!existsSync(fileURLToPath(file))) continue;
|
|
38
|
+
const { buildKernel } = await import(file.href);
|
|
39
|
+
// Both values are always passed, so the kernel's own config never falls back to a credentials file
|
|
40
|
+
// on disk - a stored token is bound to the endpoint that issued it (config.js) and must not follow
|
|
41
|
+
// `--api-url` somewhere else.
|
|
42
|
+
return buildKernel({ env: { VOROA_MCP_URL: apiUrl, VOROA_MCP_TOKEN: token }, fetchImpl, logger: SILENT });
|
|
43
|
+
}
|
|
44
|
+
throw new CliError('This install of voroa-cli is incomplete. Reinstall it with `npm install -g voroa-cli`.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const commandName = (toolName) => toolName.replace(/_/g, '-');
|
|
48
|
+
const flagName = (prop) => `--${prop.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
49
|
+
const squash = (s) => String(s).replace(/^--?/, '').replace(/[-_]/g, '').toLowerCase();
|
|
50
|
+
|
|
51
|
+
export function findTool(kernel, name) {
|
|
52
|
+
const wanted = String(name || '').replace(/-/g, '_');
|
|
53
|
+
return kernel.listTools().find((t) => t.name === wanted) || null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function typeOf(schema) {
|
|
57
|
+
if (Array.isArray(schema.type)) return schema.type.find((t) => t !== 'null') || 'string';
|
|
58
|
+
return schema.type || 'string';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function coerce(flag, raw, schema) {
|
|
62
|
+
const type = typeOf(schema);
|
|
63
|
+
if (type === 'boolean') {
|
|
64
|
+
if (raw === true || raw === 'true') return true;
|
|
65
|
+
if (raw === 'false') return false;
|
|
66
|
+
throw new CliError(`${flag} is true or false. Got "${raw}".`);
|
|
67
|
+
}
|
|
68
|
+
if (raw === true) throw new CliError(`${flag} needs a value.`);
|
|
69
|
+
if (type === 'integer' || type === 'number') {
|
|
70
|
+
const n = Number(String(raw).trim());
|
|
71
|
+
if (!Number.isFinite(n) || (type === 'integer' && !Number.isInteger(n))) {
|
|
72
|
+
throw new CliError(`${flag} takes a ${type === 'integer' ? 'whole number' : 'number'}. Got "${raw}".`);
|
|
73
|
+
}
|
|
74
|
+
return n;
|
|
75
|
+
}
|
|
76
|
+
if (type === 'object' || type === 'array') {
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(raw);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
throw because(`${flag} takes JSON (${type === 'array' ? 'a list' : 'an object'}).`, e);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return String(raw);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readJsonObject(text, what) {
|
|
87
|
+
let parsed;
|
|
88
|
+
try {
|
|
89
|
+
parsed = JSON.parse(text);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
throw because(`${what} is not valid JSON.`, e);
|
|
92
|
+
}
|
|
93
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new CliError(`${what} must be a JSON object.`);
|
|
94
|
+
return parsed;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// argv AFTER the tool name -> { args, help, asJson }. Flags are matched against the tool's own
|
|
98
|
+
// schema, so `--service-id`, `--serviceId` and `--service_id` are the same flag and an unknown one is
|
|
99
|
+
// refused by name rather than silently dropped. Bare words fill the REQUIRED arguments in schema order:
|
|
100
|
+
// `voroa get-database <id>`.
|
|
101
|
+
export function parseToolArgv(tool, argv) {
|
|
102
|
+
const props = (tool.inputSchema && tool.inputSchema.properties) || {};
|
|
103
|
+
const bySquashed = new Map(Object.keys(props).map((p) => [squash(p), p]));
|
|
104
|
+
const result = { args: {}, help: false, asJson: false };
|
|
105
|
+
const bare = [];
|
|
106
|
+
|
|
107
|
+
for (let i = 0; i < argv.length; i++) {
|
|
108
|
+
const token = argv[i];
|
|
109
|
+
if (!token.startsWith('--')) {
|
|
110
|
+
bare.push(token);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const eq = token.indexOf('=');
|
|
114
|
+
const flag = eq === -1 ? token : token.slice(0, eq);
|
|
115
|
+
const inline = eq === -1 ? undefined : token.slice(eq + 1);
|
|
116
|
+
const takeValue = () => {
|
|
117
|
+
if (inline !== undefined) return inline;
|
|
118
|
+
if (i + 1 >= argv.length) throw new CliError(`${flag} needs a value.`);
|
|
119
|
+
return argv[++i];
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// A tool's own argument wins over a CLI-wide flag of the same name (`propose` has an `args`).
|
|
123
|
+
const prop = bySquashed.get(squash(flag));
|
|
124
|
+
if (prop) {
|
|
125
|
+
const isBool = typeOf(props[prop]) === 'boolean';
|
|
126
|
+
const next = argv[i + 1];
|
|
127
|
+
// `--enabled` alone is true; `--enabled false` and `--enabled=false` are both false.
|
|
128
|
+
const raw = !isBool ? takeValue() : inline !== undefined ? inline : next === 'true' || next === 'false' ? argv[++i] : true;
|
|
129
|
+
result.args[prop] = coerce(flag, raw, props[prop]);
|
|
130
|
+
} else if (flag === '--help') result.help = true;
|
|
131
|
+
else if (flag === '--json') result.asJson = true;
|
|
132
|
+
// Already applied by index.js when it built the settings; consumed here so they are not "unknown".
|
|
133
|
+
else if (flag === '--api-url' || flag === '--workspace') takeValue();
|
|
134
|
+
else if (flag === '--args') result.args = { ...readJsonObject(takeValue(), '--args'), ...result.args };
|
|
135
|
+
else if (flag === '--args-file') {
|
|
136
|
+
// `-` is stdin. A value on the command line lands in shell history and in `ps`; a secret (an
|
|
137
|
+
// environment variable's value, a secret file's contents) should arrive by pipe or by file.
|
|
138
|
+
const file = takeValue();
|
|
139
|
+
let text;
|
|
140
|
+
try {
|
|
141
|
+
text = readFileSync(file === '-' ? 0 : file, 'utf8');
|
|
142
|
+
} catch (e) {
|
|
143
|
+
throw because(file === '-' ? 'Nothing arrived on standard input.' : `Cannot read ${file}.`, e);
|
|
144
|
+
}
|
|
145
|
+
result.args = { ...readJsonObject(text, file), ...result.args };
|
|
146
|
+
} else {
|
|
147
|
+
throw new CliError(`${commandName(tool.name)} has no ${flag} argument. Run \`voroa ${commandName(tool.name)} --help\`.`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const open = ((tool.inputSchema && tool.inputSchema.required) || []).filter((p) => !(p in result.args));
|
|
152
|
+
if (bare.length > open.length) throw new CliError(`Unexpected "${bare[open.length]}". Run \`voroa ${commandName(tool.name)} --help\`.`);
|
|
153
|
+
bare.forEach((value, n) => {
|
|
154
|
+
result.args[open[n]] = coerce(flagName(open[n]), value, props[open[n]]);
|
|
155
|
+
});
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const firstSentence = (text) => {
|
|
160
|
+
const s = String(text || '').replace(/\s+/g, ' ').trim();
|
|
161
|
+
const end = s.search(/\.(\s|$)/);
|
|
162
|
+
return end === -1 ? s : s.slice(0, end + 1);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export function toolsList(kernel) {
|
|
166
|
+
return renderTable(['COMMAND', 'WHAT IT DOES'], kernel.listTools().map((t) => [commandName(t.name), firstSentence(t.description)]));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function toolHelp(tool, { explicit = false } = {}) {
|
|
170
|
+
const props = (tool.inputSchema && tool.inputSchema.properties) || {};
|
|
171
|
+
const required = new Set((tool.inputSchema && tool.inputSchema.required) || []);
|
|
172
|
+
const lines = [`voroa ${explicit ? 'tool ' : ''}${commandName(tool.name)}`, '', String(tool.description || '').replace(/\s+/g, ' ').trim()];
|
|
173
|
+
const names = Object.keys(props);
|
|
174
|
+
if (names.length) {
|
|
175
|
+
lines.push('', 'Arguments');
|
|
176
|
+
lines.push(
|
|
177
|
+
renderTable(
|
|
178
|
+
['FLAG', 'TYPE', 'REQUIRED', 'MEANING'],
|
|
179
|
+
names.map((p) => [
|
|
180
|
+
flagName(p),
|
|
181
|
+
props[p].enum ? 'one of' : typeOf(props[p]),
|
|
182
|
+
required.has(p) ? 'yes' : 'no',
|
|
183
|
+
props[p].enum ? `${props[p].enum.join(', ')}` : String(props[p].description || '').replace(/\s+/g, ' ').trim(),
|
|
184
|
+
])
|
|
185
|
+
)
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
lines.push('', 'Also: --args \'<json>\' or --args-file <path> (- for standard input) to pass every argument as one JSON object; --json for raw output.');
|
|
189
|
+
return lines.join('\n');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A list of flat records reads better as a table; anything else is printed as the JSON it is. The
|
|
193
|
+
// payload is whatever the tool projected - nothing is added or widened here.
|
|
194
|
+
function printResult(payload, asJson) {
|
|
195
|
+
if (asJson) return json(payload);
|
|
196
|
+
const lists = Array.isArray(payload)
|
|
197
|
+
? [payload]
|
|
198
|
+
: payload && typeof payload === 'object'
|
|
199
|
+
? Object.values(payload).filter((v) => Array.isArray(v))
|
|
200
|
+
: [];
|
|
201
|
+
const rows = lists.length === 1 ? lists[0] : null;
|
|
202
|
+
const flat = (v) => v === null || ['string', 'number', 'boolean'].includes(typeof v);
|
|
203
|
+
if (rows && rows.length && rows.every((r) => r && typeof r === 'object' && !Array.isArray(r))) {
|
|
204
|
+
const cols = Object.keys(rows[0]).filter((k) => rows.every((r) => flat(r[k] ?? null)));
|
|
205
|
+
const scalars = Array.isArray(payload) ? [] : Object.entries(payload).filter(([, v]) => flat(v));
|
|
206
|
+
const nested = Array.isArray(payload) ? 0 : Object.keys(payload).length - scalars.length - 1;
|
|
207
|
+
if (cols.length && cols.length === Object.keys(rows[0]).length && nested === 0) {
|
|
208
|
+
out(renderTable(cols.map((c) => c.toUpperCase()), rows.map((r) => cols.map((c) => r[c]))));
|
|
209
|
+
for (const [k, v] of scalars) out(`${k}: ${v}`);
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return json(payload);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// `voroa tools`, `voroa tool <name> ...` and the shorthand `voroa <tool-name> ...`.
|
|
217
|
+
export async function runTool(settings, name, argv, { fetchImpl, asJson = false, explicit = false } = {}) {
|
|
218
|
+
const token = isApiToken(settings.token) ? settings.token : settings.apiToken || '';
|
|
219
|
+
// Listing and help need no credential: both come from the tool definitions. Only a call does.
|
|
220
|
+
const kernel = await loadKernel({ apiUrl: settings.apiUrl, token, fetchImpl });
|
|
221
|
+
if (name === 'tools') {
|
|
222
|
+
if (asJson || argv.includes('--json')) json(kernel.listTools().map((t) => ({ command: commandName(t.name), description: t.description, inputSchema: t.inputSchema })));
|
|
223
|
+
else out(toolsList(kernel));
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
const tool = findTool(kernel, name);
|
|
227
|
+
if (!tool) throw new CliError(`Unknown command "${name}". Run \`voroa --help\`, or \`voroa tools\` for the full list.`);
|
|
228
|
+
|
|
229
|
+
const parsed = parseToolArgv(tool, argv);
|
|
230
|
+
if (parsed.help) {
|
|
231
|
+
out(toolHelp(tool, { explicit }));
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
if (!token) throw new CliError(NO_API_TOKEN);
|
|
235
|
+
|
|
236
|
+
// A service can be named, or left out in a linked folder - the same resolver the hand-written commands
|
|
237
|
+
// use, so the answer still comes from the server's own workspace-scoped list (resolve.js).
|
|
238
|
+
const props = tool.inputSchema.properties || {};
|
|
239
|
+
if ('serviceId' in props && tool.name !== 'list_services') {
|
|
240
|
+
const required = (tool.inputSchema.required || []).includes('serviceId');
|
|
241
|
+
if (parsed.args.serviceId || required) {
|
|
242
|
+
const ctx = contextFrom({ ...settings, token });
|
|
243
|
+
parsed.args.serviceId = (await resolveService(ctx, parsed.args.serviceId)).uuid;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let result;
|
|
248
|
+
try {
|
|
249
|
+
result = await kernel.callTool(tool.name, parsed.args);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
// The kernel's messages are generic and secret-free by contract (customer-mcp/src/lib/http.js).
|
|
252
|
+
throw because(e && e.message ? e.message : 'That did not work. Try again.', e);
|
|
253
|
+
}
|
|
254
|
+
const text = result && result.content && result.content[0] && result.content[0].text;
|
|
255
|
+
let payload;
|
|
256
|
+
try {
|
|
257
|
+
payload = JSON.parse(text);
|
|
258
|
+
} catch (e) {
|
|
259
|
+
// Every tool answers in JSON (customer-mcp/src/lib/response.js). Anything else is a kernel this
|
|
260
|
+
// build does not understand - say so rather than print something half-read.
|
|
261
|
+
throw because('Voroa answered in a form this version cannot read. Update with `npm install -g voroa-cli`.', e);
|
|
262
|
+
}
|
|
263
|
+
printResult(payload, parsed.asJson || asJson);
|
|
264
|
+
return result && result.isError ? 1 : 0;
|
|
265
|
+
}
|
package/src/output.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Terminal output helpers. Plain text, no colour: a CLI's output gets piped, redirected and pasted
|
|
2
|
+
// into tickets, and ANSI codes survive all three as garbage. The state is carried by the WORD
|
|
3
|
+
// ("Crash looping"), never by a colour a pipe would strip anyway.
|
|
4
|
+
|
|
5
|
+
// Column-aligned table. Values are stringified defensively - a null cell renders as the em dash, so
|
|
6
|
+
// an empty column never reads as an empty VALUE.
|
|
7
|
+
export const EMPTY_CELL = '-';
|
|
8
|
+
|
|
9
|
+
function cell(value) {
|
|
10
|
+
if (value === null || value === undefined || value === '') return EMPTY_CELL;
|
|
11
|
+
return String(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function renderTable(headers, rows) {
|
|
15
|
+
const body = rows.map((r) => r.map(cell));
|
|
16
|
+
const widths = headers.map((h, i) => Math.max(String(h).length, ...body.map((r) => (r[i] || '').length), 0));
|
|
17
|
+
const line = (cells) => cells.map((c, i) => (i === cells.length - 1 ? c : c.padEnd(widths[i]))).join(' ').trimEnd();
|
|
18
|
+
return [line(headers.map(String)), ...body.map(line)].join('\n');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Key/value block for a single record.
|
|
22
|
+
export function renderFields(pairs) {
|
|
23
|
+
const width = Math.max(...pairs.map(([k]) => k.length), 0);
|
|
24
|
+
return pairs.map(([k, v]) => `${k.padEnd(width)} ${cell(v)}`).join('\n');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Relative time from epoch SECONDS (what the API sends). Absolute dates are avoided because the
|
|
28
|
+
// server speaks UTC and the reader thinks in their own zone; "3 hours ago" is unambiguous in both.
|
|
29
|
+
export function timeAgo(epochSeconds) {
|
|
30
|
+
const secs = Number(epochSeconds);
|
|
31
|
+
if (!Number.isFinite(secs) || secs <= 0) return null;
|
|
32
|
+
const diff = Math.floor(Date.now() / 1000) - secs;
|
|
33
|
+
if (diff < 0) return 'just now'; // clock skew - never render a negative age
|
|
34
|
+
if (diff < 60) return 'just now';
|
|
35
|
+
const mins = Math.floor(diff / 60);
|
|
36
|
+
if (mins < 60) return `${mins}m ago`;
|
|
37
|
+
const hours = Math.floor(mins / 60);
|
|
38
|
+
if (hours < 24) return `${hours}h ago`;
|
|
39
|
+
const days = Math.floor(hours / 24);
|
|
40
|
+
if (days < 30) return `${days}d ago`;
|
|
41
|
+
const months = Math.floor(days / 30);
|
|
42
|
+
if (months < 12) return `${months}mo ago`;
|
|
43
|
+
return `${Math.floor(months / 12)}y ago`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function out(text = '') {
|
|
47
|
+
process.stdout.write(`${text}\n`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function err(text = '') {
|
|
51
|
+
process.stderr.write(`${text}\n`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function json(value) {
|
|
55
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
56
|
+
}
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
|
|
17
|
+
class PromptTimeout extends Error {
|
|
18
|
+
constructor() {
|
|
19
|
+
super('prompt timed out');
|
|
20
|
+
this.name = 'PromptTimeout';
|
|
21
|
+
this.timedOut = true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let rl = null;
|
|
26
|
+
let closed = false;
|
|
27
|
+
const waiting = []; // resolvers for prompts that have been asked but not yet answered
|
|
28
|
+
const buffered = []; // lines that arrived before anything asked for them
|
|
29
|
+
|
|
30
|
+
function iface() {
|
|
31
|
+
if (!rl) {
|
|
32
|
+
rl = readline.createInterface({
|
|
33
|
+
input: process.stdin,
|
|
34
|
+
// stderr, not stdout: a prompt is for the person, and stdout is for what a script captures
|
|
35
|
+
// (`--json`, `db connect --show-url`). A prompt mixed into it corrupts exactly that output.
|
|
36
|
+
output: process.stderr,
|
|
37
|
+
terminal: !!process.stdin.isTTY,
|
|
38
|
+
});
|
|
39
|
+
closed = false;
|
|
40
|
+
// We consume 'line' rather than rl.question(), and we QUEUE what nobody has asked for yet. On a
|
|
41
|
+
// pipe, readline emits a 'line' for every line the moment the chunk arrives - so a line that
|
|
42
|
+
// shows up while the CLI is mid-request (between "email" and "code" there is a network call)
|
|
43
|
+
// would otherwise be emitted to nobody and lost, and the next prompt would wait forever for
|
|
44
|
+
// input that had already been read.
|
|
45
|
+
rl.on('line', (line) => {
|
|
46
|
+
const next = waiting.shift();
|
|
47
|
+
if (next) next(line);
|
|
48
|
+
else buffered.push(line);
|
|
49
|
+
});
|
|
50
|
+
rl.on('close', () => {
|
|
51
|
+
closed = true;
|
|
52
|
+
while (waiting.length) waiting.shift()(''); // EOF - unblock anything still asking
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return rl;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// An open readline interface holds the event loop open, so the process would never exit. Every
|
|
59
|
+
// command path closes prompts when it finishes (index.js does it centrally).
|
|
60
|
+
export function closePrompts() {
|
|
61
|
+
if (rl) {
|
|
62
|
+
rl.close();
|
|
63
|
+
rl = null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function withTimeout(promise, timeoutMs) {
|
|
68
|
+
if (!timeoutMs) return promise;
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
closePrompts();
|
|
72
|
+
reject(new PromptTimeout());
|
|
73
|
+
}, timeoutMs);
|
|
74
|
+
promise.then(
|
|
75
|
+
(v) => {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
resolve(v);
|
|
78
|
+
},
|
|
79
|
+
(e) => {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
reject(e);
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function question(text, { hide = false } = {}) {
|
|
88
|
+
const r = iface();
|
|
89
|
+
process.stderr.write(text);
|
|
90
|
+
|
|
91
|
+
// Already have the line (it arrived while we were busy) - answer immediately.
|
|
92
|
+
if (buffered.length) return Promise.resolve(String(buffered.shift()));
|
|
93
|
+
if (closed) return Promise.resolve(''); // stdin ended - the caller validates the empty answer
|
|
94
|
+
|
|
95
|
+
const hiding = hide && !!process.stdin.isTTY;
|
|
96
|
+
const original = r._writeToOutput;
|
|
97
|
+
if (hiding) {
|
|
98
|
+
// Suppress the echo readline would write for each keystroke. There is nothing to suppress on a
|
|
99
|
+
// pipe, which is why this is TTY-only.
|
|
100
|
+
r._writeToOutput = function writeToOutput() {};
|
|
101
|
+
}
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
waiting.push((line) => {
|
|
104
|
+
if (hiding) {
|
|
105
|
+
r._writeToOutput = original;
|
|
106
|
+
r.output.write('\n'); // the newline Enter would have echoed
|
|
107
|
+
}
|
|
108
|
+
resolve(String(line || ''));
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function ask(text, { timeoutMs } = {}) {
|
|
114
|
+
return withTimeout(
|
|
115
|
+
question(text).then((v) => v.trim()),
|
|
116
|
+
timeoutMs
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Hidden input. `terminal: true` is what gives readline control of the echo; overriding
|
|
121
|
+
// `_writeToOutput` suppresses it. On a non-TTY stdin (a pipe, a CI step) there is no echo to
|
|
122
|
+
// suppress, so the line is simply read.
|
|
123
|
+
export function askHidden(text, { timeoutMs } = {}) {
|
|
124
|
+
return withTimeout(question(text, { hide: true }), timeoutMs);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
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)';
|
package/src/resolve.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// "myapp" -> a service uuid.
|
|
2
|
+
//
|
|
3
|
+
// TENANCY (golden rule 2, bug #14): the candidate set is EXCLUSIVELY `GET /api/apps` called with
|
|
4
|
+
// the active workspace header. That response is already scoped by the server to this caller AND
|
|
5
|
+
// this workspace, so a name can only ever resolve to something they own here. The CLI keeps no
|
|
6
|
+
// local name->uuid map: a cached id from another workspace would be a client-side claim about
|
|
7
|
+
// ownership, and the server is the only thing entitled to make that claim.
|
|
8
|
+
//
|
|
9
|
+
// A uuid is accepted as-is only when it matches a row in that same response - so passing a uuid is
|
|
10
|
+
// a shortcut past the name lookup, not past the ownership check.
|
|
11
|
+
import { listApps } from './api.js';
|
|
12
|
+
import { CliError } from './errors.js';
|
|
13
|
+
import { findLink } from './link.js';
|
|
14
|
+
import { err } from './output.js';
|
|
15
|
+
|
|
16
|
+
// The ONE name-or-id matcher (services and databases both use it). Order: uuid, then exact name, then
|
|
17
|
+
// case-insensitive name. Returns { match } | { ambiguous: rows } | {}.
|
|
18
|
+
//
|
|
19
|
+
// `idOnly` is for a needle that did NOT come from the person typing - a `.voroa.json` link. That file is
|
|
20
|
+
// meant to be committed, so a cloned repo's link saying `"serviceId": "api"` must not fall through to
|
|
21
|
+
// NAME matching and pick the reader's own service called "api".
|
|
22
|
+
export function pickByNameOrId(rows, needle, { idOnly = false } = {}) {
|
|
23
|
+
const byUuid = rows.find((r) => r.uuid === needle);
|
|
24
|
+
if (byUuid) return { match: byUuid };
|
|
25
|
+
if (idOnly) return {};
|
|
26
|
+
const exact = rows.filter((r) => r.name === needle);
|
|
27
|
+
const matches = exact.length ? exact : rows.filter((r) => String(r.name).toLowerCase() === String(needle).toLowerCase());
|
|
28
|
+
if (matches.length === 1) return { match: matches[0] };
|
|
29
|
+
if (matches.length > 1) return { ambiguous: matches };
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ambiguityList(rows) {
|
|
34
|
+
return rows.map((r) => ` ${r.name} ${r.uuid}`).join('\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function resolveService(ctx, needle, { useLink = true } = {}) {
|
|
38
|
+
// No name given -> the folder's link, if any. The linked id is only a NEEDLE: it goes through the
|
|
39
|
+
// same scoped listing below as a typed one, so a link is a shortcut past typing, never past ownership.
|
|
40
|
+
let linked = null;
|
|
41
|
+
if (!needle && useLink) {
|
|
42
|
+
linked = findLink();
|
|
43
|
+
if (linked) {
|
|
44
|
+
needle = linked.serviceId;
|
|
45
|
+
// The link remembers its workspace; an explicit --workspace still wins.
|
|
46
|
+
if (linked.workspaceId && !ctx.workspaceExplicit) ctx.workspaceId = linked.workspaceId;
|
|
47
|
+
// Said on stderr, so a deploy never acts on a service the user did not see named.
|
|
48
|
+
err(`Using linked service ${linked.serviceName || linked.serviceId} (${linked.file})`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!needle) throw new CliError('Which service? Pass a service name or id, or run `voroa link <service>` in this folder.');
|
|
52
|
+
const apps = await listApps(ctx);
|
|
53
|
+
if (apps.length === 0) {
|
|
54
|
+
throw new CliError('There are no services in this workspace. Check `voroa workspace list`.');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const found = pickByNameOrId(apps, needle, { idOnly: !!linked });
|
|
58
|
+
if (found.match) return found.match;
|
|
59
|
+
if (found.ambiguous) throw new CliError(`Multiple services match "${needle}", use the id:\n${ambiguityList(found.ambiguous)}`);
|
|
60
|
+
if (linked) {
|
|
61
|
+
throw new CliError(`This folder is linked to ${linked.serviceName || linked.serviceId}, which is not in this workspace. Run \`voroa link <service>\` again.`);
|
|
62
|
+
}
|
|
63
|
+
throw new CliError(`No service called "${needle}" in this workspace. Run \`voroa services\` to see them.`);
|
|
64
|
+
}
|