voroa-cli 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +247 -2
  3. package/package.json +41 -4
  4. package/src/agent.js +95 -0
  5. package/src/api.js +552 -0
  6. package/src/commands/db.js +99 -0
  7. package/src/commands/deploy.js +130 -0
  8. package/src/commands/domains.js +122 -0
  9. package/src/commands/env.js +143 -0
  10. package/src/commands/lifecycle.js +40 -0
  11. package/src/commands/link.js +92 -0
  12. package/src/commands/login.js +104 -0
  13. package/src/commands/logs.js +304 -0
  14. package/src/commands/services.js +72 -0
  15. package/src/commands/ssh.js +46 -0
  16. package/src/commands/status.js +50 -0
  17. package/src/commands/tokens.js +81 -0
  18. package/src/commands/workspace.js +63 -0
  19. package/src/config.js +108 -0
  20. package/src/errors.js +53 -0
  21. package/src/flags.js +32 -0
  22. package/src/index.js +212 -0
  23. package/src/interactive.js +34 -0
  24. package/src/link.js +72 -0
  25. package/src/mcp/build.js +72 -0
  26. package/src/mcp/kernel/index.js +59 -0
  27. package/src/mcp/kernel/logger.js +28 -0
  28. package/src/mcp/lib/config.js +72 -0
  29. package/src/mcp/lib/credentials.js +51 -0
  30. package/src/mcp/lib/http.js +136 -0
  31. package/src/mcp/lib/response.js +14 -0
  32. package/src/mcp/lib/reversibility.js +33 -0
  33. package/src/mcp/reversibilityMap.json +77 -0
  34. package/src/mcp/tools/_action.js +49 -0
  35. package/src/mcp/tools/_ids.js +42 -0
  36. package/src/mcp/tools/_shape.js +318 -0
  37. package/src/mcp/tools/actions.js +94 -0
  38. package/src/mcp/tools/getBuildLog.js +39 -0
  39. package/src/mcp/tools/getDeployHistory.js +42 -0
  40. package/src/mcp/tools/getRuntimeLogs.js +44 -0
  41. package/src/mcp/tools/getServiceStatus.js +41 -0
  42. package/src/mcp/tools/getUsage.js +34 -0
  43. package/src/mcp/tools/listDatabases.js +25 -0
  44. package/src/mcp/tools/listDomains.js +33 -0
  45. package/src/mcp/tools/listServices.js +20 -0
  46. package/src/mcp/tools/propose.js +124 -0
  47. package/src/mcp/tools/reads.js +756 -0
  48. package/src/mcp/tools/writes.js +632 -0
  49. package/src/mcpTools.js +292 -0
  50. package/src/output.js +145 -0
  51. package/src/prompt.js +229 -0
  52. package/src/region.js +5 -0
  53. package/src/resolve.js +64 -0
  54. package/src/session.js +270 -0
  55. package/src/status.js +199 -0
  56. package/src/stepup.js +69 -0
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
+ }