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 +54 -0
- package/package.json +1 -1
- package/src/agent.js +95 -0
- package/src/commands/deploy.js +39 -13
- package/src/commands/logs.js +23 -4
- package/src/commands/services.js +2 -2
- package/src/commands/status.js +17 -3
- package/src/errors.js +44 -1
- package/src/index.js +28 -6
- package/src/interactive.js +6 -0
- package/src/mcpTools.js +37 -10
- package/src/output.js +94 -5
- package/src/prompt.js +102 -0
- package/src/resolve.js +4 -4
- package/src/session.js +270 -0
- package/src/status.js +21 -0
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
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
|
+
}
|
package/src/commands/deploy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
}
|
package/src/commands/logs.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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;
|
package/src/commands/services.js
CHANGED
|
@@ -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)
|
|
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
|
|
package/src/commands/status.js
CHANGED
|
@@ -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)
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
package/src/interactive.js
CHANGED
|
@@ -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
|
-
|
|
166
|
-
|
|
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)}`, '',
|
|
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(', ')}` :
|
|
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
|
-
|
|
223
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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] || '')
|
|
17
|
-
const line = (cells) => cells.map((c, i) => (i === cells.length - 1 ? c : c
|
|
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;
|