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/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.', 1, 'BAD_INPUT');
|
|
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`.', 1, 'NOT_FOUND');
|
|
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)}`, 1, '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.`, 1, 'NOT_FOUND');
|
|
64
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// `voroa` with nothing after it: an interactive session (plan docs/plans/2026-11-11-cli-interactive-session.md).
|
|
2
|
+
//
|
|
3
|
+
// A session adds NO commands of its own beyond /help, /clear and /exit. Every line is split into words
|
|
4
|
+
// and handed to the same `run()` that `voroa <args>` uses, so a command cannot behave one way typed
|
|
5
|
+
// here and another way typed in a shell (rule #10). What a session does add is the terminal: a welcome
|
|
6
|
+
// box, Tab completion, Up-arrow history, and a Ctrl+C that stops a log tail without ending the session.
|
|
7
|
+
//
|
|
8
|
+
// The terminal itself belongs to prompt.js - ONE readline interface per process, for the reason in its
|
|
9
|
+
// header. This file never creates one.
|
|
10
|
+
import { askCommand, configureSession, promptsEnded, dropTypeAhead, clearTypedLine, reprompt, closePrompts } from './prompt.js';
|
|
11
|
+
import { readSettings, readConfig } from './config.js';
|
|
12
|
+
import { contextFrom, listApps, batchStatus, isApiToken } from './api.js';
|
|
13
|
+
import { classify, TONE_WORDS } from './status.js';
|
|
14
|
+
import { loadKernel, commandName } from './mcpTools.js';
|
|
15
|
+
import { CliError } from './errors.js';
|
|
16
|
+
import { out, err, style, fancy, glyphs } from './output.js';
|
|
17
|
+
|
|
18
|
+
// The welcome box makes one call. It must never be the reason a session does not open.
|
|
19
|
+
const WELCOME_TIMEOUT_MS = 3000;
|
|
20
|
+
const STATUS_TIMEOUT_MS = 4000;
|
|
21
|
+
const SESSION_WORDS = ['help', 'clear', 'exit'];
|
|
22
|
+
|
|
23
|
+
// Both ends must be a real terminal. A pipe on either side is a script, and a script gets exactly what
|
|
24
|
+
// it got before sessions existed: the usage text.
|
|
25
|
+
export function wantsSession(stdin = process.stdin, stdout = process.stdout) {
|
|
26
|
+
return !!(stdin && stdin.isTTY && stdout && stdout.isTTY);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Words, with quotes: `--command "node job.js"` is one argument. NOT a shell - nothing is expanded or
|
|
30
|
+
// executed, so `$HOME`, backticks, `|` and `>` are ordinary text. A backslash is ordinary text too
|
|
31
|
+
// (Windows paths), except before a quote or another backslash inside double quotes.
|
|
32
|
+
export function tokenize(line) {
|
|
33
|
+
const words = [];
|
|
34
|
+
let word = '';
|
|
35
|
+
let quote = null;
|
|
36
|
+
let open = false; // a word has begun, even if it is empty so far ("")
|
|
37
|
+
for (let i = 0; i < line.length; i++) {
|
|
38
|
+
const c = line[i];
|
|
39
|
+
if (quote) {
|
|
40
|
+
if (c === quote) quote = null;
|
|
41
|
+
else if (quote === '"' && c === '\\' && (line[i + 1] === '"' || line[i + 1] === '\\')) word += line[++i];
|
|
42
|
+
else word += c;
|
|
43
|
+
} else if (c === '"' || c === "'") {
|
|
44
|
+
quote = c;
|
|
45
|
+
open = true;
|
|
46
|
+
} else if (c === ' ' || c === '\t') {
|
|
47
|
+
if (open) words.push(word);
|
|
48
|
+
word = '';
|
|
49
|
+
open = false;
|
|
50
|
+
} else {
|
|
51
|
+
word += c;
|
|
52
|
+
open = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (quote) throw new CliError(`That line has an unclosed ${quote} quote.`);
|
|
56
|
+
if (open) words.push(word);
|
|
57
|
+
return words;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Tab completion. The first word completes to a command; any later word to a service name.
|
|
61
|
+
export function complete(line, { commands, services }) {
|
|
62
|
+
const lastSpace = line.lastIndexOf(' ');
|
|
63
|
+
if (lastSpace === -1) {
|
|
64
|
+
const slash = line.startsWith('/') ? '/' : '';
|
|
65
|
+
const typed = line.slice(slash.length);
|
|
66
|
+
return [commands.filter((c) => c.startsWith(typed)).map((c) => slash + c), line];
|
|
67
|
+
}
|
|
68
|
+
const typed = line.slice(lastSpace + 1);
|
|
69
|
+
if (typed.startsWith('-')) return [[], typed];
|
|
70
|
+
return [services.filter((s) => s.startsWith(typed)), typed];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function box(lines) {
|
|
74
|
+
const width = Math.max(...lines.map((l) => l.length));
|
|
75
|
+
const edge = `+${'-'.repeat(width + 2)}+`;
|
|
76
|
+
// Plain ASCII on purpose: box-drawing characters depend on the terminal's code page, and on a
|
|
77
|
+
// Windows console that is not UTF-8 they arrive as garbage.
|
|
78
|
+
return [edge, ...lines.map((l) => `| ${l.padEnd(width)} |`), edge].join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function withDeadline(promise, ms) {
|
|
82
|
+
let timer;
|
|
83
|
+
const late = new Promise((_, reject) => {
|
|
84
|
+
timer = setTimeout(() => reject(new CliError('Voroa did not answer in time.')), ms);
|
|
85
|
+
});
|
|
86
|
+
return Promise.race([promise, late]).finally(() => clearTimeout(timer));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function startSession({ run, version, commands }) {
|
|
90
|
+
const state = { running: false, sigints: 0, services: [], commands: [...commands, 'tools', ...SESSION_WORDS] };
|
|
91
|
+
|
|
92
|
+
// The service names offered by Tab come from the server's own workspace-scoped list - the same one
|
|
93
|
+
// the resolver uses - and are thrown away whenever the account or the workspace may have changed.
|
|
94
|
+
async function loadServices() {
|
|
95
|
+
const settings = readSettings();
|
|
96
|
+
if (!settings.token) {
|
|
97
|
+
state.services = [];
|
|
98
|
+
return { signedIn: false };
|
|
99
|
+
}
|
|
100
|
+
const apps = await withDeadline(listApps(contextFrom(settings)), WELCOME_TIMEOUT_MS);
|
|
101
|
+
state.services = apps.map((a) => a.name);
|
|
102
|
+
return { signedIn: true, count: apps.length, viaToken: isApiToken(settings.token), apps, ctx: contextFrom(settings) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// How many are running, idle, failed. One more read, on its own short deadline - and if it does not
|
|
106
|
+
// answer for EVERY service the line falls back to the plain count, because "7 running" over a partial
|
|
107
|
+
// answer would be a number we did not measure.
|
|
108
|
+
async function tally(w) {
|
|
109
|
+
if (!w.count) return null;
|
|
110
|
+
const statuses = await withDeadline(batchStatus(w.ctx, w.apps.map((a) => a.uuid)), STATUS_TIMEOUT_MS);
|
|
111
|
+
if (statuses.size < w.count) return null;
|
|
112
|
+
const n = { green: 0, gray: 0, amber: 0, red: 0 };
|
|
113
|
+
for (const st of statuses.values()) n[classify(st.containerStatus, st.deploymentStatus, st.verdict).tone] += 1;
|
|
114
|
+
return n;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function servicesLine(w, n, pretty) {
|
|
118
|
+
if (!n) return `${w.count} ${w.count === 1 ? 'service' : 'services'} in this workspace`;
|
|
119
|
+
const g = glyphs();
|
|
120
|
+
const part = (tone, mark) => (pretty ? `${style(tone, g[mark])} ${n[tone]} ${TONE_WORDS[tone]}` : `${n[tone]} ${TONE_WORDS[tone]}`);
|
|
121
|
+
const parts = [part('green', 'run'), part('gray', 'idle')];
|
|
122
|
+
if (n.amber) parts.push(part('amber', 'busy'));
|
|
123
|
+
parts.push(part('red', 'fail'));
|
|
124
|
+
return parts.join(pretty ? ' ' : ', ');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function welcome() {
|
|
128
|
+
const pretty = fancy();
|
|
129
|
+
const rows = []; // [label, value] - the same facts for both renderings
|
|
130
|
+
let note = null;
|
|
131
|
+
try {
|
|
132
|
+
const w = await loadServices();
|
|
133
|
+
if (!w.signedIn) note = 'Not signed in - type /login';
|
|
134
|
+
else {
|
|
135
|
+
const email = readConfig().email;
|
|
136
|
+
rows.push(['Account', w.viaToken ? 'the API token in VOROA_TOKEN' : email || 'signed in']);
|
|
137
|
+
let n = null;
|
|
138
|
+
try {
|
|
139
|
+
n = await tally(w);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
// The header simply shows the count; a command that needs statuses will report the reason.
|
|
142
|
+
state.lastTallyError = e;
|
|
143
|
+
}
|
|
144
|
+
rows.push(['Services', servicesLine(w, n, pretty)]);
|
|
145
|
+
}
|
|
146
|
+
} catch (e) {
|
|
147
|
+
// Offline, an expired session, a slow answer: say which, and open the prompt anyway.
|
|
148
|
+
note = e && e.message ? e.message : 'Could not reach Voroa right now.';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (pretty) {
|
|
152
|
+
const g = glyphs();
|
|
153
|
+
const bar = style('violet', g.bar);
|
|
154
|
+
const lines = [`${bar} ${style('violet', style('bold', `${g.logo} voroa`))} ${style('dim', `CLI ${version}`)}`, bar];
|
|
155
|
+
for (const [label, value] of rows) lines.push(`${bar} ${style('dim', label.padEnd(9))} ${value}`);
|
|
156
|
+
if (note) lines.push(`${bar} ${note}`);
|
|
157
|
+
out('');
|
|
158
|
+
out(lines.map((l) => ` ${l}`).join('\n'));
|
|
159
|
+
out('');
|
|
160
|
+
out(` ${style('dim', ['/help commands', 'Tab complete', 'Ctrl+C stop', '/exit'].join(` ${g.dot} `))}`);
|
|
161
|
+
out('');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// Off a terminal: the plain box, with the same facts as sentences.
|
|
165
|
+
const plain = [`Voroa CLI ${version}`];
|
|
166
|
+
for (const [label, value] of rows) {
|
|
167
|
+
if (label !== 'Account') plain.push(value);
|
|
168
|
+
else plain.push(value === 'signed in' ? 'Signed in' : value.startsWith('the API token') ? 'Using the API token in VOROA_TOKEN' : `Signed in as ${value}`);
|
|
169
|
+
}
|
|
170
|
+
if (note) plain.push(note);
|
|
171
|
+
plain.push('Type /help for commands, Tab to complete, /exit to leave');
|
|
172
|
+
out(box(plain));
|
|
173
|
+
out('');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function onSigint() {
|
|
177
|
+
if (state.running) {
|
|
178
|
+
// A tail registered its own stop handler on the process; give it the keypress.
|
|
179
|
+
if (process.listenerCount('SIGINT') > 0) {
|
|
180
|
+
process.emit('SIGINT');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
state.sigints += 1;
|
|
184
|
+
if (state.sigints >= 2) {
|
|
185
|
+
// The one deliberate hard exit in this CLI (index.js explains why it is avoided elsewhere):
|
|
186
|
+
// the person has asked twice to leave a command that cannot be interrupted.
|
|
187
|
+
process.exit(130);
|
|
188
|
+
}
|
|
189
|
+
err('This command cannot be stopped midway. Press Ctrl+C again to leave the session.');
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (clearTypedLine()) return;
|
|
193
|
+
state.sigints += 1;
|
|
194
|
+
if (state.sigints >= 2) {
|
|
195
|
+
closePrompts(); // ends the loop below, exactly as Ctrl+D does
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
err('\n(Press Ctrl+C again, or type /exit, to leave.)');
|
|
199
|
+
reprompt();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
configureSession({ completer: (line) => complete(line, state), onSigint });
|
|
203
|
+
try {
|
|
204
|
+
try {
|
|
205
|
+
const kernel = await loadKernel();
|
|
206
|
+
state.commands.push(...kernel.listTools().map((t) => commandName(t.name)));
|
|
207
|
+
} catch (e) {
|
|
208
|
+
// Completion for the tool commands is a nicety; the commands themselves report this properly
|
|
209
|
+
// when one is run. Said once here so a broken install is not silent.
|
|
210
|
+
err(e && e.message ? e.message : 'The tool commands could not be loaded.');
|
|
211
|
+
}
|
|
212
|
+
state.commands = [...new Set(state.commands)].sort();
|
|
213
|
+
await welcome();
|
|
214
|
+
|
|
215
|
+
for (;;) {
|
|
216
|
+
// The prompt is drawn on stderr (prompt.js), so that is the stream that decides how it looks.
|
|
217
|
+
const prompt = fancy(process.stderr) ? `${style('violet', style('bold', `voroa ${glyphs().prompt}`, process.stderr), process.stderr)} ` : '> ';
|
|
218
|
+
const line = (await askCommand(prompt)).trim();
|
|
219
|
+
if (!line) {
|
|
220
|
+
if (promptsEnded()) break;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
state.sigints = 0;
|
|
224
|
+
let words;
|
|
225
|
+
try {
|
|
226
|
+
words = tokenize(line.startsWith('/') ? line.slice(1) : line);
|
|
227
|
+
} catch (e) {
|
|
228
|
+
err(e.message);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
// Habit from the shell: `voroa services` typed inside the session means `services`.
|
|
232
|
+
if (words[0] === 'voroa' && words.length > 1) words.shift();
|
|
233
|
+
const name = words[0];
|
|
234
|
+
if (!name) continue;
|
|
235
|
+
if (name === 'exit' || name === 'quit') break;
|
|
236
|
+
if (name === 'session') {
|
|
237
|
+
err('You are already in a session.');
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (name === 'clear') {
|
|
241
|
+
if (process.stdout.isTTY) process.stdout.write('\x1b[2J\x1b[H');
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
state.running = true;
|
|
245
|
+
try {
|
|
246
|
+
await run(name === 'help' ? ['--help'] : words);
|
|
247
|
+
if (name === 'help') out('\nIn a session: the leading / is optional, Tab completes, Ctrl+C stops a log tail, /clear and /exit.');
|
|
248
|
+
} catch (e) {
|
|
249
|
+
// Same rule as the binary: a message, never an error object (the last place a token could surface).
|
|
250
|
+
err(e && e.message ? e.message : 'Something went wrong.');
|
|
251
|
+
} finally {
|
|
252
|
+
state.running = false;
|
|
253
|
+
state.sigints = 0;
|
|
254
|
+
dropTypeAhead();
|
|
255
|
+
}
|
|
256
|
+
if (name === 'login' || name === 'workspace') {
|
|
257
|
+
try {
|
|
258
|
+
await loadServices();
|
|
259
|
+
} catch (e) {
|
|
260
|
+
// Only Tab's suggestions depend on this; the next command will report a real problem itself.
|
|
261
|
+
state.services = [];
|
|
262
|
+
state.lastRefreshError = e;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} finally {
|
|
267
|
+
configureSession(null);
|
|
268
|
+
}
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
package/src/status.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Status classify - the CLI's copy of the SPA's status vocabulary.
|
|
2
|
+
//
|
|
3
|
+
// WHY A COPY AND NOT AN IMPORT: `frontend/src/status.js` is a Vite/ESM module inside the SPA's
|
|
4
|
+
// package; this package ships with zero dependencies and is installed on its own. It cannot reach
|
|
5
|
+
// across into the frontend tree at runtime. So the two `classify` / `statusLabel` bodies below are
|
|
6
|
+
// a DELIBERATE, PINNED duplicate - the same arrangement `shared/planGate.mjs` has with
|
|
7
|
+
// `frontend/src/lib/planGate.js`.
|
|
8
|
+
//
|
|
9
|
+
// PINNED REGION - `classify` and `statusLabel` are byte-identical to the SPA's, and
|
|
10
|
+
// `backend/tests/cliStatusParity.test.js` fails CI the moment one side drifts from the other. It
|
|
11
|
+
// pins them two ways: the extracted function bodies must match character for character, AND both
|
|
12
|
+
// implementations must answer an adversarial case table identically. If you change a status label,
|
|
13
|
+
// change it in `frontend/src/status.js` and copy it here in the SAME commit.
|
|
14
|
+
//
|
|
15
|
+
// The SPA's other exports (`isStoppedStatus`, `serviceBucket`, `parseDomains`, `primaryDomain`) are
|
|
16
|
+
// deliberately NOT copied - the CLI has no tab filter and no domain chip row, and an unused copy is
|
|
17
|
+
// a copy that drifts unnoticed.
|
|
18
|
+
//
|
|
19
|
+
// Anything outside this file that decides a state from a raw status or verdict token is a rule #10
|
|
20
|
+
// violation and is caught by the deny-list in the same test. Import from here.
|
|
21
|
+
|
|
22
|
+
// Platform raw status "state:health" format me deta hai (e.g. "running:healthy",
|
|
23
|
+
// "running:unknown", "exited:unhealthy"). Raw string user ko dikhana confusing hai
|
|
24
|
+
// (jaise "running:unknown" -> lagta hai kuch galat hai, jabki app theek chal raha).
|
|
25
|
+
// Isliye saaf human label + rang me map karte hain.
|
|
26
|
+
//
|
|
27
|
+
// deploymentStatus (backend se) agar 'queued'/'in_progress' hai to build chal raha hai -
|
|
28
|
+
// container ka transient status (jaise "exited:unhealthy") galat hota hai, isliye usko
|
|
29
|
+
// override karke "Deploying..." dikhate hain.
|
|
30
|
+
export function classify(status, deploymentStatus, verdict) {
|
|
31
|
+
if (deploymentStatus === 'in_progress' || deploymentStatus === 'queued') {
|
|
32
|
+
return { label: 'Deploying…', tone: 'amber', live: true };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Honest Deploy Verdict (build finish ke baad ka runtime gate). CONTRACT-AGNOSTIC by design: verdict
|
|
36
|
+
// chahe alag `verdict` field me aaye ya EXISTING `status` field me overlay ho - dono handle. `v` =
|
|
37
|
+
// verdict (agar diya) warna status; extended enum building|live|failed|starting|failed_to_start.
|
|
38
|
+
// 'starting'/'failed_to_start'/'failed' ko explicit map karo, state:health split se PEHLE.
|
|
39
|
+
// Render-style: runtime-fail (build theek par app up nahi hua) = "Failed" (subject = deploy);
|
|
40
|
+
// build-stage fail = "Build failed". Dono distinct, warna dono includes('fail') -> generic "Failed".
|
|
41
|
+
const v = verdict || status;
|
|
42
|
+
// Idle-sleep (ADR-0010): ek SLEEPABLE free app idle par so gaya (backend `verdict:'sleeping'` bhejta jab
|
|
43
|
+
// apps.slept_at set). NEUTRAL "Sleeping" - crash/fail NAHI (red nahi). Agle request par khud jaag jaata.
|
|
44
|
+
if (v === 'sleeping') return { label: 'Sleeping', tone: 'gray', live: true };
|
|
45
|
+
// Crash-loop suspend (ADR-0124): humne rok di kyunki wo baar-baar mar rahi thi. Ye 'failed_to_start'
|
|
46
|
+
// (laal) se alag hai aur 'sleeping' (gray, khud jaag jaata) se bhi - isliye AMBER: kuch toota nahi hai
|
|
47
|
+
// is waqt, par jab tak customer khud start nahi karta tab tak kuch chalega bhi nahi. Ek hi laal badge me
|
|
48
|
+
// "aapka app crash hua" aur "humne aapka app rok diya" ko mila dena customer se agla kadam chhupa deta.
|
|
49
|
+
if (v === 'suspended') return { label: 'Stopped by Voroa', tone: 'amber' };
|
|
50
|
+
if (v === 'paused_free_hours') return { label: 'Paused: free hours used', tone: 'amber' };
|
|
51
|
+
// ADR-0314 zero setup: service ka pehla deploy uske add-on database ka intezaar kar raha hai. Amber -
|
|
52
|
+
// kuch toota nahi, ek kadam baaki hai (payment ya database ka banna); service page par do button.
|
|
53
|
+
if (v === 'waiting_for_addon') return { label: 'Waiting for database', tone: 'amber' };
|
|
54
|
+
// `unknown` (ADR-0243) - EXPLICIT, not a fallthrough. The deploy is past every deadline and the
|
|
55
|
+
// container that could have answered is gone, so we cannot say whether the service came up. It looks
|
|
56
|
+
// the same as the catch-all below and must not BE the catch-all: `status.test.js` forbids any backend
|
|
57
|
+
// verdict reaching that line, because a verdict nobody wrote a branch for renders as an unexplained
|
|
58
|
+
// grey badge. This one is explained - grey and honest, not amber (nothing is pending) and not red
|
|
59
|
+
// (nothing is wrong). A container status, when we have one, still wins over it below.
|
|
60
|
+
if (v === 'unknown') return { label: 'Unknown', tone: 'gray' };
|
|
61
|
+
if (v === 'starting') return { label: 'Starting…', tone: 'amber', live: true };
|
|
62
|
+
if (v === 'failed_to_start') return { label: 'Failed', tone: 'red' };
|
|
63
|
+
if (v === 'failed') return { label: 'Build failed', tone: 'red' };
|
|
64
|
+
|
|
65
|
+
// Koi container status hai hi nahi. Ye NORMAL hai: platform ka apna runtime read fail ho sakta
|
|
66
|
+
// hai (ya container abhi swap ho raha ho) aur tab status null aata hai - wo best-effort hai.
|
|
67
|
+
// `verdict` ek ALAG axis pe banta hai, isliye wo abhi bhi sach hota hai - use padho, "Unknown"
|
|
68
|
+
// mat bolo. (Wording yahan jaan-boojh ke SPA se alag hai: ye file customer ki disk pe as-is
|
|
69
|
+
// install hoti hai, to yahan platform ke substrate ka naam nahi jaa sakta - golden rule 6.)
|
|
70
|
+
// NOTE: ye branch SIRF tab chalti hai jab status missing ho. Ek MAUJOOD status hamesha jeetta hai
|
|
71
|
+
// (neeche state:health parse) - warna verdict:'live' ek 'restarting:unhealthy' container ko green
|
|
72
|
+
// "Running" bana deta aur bug #30 ki poori honesty ulti ho jaati.
|
|
73
|
+
if (!status) {
|
|
74
|
+
if (v === 'live') return { label: 'Running', tone: 'green' };
|
|
75
|
+
if (v === 'building') return { label: 'Deploying…', tone: 'amber', live: true };
|
|
76
|
+
return { label: 'Unknown', tone: 'gray' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// trim() bhi (backend lib/runtimeState.js jaisa) - engine spacing me consistent nahi hai, aur ek
|
|
80
|
+
// untrimmed ' unhealthy ' crash-loop branch ko chupchaap miss kar deta tha (parity guard ne pakda).
|
|
81
|
+
const [stateRaw, healthRaw] = String(status).split(':');
|
|
82
|
+
const state = (stateRaw || '').trim().toLowerCase();
|
|
83
|
+
const health = (healthRaw || '').trim().toLowerCase();
|
|
84
|
+
|
|
85
|
+
if (state.includes('running')) {
|
|
86
|
+
// "unknown"/missing health ka matlab sirf health-check off hai - app chal raha hai.
|
|
87
|
+
if (health === 'unhealthy') return { label: 'Unhealthy', tone: 'amber' };
|
|
88
|
+
return { label: 'Running', tone: 'green' };
|
|
89
|
+
}
|
|
90
|
+
if (state.includes('start') || state.includes('restart')) {
|
|
91
|
+
// Crash-loop honestly (bug #30): ek container jo BAAR-BAAR restart ho raha hai wo "Starting…" nahi
|
|
92
|
+
// hai - wo gir raha hai. Backend ise `restarting:unhealthy` bhejta hai (native container ka apna
|
|
93
|
+
// healthcheck FAIL, ya restart-flap detect hua). Sirf tab red - `restarting:unknown` (ek transient
|
|
94
|
+
// restart, health-check off) pehle jaisa "Starting…" hi rehta hai.
|
|
95
|
+
if (state.includes('restart') && health === 'unhealthy') {
|
|
96
|
+
return { label: 'Crash looping', tone: 'red' };
|
|
97
|
+
}
|
|
98
|
+
return { label: 'Starting…', tone: 'amber', live: true };
|
|
99
|
+
}
|
|
100
|
+
if (state.includes('build') || state.includes('deploy') || state.includes('queue')) {
|
|
101
|
+
return { label: 'Deploying…', tone: 'amber', live: true };
|
|
102
|
+
}
|
|
103
|
+
if (state.includes('degraded')) {
|
|
104
|
+
return { label: 'Degraded', tone: 'red' };
|
|
105
|
+
}
|
|
106
|
+
if (state.includes('error') || state.includes('fail')) {
|
|
107
|
+
return { label: 'Failed', tone: 'red' };
|
|
108
|
+
}
|
|
109
|
+
if (state.includes('exit') || state.includes('stop') || state.includes('dead')) {
|
|
110
|
+
return { label: 'Stopped', tone: 'gray' };
|
|
111
|
+
}
|
|
112
|
+
// Anjaan state - raw dikha do par clean
|
|
113
|
+
return { label: stateRaw || 'Unknown', tone: 'gray' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Pure label mapper (bug #3) - raw "state:health" (e.g. "running:unknown") ko kabhi user ko
|
|
117
|
+
// NAHI dikhate; hamesha saaf label. classify() pe delegate karta hai (single source of truth),
|
|
118
|
+
// sirf string label deta hai. Regression test isi ko pin karta hai.
|
|
119
|
+
export function statusLabel(status, deploymentStatus) {
|
|
120
|
+
return classify(status, deploymentStatus).label;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------------------------
|
|
124
|
+
// END OF THE PINNED REGION. Everything below is CLI-only and has no counterpart in the SPA - the
|
|
125
|
+
// parity test extracts the two functions above by name, so additions here never affect it.
|
|
126
|
+
// ---------------------------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
// Verdict -> process exit code. This is the CLI's published contract (README "Exit codes") and it
|
|
129
|
+
// lives here, next to the vocabulary it reads, so no command file ever compares a verdict token
|
|
130
|
+
// itself.
|
|
131
|
+
//
|
|
132
|
+
// The input is ALWAYS the honest `verdict` field, NEVER the raw build `status` (bug #24: `status`
|
|
133
|
+
// stays the raw build state - 'live' there means "the image built and the start job returned", which
|
|
134
|
+
// is exactly the lie the verdict field was added to stop telling).
|
|
135
|
+
//
|
|
136
|
+
// done:true -> stop waiting, exit with `code`
|
|
137
|
+
// done:false -> the deploy has not resolved yet; keep polling
|
|
138
|
+
//
|
|
139
|
+
// A verdict we do not recognise is treated as unresolved rather than as success. The failure mode
|
|
140
|
+
// then is "we waited and timed out", which is honest, instead of "we called an unknown state a
|
|
141
|
+
// green deploy".
|
|
142
|
+
// One word per TONE, for a line that counts services by state (the session header). It lives here
|
|
143
|
+
// because this is the only file allowed to hold status vocabulary - a second list elsewhere is how
|
|
144
|
+
// two screens end up calling the same state two things.
|
|
145
|
+
export const TONE_WORDS = { green: 'running', gray: 'idle', amber: 'changing', red: 'failed' };
|
|
146
|
+
|
|
147
|
+
// What each `verdict` means, for a PROGRAM reading `status --json` or `services --json`. A raw
|
|
148
|
+
// container status can read like a crash for a service that is merely asleep - the verdict is the
|
|
149
|
+
// field that was built to be believed (bug #24), and `voroa agent` prints this table.
|
|
150
|
+
export const VERDICT_MEANINGS = {
|
|
151
|
+
live: 'Running and serving.',
|
|
152
|
+
sleeping: 'Idle and asleep. NOT broken - it wakes on the next request.',
|
|
153
|
+
building: 'A deploy is in progress.',
|
|
154
|
+
starting: 'Built; waiting for the service to come up.',
|
|
155
|
+
failed: 'The build failed. Read the build log.',
|
|
156
|
+
failed_to_start: 'Built, but the service did not come up. Read the logs.',
|
|
157
|
+
suspended: 'Stopped by Voroa after repeated crashes. A person must start it.',
|
|
158
|
+
paused_free_hours: 'Paused: the free hours for this month are used.',
|
|
159
|
+
waiting_for_addon: 'Waiting for its database to be created.',
|
|
160
|
+
unknown: 'Could not be determined. Not a failure; check again.',
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export const EXIT_OK = 0;
|
|
164
|
+
export const EXIT_FAILED = 1;
|
|
165
|
+
export const EXIT_TIMEOUT = 2;
|
|
166
|
+
|
|
167
|
+
// "Is there more build log coming?" This reads the RAW build status on purpose, and only for that
|
|
168
|
+
// one question - the build stream ends when the build ends, which is exactly what the raw field
|
|
169
|
+
// describes. It is never turned into a state shown to a user; `classify` and `verdictOutcome` own
|
|
170
|
+
// that, and the difference is the whole point of bug #24.
|
|
171
|
+
// isBuildRunning - the OTHER half of isBuildFinished, and deliberately not its negation. `!isBuildFinished`
|
|
172
|
+
// is TRUE for a null status, which is the "we could not read it" answer - a caller that follows on that
|
|
173
|
+
// waits out its own deadline instead of saying the honest thing. Callers that must keep following a live
|
|
174
|
+
// build (the build-log follow loop) need "we KNOW it is running", which is only this.
|
|
175
|
+
export function isBuildRunning(buildStatus) {
|
|
176
|
+
return buildStatus === 'building';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function isBuildFinished(buildStatus) {
|
|
180
|
+
return buildStatus != null && buildStatus !== 'building';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function verdictOutcome(verdict) {
|
|
184
|
+
if (verdict === 'live') return { done: true, code: EXIT_OK, message: 'Live.' };
|
|
185
|
+
if (verdict === 'failed_to_start') {
|
|
186
|
+
return { done: true, code: EXIT_FAILED, message: 'The build finished but the service did not start.' };
|
|
187
|
+
}
|
|
188
|
+
if (verdict === 'failed') return { done: true, code: EXIT_FAILED, message: 'The build failed.' };
|
|
189
|
+
// UNKNOWN IS TERMINAL, and that is the whole point of handling it here. The fallback below is
|
|
190
|
+
// `{done:false}`, so without this line `--wait` would poll forever on a deploy we can never judge - and
|
|
191
|
+
// that is not a corner case: a long build plus a wait crosses the verdict window routinely.
|
|
192
|
+
// EXIT_OK, not EXIT_FAILED: the same aged-out deploy exits 0 today, and failing a customer's pipeline
|
|
193
|
+
// over OUR missing evidence would turn our gap into their red build. The message must not reuse "Live."
|
|
194
|
+
// - saying nothing happened is exactly the claim we cannot make.
|
|
195
|
+
if (verdict === 'unknown') {
|
|
196
|
+
return { done: true, code: EXIT_OK, message: 'Deploy finished. We could not confirm the service came up.' };
|
|
197
|
+
}
|
|
198
|
+
return { done: false, code: null, message: null };
|
|
199
|
+
}
|
package/src/stepup.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// "Confirm it is you" from a terminal.
|
|
2
|
+
//
|
|
3
|
+
// A few routes (revealing a database password, minting an API token) need a FRESH proof of identity
|
|
4
|
+
// on the session - ten minutes old at most - on top of being signed in. The server answers
|
|
5
|
+
// 403 { code: 'step_up_required' } when it is missing. We react to exactly that answer, ask for the
|
|
6
|
+
// proof, and retry the call ONCE. We never ask up front: the server decides what needs it, and a
|
|
7
|
+
// prompt the server did not ask for teaches people to type secrets on reflex.
|
|
8
|
+
//
|
|
9
|
+
// What can be used here, in the server's own order (services/mfa.js verifyForStepUp):
|
|
10
|
+
// * 2FA on -> an authenticator code, or a recovery code with --recovery. A passkey cannot be used
|
|
11
|
+
// from a terminal - the dashboard is the answer for a passkey-only account.
|
|
12
|
+
// * 2FA off -> the password, or an emailed code for an account without one.
|
|
13
|
+
// None of these is ever echoed, and none is kept after the call.
|
|
14
|
+
import { ApiError, isApiToken, securityState, reauth, reauthEmailCode } from './api.js';
|
|
15
|
+
import { askHidden } from './prompt.js';
|
|
16
|
+
import { err } from './output.js';
|
|
17
|
+
import { CliError } from './errors.js';
|
|
18
|
+
|
|
19
|
+
const PROMPT_MS = 5 * 60_000;
|
|
20
|
+
|
|
21
|
+
function isStepUpRequired(e) {
|
|
22
|
+
return e instanceof ApiError && e.httpStatus === 403 && e.code === 'step_up_required';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function confirmIdentity(ctx, { recovery = false } = {}) {
|
|
26
|
+
const state = await securityState(ctx);
|
|
27
|
+
const mfa = (state && state.mfa) || {};
|
|
28
|
+
// stderr: stdout may be JSON or a URL a script is capturing.
|
|
29
|
+
err('Please confirm it is you to continue.');
|
|
30
|
+
|
|
31
|
+
let body;
|
|
32
|
+
if (mfa.enabled) {
|
|
33
|
+
if (recovery) {
|
|
34
|
+
body = { method: 'recovery', code: await askHidden('Recovery code: ', { timeoutMs: PROMPT_MS }) };
|
|
35
|
+
} else if (mfa.totp && mfa.totp.confirmed) {
|
|
36
|
+
body = { method: 'totp', code: await askHidden('Authenticator code: ', { timeoutMs: PROMPT_MS }) };
|
|
37
|
+
} else {
|
|
38
|
+
throw new CliError('Your account confirms with a passkey, which cannot be used from a terminal. Use --recovery with a recovery code, or do this in the dashboard.');
|
|
39
|
+
}
|
|
40
|
+
} else if (mfa.hasPassword) {
|
|
41
|
+
body = { method: 'password', password: await askHidden('Password: ', { timeoutMs: PROMPT_MS }) };
|
|
42
|
+
} else {
|
|
43
|
+
await reauthEmailCode(ctx);
|
|
44
|
+
err('We have emailed you a 6-digit code.');
|
|
45
|
+
body = { method: 'email_code', code: await askHidden('Code: ', { timeoutMs: PROMPT_MS }) };
|
|
46
|
+
}
|
|
47
|
+
if (!body.code && !body.password) throw new CliError('Nothing was entered - stopped.');
|
|
48
|
+
await reauth(ctx, body);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function withStepUp(ctx, fn, { recovery = false } = {}) {
|
|
52
|
+
try {
|
|
53
|
+
return await fn();
|
|
54
|
+
} catch (e) {
|
|
55
|
+
if (!isStepUpRequired(e)) throw e;
|
|
56
|
+
// A token has no session to step up on. The server refuses these routes to a token anyway; this
|
|
57
|
+
// just says it in words.
|
|
58
|
+
if (isApiToken(ctx.token)) throw new CliError('This needs a signed-in session, not an API token. Run `voroa login`.');
|
|
59
|
+
await confirmIdentity(ctx, { recovery });
|
|
60
|
+
try {
|
|
61
|
+
return await fn();
|
|
62
|
+
} catch (again) {
|
|
63
|
+
// Retried exactly once. A second demand means the confirmation did not stick - say that, not the
|
|
64
|
+
// generic "you do not have access", which would read as a permissions problem.
|
|
65
|
+
if (isStepUpRequired(again)) throw new CliError('The confirmation did not go through. Please try again.');
|
|
66
|
+
throw again;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|