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.
- package/LICENSE +21 -0
- package/README.md +247 -2
- package/package.json +41 -4
- package/src/agent.js +95 -0
- package/src/api.js +552 -0
- package/src/commands/db.js +99 -0
- package/src/commands/deploy.js +130 -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 +304 -0
- package/src/commands/services.js +72 -0
- package/src/commands/ssh.js +46 -0
- package/src/commands/status.js +50 -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 +53 -0
- package/src/flags.js +32 -0
- package/src/index.js +212 -0
- package/src/interactive.js +34 -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 +292 -0
- package/src/output.js +145 -0
- package/src/prompt.js +229 -0
- package/src/region.js +5 -0
- package/src/resolve.js +64 -0
- package/src/session.js +270 -0
- package/src/status.js +199 -0
- package/src/stepup.js +69 -0
package/src/api.js
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
// The CLI's only HTTP layer. Every request to Voroa goes through `request` below.
|
|
2
|
+
//
|
|
3
|
+
// THIS FILE IS THE ONE PLACE ALLOWED TO TOUCH A `.status` PROPERTY, and that is enforced by
|
|
4
|
+
// `backend/tests/cliGuards.test.js`. Two unrelated things are called `status` on the wire and
|
|
5
|
+
// confusing them is known bug #24:
|
|
6
|
+
//
|
|
7
|
+
// app status payload .status = the CONTAINER's "state:health" string ("running:healthy").
|
|
8
|
+
// Legitimate - it is `classify`'s first argument.
|
|
9
|
+
// deployment row .status = the RAW BUILD status ('building'|'live'|'failed'). This one
|
|
10
|
+
// LIES about the runtime: 'live' means the image built and the
|
|
11
|
+
// start job returned, not that the service came up. The honest
|
|
12
|
+
// answer is the separate `verdict` field.
|
|
13
|
+
//
|
|
14
|
+
// So the rename happens here, once, at the boundary: `.status` becomes `containerStatus` on a
|
|
15
|
+
// status payload and `buildStatus` on a deployment row. Past this file the ambiguous name does not
|
|
16
|
+
// exist, and no command can pick the wrong one by accident.
|
|
17
|
+
//
|
|
18
|
+
// Golden rule 3: the server already returns a generic `clientMessage` for anything upstream. We
|
|
19
|
+
// surface that string and never invent detail from a transport error - a raw fetch error can carry
|
|
20
|
+
// an internal hostname.
|
|
21
|
+
import { DEFAULT_API_URL } from './config.js';
|
|
22
|
+
|
|
23
|
+
export class ApiError extends Error {
|
|
24
|
+
// `httpStatus`, deliberately not `status` - see the header. Callers branch on this.
|
|
25
|
+
constructor(message, { httpStatus = 0, code = null } = {}) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'ApiError';
|
|
28
|
+
this.httpStatus = httpStatus;
|
|
29
|
+
this.code = code;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const SESSION_EXPIRED = 'Your session has expired. Run `voroa login`.';
|
|
34
|
+
|
|
35
|
+
// Batch-status cap. Mirrors MAX_BATCH in backend/src/routes/apps/statusBatch.js, which dedupes and
|
|
36
|
+
// then SLICES - anything past the cap is dropped silently. So we chunk instead of hoping.
|
|
37
|
+
export const MAX_BATCH = 100;
|
|
38
|
+
|
|
39
|
+
// TWO CREDENTIALS, TWO DOORS. A session (from `voroa login`) goes to /api/*. An API token - the scoped,
|
|
40
|
+
// revocable, workspace-pinned `voroa_cat_` kind minted in the dashboard - is accepted ONLY on
|
|
41
|
+
// /api/ext/customer/*, which re-mounts the same routes behind the token's scopes and a capability
|
|
42
|
+
// allow-list (backend/src/lib/customerMcpCapabilities.js). Same paths after the prefix, same response
|
|
43
|
+
// shapes, so the switch happens here once and no command knows which door it used. This is the CI path:
|
|
44
|
+
// a session in a CI secret is a full-power login; a token is scoped, expiring and audited.
|
|
45
|
+
const API_TOKEN_PREFIX = 'voroa_cat_';
|
|
46
|
+
const SESSION_BASE = '/api';
|
|
47
|
+
const TOKEN_BASE = '/api/ext/customer';
|
|
48
|
+
|
|
49
|
+
export function isApiToken(token) {
|
|
50
|
+
return typeof token === 'string' && token.startsWith(API_TOKEN_PREFIX);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function pathFor(ctx, pathname) {
|
|
54
|
+
if (!isApiToken(ctx.token) || !pathname.startsWith(`${SESSION_BASE}/`)) return pathname;
|
|
55
|
+
return TOKEN_BASE + pathname.slice(SESSION_BASE.length);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function joinUrl(base, pathname) {
|
|
59
|
+
return `${String(base).replace(/\/+$/, '')}${pathname}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function readJson(res) {
|
|
63
|
+
try {
|
|
64
|
+
return await res.json();
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const TOKEN_REJECTED = 'VOROA_TOKEN was rejected. It may be revoked or expired - create a new one in the dashboard.';
|
|
71
|
+
|
|
72
|
+
function messageFor(httpStatus, body, viaToken = false) {
|
|
73
|
+
// A token has no session to expire and nothing `voroa login` could fix - say what is actually wrong.
|
|
74
|
+
if (httpStatus === 401) return viaToken ? TOKEN_REJECTED : SESSION_EXPIRED;
|
|
75
|
+
if (httpStatus === 403) return viaToken ? "This token's scopes do not allow that." : 'You do not have access to that.';
|
|
76
|
+
// On the token door an action outside the allow-list answers 404, exactly like a missing service.
|
|
77
|
+
if (httpStatus === 404) return viaToken ? 'Not found - or not available to an API token.' : 'Not found.';
|
|
78
|
+
if (httpStatus === 429) return 'Too many requests. Wait a moment and try again.';
|
|
79
|
+
// The server's own message is already customer-safe (golden rule 3). Anything else is generic.
|
|
80
|
+
if (body && typeof body.error === 'string' && body.error) return body.error;
|
|
81
|
+
return `The request failed (HTTP ${httpStatus}).`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function contextFrom(settings) {
|
|
85
|
+
const apiUrl = settings.apiUrl || DEFAULT_API_URL;
|
|
86
|
+
return {
|
|
87
|
+
apiUrl,
|
|
88
|
+
token: settings.token || null,
|
|
89
|
+
workspaceId: settings.workspaceId || null,
|
|
90
|
+
// Why there is no token, in the words the caller needs. A session withheld because it belongs to
|
|
91
|
+
// a different endpoint is a different problem from never having signed in, and "you are not
|
|
92
|
+
// signed in" would send someone to `voroa login` without explaining what happened to the session
|
|
93
|
+
// they know they have.
|
|
94
|
+
noTokenMessage: settings.tokenWithheldFrom
|
|
95
|
+
? `You are signed in to ${settings.tokenWithheldFrom}, not ${apiUrl}. That session is not sent to another endpoint. ` +
|
|
96
|
+
`Run \`voroa login --api-url ${apiUrl}\`, or set VOROA_TOKEN.`
|
|
97
|
+
: 'You are not signed in. Run `voroa login`.',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Headers for an authenticated call. The token goes here and NOWHERE else (rule 13).
|
|
102
|
+
function authHeaders(ctx) {
|
|
103
|
+
const headers = { Accept: 'application/json' };
|
|
104
|
+
if (ctx.token) headers.Authorization = `Bearer ${ctx.token}`;
|
|
105
|
+
// No stored workspace -> no header -> the server picks the caller's default. We never guess an id.
|
|
106
|
+
// An API token is pinned to ONE workspace server-side, so the header is not sent with it: a stored
|
|
107
|
+
// workspace from a different login must not look like it had any say.
|
|
108
|
+
if (ctx.workspaceId && !isApiToken(ctx.token)) headers['X-Workspace-Id'] = ctx.workspaceId;
|
|
109
|
+
return headers;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function request(ctx, pathname, { method = 'GET', body, auth = true } = {}) {
|
|
113
|
+
if (auth && !ctx.token) {
|
|
114
|
+
throw new ApiError(ctx.noTokenMessage || 'You are not signed in. Run `voroa login`.', { httpStatus: 401 });
|
|
115
|
+
}
|
|
116
|
+
const headers = auth ? authHeaders(ctx) : { Accept: 'application/json' };
|
|
117
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
118
|
+
|
|
119
|
+
let res;
|
|
120
|
+
try {
|
|
121
|
+
res = await fetch(joinUrl(ctx.apiUrl, auth ? pathFor(ctx, pathname) : pathname), {
|
|
122
|
+
method,
|
|
123
|
+
headers,
|
|
124
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
125
|
+
});
|
|
126
|
+
} catch {
|
|
127
|
+
// The transport error object is dropped on purpose: it can carry resolved hosts and, on some
|
|
128
|
+
// runtimes, the request headers. We say what we know.
|
|
129
|
+
throw new ApiError(`Could not reach Voroa at ${ctx.apiUrl}.`, { httpStatus: 0 });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const data = await readJson(res);
|
|
133
|
+
if (!res.ok) throw new ApiError(messageFor(res.status, data, auth && isApiToken(ctx.token)), { httpStatus: res.status, code: data && data.code });
|
|
134
|
+
return data || {};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- auth -------------------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
// Is the code path usable? A transport failure is RETHROWN rather than answered "no": telling
|
|
140
|
+
// someone sign-in codes are unavailable and to try --password, when the real problem is that the
|
|
141
|
+
// endpoint cannot be reached at all, sends them down a path that fails the same way for a reason
|
|
142
|
+
// they were not told. Only a real answer (or an older backend that has no such route) falls through
|
|
143
|
+
// to the password suggestion.
|
|
144
|
+
export async function otpAvailable(ctx) {
|
|
145
|
+
try {
|
|
146
|
+
const d = await request(ctx, '/api/auth/otp/available', { auth: false });
|
|
147
|
+
return !!(d && d.enabled);
|
|
148
|
+
} catch (e) {
|
|
149
|
+
if (e instanceof ApiError && e.httpStatus === 0) throw e;
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Always 200 for a well-formed address, whether or not the account exists and whether or not the
|
|
155
|
+
// send was throttled (the endpoint is enumeration-safe on purpose). So a success here means "we
|
|
156
|
+
// asked", not "an email is on its way" - the login copy says exactly that.
|
|
157
|
+
export async function requestOtp(ctx, email) {
|
|
158
|
+
return request(ctx, '/api/auth/otp/request', { method: 'POST', body: { email }, auth: false });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function verifyOtp(ctx, email, code) {
|
|
162
|
+
return request(ctx, '/api/auth/otp/verify', { method: 'POST', body: { email, code }, auth: false });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function passwordLogin(ctx, email, password) {
|
|
166
|
+
return request(ctx, '/api/auth/login', { method: 'POST', body: { email, password }, auth: false });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- workspaces -------------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
export async function listWorkspaces(ctx) {
|
|
172
|
+
const d = await request(ctx, '/api/workspaces');
|
|
173
|
+
return Array.isArray(d.workspaces) ? d.workspaces : [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- services ---------------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
// GET /api/apps is already scoped to the caller AND the active workspace by the server. It is the
|
|
179
|
+
// ONLY candidate set for name resolution - a name is never looked up anywhere wider (golden rule 2,
|
|
180
|
+
// bug #14).
|
|
181
|
+
export async function listApps(ctx) {
|
|
182
|
+
const d = await request(ctx, '/api/apps');
|
|
183
|
+
const rows = Array.isArray(d.apps) ? d.apps : [];
|
|
184
|
+
return rows.map((r) => ({
|
|
185
|
+
uuid: r.uuid,
|
|
186
|
+
name: r.name,
|
|
187
|
+
branch: r.git_branch || null,
|
|
188
|
+
repository: r.git_repository || null,
|
|
189
|
+
serviceType: r.serviceType || null,
|
|
190
|
+
createdAt: r.created_at ?? null,
|
|
191
|
+
raw: r,
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function foldStatus(raw) {
|
|
196
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
197
|
+
return {
|
|
198
|
+
uuid: raw.uuid || null,
|
|
199
|
+
containerStatus: raw.status ?? null, // renamed at the boundary - see the file header
|
|
200
|
+
deploymentStatus: raw.deploymentStatus ?? null,
|
|
201
|
+
verdict: raw.verdict ?? null,
|
|
202
|
+
fqdn: raw.fqdn ?? null,
|
|
203
|
+
branch: raw.branch ?? null,
|
|
204
|
+
raw,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function appStatus(ctx, uuid) {
|
|
209
|
+
return foldStatus(await request(ctx, `/api/apps/${encodeURIComponent(uuid)}/status`));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Batch status, chunked to MAX_BATCH. Returns a Map so a caller can tell "this service was not in
|
|
213
|
+
// the answer" apart from "this service has no status" - the server drops unknown uuids silently,
|
|
214
|
+
// and rendering that as a blank cell would be a quiet lie.
|
|
215
|
+
export async function batchStatus(ctx, uuids) {
|
|
216
|
+
const out = new Map();
|
|
217
|
+
const unique = [...new Set(uuids.filter(Boolean))];
|
|
218
|
+
// The batch endpoint is not on the token door, so ask per service, a few at a time. A failure leaves
|
|
219
|
+
// that uuid out of the map - the same honest gap as a dropped batch chunk below.
|
|
220
|
+
if (isApiToken(ctx.token)) {
|
|
221
|
+
for (let i = 0; i < unique.length; i += 5) {
|
|
222
|
+
await Promise.all(
|
|
223
|
+
unique.slice(i, i + 5).map(async (uuid) => {
|
|
224
|
+
try {
|
|
225
|
+
const st = await appStatus(ctx, uuid);
|
|
226
|
+
if (st) out.set(uuid, st);
|
|
227
|
+
} catch {
|
|
228
|
+
/* left out -> "Status unavailable" */
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return out;
|
|
234
|
+
}
|
|
235
|
+
for (let i = 0; i < unique.length; i += MAX_BATCH) {
|
|
236
|
+
const chunk = unique.slice(i, i + MAX_BATCH);
|
|
237
|
+
let d;
|
|
238
|
+
try {
|
|
239
|
+
d = await request(ctx, `/api/apps/status?uuids=${chunk.map(encodeURIComponent).join(',')}`);
|
|
240
|
+
} catch {
|
|
241
|
+
// One failed chunk leaves ITS uuids out of the map, and the caller renders them "Status
|
|
242
|
+
// unavailable" - which is true, and is the same thing it already says for a uuid the server
|
|
243
|
+
// dropped. Throwing instead would take the whole listing away over a partial failure, which
|
|
244
|
+
// is a worse answer than an honest gap in one column.
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const statuses = d && d.statuses && typeof d.statuses === 'object' ? d.statuses : {};
|
|
248
|
+
for (const [uuid, value] of Object.entries(statuses)) {
|
|
249
|
+
const folded = foldStatus(value);
|
|
250
|
+
if (folded) out.set(uuid, folded);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- deployments ------------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
export async function deployHistory(ctx, uuid) {
|
|
259
|
+
const d = await request(ctx, `/api/apps/${encodeURIComponent(uuid)}/deployments`);
|
|
260
|
+
const rows = Array.isArray(d.deployments) ? d.deployments : [];
|
|
261
|
+
return rows.map((r) => ({
|
|
262
|
+
deploymentUuid: r.deploymentUuid,
|
|
263
|
+
buildStatus: r.status ?? null, // renamed: raw BUILD state, never a runtime answer (bug #24)
|
|
264
|
+
verdict: r.verdict ?? null,
|
|
265
|
+
commit: r.commit || null,
|
|
266
|
+
commitMessage: r.commitMessage || null,
|
|
267
|
+
createdAt: r.createdAt ?? null,
|
|
268
|
+
finishedAt: r.finishedAt ?? null,
|
|
269
|
+
authorName: r.authorName || null,
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function redeploy(ctx, uuid) {
|
|
274
|
+
return request(ctx, `/api/apps/${encodeURIComponent(uuid)}/redeploy`, { method: 'POST' });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// The deployment uuid a trigger response carries, or null (an isolated deploy learns its uuid later).
|
|
278
|
+
export function triggeredDeploymentUuid(resp) {
|
|
279
|
+
const rows = resp && resp.deployment && Array.isArray(resp.deployment.deployments) ? resp.deployment.deployments : [];
|
|
280
|
+
return (rows[0] && rows[0].deployment_uuid) || null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// --- lifecycle --------------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
const LIFECYCLE = new Set(['restart', 'stop', 'start']);
|
|
286
|
+
|
|
287
|
+
export async function lifecycle(ctx, uuid, action) {
|
|
288
|
+
if (!LIFECYCLE.has(action)) throw new Error(`unknown lifecycle action ${action}`);
|
|
289
|
+
return request(ctx, `/api/apps/${encodeURIComponent(uuid)}/${action}`, { method: 'POST' });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// No id -> the server's one-step "previous version". The artifact is resolved server-side from the row.
|
|
293
|
+
export async function rollback(ctx, uuid, deploymentId) {
|
|
294
|
+
return request(ctx, `/api/apps/${encodeURIComponent(uuid)}/rollback`, {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
body: deploymentId ? { deploymentId } : {},
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// --- environment variables ----------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
// NAMES ONLY. Golden rule 4: a stored value is write-only and no read path returns it, so there is no
|
|
303
|
+
// value field to map - and no `env pull` can exist.
|
|
304
|
+
export async function listEnv(ctx, uuid) {
|
|
305
|
+
const d = await request(ctx, `/api/apps/${encodeURIComponent(uuid)}/env`);
|
|
306
|
+
const rows = Array.isArray(d.env) ? d.env : [];
|
|
307
|
+
return rows
|
|
308
|
+
.filter((r) => r && typeof r.key === 'string')
|
|
309
|
+
.map((r) => ({ id: r.uuid || r.key, key: r.key, buildTime: !!r.isBuildTime, platformManaged: !!r.isBuildpackControl }));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Upserts exactly the keys given, then the server redeploys. `overwrite: true` is what the API-token
|
|
313
|
+
// door requires to replace an existing key (ADR-0164); the session door ignores the field, so the
|
|
314
|
+
// command enforces the same rule itself before calling.
|
|
315
|
+
export async function setEnv(ctx, uuid, entries, { overwrite = false } = {}) {
|
|
316
|
+
return request(ctx, `/api/apps/${encodeURIComponent(uuid)}/env`, {
|
|
317
|
+
method: 'PUT',
|
|
318
|
+
body: { envVars: entries, ...(overwrite ? { overwrite: true } : {}) },
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function deleteEnv(ctx, uuid, envId) {
|
|
323
|
+
return request(ctx, `/api/apps/${encodeURIComponent(uuid)}/env/${encodeURIComponent(envId)}`, { method: 'DELETE' });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// --- two-step verification & step-up -------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
// Second half of a 2FA sign-in: the challenge from login + one factor -> { token, user }.
|
|
329
|
+
export async function mfaVerify(ctx, challengeToken, method, code) {
|
|
330
|
+
return request(ctx, '/api/auth/mfa/verify', { method: 'POST', body: { challengeToken, method, code }, auth: false });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// { mfa: { enabled, hasPassword, ... }, stepUpFresh } - never a secret or a code.
|
|
334
|
+
export async function securityState(ctx) {
|
|
335
|
+
return request(ctx, '/api/security');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Re-confirm identity on THIS session for ten minutes. body: { method, code? , password? }.
|
|
339
|
+
export async function reauth(ctx, body) {
|
|
340
|
+
return request(ctx, '/api/security/reauth', { method: 'POST', body });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export async function reauthEmailCode(ctx) {
|
|
344
|
+
return request(ctx, '/api/security/reauth/email-code', { method: 'POST' });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// --- databases --------------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
export async function listDatabases(ctx) {
|
|
350
|
+
const d = await request(ctx, '/api/databases');
|
|
351
|
+
const rows = Array.isArray(d.databases) ? d.databases : [];
|
|
352
|
+
return rows.map((r) => ({ uuid: r.uuid, name: r.name, type: r.type || null, state: r.status || null })); // status renamed at the boundary (bug #24)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Host/port/user/database name. Never a password - the urlTemplate carries '***'.
|
|
356
|
+
export async function databaseConnection(ctx, uuid) {
|
|
357
|
+
return request(ctx, `/api/databases/${encodeURIComponent(uuid)}/connection`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ADR-0182: the one route that decrypts a stored secret. Needs a fresh step-up; every attempt is logged
|
|
361
|
+
// server-side. The caller holds the value only long enough to hand it to psql, and never prints it
|
|
362
|
+
// unless explicitly asked.
|
|
363
|
+
export async function revealDatabasePassword(ctx, uuid) {
|
|
364
|
+
return request(ctx, `/api/databases/${encodeURIComponent(uuid)}/password/reveal`, { method: 'POST' });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// --- API tokens (the dashboard calls these "MCP tokens"; they are also the CI credential) ----------
|
|
368
|
+
|
|
369
|
+
export async function listApiTokens(ctx) {
|
|
370
|
+
return request(ctx, '/api/mcp-tokens');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// The raw value exists in this response and nowhere else, ever.
|
|
374
|
+
export async function mintApiToken(ctx, name, scopes) {
|
|
375
|
+
return request(ctx, '/api/mcp-tokens', { method: 'POST', body: { name, scopes } });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// --- domains & ssh --------------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
// A domain's routing state gets its OWN words at the boundary. The server calls a served domain `live`,
|
|
381
|
+
// the same word a deployment uses - and the CLI keeps deploy vocabulary out of every file but status.js
|
|
382
|
+
// (backend/tests/cliStatusParity.test.js). Unquoted keys: a lookup, not a decision on a raw token.
|
|
383
|
+
const DOMAIN_ROUTING = { pending: 'dns_pending', unrouted: 'unrouted', live: 'serving' };
|
|
384
|
+
|
|
385
|
+
function foldDomain(r) {
|
|
386
|
+
const routing = DOMAIN_ROUTING[r.status] || null;
|
|
387
|
+
return {
|
|
388
|
+
id: r.id,
|
|
389
|
+
domain: r.domain,
|
|
390
|
+
verified: r.verifiedStatus === 'verified',
|
|
391
|
+
certificate: r.certificateStatus || null,
|
|
392
|
+
serving: routing === 'serving',
|
|
393
|
+
// `status` renamed at the boundary (bug #24): pending (DNS not here) | unrouted (DNS here, not served) | live
|
|
394
|
+
routing,
|
|
395
|
+
// ADR-0268: a wildcard says so; `ownership: 'pending'` means its id is a CLAIM id (not a domain id),
|
|
396
|
+
// and `dns` is then an ARRAY of two records rather than one record.
|
|
397
|
+
wildcard: r.wildcard === true,
|
|
398
|
+
ownership: r.ownership || null,
|
|
399
|
+
dns: r.dns && typeof r.dns === 'object' ? r.dns : null,
|
|
400
|
+
// ADR-0264: the domain's own edge state { state, reason }; absent until the edge network is in play.
|
|
401
|
+
edgeProtection: r.edgeProtection && typeof r.edgeProtection === 'object' ? r.edgeProtection : null,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export async function listDomains(ctx, uuid) {
|
|
406
|
+
const d = await request(ctx, `/api/apps/${encodeURIComponent(uuid)}/domains`);
|
|
407
|
+
return {
|
|
408
|
+
defaultDomain: d.defaultDomain || null,
|
|
409
|
+
custom: (Array.isArray(d.custom) ? d.custom : []).map(foldDomain),
|
|
410
|
+
quota: d.quota || null,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function addDomain(ctx, uuid, domain) {
|
|
415
|
+
return foldDomain(await request(ctx, `/api/apps/${encodeURIComponent(uuid)}/domains`, { method: 'POST', body: { domain } }));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export async function reprovisionDomain(ctx, uuid, id) {
|
|
419
|
+
return request(ctx, `/api/apps/${encodeURIComponent(uuid)}/domains/${encodeURIComponent(id)}/reprovision`, { method: 'POST' });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// { entitled, host, port, username, keys, running, hostKeyFingerprint } or { entitled:false, requiredPlans }.
|
|
423
|
+
export async function sshAccess(ctx, uuid) {
|
|
424
|
+
return request(ctx, `/api/apps/${encodeURIComponent(uuid)}/ssh`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export async function revokeApiToken(ctx, id) {
|
|
428
|
+
return request(ctx, `/api/mcp-tokens/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// --- logs -------------------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
// Runtime container tail. `lines` is ALWAYS sent (known bug #8: without it the endpoint can return
|
|
434
|
+
// an enormous payload; the server clamps 1-1000). backend/tests/cliGuards.test.js greps for it.
|
|
435
|
+
// `search` (ADR-0370): only lines containing the text, matched by the server over the last 24 hours.
|
|
436
|
+
export async function runtimeLogs(ctx, uuid, lines, search = '') {
|
|
437
|
+
const n = Math.max(1, Math.min(1000, Number(lines) || 100));
|
|
438
|
+
const q = search ? `&q=${encodeURIComponent(search)}` : '';
|
|
439
|
+
const d = await request(ctx, `/api/apps/${encodeURIComponent(uuid)}/logs?lines=${n}${q}`);
|
|
440
|
+
return typeof d.logs === 'string' ? d.logs : '';
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Build-log snapshot (also the fallback when the stream is unavailable). Lines arrive
|
|
444
|
+
// server-redacted and are printed as-is - re-parsing or re-filtering them here would be a second,
|
|
445
|
+
// weaker white-label filter (golden rules 3 and 6).
|
|
446
|
+
export async function buildLog(ctx, uuid, deploymentUuid) {
|
|
447
|
+
const d = await request(
|
|
448
|
+
ctx,
|
|
449
|
+
`/api/apps/${encodeURIComponent(uuid)}/deployments/${encodeURIComponent(deploymentUuid)}/log`
|
|
450
|
+
);
|
|
451
|
+
return {
|
|
452
|
+
available: !!d.available,
|
|
453
|
+
reason: d.reason || null,
|
|
454
|
+
buildStatus: d.status ?? null, // renamed at the boundary
|
|
455
|
+
lines: Array.isArray(d.lines) ? d.lines : [],
|
|
456
|
+
buildMs: d.buildMs ?? null,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// SSE build-log stream. The pre-flight contract is the server's, not ours (routes/apps/deployments.js):
|
|
461
|
+
// * 200 with application/json -> { available:false }: the store is off or the live-stream cap is
|
|
462
|
+
// full. NOT an error - the caller polls instead.
|
|
463
|
+
// * 401 / 403 / 404 -> also JSON, also "no stream": fall back so the caller still gets
|
|
464
|
+
// a snapshot (or an honest refusal) rather than a hang.
|
|
465
|
+
// * text/event-stream -> named events: `log` { lines:[{ts,text}] }, `phase`, and `status`
|
|
466
|
+
// which is TERMINAL (the server closes after it).
|
|
467
|
+
// Returns a stop() that aborts the read.
|
|
468
|
+
export function streamBuildLog(ctx, uuid, deploymentUuid, { onLog, onPhase, onDone, onUnavailable, onError } = {}) {
|
|
469
|
+
const controller = new AbortController();
|
|
470
|
+
let stopped = false;
|
|
471
|
+
let terminal = false;
|
|
472
|
+
|
|
473
|
+
function handleFrame(frame) {
|
|
474
|
+
let event = 'message';
|
|
475
|
+
const dataParts = [];
|
|
476
|
+
for (const line of frame.split('\n')) {
|
|
477
|
+
if (!line || line.startsWith(':')) continue; // comment / keepalive
|
|
478
|
+
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
479
|
+
else if (line.startsWith('data:')) dataParts.push(line.slice(5).trim());
|
|
480
|
+
}
|
|
481
|
+
if (!dataParts.length) return;
|
|
482
|
+
let data;
|
|
483
|
+
try {
|
|
484
|
+
data = JSON.parse(dataParts.join('\n'));
|
|
485
|
+
} catch {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (event === 'log') onLog && onLog(data);
|
|
489
|
+
else if (event === 'phase') onPhase && onPhase(data);
|
|
490
|
+
else if (event === 'status') {
|
|
491
|
+
terminal = true;
|
|
492
|
+
// Hand the caller the honest field only. The raw build state in this event is the thing the
|
|
493
|
+
// verdict exists to correct, so it is not forwarded.
|
|
494
|
+
onDone && onDone({ verdict: data.verdict ?? null, buildMs: data.buildMs ?? null });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
(async () => {
|
|
499
|
+
let res;
|
|
500
|
+
try {
|
|
501
|
+
res = await fetch(
|
|
502
|
+
joinUrl(
|
|
503
|
+
ctx.apiUrl,
|
|
504
|
+
// Not on the token door's allow-list -> 404 -> onUnavailable -> the caller polls. Correct, not broken.
|
|
505
|
+
pathFor(ctx, `/api/apps/${encodeURIComponent(uuid)}/deployments/${encodeURIComponent(deploymentUuid)}/logs/stream?from=0`)
|
|
506
|
+
),
|
|
507
|
+
{ headers: authHeaders(ctx), signal: controller.signal }
|
|
508
|
+
);
|
|
509
|
+
} catch {
|
|
510
|
+
if (!stopped) onError && onError(new ApiError(`Could not reach Voroa at ${ctx.apiUrl}.`, { httpStatus: 0 }));
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const ct = res.headers.get('content-type') || '';
|
|
514
|
+
if (res.status === 404 || res.status === 401 || res.status === 403 || ct.includes('application/json')) {
|
|
515
|
+
const body = await readJson(res);
|
|
516
|
+
if (!stopped) onUnavailable && onUnavailable({ httpStatus: res.status, body });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (!res.ok || !res.body) {
|
|
520
|
+
if (!stopped) onUnavailable && onUnavailable({ httpStatus: res.status, body: null });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const decoder = new TextDecoder();
|
|
524
|
+
let buf = '';
|
|
525
|
+
try {
|
|
526
|
+
for await (const chunk of res.body) {
|
|
527
|
+
// CRLF -> LF so a proxy rewriting line endings cannot break the blank-line frame split.
|
|
528
|
+
buf += decoder.decode(chunk, { stream: true }).replace(/\r\n/g, '\n');
|
|
529
|
+
let idx;
|
|
530
|
+
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
531
|
+
handleFrame(buf.slice(0, idx));
|
|
532
|
+
buf = buf.slice(idx + 2);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
} catch {
|
|
536
|
+
if (!stopped && !terminal) onError && onError(new Error('The log stream dropped.'));
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
// Closed without a terminal event -> the caller falls back to a snapshot, which is what makes
|
|
540
|
+
// the poll path the correctness guarantee and the stream only a speed-up.
|
|
541
|
+
if (!stopped && !terminal) onError && onError(new Error('The log stream closed early.'));
|
|
542
|
+
})();
|
|
543
|
+
|
|
544
|
+
return () => {
|
|
545
|
+
stopped = true;
|
|
546
|
+
try {
|
|
547
|
+
controller.abort();
|
|
548
|
+
} catch {
|
|
549
|
+
/* already gone */
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// `voroa db list` / `voroa db connect <database> [--show-url] [--recovery]`
|
|
2
|
+
//
|
|
3
|
+
// `connect` is the one command that handles a live database password. The rules, in order:
|
|
4
|
+
// * it is fetched through the reveal route, which needs a fresh step-up and logs every attempt
|
|
5
|
+
// (ADR-0182) - there is no other way to get it, and this command does not look for one.
|
|
6
|
+
// * it goes to psql in the ENVIRONMENT (PGPASSWORD), never argv: argv is visible to every user on
|
|
7
|
+
// the machine through the process list.
|
|
8
|
+
// * it is printed only with --show-url, with a warning, because a printed URL ends up in terminal
|
|
9
|
+
// scrollback, screen shares and pasted tickets (golden rule 13).
|
|
10
|
+
import { listDatabases, databaseConnection, revealDatabasePassword, isApiToken } from '../api.js';
|
|
11
|
+
import { pickByNameOrId, ambiguityList } from '../resolve.js';
|
|
12
|
+
import { runInteractive } from '../interactive.js';
|
|
13
|
+
import { withStepUp } from '../stepup.js';
|
|
14
|
+
import { renderTable, out, err, json } from '../output.js';
|
|
15
|
+
import { CliError } from '../errors.js';
|
|
16
|
+
|
|
17
|
+
// Same tenancy rule as services: the candidate set is the server's scoped listing and nothing wider.
|
|
18
|
+
async function resolveDatabase(ctx, needle) {
|
|
19
|
+
if (!needle) throw new CliError('Which database? Run `voroa db list` to see them.');
|
|
20
|
+
const rows = await listDatabases(ctx);
|
|
21
|
+
const found = pickByNameOrId(rows, needle);
|
|
22
|
+
if (found.match) return found.match;
|
|
23
|
+
if (found.ambiguous) throw new CliError(`Multiple databases match "${needle}", use the id:\n${ambiguityList(found.ambiguous)}`);
|
|
24
|
+
throw new CliError(`No database called "${needle}" in this workspace. Run \`voroa db list\` to see them.`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function db(ctx, { positionals, flags }) {
|
|
28
|
+
const sub = positionals[0];
|
|
29
|
+
|
|
30
|
+
if (!sub || sub === 'list') {
|
|
31
|
+
const rows = await listDatabases(ctx);
|
|
32
|
+
if (flags.json) {
|
|
33
|
+
json({ databases: rows });
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
if (!rows.length) {
|
|
37
|
+
out('No databases in this workspace.');
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
out(renderTable(['NAME', 'TYPE', 'STATUS', 'ID'], rows.map((r) => [r.name, r.type, r.state, r.uuid])));
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (sub === 'connect') {
|
|
45
|
+
if (isApiToken(ctx.token)) throw new CliError('Connecting needs a signed-in session, not an API token. Run `voroa login`.');
|
|
46
|
+
const target = await resolveDatabase(ctx, positionals[1]);
|
|
47
|
+
if (target.type && target.type !== 'postgres' && target.type !== 'postgresql') {
|
|
48
|
+
throw new CliError(`voroa db connect supports PostgreSQL. ${target.name} is ${target.type} - find its connection details in the dashboard.`);
|
|
49
|
+
}
|
|
50
|
+
const conn = await databaseConnection(ctx, target.uuid);
|
|
51
|
+
const ext = conn && conn.external;
|
|
52
|
+
if (!ext || !ext.host || !ext.port) {
|
|
53
|
+
throw new CliError(`${target.name} is not reachable from outside Voroa. Turn on external access for it in the dashboard first.`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const revealed = await withStepUp(ctx, () => revealDatabasePassword(ctx, target.uuid), { recovery: !!flags.recovery });
|
|
57
|
+
if (!revealed || !revealed.password) {
|
|
58
|
+
throw new CliError(`${target.name}'s password was not kept, so it cannot be shown. Reset it in the dashboard, then try again.`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (ext.pooled) err('Note: this endpoint is pooled - LISTEN/NOTIFY, session advisory locks and temporary tables do not behave as on a direct connection.');
|
|
62
|
+
|
|
63
|
+
// A clean environment for psql: no VOROA_* (the CLI's own token must not reach psql or a shell it
|
|
64
|
+
// opens with \!), and no inherited PG* - PGHOSTADDR or PGSERVICE would override PGHOST and send the
|
|
65
|
+
// password to a different host.
|
|
66
|
+
const base = {};
|
|
67
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
68
|
+
if (!/^(VOROA_|PG)/i.test(k)) base[k] = v;
|
|
69
|
+
}
|
|
70
|
+
const env = {
|
|
71
|
+
...base,
|
|
72
|
+
PGHOST: ext.host,
|
|
73
|
+
PGPORT: String(ext.port),
|
|
74
|
+
// The revealed password belongs to the built-in `postgres` user (it is what create and rotate
|
|
75
|
+
// store), NOT to the connection page's designated default role - pairing it with that role fails.
|
|
76
|
+
PGUSER: 'postgres',
|
|
77
|
+
PGDATABASE: conn.dbName || 'postgres',
|
|
78
|
+
PGPASSWORD: revealed.password,
|
|
79
|
+
PGSSLMODE: 'require',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
if (flags.showUrl) {
|
|
83
|
+
err('Warning: the line below contains your database password. Do not paste it anywhere shared.');
|
|
84
|
+
const u = new URL('postgresql://placeholder');
|
|
85
|
+
u.hostname = ext.host;
|
|
86
|
+
u.port = String(ext.port);
|
|
87
|
+
u.username = env.PGUSER;
|
|
88
|
+
u.password = revealed.password;
|
|
89
|
+
u.pathname = `/${env.PGDATABASE}`;
|
|
90
|
+
u.searchParams.set('sslmode', 'require');
|
|
91
|
+
out(u.toString());
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return runInteractive('psql', [], { env, notFound: 'psql was not found. Install the PostgreSQL client, or use --show-url with another tool.' });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
throw new CliError(`Unknown db command "${sub}". Try \`voroa db list\` or \`voroa db connect <database>\`.`);
|
|
99
|
+
}
|