voroa-cli 0.2.0 → 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/README.md CHANGED
@@ -109,6 +109,60 @@ to commit). In that folder and any subfolder, `<service>` becomes optional. The
109
109
  against your workspace on every command, so a link copied from someone else's repository does
110
110
  nothing. An explicit `--workspace` still wins over the one the link remembers.
111
111
 
112
+ ## Interactive session
113
+
114
+ Run `voroa` with nothing after it and you get a session instead of a single command:
115
+
116
+ ```
117
+ $ voroa
118
+
119
+ | ^ voroa CLI 0.3.0
120
+ |
121
+ | Account you@example.com
122
+ | Services * 2 running o 1 idle x 0 failed
123
+
124
+ /help commands - Tab complete - Ctrl+C stop - /exit
125
+
126
+ voroa > services
127
+ voroa > logs web --follow
128
+ voroa > deploy web --wait
129
+ voroa > /exit
130
+ ```
131
+
132
+ Every command on this page works there, with or without the leading `/`. **Tab** completes a command
133
+ name, then a service name. **Up arrow** recalls earlier commands - only commands: what you type into a
134
+ password, code or value prompt is never kept. **Ctrl+C** stops a running log tail and returns to the
135
+ prompt; at an empty prompt, pressing it twice leaves, as does `/exit` or Ctrl+D. A command that fails
136
+ prints its message and the session carries on.
137
+
138
+ It is not a shell: quotes group words (`--command "node job.js"`), and nothing else on the line is
139
+ expanded or executed. In a pipe or in CI, `voroa` with no arguments prints the usage text exactly as
140
+ before; `voroa session` opens a session by name.
141
+
142
+ On a real terminal the header, the status marks (green running, grey idle, amber changing, red failed)
143
+ and the last line of a deploy are coloured. That is decoration on top of the words: set `NO_COLOR`, or
144
+ pipe the output anywhere, and you get the same text with no colour and no escape codes.
145
+
146
+ ## For scripts and AI agents
147
+
148
+ ```
149
+ voroa agent the rules, written for a model to read
150
+ voroa agent --json the same as data
151
+ ```
152
+
153
+ - `--json` on any command: the result is JSON on stdout, and a failure is one object on stderr,
154
+ `{"error": "...", "code": "NOT_FOUND"}`. The codes are a short fixed list - `voroa agent` prints them.
155
+ - `--no-input` or `VOROA_NO_INPUT=1`: a step that would ask a question (a sign-in code, a password)
156
+ fails at once with `NEEDS_HUMAN` instead of waiting for an answer nobody will type.
157
+ - `deploy --wait --json` prints one object when it ends, with `outcome` (`live`, `failed`,
158
+ `failed_to_start`, `timeout`, ...) and the same `exitCode` the process returns.
159
+ - `logs --json` prints `{"service", "lines"}`; with `--follow`, one `{"line"}` object per line.
160
+ - `status --all --json` gives every service with its `verdict` in one call.
161
+ - `voroa tools <word>` narrows the list (`voroa tools bucket`); each command is marked as changing
162
+ something or not.
163
+ - A flag a command does not have is refused (`BAD_INPUT`), never ignored.
164
+ - Authenticate an agent with `VOROA_TOKEN`, never with a sign-in.
165
+
112
166
  ## Everything else: `voroa tools`
113
167
 
114
168
  The commands above are the everyday ones. Everything else the platform lets a token do is a command
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voroa-cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Voroa command line interface - deploy, inspect and manage your services, databases, cron jobs and storage from the terminal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/agent.js ADDED
@@ -0,0 +1,95 @@
1
+ // `voroa agent` - how to drive this CLI, written for an AI agent to read.
2
+ //
3
+ // A customer's coding agent (Claude Code, Cursor, anything that can run a shell command) does not need
4
+ // a plugin to use Voroa: it needs to know five rules and where to look things up. This prints them.
5
+ // The exit codes and error codes are READ from the modules that own them, not retyped, so the guide
6
+ // cannot describe a contract the binary does not keep (test/agent.test.js).
7
+ import { ERROR_CODES } from './errors.js';
8
+ import { EXIT_OK, EXIT_FAILED, EXIT_TIMEOUT, VERDICT_MEANINGS } from './status.js';
9
+ import { out, json } from './output.js';
10
+
11
+ const EXIT_CODES = {
12
+ [EXIT_OK]: 'Succeeded. With deploy --wait: the service is live.',
13
+ [EXIT_FAILED]: 'Failed. The error says why.',
14
+ [EXIT_TIMEOUT]: 'Still going when the wait ended (deploy --wait), or not live yet (domains verify). Not a failure and not a success - check again.',
15
+ };
16
+
17
+ const RULES = [
18
+ 'Authenticate with the VOROA_TOKEN environment variable: an API token that starts with voroa_cat_. A person creates it (dashboard: Settings -> API tokens, or `voroa tokens create`). You cannot sign in yourself - `voroa login` sends a code to a person\'s email.',
19
+ 'Add --json to every command. The result is JSON on stdout. A failure is one JSON object on stderr: {"error": "...", "code": "..."}.',
20
+ 'Set VOROA_NO_INPUT=1 (or pass --no-input). A step that needs a person then fails at once with code NEEDS_HUMAN instead of waiting for an answer that will not come.',
21
+ 'Read the exit code, not the text. It says whether the COMMAND worked - not whether a service is healthy: `voroa status` exits 0 for a stopped service too. For health, read the "verdict" field (table below), never the raw "status" field.',
22
+ 'Prefer the everyday commands (services, status, logs, deploy, restart, rollback, env, domains). The `voroa tools` commands cover everything else; a few overlap (services and list-services are the same list).',
23
+ 'To stay read-only, run only commands that `voroa tools` marks "changes: no", plus services, status and logs.',
24
+ 'A service is named by its name or its id. Every other resource (database, bucket, task) takes an id - get it from the matching list command.',
25
+ 'Creating or deleting a resource is a PROPOSAL: `voroa propose` returns a request a person approves in the dashboard. Tell the person; do not retry, and do not look for another way.',
26
+ 'Never put a secret value in a command line. Pipe it: `printf %s "$VALUE" | voroa env set KEY <service>`, or pass arguments with --args-file.',
27
+ ];
28
+
29
+ const DISCOVER = [
30
+ ['voroa tools <word> --json', 'platform commands matching a word (bucket, backup, task...), each with its argument schema and whether it changes anything'],
31
+ ['voroa tools --json', 'all of them (large)'],
32
+ ['voroa <tools-command> --help', 'one of those: what it does and what it takes'],
33
+ ['voroa --help', 'the everyday commands and their flags'],
34
+ ['voroa agent --json', 'this guide as data'],
35
+ ];
36
+
37
+ const EXAMPLES = [
38
+ ['voroa services --json', 'the services (names, ids, repository, branch)'],
39
+ ['voroa status --all --json', 'how every service is doing: its verdict'],
40
+ ['voroa status <service> --json', 'one service in full'],
41
+ ['voroa deploy <service> --wait --json', 'deploy and wait; exit 0 only when live'],
42
+ ['voroa logs <service> --lines 100 --json', 'recent output'],
43
+ ['voroa get-deploy-history <service> --json', 'what was deployed, and how it went'],
44
+ ['voroa rollback <service> --json', 'serve the previous version again'],
45
+ ];
46
+
47
+ export function agentGuide(version) {
48
+ return {
49
+ cli: 'voroa',
50
+ version,
51
+ rules: RULES,
52
+ exitCodes: EXIT_CODES,
53
+ errorCodes: ERROR_CODES,
54
+ verdicts: VERDICT_MEANINGS,
55
+ discover: DISCOVER.map(([command, what]) => ({ command, what })),
56
+ examples: EXAMPLES.map(([command, what]) => ({ command, what })),
57
+ };
58
+ }
59
+
60
+ const table = (rows) => {
61
+ const width = Math.max(...rows.map(([a]) => String(a).length));
62
+ return rows.map(([a, b]) => ` ${String(a).padEnd(width)} ${b}`).join('\n');
63
+ };
64
+
65
+ export function agent({ flags, version }) {
66
+ const guide = agentGuide(version);
67
+ if (flags.json) {
68
+ json(guide);
69
+ return 0;
70
+ }
71
+ out(
72
+ [
73
+ `Voroa CLI ${version} - guide for an AI agent`,
74
+ '',
75
+ 'Rules',
76
+ ...RULES.map((r, i) => ` ${i + 1}. ${r}`),
77
+ '',
78
+ 'Exit codes',
79
+ table(Object.entries(EXIT_CODES)),
80
+ '',
81
+ 'Error codes (the "code" field of a --json failure)',
82
+ table(Object.entries(ERROR_CODES)),
83
+ '',
84
+ 'A service\'s "verdict"',
85
+ table(Object.entries(VERDICT_MEANINGS)),
86
+ '',
87
+ 'Look things up',
88
+ table(DISCOVER),
89
+ '',
90
+ 'Examples',
91
+ table(EXAMPLES),
92
+ ].join('\n')
93
+ );
94
+ return 0;
95
+ }
@@ -20,7 +20,7 @@ import { showBuildLog } from './logs.js';
20
20
  import { verdictOutcome, EXIT_TIMEOUT } from '../status.js';
