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
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// `voroa logs <service>` - the running container's output, and with --build the build log.
|
|
2
|
+
//
|
|
3
|
+
// Every line printed here is printed AS THE SERVER SENT IT. Voroa redacts build output server-side
|
|
4
|
+
// before it is ever returned (golden rules 3 and 6). Re-parsing or re-filtering it here would be a
|
|
5
|
+
// second, weaker filter that drifts from the real one - so we do not touch the text.
|
|
6
|
+
import { runtimeLogs, buildLog, deployHistory, streamBuildLog, ApiError, SESSION_EXPIRED } from '../api.js';
|
|
7
|
+
import { isBuildFinished, isBuildRunning } from '../status.js';
|
|
8
|
+
import { resolveService } from '../resolve.js';
|
|
9
|
+
import { numberFlag } from '../flags.js';
|
|
10
|
+
import { out, err } from '../output.js';
|
|
11
|
+
import { CliError } from '../errors.js';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_LINES = 100;
|
|
14
|
+
const MAX_LINES = 1000; // the server's own clamp - asking for more cannot return more
|
|
15
|
+
// A build that never reaches a terminal state would otherwise hold the poll loop open forever. The
|
|
16
|
+
// cap is generous (a Voroa build timeout is far below it) so it only ever catches the pathological
|
|
17
|
+
// case, and it says what happened rather than exiting as if the log had ended.
|
|
18
|
+
const BUILD_WAIT_MAX_MS = 60 * 60_000;
|
|
19
|
+
const FOLLOW_BASE_MS = 2000;
|
|
20
|
+
const FOLLOW_IDLE_AFTER_MS = 60_000;
|
|
21
|
+
const FOLLOW_SLOW_MS = 10_000;
|
|
22
|
+
const FOLLOW_MAX_MS = 60_000;
|
|
23
|
+
const BUILD_POLL_MS = 2000;
|
|
24
|
+
|
|
25
|
+
function splitLines(blob) {
|
|
26
|
+
if (!blob) return [];
|
|
27
|
+
return String(blob).replace(/\r\n/g, '\n').split('\n');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The runtime endpoint returns a fixed-size TAIL, so successive polls overlap and the window
|
|
31
|
+
// slides. Find the longest suffix of what we already printed that is a prefix of the new tail, and
|
|
32
|
+
// print only what follows it.
|
|
33
|
+
//
|
|
34
|
+
// No overlap at all means the service produced more than a whole window between two polls: we have
|
|
35
|
+
// genuinely lost track of where we were, so we print the new window rather than guess. Duplicated
|
|
36
|
+
// output is a visible, self-explaining failure; silently skipped output is not.
|
|
37
|
+
// keepFollowingEmptyBuild(d) -> boolean. An empty build log is not the same answer twice: for the first
|
|
38
|
+
// seconds of every off-box build there are no lines yet while the build is perfectly healthy, and saying
|
|
39
|
+
// "No build log is available for this deploy." there tells a customer their log does not exist seconds
|
|
40
|
+
// before it arrives. `/log` carries the deployments row's status even on an empty read, so the two are
|
|
41
|
+
// distinguishable - and the test is EXPLICITLY 'building', not `!isBuildFinished(...)`: a null status is
|
|
42
|
+
// the feature-off answer and must reach the honest message instead of waiting out the caller's deadline.
|
|
43
|
+
export function keepFollowingEmptyBuild(d) {
|
|
44
|
+
return !!d && d.available === false && d.reason !== 'retention' && isBuildRunning(d.buildStatus);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function newTailLines(prev, next) {
|
|
48
|
+
if (!prev.length) return next;
|
|
49
|
+
const max = Math.min(prev.length, next.length);
|
|
50
|
+
for (let k = max; k > 0; k--) {
|
|
51
|
+
let same = true;
|
|
52
|
+
for (let i = 0; i < k; i++) {
|
|
53
|
+
if (prev[prev.length - k + i] !== next[i]) {
|
|
54
|
+
same = false;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (same) return next.slice(k);
|
|
59
|
+
}
|
|
60
|
+
return next;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// One place decides which failures end a follow. A 401 is worth its own sentence because "expired"
|
|
64
|
+
// is actionable and "forbidden" is not the same thing.
|
|
65
|
+
function fatalFor(e) {
|
|
66
|
+
if (!(e instanceof ApiError)) return e;
|
|
67
|
+
if (e.httpStatus === 401) return new CliError(SESSION_EXPIRED);
|
|
68
|
+
if (e.httpStatus === 403) return new CliError('You do not have access to this service\'s logs.');
|
|
69
|
+
if (e.httpStatus === 404) return new CliError('That service no longer exists.');
|
|
70
|
+
return new CliError(e.message);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Ctrl-C on a watch command is how you STOP watching - a normal, successful end, not a failure. It
|
|
74
|
+
// sets a flag and wakes the sleep, so the command returns through the ordinary path with exit 0
|
|
75
|
+
// rather than calling process.exit() (see the note in index.js about why that call is avoided).
|
|
76
|
+
function interruptible() {
|
|
77
|
+
const state = { stopped: false };
|
|
78
|
+
let wake = () => {};
|
|
79
|
+
state.onSigint = () => {
|
|
80
|
+
state.stopped = true;
|
|
81
|
+
wake();
|
|
82
|
+
};
|
|
83
|
+
state.wait = (ms) =>
|
|
84
|
+
new Promise((resolve) => {
|
|
85
|
+
const timer = setTimeout(resolve, ms);
|
|
86
|
+
wake = () => {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
resolve();
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function followRuntime(ctx, app, lines, search) {
|
|
95
|
+
const ctl = interruptible();
|
|
96
|
+
process.on('SIGINT', ctl.onSigint);
|
|
97
|
+
|
|
98
|
+
let interval = FOLLOW_BASE_MS;
|
|
99
|
+
let prev = [];
|
|
100
|
+
let lastOutputAt = Date.now();
|
|
101
|
+
let saidRateLimited = false;
|
|
102
|
+
let first = true;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
while (!ctl.stopped) {
|
|
106
|
+
try {
|
|
107
|
+
const next = splitLines(await runtimeLogs(ctx, app.uuid, lines, search));
|
|
108
|
+
const fresh = first ? next : newTailLines(prev, next);
|
|
109
|
+
first = false;
|
|
110
|
+
prev = next;
|
|
111
|
+
if (fresh.length && fresh.some((l) => l !== '')) {
|
|
112
|
+
for (const line of fresh) out(line);
|
|
113
|
+
lastOutputAt = Date.now();
|
|
114
|
+
interval = FOLLOW_BASE_MS; // new output -> back to a tight poll
|
|
115
|
+
}
|
|
116
|
+
} catch (e) {
|
|
117
|
+
if (e instanceof ApiError && e.httpStatus === 429) {
|
|
118
|
+
interval = Math.min(interval * 2, FOLLOW_MAX_MS);
|
|
119
|
+
if (!saidRateLimited) {
|
|
120
|
+
err('Rate limited - slowing down.'); // said once, not once per poll
|
|
121
|
+
saidRateLimited = true;
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
throw fatalFor(e);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Nothing for a minute: this service is idle, so stop asking every two seconds.
|
|
129
|
+
if (Date.now() - lastOutputAt > FOLLOW_IDLE_AFTER_MS && interval < FOLLOW_SLOW_MS) {
|
|
130
|
+
interval = FOLLOW_SLOW_MS;
|
|
131
|
+
}
|
|
132
|
+
await ctl.wait(interval);
|
|
133
|
+
}
|
|
134
|
+
} finally {
|
|
135
|
+
process.off('SIGINT', ctl.onSigint);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function printBuildLines(lines) {
|
|
140
|
+
for (const l of lines) {
|
|
141
|
+
if (!l) continue;
|
|
142
|
+
out(typeof l === 'string' ? l : String(l.text ?? ''));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Snapshot loop. This is the CORRECTNESS path: the stream is only a speed-up, and every way the
|
|
147
|
+
// stream can decline or drop lands here.
|
|
148
|
+
async function pollBuildLog(ctx, app, deploymentUuid, { alreadyPrinted = 0, deadline: callerDeadline = null }, ctl) {
|
|
149
|
+
let printed = alreadyPrinted;
|
|
150
|
+
const deadline = callerDeadline ?? Date.now() + BUILD_WAIT_MAX_MS;
|
|
151
|
+
while (!ctl.stopped) {
|
|
152
|
+
if (Date.now() >= deadline) {
|
|
153
|
+
// A caller's own deadline (deploy --follow --timeout) reports its timeout itself.
|
|
154
|
+
if (callerDeadline === null) out('Stopped following this build log after an hour - the build has still not reported an end.');
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const d = await buildLog(ctx, app.uuid, deploymentUuid);
|
|
158
|
+
if (!d.available) {
|
|
159
|
+
if (d.reason === 'retention') {
|
|
160
|
+
out('This build log is past its retention window and is no longer available.');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// A BUILD THAT HAS NOT PRINTED YET IS NOT A BUILD WITH NO LOG - the same defect the deploy page had.
|
|
164
|
+
// `available` is "are there lines", and for the first seconds of every off-box build the answer is no
|
|
165
|
+
// while the build is perfectly healthy. Giving up there told the customer their log did not exist,
|
|
166
|
+
// seconds before it arrived. The route now carries the row's status on an empty read, so we can tell
|
|
167
|
+
// the two apart: keep following while it explicitly says 'building', and the loop's own deadline above
|
|
168
|
+
// still bounds the wait. Explicitly 'building', not `!isBuildFinished(...)` - a null status (the
|
|
169
|
+
// feature-off answer) must fall through to the honest message rather than wait out the deadline.
|
|
170
|
+
if (keepFollowingEmptyBuild(d)) {
|
|
171
|
+
await ctl.wait(Math.max(0, Math.min(BUILD_POLL_MS, deadline - Date.now())));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (printed === 0) out('No build log is available for this deploy.');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
printBuildLines(d.lines.slice(printed));
|
|
178
|
+
printed = d.lines.length;
|
|
179
|
+
if (isBuildFinished(d.buildStatus)) return;
|
|
180
|
+
// Never sleep past the deadline - same rule as deploy --wait.
|
|
181
|
+
await ctl.wait(Math.max(0, Math.min(BUILD_POLL_MS, deadline - Date.now())));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Returns true when the user pressed Ctrl-C, so a caller can stop too instead of carrying on.
|
|
186
|
+
// `deadline` (epoch ms) bounds the whole thing - stream and poll - for a caller with its own timeout.
|
|
187
|
+
export async function showBuildLog(ctx, app, wanted, { deadline = null } = {}) {
|
|
188
|
+
let deploymentUuid = typeof wanted === 'string' ? wanted : null;
|
|
189
|
+
if (!deploymentUuid) {
|
|
190
|
+
const history = await deployHistory(ctx, app.uuid);
|
|
191
|
+
if (!history.length) throw new CliError(`${app.name} has not been deployed yet.`);
|
|
192
|
+
deploymentUuid = history[0].deploymentUuid;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const ctl = interruptible();
|
|
196
|
+
let abortStream = () => {};
|
|
197
|
+
const onSigint = () => {
|
|
198
|
+
ctl.onSigint();
|
|
199
|
+
abortStream();
|
|
200
|
+
};
|
|
201
|
+
process.on('SIGINT', onSigint);
|
|
202
|
+
let deadlineTimer = null;
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
let printed = 0;
|
|
206
|
+
const finished = await new Promise((resolve) => {
|
|
207
|
+
abortStream = streamBuildLog(ctx, app.uuid, deploymentUuid, {
|
|
208
|
+
onLog: (data) => {
|
|
209
|
+
const lines = Array.isArray(data && data.lines) ? data.lines : [];
|
|
210
|
+
printBuildLines(lines);
|
|
211
|
+
printed += lines.length;
|
|
212
|
+
},
|
|
213
|
+
onDone: () => {
|
|
214
|
+
abortStream();
|
|
215
|
+
resolve(true);
|
|
216
|
+
},
|
|
217
|
+
onUnavailable: () => {
|
|
218
|
+
abortStream();
|
|
219
|
+
resolve(false);
|
|
220
|
+
},
|
|
221
|
+
onError: () => {
|
|
222
|
+
abortStream();
|
|
223
|
+
resolve(false);
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
if (ctl.stopped) resolve(false); // Ctrl-C landed before the stream even opened
|
|
227
|
+
if (deadline !== null) {
|
|
228
|
+
deadlineTimer = setTimeout(() => {
|
|
229
|
+
abortStream();
|
|
230
|
+
resolve(false);
|
|
231
|
+
}, Math.max(0, deadline - Date.now()));
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
clearTimeout(deadlineTimer);
|
|
235
|
+
|
|
236
|
+
// Not finished over the stream -> the snapshot endpoint, skipping what we already printed. This
|
|
237
|
+
// is the correctness path; the stream only makes it feel live.
|
|
238
|
+
if (!finished && !ctl.stopped) await pollBuildLog(ctx, app, deploymentUuid, { alreadyPrinted: printed, deadline }, ctl);
|
|
239
|
+
return ctl.stopped;
|
|
240
|
+
} finally {
|
|
241
|
+
process.off('SIGINT', onSigint);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function logs(ctx, { positionals, flags }) {
|
|
246
|
+
const app = await resolveService(ctx, positionals[0]);
|
|
247
|
+
|
|
248
|
+
if (flags.build !== undefined) {
|
|
249
|
+
if (flags.search !== undefined) throw new CliError('--search works on the running service\'s output, not a build log.');
|
|
250
|
+
await showBuildLog(ctx, app, flags.build);
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// `--lines abc` used to become 100 without a word. A number over the server's own maximum is
|
|
255
|
+
// clamped (that ceiling is published, so clamping is what it means); anything unparseable is
|
|
256
|
+
// refused, because there is no defensible value to substitute - see cli/src/flags.js.
|
|
257
|
+
const lines = numberFlag('--lines', flags.lines, {
|
|
258
|
+
fallback: DEFAULT_LINES,
|
|
259
|
+
min: 0,
|
|
260
|
+
max: MAX_LINES,
|
|
261
|
+
clampToMax: true,
|
|
262
|
+
integer: true, // a fractional line count was being rounded downstream without a word
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// --search: the dashboard's search, run by the server. Same rule as the server, refused here before any request.
|
|
266
|
+
const search = typeof flags.search === 'string' ? flags.search.trim() : '';
|
|
267
|
+
if (search.length > 200 || /[`\r\n]/.test(search)) {
|
|
268
|
+
throw new CliError('--search takes one line of up to 200 characters, without backticks.');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (flags.follow) {
|
|
272
|
+
// Returns when Ctrl-C stops the watch, which is a successful end - see interruptible() above.
|
|
273
|
+
// Any other way out of that loop is a throw.
|
|
274
|
+
await followRuntime(ctx, app, lines, search);
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const blob = await runtimeLogs(ctx, app.uuid, lines, search);
|
|
279
|
+
if (!blob.trim()) {
|
|
280
|
+
out(search ? `No lines from ${app.name} in the last 24 hours contain "${search}".` : `${app.name} has not printed anything yet.`);
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
|
283
|
+
process.stdout.write(blob.endsWith('\n') ? blob : `${blob}\n`);
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// `voroa services` - the services in the active workspace.
|
|
2
|
+
//
|
|
3
|
+
// The status column comes from the BATCH endpoint, which dedupes its uuid list and then slices it
|
|
4
|
+
// at a cap, dropping anything past it without a word. So we chunk (api.js owns the cap) and we
|
|
5
|
+
// track which uuids came back: a service missing from the merged answer renders "Status
|
|
6
|
+
// unavailable", never a blank cell. A blank would read as "this service has no status", which is a
|
|
7
|
+
// different and false claim - the same silent-degrade shape as known bug #26.
|
|
8
|
+
import { listApps, batchStatus, deployHistory } from '../api.js';
|
|
9
|
+
import { classify } from '../status.js';
|
|
10
|
+
import { REGION_LABEL } from '../region.js';
|
|
11
|
+
import { renderTable, timeAgo, out, json } from '../output.js';
|
|
12
|
+
|
|
13
|
+
const STATUS_UNAVAILABLE = 'Status unavailable';
|
|
14
|
+
|
|
15
|
+
// How many "last deploy" reads run at once. The list endpoint carries no updated-at, so the column
|
|
16
|
+
// is the newest deploy's time - one small read per service, the same source the dashboard uses.
|
|
17
|
+
const HISTORY_CONCURRENCY = 6;
|
|
18
|
+
|
|
19
|
+
async function mapWithLimit(items, limit, fn) {
|
|
20
|
+
const results = new Array(items.length);
|
|
21
|
+
let cursor = 0;
|
|
22
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
23
|
+
for (;;) {
|
|
24
|
+
const i = cursor++;
|
|
25
|
+
if (i >= items.length) return;
|
|
26
|
+
results[i] = await fn(items[i]);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
await Promise.all(workers);
|
|
30
|
+
return results;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function services(ctx, { flags }) {
|
|
34
|
+
const apps = await listApps(ctx);
|
|
35
|
+
|
|
36
|
+
if (flags.json) {
|
|
37
|
+
// Raw rows, exactly as the API sent them - the point of --json is that a script sees the
|
|
38
|
+
// server's shape, not our rendering of it.
|
|
39
|
+
json({ services: apps.map((a) => a.raw) });
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (apps.length === 0) {
|
|
44
|
+
out('No services in this workspace.');
|
|
45
|
+
out('Create one at https://app.getvoroa.com, then run this again.');
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const statuses = await batchStatus(ctx, apps.map((a) => a.uuid));
|
|
50
|
+
|
|
51
|
+
// Last deploy per service. Best-effort: one failed read shows an em dash in that row, it does not
|
|
52
|
+
// fail the listing.
|
|
53
|
+
const updated = await mapWithLimit(apps, HISTORY_CONCURRENCY, async (app) => {
|
|
54
|
+
try {
|
|
55
|
+
const rows = await deployHistory(ctx, app.uuid);
|
|
56
|
+
return rows.length ? timeAgo(rows[0].createdAt) : null;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const rows = apps.map((app, i) => {
|
|
63
|
+
const st = statuses.get(app.uuid);
|
|
64
|
+
const label = st ? classify(st.containerStatus, st.deploymentStatus, st.verdict).label : STATUS_UNAVAILABLE;
|
|
65
|
+
return [app.name, label, REGION_LABEL, updated[i]];
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
out(renderTable(['NAME', 'STATUS', 'REGION', 'UPDATED'], rows));
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { STATUS_UNAVAILABLE };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// `voroa ssh [service]` - a shell in the running service, through Voroa's SSH gateway.
|
|
2
|
+
//
|
|
3
|
+
// No new authentication: the gateway accepts the SSH keys already on the account (added in the
|
|
4
|
+
// dashboard, behind a step-up), and there is no password login. This command only asks the server
|
|
5
|
+
// where to connect and as whom, then hands the terminal to the system `ssh`.
|
|
6
|
+
import { sshAccess, isApiToken } from '../api.js';
|
|
7
|
+
import { runInteractive } from '../interactive.js';
|
|
8
|
+
import { resolveService } from '../resolve.js';
|
|
9
|
+
import { out, err, json } from '../output.js';
|
|
10
|
+
import { CliError } from '../errors.js';
|
|
11
|
+
|
|
12
|
+
export async function ssh(ctx, { positionals, flags }) {
|
|
13
|
+
if (isApiToken(ctx.token)) throw new CliError('SSH needs a signed-in session, not an API token. Run `voroa login`.');
|
|
14
|
+
const app = await resolveService(ctx, positionals[0]);
|
|
15
|
+
const a = await sshAccess(ctx, app.uuid);
|
|
16
|
+
|
|
17
|
+
if (!a || a.entitled === false) {
|
|
18
|
+
const plans = Array.isArray(a && a.requiredPlans) && a.requiredPlans.length ? ` It is included in: ${a.requiredPlans.join(', ')}.` : '';
|
|
19
|
+
throw new CliError(`SSH is not included in this workspace's plan.${plans}`);
|
|
20
|
+
}
|
|
21
|
+
// The server's values go to ssh as arguments, so they are checked like input: a username beginning
|
|
22
|
+
// with "-" would be read as an ssh OPTION (-oProxyCommand runs a command locally). No shell is used,
|
|
23
|
+
// and `--` ends option parsing on top of the checks.
|
|
24
|
+
const port = Number(a.port);
|
|
25
|
+
if (
|
|
26
|
+
typeof a.username !== 'string' || !/^[a-z0-9][a-z0-9_-]*$/i.test(a.username) ||
|
|
27
|
+
typeof a.host !== 'string' || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i.test(a.host) ||
|
|
28
|
+
!Number.isInteger(port) || port < 1 || port > 65535
|
|
29
|
+
) {
|
|
30
|
+
throw new CliError('SSH details are not available for this service right now.');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const args = ['-p', String(port), '--', `${a.username}@${a.host}`];
|
|
34
|
+
if (flags.json || flags.print) {
|
|
35
|
+
if (flags.json) json({ host: a.host, port: a.port, username: a.username, hostKeyFingerprint: a.hostKeyFingerprint || null, keys: Array.isArray(a.keys) ? a.keys.length : 0 });
|
|
36
|
+
else out(`ssh ${a.username}@${a.host} -p ${port}`);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
if (Array.isArray(a.keys) && a.keys.length === 0) {
|
|
40
|
+
throw new CliError('Add an SSH public key to your account in the dashboard first - there is no password login.');
|
|
41
|
+
}
|
|
42
|
+
if (a.running === false) throw new CliError(`${app.name} is not running, so there is nothing to connect to. Start it with \`voroa start\`.`);
|
|
43
|
+
if (a.hostKeyFingerprint) err(`Gateway host key: ${a.hostKeyFingerprint} - compare it if ssh asks you to confirm.`);
|
|
44
|
+
|
|
45
|
+
return runInteractive('ssh', args, { notFound: 'ssh was not found. Install an OpenSSH client.' });
|
|
46
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// `voroa status <service>` - one service's live state.
|
|
2
|
+
//
|
|
3
|
+
// The label comes from the pinned `classify` in ../status.js and nowhere else. This file never
|
|
4
|
+
// inspects a raw state token itself: that is what put a green "Running" badge on a container that
|
|
5
|
+
// had been crash-looping for the better part of an hour (known bug #30).
|
|
6
|
+
import { appStatus } from '../api.js';
|
|
7
|
+
import { classify } from '../status.js';
|
|
8
|
+
import { REGION_LABEL } from '../region.js';
|
|
9
|
+
import { resolveService } from '../resolve.js';
|
|
10
|
+
import { renderFields, out, json } from '../output.js';
|
|
11
|
+
|
|
12
|
+
export async function status(ctx, { positionals, flags }) {
|
|
13
|
+
const app = await resolveService(ctx, positionals[0]);
|
|
14
|
+
const st = await appStatus(ctx, app.uuid);
|
|
15
|
+
|
|
16
|
+
if (flags.json) {
|
|
17
|
+
json(st ? st.raw : null);
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const label = st ? classify(st.containerStatus, st.deploymentStatus, st.verdict).label : 'Status unavailable';
|
|
22
|
+
// fqdn arrives comma-separated ("https://a,https://b"); the first entry is the primary.
|
|
23
|
+
const url = st && st.fqdn ? String(st.fqdn).split(',')[0].trim() : null;
|
|
24
|
+
|
|
25
|
+
out(
|
|
26
|
+
renderFields([
|
|
27
|
+
['Service', app.name],
|
|
28
|
+
['Id', app.uuid],
|
|
29
|
+
['Status', label],
|
|
30
|
+
['Region', REGION_LABEL],
|
|
31
|
+
['Branch', (st && st.branch) || app.branch],
|
|
32
|
+
['URL', url],
|
|
33
|
+
])
|
|
34
|
+
);
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// `voroa tokens list|create|revoke` - API tokens for CI and AI clients.
|
|
2
|
+
//
|
|
3
|
+
// A token is created from a signed-in SESSION only (the mint route needs a fresh step-up, so a stolen
|
|
4
|
+
// session cannot quietly turn itself into a long-lived credential - ADR-0200 D7). The raw value is shown
|
|
5
|
+
// ONCE, on stdout, because that is the whole point of the command; it is never written anywhere by us -
|
|
6
|
+
// unless `--save` asks for it, in which case it goes into the CLI's own 0600 config for the tool commands
|
|
7
|
+
// (mcpTools.js) and is NOT printed at all: a value that is stored has no reason to cross a screen.
|
|
8
|
+
import { listApiTokens, mintApiToken, revokeApiToken, isApiToken } from '../api.js';
|
|
9
|
+
import { withStepUp } from '../stepup.js';
|
|
10
|
+
import { renderTable, out, err, json } from '../output.js';
|
|
11
|
+
import { CliError } from '../errors.js';
|
|
12
|
+
import { saveSettings } from '../config.js';
|
|
13
|
+
|
|
14
|
+
function when(epochSec) {
|
|
15
|
+
return epochSec ? new Date(epochSec * 1000).toISOString().slice(0, 10) : '';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function tokens(ctx, { positionals, flags }) {
|
|
19
|
+
if (isApiToken(ctx.token)) throw new CliError('Tokens are managed from a signed-in session, not with an API token. Run `voroa login`.');
|
|
20
|
+
const sub = positionals[0];
|
|
21
|
+
|
|
22
|
+
if (!sub || sub === 'list') {
|
|
23
|
+
const d = await listApiTokens(ctx);
|
|
24
|
+
const rows = Array.isArray(d.tokens) ? d.tokens : [];
|
|
25
|
+
if (flags.json) {
|
|
26
|
+
// Metadata only - the list route never returns a value or a hash.
|
|
27
|
+
json({ tokens: rows, scopes: d.grantedScopes || d.scopes || [] });
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
if (!rows.length) out('No API tokens in this workspace.');
|
|
31
|
+
else {
|
|
32
|
+
out(
|
|
33
|
+
renderTable(
|
|
34
|
+
['NAME', 'SCOPES', 'EXPIRES', 'STATE', 'ID'],
|
|
35
|
+
rows.map((r) => [r.name, r.scopes, when(r.expiresAt), r.revokedAt ? 'revoked' : 'active', r.id])
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const scopes = d.grantedScopes || d.scopes;
|
|
40
|
+
if (Array.isArray(scopes) && scopes.length) out(`\nScopes you can grant: ${scopes.join(', ')}`);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (sub === 'create') {
|
|
45
|
+
const name = typeof flags.name === 'string' ? flags.name.trim() : '';
|
|
46
|
+
if (!name) throw new CliError('Give the token a name: `voroa tokens create --name ci --scope services:read,services:act`.');
|
|
47
|
+
const scopes = typeof flags.scope === 'string' ? flags.scope.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
48
|
+
if (!scopes.length) throw new CliError('Choose at least one scope with --scope. `voroa tokens list` shows the ones you can grant.');
|
|
49
|
+
const d = await withStepUp(ctx, () => mintApiToken(ctx, name, scopes), { recovery: !!flags.recovery });
|
|
50
|
+
const t = d && d.token;
|
|
51
|
+
if (!t || !t.value) throw new CliError('The token was not created. Try again.');
|
|
52
|
+
if (flags.save) {
|
|
53
|
+
saveSettings({ apiToken: t.value, apiTokenUrl: ctx.apiUrl });
|
|
54
|
+
if (flags.json) json({ id: t.id, name: t.name, scopes: t.scopes, expiresAt: t.expiresAt, saved: true });
|
|
55
|
+
else out(`Token "${t.name}" saved for this machine. It expires ${when(t.expiresAt)}. ` + 'Run `voroa tools` to see what it unlocks.');
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
if (flags.json) {
|
|
59
|
+
json({ id: t.id, name: t.name, scopes: t.scopes, expiresAt: t.expiresAt, value: t.value });
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
// The scopes that were actually granted can be fewer than asked - an unknown scope is dropped.
|
|
63
|
+
out(renderTable(['NAME', 'SCOPES', 'EXPIRES', 'ID'], [[t.name, t.scopes, when(t.expiresAt), t.id]]));
|
|
64
|
+
out('');
|
|
65
|
+
out(t.value);
|
|
66
|
+
out('');
|
|
67
|
+
err('Copy this value now. It is shown once and cannot be retrieved again. Use it as VOROA_TOKEN.');
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (sub === 'revoke') {
|
|
72
|
+
const id = positionals[1];
|
|
73
|
+
if (!id) throw new CliError('Which token? `voroa tokens revoke <id>` - see `voroa tokens list`.');
|
|
74
|
+
const d = await revokeApiToken(ctx, id);
|
|
75
|
+
if (flags.json) json({ id, revoked: !!(d && d.revoked) });
|
|
76
|
+
else out(d && d.revoked ? 'Token revoked. It stops working immediately.' : 'That token was already revoked, or does not exist.');
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw new CliError(`Unknown tokens command "${sub}". Try \`voroa tokens list\`, \`create\` or \`revoke <id>\`.`);
|
|
81
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// `voroa workspace list` / `voroa workspace set <id>`
|
|
2
|
+
//
|
|
3
|
+
// The stored id is sent as the `X-Workspace-Id` header. It is a CLIENT-SUPPLIED value and the
|
|
4
|
+
// server treats it as one: `middleware/workspaceScope.js` validates it against the caller's own
|
|
5
|
+
// workspaces on every single request, and a foreign or forged id is a 403. So the check below is a
|
|
6
|
+
// courtesy - it turns a typo into a sentence instead of a 403 on the next command - and it is not,
|
|
7
|
+
// and must never become, the thing that decides access.
|
|
8
|
+
//
|
|
9
|
+
// Nothing stored means no header, which means the server uses the caller's default workspace. That
|
|
10
|
+
// is deliberately not the same as the CLI picking one: an id we guessed is an ownership claim we
|
|
11
|
+
// are not entitled to make.
|
|
12
|
+
import { listWorkspaces, isApiToken } from '../api.js';
|
|
13
|
+
import { saveSettings, readConfig } from '../config.js';
|
|
14
|
+
import { renderTable, out, json } from '../output.js';
|
|
15
|
+
import { CliError } from '../errors.js';
|
|
16
|
+
|
|
17
|
+
export async function workspace(ctx, { positionals, flags }) {
|
|
18
|
+
const sub = positionals[0];
|
|
19
|
+
// An API token is pinned to one workspace on the server; there is nothing here for it to list or pick.
|
|
20
|
+
if (isApiToken(ctx.token)) {
|
|
21
|
+
throw new CliError('An API token is pinned to one workspace, so workspace commands do not apply to it.');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!sub || sub === 'list') {
|
|
25
|
+
const rows = await listWorkspaces(ctx);
|
|
26
|
+
if (flags.json) {
|
|
27
|
+
json({ workspaces: rows });
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
if (!rows.length) {
|
|
31
|
+
out('No workspaces.');
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
const chosen = readConfig().workspaceId || null;
|
|
35
|
+
out(
|
|
36
|
+
renderTable(
|
|
37
|
+
['', 'NAME', 'ID'],
|
|
38
|
+
rows.map((w) => [
|
|
39
|
+
// With nothing chosen the server's default is what commands actually hit, so mark that
|
|
40
|
+
// one - showing no marker at all would imply no workspace is in effect.
|
|
41
|
+
(chosen ? w.id === chosen : !!w.isDefault) ? '*' : '',
|
|
42
|
+
w.name,
|
|
43
|
+
w.id,
|
|
44
|
+
])
|
|
45
|
+
)
|
|
46
|
+
);
|
|
47
|
+
if (!chosen) out('\n* your default workspace. Use `voroa workspace set <id>` to pick another.');
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (sub === 'set') {
|
|
52
|
+
const id = positionals[1];
|
|
53
|
+
if (!id) throw new CliError('Which workspace? `voroa workspace set <id>` - run `voroa workspace list` for the ids.');
|
|
54
|
+
const rows = await listWorkspaces(ctx);
|
|
55
|
+
const match = rows.find((w) => w.id === id);
|
|
56
|
+
if (!match) throw new CliError(`No workspace with the id "${id}". Run \`voroa workspace list\` to see yours.`);
|
|
57
|
+
saveSettings({ workspaceId: id });
|
|
58
|
+
out(`Commands now act on ${match.name}.`);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
throw new CliError(`Unknown workspace command "${sub}". Try \`voroa workspace list\` or \`voroa workspace set <id>\`.`);
|
|
63
|
+
}
|