21
21
  import { resolveService } from '../resolve.js';
22
22
  import { numberFlag } from '../flags.js';
23
- import { out } from '../output.js';
23
+ import { out, json, spinner, style, glyphs, elapsed, fancy } from '../output.js';
24
24
  import { CliError } from '../errors.js';
25
25
 
26
26
  const DEFAULT_TIMEOUT_MIN = 10;
@@ -37,8 +37,16 @@ export async function deploy(ctx, { positionals, flags }) {
37
37
 
38
38
  const app = await resolveService(ctx, positionals[0]);
39
39
 
40
+ const startedAt = Date.now();
40
41
  const triggered = await redeploy(ctx, app.uuid);
41
- out(`Deploy triggered for ${app.name}.`);
42
+ const deploymentUuid = triggeredDeploymentUuid(triggered);
43
+ // --json: ONE object at the end and nothing before it, so stdout parses. The exit code is unchanged.
44
+ const say = flags.json ? () => {} : out;
45
+ const finish = (code, outcome, message) => {
46
+ if (flags.json) json({ service: app.name, id: app.uuid, deployment: deploymentUuid, outcome, message, exitCode: code });
47
+ return code;
48
+ };
49
+ say(`Deploy triggered for ${app.name}.`);
42
50
 
43
51
  // --follow = the build log as it happens, then the same verdict wait as --wait (same exit codes).
44
52
  // Only the uuid THIS trigger returned is followed: "the latest deploy" could be the previous one for
@@ -46,13 +54,13 @@ export async function deploy(ctx, { positionals, flags }) {
46
54
  // The deadline starts at the trigger and covers the log phase too, so --timeout means one thing.
47
55
  const deadline = Date.now() + minutes * 60_000;
48
56
 
49
- if (flags.follow) {
50
- const deploymentUuid = triggeredDeploymentUuid(triggered);
57
+ // A build log is text for a person; with --json, --follow simply waits like --wait.
58
+ if (flags.follow && !flags.json) {
51
59
  if (deploymentUuid) {
52
60
  // Ctrl-C while following ends the command - the deploy itself keeps running on the server.
53
61
  if (await showBuildLog(ctx, app, deploymentUuid, { deadline })) {
54
62
  out(`Stopped following. The deploy is still running - check \`voroa status ${app.name}\`.`);
55
- return 0;
63
+ return finish(0, 'stopped_following', 'Stopped following. The deploy is still running.');
56
64
  }
57
65
  } else {
58
66
  out('The build log is not available yet for this deploy - waiting for the outcome instead.');
@@ -60,12 +68,22 @@ export async function deploy(ctx, { positionals, flags }) {
60
68
  }
61
69
 
62
70
  if (!flags.wait && !flags.follow) {
63
- out('Run `voroa status ' + app.name + '` to check on it, or add --wait next time.');
64
- return 0;
71
+ say('Run `voroa status ' + app.name + '` to check on it, or add --wait next time.');
72
+ return finish(0, 'triggered', 'Deploy triggered. It has not been waited for.');
65
73
  }
66
74
 
67
- out('Waiting for the deploy to finish...');
75
+ const waiting = flags.json ? { stop() {} } : spinner('Waiting for the deploy to finish...');
76
+ let url = null;
77
+ // On a terminal the last line carries a mark, how long it took and where the service answers.
78
+ const lastLine = (outcome) => {
79
+ if (!fancy()) return outcome.message;
80
+ const g = glyphs();
81
+ const mark = outcome.code === 0 ? style('green', g.ok) : style('red', g.bad);
82
+ const where = outcome.code === 0 && url ? ` ${style('dim', g.arrow)} ${style('cyan', url)}` : '';
83
+ return ` ${mark} ${outcome.message} ${style('dim', elapsed(Date.now() - startedAt))}${where}`;
84
+ };
68
85
 
86
+ try {
69
87
  for (;;) {
70
88
  // A read failure mid-wait is not a verdict - a network blip must not be reported as a failed
71
89
  // deploy. But "we could not ask" and "it has not resolved yet" are DIFFERENT answers, and
@@ -77,6 +95,7 @@ export async function deploy(ctx, { positionals, flags }) {
77
95
  try {
78
96
  const st = await appStatus(ctx, app.uuid);
79
97
  verdict = st ? st.verdict : null;
98
+ if (st && st.fqdn) url = String(st.fqdn).split(',')[0].trim();
80
99
  } catch (e) {
81
100
  if (e instanceof ApiError && (e.httpStatus === 401 || e.httpStatus === 403 || e.httpStatus === 404)) {
82
101
  throw new CliError(`${e.message} The deploy was triggered and is still running - check it from the dashboard.`);
@@ -86,19 +105,26 @@ export async function deploy(ctx, { positionals, flags }) {
86
105
 
87
106
  const outcome = verdictOutcome(verdict);
88
107
  if (outcome.done) {
89
- out(outcome.message);
90
- return outcome.code;
108
+ waiting.stop();
109
+ say(lastLine(outcome));
110
+ return finish(outcome.code, verdict, outcome.message);
91
111
  }
92
112
 
93
113
  const remaining = deadline - Date.now();
94
114
  if (remaining <= 0) {
95
- out(`Timed out after ${minutes} minute${minutes === 1 ? '' : 's'}. The deploy has not finished.`);
96
- out(`This is not a failure and not a success - check \`voroa status ${app.name}\`.`);
97
- return EXIT_TIMEOUT;
115
+ waiting.stop();
116
+ const timedOut = `Timed out after ${minutes} minute${minutes === 1 ? '' : 's'}. The deploy has not finished.`;
117
+ say(timedOut);
118
+ say(`This is not a failure and not a success - check \`voroa status ${app.name}\`.`);
119
+ return finish(EXIT_TIMEOUT, 'timeout', timedOut);
98
120
  }
99
121
 
100
122
  // Never sleep past the deadline: a --timeout is a promise about when we stop, and overshooting
101
123
  // it by a poll interval makes the number in the flag a lie.
102
124
  await sleep(Math.min(POLL_MS, remaining));
103
125
  }
126
+ } finally {
127
+ // A thrown refusal must not leave the spinner's timer drawing over the error.
128
+ waiting.stop();
129
+ }
104
130
  }
@@ -7,7 +7,7 @@ import { runtimeLogs, buildLog, deployHistory, streamBuildLog, ApiError, SESSION
7
7
  import { isBuildFinished, isBuildRunning } from '../status.js';
8
8
  import { resolveService } from '../resolve.js';
9
9
  import { numberFlag } from '../flags.js';
10
- import { out, err } from '../output.js';
10
+ import { out, err, json } from '../output.js';
11
11
  import { CliError } from '../errors.js';
12
12
 
13
13
  const DEFAULT_LINES = 100;
@@ -91,7 +91,7 @@ function interruptible() {
91
91
  return state;
92
92
  }
93
93
 
94
- async function followRuntime(ctx, app, lines, search) {
94
+ async function followRuntime(ctx, app, lines, search, emit = out) {
95
95
  const ctl = interruptible();
96
96
  process.on('SIGINT', ctl.onSigint);
97
97
 
@@ -109,7 +109,7 @@ async function followRuntime(ctx, app, lines, search) {
109
109
  first = false;
110
110
  prev = next;
111
111
  if (fresh.length && fresh.some((l) => l !== '')) {
112
- for (const line of fresh) out(line);
112
+ for (const line of fresh) emit(line);
113
113
  lastOutputAt = Date.now();
114
114
  interval = FOLLOW_BASE_MS; // new output -> back to a tight poll
115
115
  }
@@ -247,6 +247,19 @@ export async function logs(ctx, { positionals, flags }) {
247
247
 
248
248
  if (flags.build !== undefined) {
249
249
  if (flags.search !== undefined) throw new CliError('--search works on the running service\'s output, not a build log.');
250
+ if (flags.json) {
251
+ // A snapshot, not a stream: one object a program can parse. `status` says whether more is coming.
252
+ let deployment = typeof flags.build === 'string' ? flags.build : null;
253
+ if (!deployment) {
254
+ const history = await deployHistory(ctx, app.uuid);
255
+ if (!history.length) throw new CliError(`${app.name} has not been deployed yet.`, 1, 'NOT_FOUND');
256
+ deployment = history[0].deploymentUuid;
257
+ }
258
+ const d = await buildLog(ctx, app.uuid, deployment);
259
+ const text = (l) => (typeof l === 'string' ? l : String(l.text ?? ''));
260
+ json({ service: app.name, deployment, status: d.buildStatus ?? null, available: !!d.available, lines: (d.lines || []).filter(Boolean).map(text) });
261
+ return 0;
262
+ }
250
263
  await showBuildLog(ctx, app, flags.build);
251
264
  return 0;
252
265
  }
@@ -271,11 +284,17 @@ export async function logs(ctx, { positionals, flags }) {
271
284
  if (flags.follow) {
272
285
  // Returns when Ctrl-C stops the watch, which is a successful end - see interruptible() above.
273
286
  // Any other way out of that loop is a throw.
274
- await followRuntime(ctx, app, lines, search);
287
+ // --json while following: one JSON object per line (NDJSON), so a reader can parse as it arrives.
288
+ const emit = flags.json ? (line) => process.stdout.write(`${JSON.stringify({ line })}\n`) : out;
289
+ await followRuntime(ctx, app, lines, search, emit);
275
290
  return 0;
276
291
  }
277
292
 
278
293
  const blob = await runtimeLogs(ctx, app.uuid, lines, search);
294
+ if (flags.json) {
295
+ json({ service: app.name, lines: splitLines(blob).filter((l) => l !== '') });
296
+ return 0;
297
+ }
279
298
  if (!blob.trim()) {
280
299
  out(search ? `No lines from ${app.name} in the last 24 hours contain "${search}".` : `${app.name} has not printed anything yet.`);
281
300
  return 0;
@@ -8,7 +8,7 @@
8
8
  import { listApps, batchStatus, deployHistory } from '../api.js';
9
9
  import { classify } from '../status.js';
10
10
  import { REGION_LABEL } from '../region.js';
11
- import { renderTable, timeAgo, out, json } from '../output.js';
11
+ import { renderTable, timeAgo, out, json, statusCell } from '../output.js';
12
12
 
13
13
  const STATUS_UNAVAILABLE = 'Status unavailable';
14
14
 
@@ -61,7 +61,7 @@ export async function services(ctx, { flags }) {
61
61
 
62
62
  const rows = apps.map((app, i) => {
63
63
  const st = statuses.get(app.uuid);
64
- const label = st ? classify(st.containerStatus, st.deploymentStatus, st.verdict).label : STATUS_UNAVAILABLE;
64
+ const label = st ? statusCell(classify(st.containerStatus, st.deploymentStatus, st.verdict)) : STATUS_UNAVAILABLE;
65
65
  return [app.name, label, REGION_LABEL, updated[i]];
66
66
  });
67
67
 
@@ -3,13 +3,27 @@
3
3
  // The label comes from the pinned `classify` in ../status.js and nowhere else. This file never
4
4
  // inspects a raw state token itself: that is what put a green "Running" badge on a container that
5
5
  // had been crash-looping for the better part of an hour (known bug #30).
6
- import { appStatus } from '../api.js';
6
+ import { appStatus, listApps, batchStatus } from '../api.js';
7
7
  import { classify } from '../status.js';
8
8
  import { REGION_LABEL } from '../region.js';
9
9
  import { resolveService } from '../resolve.js';
10
- import { renderFields, out, json } from '../output.js';
10
+ import { renderFields, renderTable, out, json, statusCell } from '../output.js';
11
11
 
12
12
  export async function status(ctx, { positionals, flags }) {
13
+ // --all: every service's state at once. `services --json` deliberately makes no status calls, so
14
+ // without this a program had to ask service by service just to learn which ones are up.
15
+ if (flags.all) {
16
+ const apps = await listApps(ctx);
17
+ const statuses = await batchStatus(ctx, apps.map((a) => a.uuid));
18
+ const rows = apps.map((a) => {
19
+ const st = statuses.get(a.uuid);
20
+ return { name: a.name, id: a.uuid, verdict: st ? (st.verdict ?? null) : null, state: st ? classify(st.containerStatus, st.deploymentStatus, st.verdict) : null };
21
+ });
22
+ if (flags.json) json({ services: rows.map((r) => ({ name: r.name, id: r.id, verdict: r.verdict, state: r.state ? r.state.label : null })) });
23
+ else out(renderTable(['NAME', 'STATUS', 'ID'], rows.map((r) => [r.name, r.state ? statusCell(r.state) : 'Status unavailable', r.id])));
24
+ return 0;
25
+ }
26
+
13
27
  const app = await resolveService(ctx, positionals[0]);
14
28
  const st = await appStatus(ctx, app.uuid);
15
29
 
@@ -18,7 +32,7 @@ export async function status(ctx, { positionals, flags }) {
18
32
  return 0;
19
33
  }
20
34
 
21
- const label = st ? classify(st.containerStatus, st.deploymentStatus, st.verdict).label : 'Status unavailable';
35
+ const label = st ? statusCell(classify(st.containerStatus, st.deploymentStatus, st.verdict)) : 'Status unavailable';
22
36
  // fqdn arrives comma-separated ("https://a,https://b"); the first entry is the primary.
23
37
  const url = st && st.fqdn ? String(st.fqdn).split(',')[0].trim() : null;
24
38
 
package/src/errors.js CHANGED
@@ -1,10 +1,53 @@
1
1
  // A failure the user caused or can act on: a wrong name, a missing argument, an unusable flag.
2
2
  // Carries the exit code so `index.js` never has to guess one, and its message is written to be read
3
3
  // by a person - never a stack trace, never an internal detail.
4
+ //
5
+ // `code` is for a PROGRAM reading the failure - a script, or a customer's AI agent - so it never has
6
+ // to parse the English. The list is short and fixed on purpose: a caller can switch on it, and
7
+ // `voroa agent` prints exactly this table (test/agent.test.js keeps the two from drifting).
8
+ export const ERROR_CODES = {
9
+ NOT_SIGNED_IN: 'No usable credential. A person must run voroa login, or set VOROA_TOKEN to an API token.',
10
+ FORBIDDEN: 'The credential is valid but may not do this (role, token scope, or a protected environment).',
11
+ NOT_FOUND: 'The service, database or other resource named does not exist in this workspace.',
12
+ AMBIGUOUS: 'More than one resource matches that name. Use the id.',
13
+ BAD_INPUT: 'A flag or argument is missing, unknown or malformed. Fix the command; do not retry it unchanged.',
14
+ NEEDS_HUMAN: 'This step asks a person a question (a sign-in code, a password, a confirmation). Stop and ask them.',
15
+ TIMEOUT: 'Voroa did not answer in time. Safe to retry once.',
16
+ UNREACHABLE: 'Voroa could not be reached from this machine (network, proxy or a wrong --api-url).',
17
+ FAILED: 'Anything else. The message says what happened.',
18
+ };
19
+
4
20
  export class CliError extends Error {
5
- constructor(message, exitCode = 1) {
21
+ constructor(message, exitCode = 1, code = null) {
6
22
  super(message);
7
23
  this.name = 'CliError';
8
24
  this.exitCode = exitCode;
25
+ this.code = code;
9
26
  }
10
27
  }
28
+
29
+ const FROM_HTTP = { 0: 'UNREACHABLE', 401: 'NOT_SIGNED_IN', 403: 'FORBIDDEN', 404: 'NOT_FOUND', 408: 'TIMEOUT', 504: 'TIMEOUT' };
30
+ // What the server, or the tool kernel (customer-mcp/src/lib/http.js), calls the same things.
31
+ const FROM_OTHER = {
32
+ step_up_required: 'NEEDS_HUMAN',
33
+ UPSTREAM_TIMEOUT: 'TIMEOUT',
34
+ UPSTREAM_UNREACHABLE: 'UNREACHABLE',
35
+ NOT_CONFIGURED: 'NOT_SIGNED_IN',
36
+ INSECURE_URL: 'BAD_INPUT',
37
+ UNAUTHORIZED: 'NOT_SIGNED_IN',
38
+ };
39
+
40
+ // One answer for any thrown thing. Order matters: our own code wins, then a name the server or the
41
+ // kernel used, then the HTTP status, then FAILED - never undefined, so a reader can always switch on it.
42
+ export function errorCode(e) {
43
+ if (!e) return 'FAILED';
44
+ for (const c of [e.code, e.cause && e.cause.code]) {
45
+ if (typeof c !== 'string') continue;
46
+ if (Object.hasOwn(ERROR_CODES, c)) return c;
47
+ if (Object.hasOwn(FROM_OTHER, c)) return FROM_OTHER[c];
48
+ }
49
+ for (const s of [e.httpStatus, e.cause && e.cause.httpStatus]) {
50
+ if (Number.isInteger(s) && Object.hasOwn(FROM_HTTP, s)) return FROM_HTTP[s];
51
+ }
52
+ return 'FAILED';
53
+ }
package/src/index.js CHANGED
@@ -13,8 +13,8 @@ import { readFileSync } from 'node:fs';
13
13
  import { pathToFileURL } from 'node:url';
14
14
  import { readSettings } from './config.js';
15
15
  import { contextFrom } from './api.js';
16
- import { err, out } from './output.js';
17
- import { closePrompts } from './prompt.js';
16
+ import { out, reportError } from './output.js';
17
+ import { closePrompts, setNoInput } from './prompt.js';
18
18
  import { CliError } from './errors.js';
19
19
  import { login } from './commands/login.js';
20
20
  import { services } from './commands/services.js';
@@ -30,6 +30,8 @@ import { tokens } from './commands/tokens.js';
30
30
  import { domains } from './commands/domains.js';
31
31
  import { ssh } from './commands/ssh.js';
32
32
  import { runTool } from './mcpTools.js';
33
+ import { startSession, wantsSession } from './session.js';
34
+ import { agent } from './agent.js';
33
35
 
34
36
  // Read from the manifest rather than repeated here. A hardcoded copy goes stale at the first
35
37
  // release nobody remembers to edit in two places, and `--version` reporting the wrong number is the
@@ -43,6 +45,7 @@ Usage
43
45
  voroa login [--password] [--recovery] sign in (a 6-digit code by email, or a password)
44
46
  voroa services the services in the active workspace
45
47
  voroa status <service> one service's live state
48
+ voroa status --all every service's state
46
49
  voroa logs <service> [--lines N] the running container's output
47
50
  voroa logs <service> --search TEXT only lines containing TEXT (last 24 hours)
48
51
  voroa logs <service> --follow keep tailing it
@@ -67,13 +70,16 @@ Usage
67
70
  voroa ssh [service] [--print] a shell in the running service
68
71
  voroa workspace list your workspaces
69
72
  voroa workspace set <id> choose the workspace commands act on
70
- voroa tools every other command (projects, cron, backups, buckets, ...)
73
+ voroa tools [word] every other command (projects, cron, backups, buckets, ...)
74
+ voroa with nothing after it: an interactive session (also: voroa session)
75
+ voroa agent how an AI agent should drive this CLI (--json for data)
71
76
  voroa <command> --help what one of those takes
72
77
 
73
78
  Flags
74
79
  --workspace <id> act on this workspace, for this command only
75
80
  --api-url <url> talk to a different Voroa API
76
- --json machine-readable output
81
+ --json machine-readable output; a failure is then {"error","code"} on stderr
82
+ --no-input never ask a question - fail with NEEDS_HUMAN instead (also VOROA_NO_INPUT=1)
77
83
  --timeout <min> with deploy --wait, how long to wait (default 10)
78
84
  --lines <n> with logs, how many lines to fetch (default 100, max 1000)
79
85
  --search <text> with logs, only lines containing this text (case-insensitive)
@@ -82,7 +88,7 @@ Flags
82
88
  Exit codes
83
89
  0 succeeded (with deploy --wait: the service is live)
84
90
  1 failed (including a deploy that failed or did not start)
85
- 2 deploy --wait ran out of time while the deploy was still going
91
+ 2 deploy --wait ran out of time while the deploy was still going; domains verify: not live yet
86
92
 
87
93
  <service> is a service name or its id, resolved inside the active workspace only.
88
94
 
@@ -123,6 +129,12 @@ export function parseArgv(argv) {
123
129
  return { flags, positionals };
124
130
  }
125
131
 
132
+ // Every flag an everyday command reads. A flag outside this list is REFUSED: `--line 5` for `--lines 5`
133
+ // used to be ignored without a word, and the command then did something other than what was asked.
134
+ // (The tool commands check their own flags against their own schema - mcpTools.js.)
135
+ const KNOWN_FLAGS = new Set(['all', 'apiUrl', 'build', 'buildTime', 'fix', 'follow', 'h', 'help', 'json', 'lines', 'name', 'noInput', 'overwrite', 'password', 'print', 'recovery', 'save', 'scope', 'search', 'showUrl', 'timeout', 'to', 'v', 'version', 'wait', 'workspace']);
136
+ const asFlag = (key) => `--${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
137
+
126
138
  // Exported for test/mcpTools.test.js, which pins exactly which tool names these shadow.
127
139
  export const COMMANDS = { login, services, status, logs, deploy, workspace, link, unlink, open, env, restart, stop, start, rollback, db, tokens, domains, ssh };
128
140
 
@@ -136,8 +148,15 @@ export async function run(argv) {
136
148
  // Asking for help SUCCEEDED at what was asked, with or without a command - `voroa --help` and
137
149
  // `voroa services --help` must not disagree about that. Running the tool with nothing to do is the
138
150
  // only usage error here.
151
+ setNoInput(flags.noInput || process.env.VOROA_NO_INPUT);
139
152
  const name = positionals[0];
140
153
  const command = COMMANDS[name];
154
+ if (name === 'agent') return agent({ flags, version: VERSION });
155
+ // No command at all, in a real terminal: the interactive session (session.js). On a pipe or in CI
156
+ // nothing changes - the usage text and exit 1 below. `voroa session` asks for it by name.
157
+ if (name === 'session' || (!name && !flags.help && !flags.h && wantsSession())) {
158
+ return startSession({ run, version: VERSION, commands: Object.keys(COMMANDS) });
159
+ }
141
160
  // Anything that is not a hand-written command is looked up among the customer-MCP tools
142
161
  // (mcpTools.js). Those parse their own flags from their own schema, --help included. `tool <name>`
143
162
  // reaches one whose short name a hand-written command already owns (restart, stop, start, rollback).
@@ -158,6 +177,9 @@ export async function run(argv) {
158
177
  }
159
178
 
160
179
 
180
+ const stray = Object.keys(flags).find((key) => !KNOWN_FLAGS.has(key));
181
+ if (stray) throw new CliError(`${name} has no ${asFlag(stray)} flag. Run \`voroa --help\`.`, 1, 'BAD_INPUT');
182
+
161
183
  const settings = readSettings({ apiUrl: flags.apiUrl, workspace: flags.workspace });
162
184
  const ctx = contextFrom(settings);
163
185
  ctx.workspaceExplicit = !!flags.workspace;
@@ -181,7 +203,7 @@ if (invokedDirectly) {
181
203
  process.exitCode = code ?? 0;
182
204
  })
183
205
  .catch((e) => {
184
- err(e && e.message ? e.message : 'Something went wrong.');
206
+ reportError(e, { asJson: process.argv.includes('--json') });
185
207
  process.exitCode = e && Number.isInteger(e.exitCode) ? e.exitCode : 1;
186
208
  })
187
209
  // An open readline interface holds the event loop open. Closed centrally so no command has to
@@ -8,20 +8,26 @@
8
8
  // No shell, ever: `args` are passed straight to the program, so nothing in them is interpreted.
9
9
  import { spawn } from 'node:child_process';
10
10
  import { CliError } from './errors.js';
11
+ import { suspendPrompts, resumePrompts } from './prompt.js';
11
12
 
12
13
  export function runInteractive(cmd, args, { env, notFound } = {}) {
13
14
  const ignoreSigint = () => {};
14
15
  process.on('SIGINT', ignoreSigint);
16
+ // Inside a session readline holds the terminal; the child gets it for as long as it runs. Outside one
17
+ // both calls do nothing.
18
+ suspendPrompts();
15
19
  return new Promise((resolve, reject) => {
16
20
  // The CLI's own credential never reaches the child (ssh can forward variables with SendEnv).
17
21
  const base = env || Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^VOROA_/i.test(k)));
18
22
  const child = spawn(cmd, args, { stdio: 'inherit', env: base });
19
23
  child.on('error', (e) => {
20
24
  process.off('SIGINT', ignoreSigint);
25
+ resumePrompts();
21
26
  reject(new CliError(e && e.code === 'ENOENT' ? notFound || `${cmd} was not found.` : `Could not start ${cmd}.`));
22
27
  });
23
28
  child.on('exit', (code) => {
24
29
  process.off('SIGINT', ignoreSigint);
30
+ resumePrompts();
25
31
  resolve(code ?? 1);
26
32
  });
27
33
  });
package/src/mcpTools.js CHANGED
@@ -16,6 +16,7 @@ import { CliError } from './errors.js';
16
16
  import { contextFrom, isApiToken } from './api.js';
17
17
  import { resolveService } from './resolve.js';
18
18
  import { out, json, renderTable } from './output.js';
19
+ import { ownsStdin } from './prompt.js';
19
20
 
20
21
  const KERNEL_HOMES = [new URL('./mcp/', import.meta.url), new URL('../../customer-mcp/src/', import.meta.url)];
21
22
 
@@ -131,11 +132,13 @@ export function parseToolArgv(tool, argv) {
131
132
  else if (flag === '--json') result.asJson = true;
132
133
  // Already applied by index.js when it built the settings; consumed here so they are not "unknown".
133
134
  else if (flag === '--api-url' || flag === '--workspace') takeValue();
135
+ else if (flag === '--no-input') result.noInput = true;
134
136
  else if (flag === '--args') result.args = { ...readJsonObject(takeValue(), '--args'), ...result.args };
135
137
  else if (flag === '--args-file') {
136
138
  // `-` is stdin. A value on the command line lands in shell history and in `ps`; a secret (an
137
139
  // environment variable's value, a secret file's contents) should arrive by pipe or by file.
138
140
  const file = takeValue();
141
+ if (file === '-' && ownsStdin()) throw new CliError('Inside a session, standard input belongs to the session. Give --args-file a real file.');
139
142
  let text;
140
143
  try {
141
144
  text = readFileSync(file === '-' ? 0 : file, 'utf8');
@@ -162,14 +165,37 @@ const firstSentence = (text) => {
162
165
  return end === -1 ? s : s.slice(0, end + 1);
163
166
  };
164
167
 
165
- export function toolsList(kernel) {
166
- return renderTable(['COMMAND', 'WHAT IT DOES'], kernel.listTools().map((t) => [commandName(t.name), firstSentence(t.description)]));
168
+ // Tool descriptions were written for the connector, where a tool is `list_buckets`. Here it is
169
+ // `list-buckets`, and a description that names a command the reader cannot type is a small lie.
170
+ function forCli(text, names) {
171
+ let said = String(text || '');
172
+ // Longest first, so `list_bucket_files` is not half-replaced by `list_bucket`.
173
+ for (const name of [...names].sort((a, b) => b.length - a.length)) {
174
+ if (name.includes('_')) said = said.split(name).join(commandName(name));
175
+ }
176
+ return said;
177
+ }
178
+
179
+ // What `voroa tools` lists: optionally only those whose name or description contains every word given.
180
+ // `changes` is the tool's own declared kind - a read never changes anything.
181
+ export function listForCli(kernel, words = []) {
182
+ const wanted = words.map((w) => w.toLowerCase());
183
+ return kernel
184
+ .listTools()
185
+ .map((t) => ({ command: commandName(t.name), changes: kernel.getTool(t.name).kind !== 'read', description: forCli(t.description, kernel.toolNames()), inputSchema: t.inputSchema }))
186
+ .filter((t) => wanted.every((w) => t.command.includes(w) || t.description.toLowerCase().includes(w)));
187
+ }
188
+
189
+ export function toolsList(kernel, words = []) {
190
+ const rows = listForCli(kernel, words);
191
+ if (!rows.length) return `No command matches "${words.join(' ')}". Run \`voroa tools\` for the full list.`;
192
+ return renderTable(['COMMAND', 'CHANGES', 'WHAT IT DOES'], rows.map((t) => [t.command, t.changes ? 'yes' : 'no', firstSentence(t.description)]));
167
193
  }
168
194
 
169
- export function toolHelp(tool, { explicit = false } = {}) {
195
+ export function toolHelp(tool, { explicit = false, toolNames = [] } = {}) {
170
196
  const props = (tool.inputSchema && tool.inputSchema.properties) || {};
171
197
  const required = new Set((tool.inputSchema && tool.inputSchema.required) || []);
172
- const lines = [`voroa ${explicit ? 'tool ' : ''}${commandName(tool.name)}`, '', String(tool.description || '').replace(/\s+/g, ' ').trim()];
198
+ const lines = [`voroa ${explicit ? 'tool ' : ''}${commandName(tool.name)}`, '', forCli(tool.description, toolNames).replace(/\s+/g, ' ').trim()];
173
199
  const names = Object.keys(props);
174
200
  if (names.length) {
175
201
  lines.push('', 'Arguments');
@@ -180,7 +206,7 @@ export function toolHelp(tool, { explicit = false } = {}) {
180
206
  flagName(p),
181
207
  props[p].enum ? 'one of' : typeOf(props[p]),
182
208
  required.has(p) ? 'yes' : 'no',
183
- props[p].enum ? `${props[p].enum.join(', ')}` : String(props[p].description || '').replace(/\s+/g, ' ').trim(),
209
+ props[p].enum ? `${props[p].enum.join(', ')}` : forCli(props[p].description, toolNames).replace(/\s+/g, ' ').trim(),
184
210
  ])
185
211
  )
186
212
  );
@@ -219,19 +245,20 @@ export async function runTool(settings, name, argv, { fetchImpl, asJson = false,
219
245
  // Listing and help need no credential: both come from the tool definitions. Only a call does.
220
246
  const kernel = await loadKernel({ apiUrl: settings.apiUrl, token, fetchImpl });
221
247
  if (name === 'tools') {
222
- if (asJson || argv.includes('--json')) json(kernel.listTools().map((t) => ({ command: commandName(t.name), description: t.description, inputSchema: t.inputSchema })));
223
- else out(toolsList(kernel));
248
+ const words = argv.filter((a) => !a.startsWith('--'));
249
+ if (asJson || argv.includes('--json')) json(listForCli(kernel, words));
250
+ else out(toolsList(kernel, words));
224
251
  return 0;
225
252
  }
226
253
  const tool = findTool(kernel, name);
227
- if (!tool) throw new CliError(`Unknown command "${name}". Run \`voroa --help\`, or \`voroa tools\` for the full list.`);
254
+ if (!tool) throw new CliError(`Unknown command "${name}". Run \`voroa --help\`, or \`voroa tools\` for the full list.`, 1, 'BAD_INPUT');
228
255
 
229
256
  const parsed = parseToolArgv(tool, argv);
230
257
  if (parsed.help) {
231
- out(toolHelp(tool, { explicit }));
258
+ out(toolHelp(tool, { explicit, toolNames: kernel.toolNames() }));
232
259
  return 0;
233
260
  }
234
- if (!token) throw new CliError(NO_API_TOKEN);
261
+ if (!token) throw new CliError(NO_API_TOKEN, 1, 'NOT_SIGNED_IN');
235
262
 
236
263
  // A service can be named, or left out in a linked folder - the same resolver the hand-written commands
237
264
  // use, so the answer still comes from the server's own workspace-scoped list (resolve.js).
package/src/output.js CHANGED
@@ -1,6 +1,86 @@
1
- // Terminal output helpers. Plain text, no colour: a CLI's output gets piped, redirected and pasted
2
- // into tickets, and ANSI codes survive all three as garbage. The state is carried by the WORD
3
- // ("Crash looping"), never by a colour a pipe would strip anyway.
1
+ // Terminal output helpers. Plain text: a CLI's output gets piped, redirected and pasted into tickets,
2
+ // and ANSI codes survive all three as garbage. The state is carried by the WORD ("Crash looping"),
3
+ // never by a colour a pipe would strip anyway.
4
+ //
5
+ // The ONE exception is everything gated by `fancy()` below: colour, a status mark, a spinner. All of
6
+ // it is decoration ON TOP of the words and switches itself off unless the stream is a real terminal,
7
+ // NO_COLOR is unset and TERM is not "dumb" - so everything above still holds for every pipe, file and
8
+ // CI log, and an AI agent reading the output never sees an escape code.
9
+ import { errorCode } from './errors.js';
10
+
11
+ export function fancy(stream = process.stdout) {
12
+ return !!(stream && stream.isTTY) && !process.env.NO_COLOR && process.env.TERM !== 'dumb';
13
+ }
14
+
15
+ const OFF_COLOUR = '\x1b[39m';
16
+ const STYLES = {
17
+ bold: ['\x1b[1m', '\x1b[22m'],
18
+ dim: ['\x1b[2m', '\x1b[22m'],
19
+ green: ['\x1b[32m', OFF_COLOUR],
20
+ amber: ['\x1b[33m', OFF_COLOUR],
21
+ red: ['\x1b[31m', OFF_COLOUR],
22
+ gray: ['\x1b[90m', OFF_COLOUR],
23
+ cyan: ['\x1b[36m', OFF_COLOUR],
24
+ violet: ['\x1b[38;5;141m', OFF_COLOUR], // the brand accent, from the 256-colour table every VT terminal has
25
+ };
26
+
27
+ export function style(kind, text, stream = process.stdout) {
28
+ const codes = STYLES[kind];
29
+ if (!codes || !fancy(stream)) return text;
30
+ return `${codes[0]}${text}${codes[1]}`;
31
+ }
32
+
33
+ // Marks. The old Windows console host draws several of these as boxes, so it gets ASCII; Windows
34
+ // Terminal and editor terminals announce themselves and get the real ones.
35
+ const UNICODE = { run: '\u25cf', idle: '\u25cb', busy: '\u25d0', fail: '\u25cf', ok: '\u2714', bad: '\u2716', prompt: '\u203a', bar: '\u258c', arrow: '\u2192', logo: '\u25b2', dot: '\u00b7', spin: ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f'] };
36
+ const ASCII = { run: '*', idle: 'o', busy: '~', fail: 'x', ok: 'ok', bad: 'x', prompt: '>', bar: '|', arrow: '->', logo: '^', dot: '-', spin: ['-', '\\', '|', '/'] };
37
+
38
+ export function glyphs(env = process.env, platform = process.platform) {
39
+ return platform !== 'win32' || env.WT_SESSION || env.TERM_PROGRAM ? UNICODE : ASCII;
40
+ }
41
+
42
+ const TONE = { green: ['green', 'run'], gray: ['gray', 'idle'], amber: ['amber', 'busy'], red: ['red', 'fail'] };
43
+
44
+ // A status for a table cell. The WORD is always there; on a terminal it gains a mark and a colour.
45
+ // Takes what `classify` returned - this file never looks at a raw status itself.
46
+ export function statusCell(state, stream = process.stdout) {
47
+ if (!fancy(stream) || !TONE[state.tone]) return state.label;
48
+ const [colour, mark] = TONE[state.tone];
49
+ return style(colour, `${glyphs()[mark]} ${state.label}`, stream);
50
+ }
51
+
52
+ export function elapsed(ms) {
53
+ const s = Math.max(0, Math.round(ms / 1000));
54
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
55
+ }
56
+
57
+ // "Still working" for a wait with nothing to print. Off a terminal it is ONE plain line, exactly what
58
+ // the command printed before spinners existed, so a log file gets a sentence and not 400 frames.
59
+ export function spinner(text, { stream = process.stdout, intervalMs = 90 } = {}) {
60
+ if (!fancy(stream)) {
61
+ stream.write(`${text}\n`);
62
+ return { stop() {} };
63
+ }
64
+ const frames = glyphs().spin;
65
+ const started = Date.now();
66
+ let i = 0;
67
+ const draw = () => stream.write(`\r\x1b[2K ${style('cyan', frames[i++ % frames.length], stream)} ${text} ${style('dim', elapsed(Date.now() - started), stream)}`);
68
+ draw();
69
+ const timer = setInterval(draw, intervalMs);
70
+ return {
71
+ stop() {
72
+ clearInterval(timer);
73
+ stream.write('\r\x1b[2K');
74
+ },
75
+ };
76
+ }
77
+
78
+ // Escape codes take no room on screen, so a column is measured by what is SEEN.
79
+ const ANSI = /\x1b\[[0-9;]*m/g;
80
+ export function visibleLength(text) {
81
+ return String(text).replace(ANSI, '').length;
82
+ }
83
+ const padTo = (text, width) => text + ' '.repeat(Math.max(0, width - visibleLength(text)));
4
84
 
5
85
  // Column-aligned table. Values are stringified defensively - a null cell renders as the em dash, so
6
86
  // an empty column never reads as an empty VALUE.
@@ -13,8 +93,8 @@ function cell(value) {
13
93
 
14
94
  export function renderTable(headers, rows) {
15
95
  const body = rows.map((r) => r.map(cell));
16
- const widths = headers.map((h, i) => Math.max(String(h).length, ...body.map((r) => (r[i] || '').length), 0));
17
- const line = (cells) => cells.map((c, i) => (i === cells.length - 1 ? c : c.padEnd(widths[i]))).join(' ').trimEnd();
96
+ const widths = headers.map((h, i) => Math.max(String(h).length, ...body.map((r) => visibleLength(r[i] || '')), 0));
97
+ const line = (cells) => cells.map((c, i) => (i === cells.length - 1 ? c : padTo(c, widths[i]))).join(' ').trimEnd();
18
98
  return [line(headers.map(String)), ...body.map(line)].join('\n');
19
99
  }
20
100
 
@@ -51,6 +131,15 @@ export function err(text = '') {
51
131
  process.stderr.write(`${text}\n`);
52
132
  }
53
133
 
134
+ // A failure, for whoever is reading. With --json it is one JSON object on stderr - the same message a
135
+ // person gets, plus a code from the fixed list in errors.js. Never the error object itself: that is the
136
+ // last place a token could surface (golden rule 13).
137
+ export function reportError(e, { asJson = false } = {}) {
138
+ const message = e && e.message ? e.message : 'Something went wrong.';
139
+ if (asJson) process.stderr.write(`${JSON.stringify({ error: message, code: errorCode(e) })}\n`);
140
+ else err(message);
141
+ }
142
+
54
143
  export function json(value) {
55
144
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
56
145
  }
package/src/prompt.js CHANGED
@@ -13,6 +13,7 @@
13
13
  // * every prompt can carry a timeout. A CLI blocked forever on stdin inside CI is a hung job with
14
14
  // no explanation; timing out with a sentence beats hanging with none.
15
15
  import readline from 'node:readline';
16
+ import { CliError } from './errors.js';
16
17
 
17
18
  class PromptTimeout extends Error {
18
19
  constructor() {
@@ -27,6 +28,34 @@ let closed = false;
27
28
  const waiting = []; // resolvers for prompts that have been asked but not yet answered
28
29
  const buffered = []; // lines that arrived before anything asked for them
29
30
 
31
+ // THE INTERACTIVE SESSION (session.js) rides on this same interface - it must, for the reason above -
32
+ // and needs three things a one-shot command does not. They are set here, once, so that an interface
33
+ // RE-created later (a prompt timeout closes it) still has them:
34
+ // completer Tab completion.
35
+ // onSigint In terminal mode readline SWALLOWS Ctrl+C: the process never receives SIGINT, so the
36
+ // stop handlers `logs --follow` and `deploy --follow` register on `process` would never
37
+ // fire and a tail could not be stopped. The session decides where the keypress goes.
38
+ // history Kept in this module, so it survives a re-created interface.
39
+ let session = null;
40
+ let history = [];
41
+
42
+ export function configureSession(options) {
43
+ session = options || null;
44
+ if (!session) history = [];
45
+ }
46
+
47
+ // True while a session owns standard input - `--args-file -` cannot read it then (mcpTools.js).
48
+ export function ownsStdin() {
49
+ return !!session;
50
+ }
51
+
52
+ // Readline records EVERY line it reads, and Up arrow replays them. The answer to a prompt is not a
53
+ // command: it can be a password, a one-time code, or an environment variable's value (golden rule 13).
54
+ // Only a line the session asked for as a command stays.
55
+ function forget(line) {
56
+ if (rl && Array.isArray(rl.history) && rl.history[0] === line) rl.history.shift();
57
+ }
58
+
30
59
  function iface() {
31
60
  if (!rl) {
32
61
  rl = readline.createInterface({
@@ -35,6 +64,7 @@ function iface() {
35
64
  // (`--json`, `db connect --show-url`). A prompt mixed into it corrupts exactly that output.
36
65
  output: process.stderr,
37
66
  terminal: !!process.stdin.isTTY,
67
+ ...(session ? { completer: (line) => session.completer(line), history, historySize: 200 } : {}),
38
68
  });
39
69
  closed = false;
40
70
  // We consume 'line' rather than rl.question(), and we QUEUE what nobody has asked for yet. On a
@@ -44,17 +74,77 @@ function iface() {
44
74
  // input that had already been read.
45
75
  rl.on('line', (line) => {
46
76
  const next = waiting.shift();
77
+ if (!next || !next.isCommand) forget(line);
47
78
  if (next) next(line);
48
79
  else buffered.push(line);
49
80
  });
50
81
  rl.on('close', () => {
51
82
  closed = true;
83
+ if (rl && Array.isArray(rl.history)) history = rl.history;
52
84
  while (waiting.length) waiting.shift()(''); // EOF - unblock anything still asking
53
85
  });
86
+ // Registered ONLY for a session: with no listener readline keeps its default for one-shot commands.
87
+ if (session) rl.on('SIGINT', () => session.onSigint());
54
88
  }
55
89
  return rl;
56
90
  }
57
91
 
92
+ // Standard input has ended (Ctrl+D, or the end of a pipe) and every line that arrived has been used.
93
+ export function promptsEnded() {
94
+ return closed && buffered.length === 0;
95
+ }
96
+
97
+ // What was typed while a command was running is thrown away, not run: a stray `y` typed during a log
98
+ // tail must not become the next command. Terminal only - on a pipe every line "arrives early", and
99
+ // dropping there would discard the script.
100
+ export function dropTypeAhead() {
101
+ if (process.stdin.isTTY) buffered.length = 0;
102
+ }
103
+
104
+ // Hand the terminal to another program (psql, ssh) and take it back. Readline holds the terminal in raw
105
+ // mode and reads every key; a child that inherits it would fight for each keystroke.
106
+ export function suspendPrompts() {
107
+ if (!rl) return;
108
+ rl.pause();
109
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') process.stdin.setRawMode(false);
110
+ }
111
+
112
+ export function resumePrompts() {
113
+ if (!rl) return;
114
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') process.stdin.setRawMode(true);
115
+ rl.resume();
116
+ }
117
+
118
+ // The session's own prompt. Unlike `ask`, the text is given to readline AS its prompt, so recalling a
119
+ // line with Up arrow redraws it instead of erasing it - and the line it returns is kept in history.
120
+ export function askCommand(text) {
121
+ const r = iface();
122
+ if (buffered.length) return Promise.resolve(String(buffered.shift()));
123
+ if (closed) return Promise.resolve('');
124
+ r.setPrompt(text);
125
+ r.prompt();
126
+ return new Promise((resolve) => {
127
+ const take = (line) => {
128
+ r.setPrompt('');
129
+ resolve(String(line || ''));
130
+ };
131
+ take.isCommand = true;
132
+ waiting.push(take);
133
+ });
134
+ }
135
+
136
+ // Draw the session prompt again after something was printed over it.
137
+ export function reprompt() {
138
+ if (rl) rl.prompt();
139
+ }
140
+
141
+ // Clear whatever is typed on the current line (Ctrl+C at a prompt, the way a shell does it).
142
+ export function clearTypedLine() {
143
+ if (!rl || !rl.line) return false;
144
+ rl.write(null, { ctrl: true, name: 'u' });
145
+ return true;
146
+ }
147
+
58
148
  // An open readline interface holds the event loop open, so the process would never exit. Every
59
149
  // command path closes prompts when it finishes (index.js does it centrally).
60
150
  export function closePrompts() {
@@ -84,7 +174,19 @@ function withTimeout(promise, timeoutMs) {
84
174
  });
85
175
  }
86
176
 
177
+ // NO PERSON IS HERE (--no-input, VOROA_NO_INPUT). An agent or a pipeline that reaches a question would
178
+ // otherwise sit on it until something upstream gives up, with nothing to tell it why. It gets a code
179
+ // it can act on instead. The session's own prompt is not a question and is not affected.
180
+ let noInput = false;
181
+ export function setNoInput(value) {
182
+ noInput = !!value;
183
+ }
184
+
87
185
  function question(text, { hide = false } = {}) {
186
+ if (noInput) {
187
+ const asked = String(text).replace(/[:\s]+$/, '');
188
+ throw new CliError(`This step needs a person - it asks for "${asked}". Run it yourself in a terminal, or set VOROA_TOKEN to an API token.`, 1, 'NEEDS_HUMAN');
189
+ }
88
190
  const r = iface();
89
191
  process.stderr.write(text);
90
192
 
package/src/resolve.js CHANGED
@@ -48,17 +48,17 @@ export async function resolveService(ctx, needle, { useLink = true } = {}) {
48
48
  err(`Using linked service ${linked.serviceName || linked.serviceId} (${linked.file})`);
49
49
  }
50
50
  }
51
- if (!needle) throw new CliError('Which service? Pass a service name or id, or run `voroa link <service>` in this folder.');
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
52
  const apps = await listApps(ctx);
53
53
  if (apps.length === 0) {
54
- throw new CliError('There are no services in this workspace. Check `voroa workspace list`.');
54
+ throw new CliError('There are no services in this workspace. Check `voroa workspace list`.', 1, 'NOT_FOUND');
55
55
  }
56
56
 
57
57
  const found = pickByNameOrId(apps, needle, { idOnly: !!linked });
58
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)}`);
59
+ if (found.ambiguous) throw new CliError(`Multiple services match "${needle}", use the id:\n${ambiguityList(found.ambiguous)}`, 1, 'AMBIGUOUS');
60
60
  if (linked) {
61
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
62
  }
63
- throw new CliError(`No service called "${needle}" in this workspace. Run \`voroa services\` to see them.`);
63
+ throw new CliError(`No service called "${needle}" in this workspace. Run \`voroa services\` to see them.`, 1, 'NOT_FOUND');
64
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 CHANGED
@@ -139,6 +139,27 @@ export function statusLabel(status, deploymentStatus) {
139
139
  // A verdict we do not recognise is treated as unresolved rather than as success. The failure mode
140
140
  // then is "we waited and timed out", which is honest, instead of "we called an unknown state a
141
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
+
142
163
  export const EXIT_OK = 0;
143
164
  export const EXIT_FAILED = 1;
144
165
  export const EXIT_TIMEOUT = 2;