voroa-cli 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +247 -2
  3. package/package.json +41 -4
  4. package/src/agent.js +95 -0
  5. package/src/api.js +552 -0
  6. package/src/commands/db.js +99 -0
  7. package/src/commands/deploy.js +130 -0
  8. package/src/commands/domains.js +122 -0
  9. package/src/commands/env.js +143 -0
  10. package/src/commands/lifecycle.js +40 -0
  11. package/src/commands/link.js +92 -0
  12. package/src/commands/login.js +104 -0
  13. package/src/commands/logs.js +304 -0
  14. package/src/commands/services.js +72 -0
  15. package/src/commands/ssh.js +46 -0
  16. package/src/commands/status.js +50 -0
  17. package/src/commands/tokens.js +81 -0
  18. package/src/commands/workspace.js +63 -0
  19. package/src/config.js +108 -0
  20. package/src/errors.js +53 -0
  21. package/src/flags.js +32 -0
  22. package/src/index.js +212 -0
  23. package/src/interactive.js +34 -0
  24. package/src/link.js +72 -0
  25. package/src/mcp/build.js +72 -0
  26. package/src/mcp/kernel/index.js +59 -0
  27. package/src/mcp/kernel/logger.js +28 -0
  28. package/src/mcp/lib/config.js +72 -0
  29. package/src/mcp/lib/credentials.js +51 -0
  30. package/src/mcp/lib/http.js +136 -0
  31. package/src/mcp/lib/response.js +14 -0
  32. package/src/mcp/lib/reversibility.js +33 -0
  33. package/src/mcp/reversibilityMap.json +77 -0
  34. package/src/mcp/tools/_action.js +49 -0
  35. package/src/mcp/tools/_ids.js +42 -0
  36. package/src/mcp/tools/_shape.js +318 -0
  37. package/src/mcp/tools/actions.js +94 -0
  38. package/src/mcp/tools/getBuildLog.js +39 -0
  39. package/src/mcp/tools/getDeployHistory.js +42 -0
  40. package/src/mcp/tools/getRuntimeLogs.js +44 -0
  41. package/src/mcp/tools/getServiceStatus.js +41 -0
  42. package/src/mcp/tools/getUsage.js +34 -0
  43. package/src/mcp/tools/listDatabases.js +25 -0
  44. package/src/mcp/tools/listDomains.js +33 -0
  45. package/src/mcp/tools/listServices.js +20 -0
  46. package/src/mcp/tools/propose.js +124 -0
  47. package/src/mcp/tools/reads.js +756 -0
  48. package/src/mcp/tools/writes.js +632 -0
  49. package/src/mcpTools.js +292 -0
  50. package/src/output.js +145 -0
  51. package/src/prompt.js +229 -0
  52. package/src/region.js +5 -0
  53. package/src/resolve.js +64 -0
  54. package/src/session.js +270 -0
  55. package/src/status.js +199 -0
  56. package/src/stepup.js +69 -0
package/src/config.js ADDED
@@ -0,0 +1,108 @@
1
+ // Where the CLI keeps its session: ~/.voroa/config.json { apiUrl, token, workspaceId }.
2
+ //
3
+ // Golden rule 13 - the token is a live credential. It is written to a file the owner alone can
4
+ // read, and it never travels anywhere else: not to argv (visible in `ps` to every user on the box),
5
+ // not to a log line, not into an error message. `readSettings` is the only reader; `api.js` puts it
6
+ // straight into an Authorization header and nothing else ever holds it.
7
+ //
8
+ // PERMISSIONS: 0700 on the directory and 0600 on the file. On Linux and macOS that is enforced by
9
+ // the kernel. On Windows it is not - `fs.chmod` there only flips the read-only attribute, so the
10
+ // file inherits the user profile's ACL (which already excludes other standard users, but is not the
11
+ // same guarantee). That difference is documented in the README rather than papered over.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import os from 'node:os';
15
+
16
+ export const DEFAULT_API_URL = 'https://api.getvoroa.com';
17
+
18
+ // VOROA_CONFIG_DIR ek escape hatch hai (tests + "ye machine share hoti hai" wale cases). Absent =
19
+ // normal ~/.voroa.
20
+ export function configDir() {
21
+ return process.env.VOROA_CONFIG_DIR || path.join(os.homedir(), '.voroa');
22
+ }
23
+
24
+ export function configPath() {
25
+ return path.join(configDir(), 'config.json');
26
+ }
27
+
28
+ // Corrupt/absent file -> {} (never a throw). A CLI that cannot start because its own cache is
29
+ // malformed is worse than one that asks you to sign in again.
30
+ export function readConfig() {
31
+ try {
32
+ const parsed = JSON.parse(fs.readFileSync(configPath(), 'utf8'));
33
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
38
+
39
+ // Write via a temp file + rename so an interrupted write cannot leave a half-written config behind.
40
+ // The temp file is created 0600 from birth - never 0644-then-chmod, which is a window where the
41
+ // token is world-readable.
42
+ export function writeConfig(next) {
43
+ const dir = configDir();
44
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
45
+ const file = configPath();
46
+ const tmp = `${file}.${process.pid}.tmp`;
47
+ fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
48
+ try {
49
+ fs.chmodSync(tmp, 0o600); // belt-and-suspenders: mode above is ignored if the file already existed
50
+ } catch {
51
+ /* Windows - documented, not fatal */
52
+ }
53
+ fs.renameSync(tmp, file);
54
+ return file;
55
+ }
56
+
57
+ // Same endpoint or not, ignoring a trailing slash and letter case.
58
+ function sameEndpoint(a, b) {
59
+ return String(a).replace(/\/+$/, '').toLowerCase() === String(b).replace(/\/+$/, '').toLowerCase();
60
+ }
61
+
62
+ // Merge the three sources, most specific first: an explicit flag, then the environment (CI), then
63
+ // the stored config, then the built-in default.
64
+ //
65
+ // A STORED TOKEN IS BOUND TO THE ENDPOINT THAT ISSUED IT. If the caller points the CLI somewhere
66
+ // else with --api-url or VOROA_API_URL, the stored token is withheld rather than sent there. That
67
+ // flag is one word in a command someone can paste from anywhere, and sending a live session token
68
+ // to whatever host it names would be a silent credential leak - the CLI would look like it was
69
+ // working while handing the token to a stranger (golden rule 13).
70
+ //
71
+ // VOROA_TOKEN still wins outright: setting it is an explicit act by whoever runs the command, which
72
+ // is exactly the CI case, and it is not read off disk.
73
+ export function readSettings({ apiUrl, workspace } = {}) {
74
+ const cfg = readConfig();
75
+ const requested = apiUrl || process.env.VOROA_API_URL || null;
76
+ const resolved = requested || cfg.apiUrl || DEFAULT_API_URL;
77
+
78
+ // A token with NO recorded endpoint is treated as belonging to the DEFAULT one, not as belonging
79
+ // to whatever the caller asks for. The first version compared only when `cfg.apiUrl` existed, so a
80
+ // hand-written or pre-upgrade config holding just a token still went anywhere `--api-url` pointed -
81
+ // the exact hole the check was added to close, left open for the one config shape we did not write
82
+ // ourselves. Assuming the default is both safe (it is where an unlabelled token most likely came
83
+ // from) and sufficient (pointing elsewhere still withholds it).
84
+ const storedEndpoint = cfg.apiUrl || DEFAULT_API_URL;
85
+ const storedIsForThisEndpoint = sameEndpoint(storedEndpoint, resolved);
86
+ const envToken = process.env.VOROA_TOKEN || null;
87
+ const storedToken = storedIsForThisEndpoint ? cfg.token || null : null;
88
+
89
+ return {
90
+ apiUrl: resolved,
91
+ token: envToken || storedToken,
92
+ // The API token `voroa tokens create --save` stored for the tool commands (mcpTools.js). Bound to
93
+ // the endpoint that MINTED it (recorded beside it, because a session supplied through VOROA_TOKEN can
94
+ // mint against an endpoint the stored config has never heard of) - the same rule as the session token
95
+ // above, and for the same reason.
96
+ apiToken: cfg.apiToken && sameEndpoint(cfg.apiTokenUrl || storedEndpoint, resolved) ? cfg.apiToken : null,
97
+ workspaceId: workspace || cfg.workspaceId || null,
98
+ // Set only when a token WAS on disk and we deliberately did not send it - lets the caller
99
+ // explain the refusal instead of a bare "you are not signed in", which would be misleading.
100
+ tokenWithheldFrom: !envToken && cfg.token && !storedIsForThisEndpoint ? storedEndpoint : null,
101
+ };
102
+ }
103
+
104
+ // Save only the keys given, keeping whatever else is already stored (so `workspace set` does not
105
+ // wipe the token and `login` does not wipe the chosen workspace).
106
+ export function saveSettings(patch) {
107
+ return writeConfig({ ...readConfig(), ...patch });
108
+ }
package/src/errors.js ADDED
@@ -0,0 +1,53 @@
1
+ // A failure the user caused or can act on: a wrong name, a missing argument, an unusable flag.
2
+ // Carries the exit code so `index.js` never has to guess one, and its message is written to be read
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
+
20
+ export class CliError extends Error {
21
+ constructor(message, exitCode = 1, code = null) {
22
+ super(message);
23
+ this.name = 'CliError';
24
+ this.exitCode = exitCode;
25
+ this.code = code;
26
+ }
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/flags.js ADDED
@@ -0,0 +1,32 @@
1
+ // Numeric flag values.
2
+ //
3
+ // `Number('abc')` is NaN and every comparison against NaN is false, so the obvious shape -
4
+ // `Number(flags.timeout) > 0 ? Number(flags.timeout) : DEFAULT` - quietly discards a typo and uses
5
+ // the default. The user asked for something specific, was given something else, and was not told:
6
+ // `--timeout abc` waited ten minutes, and so did `--timeout 0` and `--timeout -5`.
7
+ //
8
+ // One home because the same shape was in two commands (--timeout and --lines) and would be in the
9
+ // next one too (rule #10).
10
+ import { CliError } from './errors.js';
11
+
12
+ // A CLAMP and a REFUSAL are different things and this module does both on purpose:
13
+ // * out-of-range against a documented ceiling (--lines 5000, where the server's own maximum is
14
+ // 1000) is clamped, because the ceiling is published and clamping is what it means.
15
+ // * unparseable or nonsensical (--lines abc, --timeout 0) is REFUSED, because there is no
16
+ // defensible value to substitute and guessing one is how the flag stopped meaning anything.
17
+ export function numberFlag(name, raw, { fallback, min = 0, max = null, clampToMax = false, integer = false } = {}) {
18
+ if (raw === undefined || raw === true) return fallback; // flag absent, or given with no value
19
+ const n = Number(String(raw).trim());
20
+ if (!Number.isFinite(n)) throw new CliError(`${name} takes a number. Got "${raw}".`);
21
+ // A count of lines has no fractional meaning, and the consumer would quietly round it: `--lines 0.5`
22
+ // survived the checks above and then became 1 downstream. That is the same silent substitution this
23
+ // module exists to stop, just one layer further down. --timeout is deliberately NOT integer-only -
24
+ // half a minute is a real thing to ask for.
25
+ if (integer && !Number.isInteger(n)) throw new CliError(`${name} takes a whole number. Got "${raw}".`);
26
+ if (n <= min) throw new CliError(`${name} must be greater than ${min}. Got "${raw}".`);
27
+ if (max !== null && n > max) {
28
+ if (!clampToMax) throw new CliError(`${name} cannot be more than ${max}. Got "${raw}".`);
29
+ return max;
30
+ }
31
+ return n;
32
+ }
package/src/index.js ADDED
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env node
2
+ // voroa - the Voroa command line interface.
3
+ //
4
+ // Everything a command needs arrives as one `ctx` (endpoint, token, workspace) built here from
5
+ // flags, environment and stored config in that order of precedence. A command never reads config or
6
+ // the environment itself, so `--workspace` genuinely applies to exactly one invocation.
7
+ //
8
+ // One error path: a command either returns an exit code or throws. Nothing here prints a stack
9
+ // trace to a user - a stack is for a log, and this output goes on a screen and into tickets
10
+ // (golden rule 13: it is also the last place a token could accidentally surface, so the catch below
11
+ // prints a message and never an error object).
12
+ import { readFileSync } from 'node:fs';
13
+ import { pathToFileURL } from 'node:url';
14
+ import { readSettings } from './config.js';
15
+ import { contextFrom } from './api.js';
16
+ import { out, reportError } from './output.js';
17
+ import { closePrompts, setNoInput } from './prompt.js';
18
+ import { CliError } from './errors.js';
19
+ import { login } from './commands/login.js';
20
+ import { services } from './commands/services.js';
21
+ import { status } from './commands/status.js';
22
+ import { logs } from './commands/logs.js';
23
+ import { deploy } from './commands/deploy.js';
24
+ import { workspace } from './commands/workspace.js';
25
+ import { link, unlink, open } from './commands/link.js';
26
+ import { env, ENV_HELP } from './commands/env.js';
27
+ import { restart, stop, start, rollback } from './commands/lifecycle.js';
28
+ import { db } from './commands/db.js';
29
+ import { tokens } from './commands/tokens.js';
30
+ import { domains } from './commands/domains.js';
31
+ import { ssh } from './commands/ssh.js';
32
+ import { runTool } from './mcpTools.js';
33
+ import { startSession, wantsSession } from './session.js';
34
+ import { agent } from './agent.js';
35
+
36
+ // Read from the manifest rather than repeated here. A hardcoded copy goes stale at the first
37
+ // release nobody remembers to edit in two places, and `--version` reporting the wrong number is the
38
+ // kind of small lie that costs an hour in a bug report. package.json ships in every npm tarball, so
39
+ // this resolves after a global install too.
40
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
41
+
42
+ const USAGE = `voroa - deploy and inspect your Voroa services
43
+
44
+ Usage
45
+ voroa login [--password] [--recovery] sign in (a 6-digit code by email, or a password)
46
+ voroa services the services in the active workspace
47
+ voroa status <service> one service's live state
48
+ voroa status --all every service's state
49
+ voroa logs <service> [--lines N] the running container's output
50
+ voroa logs <service> --search TEXT only lines containing TEXT (last 24 hours)
51
+ voroa logs <service> --follow keep tailing it
52
+ voroa logs <service> --build [id] a build's log (the latest build with no id)
53
+ voroa deploy <service> [--wait] trigger a deploy
54
+ voroa deploy <service> --follow trigger a deploy, stream its build log, wait for the outcome
55
+ voroa restart|stop|start <service> restart, stop or start the running service
56
+ voroa rollback <service> [--to <id>] serve the previous version (or a chosen deploy) again
57
+ voroa env list|set|unset environment variables - see voroa env --help
58
+ voroa link <service> bind this folder to a service; <service> is then optional here
59
+ voroa unlink remove that binding
60
+ voroa open [service] [--print] the service's dashboard page (--print: only show the URL)
61
+ voroa db list databases in the active workspace
62
+ voroa db connect <database> open psql (asks you to confirm it is you); --show-url prints it
63
+ voroa tokens list API tokens for CI and AI clients
64
+ voroa tokens create --name <n> --scope <a,b> create one - the value is shown once
65
+ voroa tokens create ... --save keep it on this machine for the voroa tools commands instead
66
+ voroa tokens revoke <id> stop a token working immediately
67
+ voroa domains list [service] custom domains and their state
68
+ voroa domains add <domain> [service] add one and print the DNS record to create
69
+ voroa domains verify <domain> [--fix] check DNS and certificate (exit 2 until live)
70
+ voroa ssh [service] [--print] a shell in the running service
71
+ voroa workspace list your workspaces
72
+ voroa workspace set <id> choose the workspace commands act on
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)
76
+ voroa <command> --help what one of those takes
77
+
78
+ Flags
79
+ --workspace <id> act on this workspace, for this command only
80
+ --api-url <url> talk to a different Voroa API
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)
83
+ --timeout <min> with deploy --wait, how long to wait (default 10)
84
+ --lines <n> with logs, how many lines to fetch (default 100, max 1000)
85
+ --search <text> with logs, only lines containing this text (case-insensitive)
86
+ --help --version
87
+
88
+ Exit codes
89
+ 0 succeeded (with deploy --wait: the service is live)
90
+ 1 failed (including a deploy that failed or did not start)
91
+ 2 deploy --wait ran out of time while the deploy was still going; domains verify: not live yet
92
+
93
+ <service> is a service name or its id, resolved inside the active workspace only.
94
+
95
+ CI
96
+ Set VOROA_TOKEN to an API token created in the dashboard (scoped, revocable, pinned to one
97
+ workspace). Sign-in, workspace switching and live build streaming need a login session instead.`;
98
+
99
+ // Flags that consume the next argument.
100
+ const VALUE_FLAGS = new Set(['--workspace', '--api-url', '--timeout', '--lines', '--search', '--to', '--name', '--scope']);
101
+ // Flags whose value is optional: `--build` alone means "the latest build".
102
+ const OPTIONAL_VALUE_FLAGS = new Set(['--build']);
103
+
104
+ export function parseArgv(argv) {
105
+ const flags = {};
106
+ const positionals = [];
107
+ for (let i = 0; i < argv.length; i++) {
108
+ const token = argv[i];
109
+ if (!token.startsWith('-')) {
110
+ positionals.push(token);
111
+ continue;
112
+ }
113
+ const [name, inline] = token.includes('=') ? [token.slice(0, token.indexOf('=')), token.slice(token.indexOf('=') + 1)] : [token, undefined];
114
+ const key = name.replace(/^--?/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase());
115
+ if (VALUE_FLAGS.has(name)) {
116
+ const value = inline !== undefined ? inline : argv[++i];
117
+ if (value === undefined) throw new CliError(`${name} needs a value.`);
118
+ flags[key] = value;
119
+ } else if (OPTIONAL_VALUE_FLAGS.has(name)) {
120
+ // Take the next token only if it is not itself a flag. A service name must therefore come
121
+ // before --build, which the usage text shows.
122
+ const next = inline !== undefined ? inline : argv[i + 1];
123
+ if (inline === undefined && next !== undefined && !next.startsWith('-')) i++;
124
+ flags[key] = inline !== undefined ? inline : next !== undefined && !next.startsWith('-') ? next : true;
125
+ } else {
126
+ flags[key] = inline !== undefined ? inline : true;
127
+ }
128
+ }
129
+ return { flags, positionals };
130
+ }
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
+
138
+ // Exported for test/mcpTools.test.js, which pins exactly which tool names these shadow.
139
+ export const COMMANDS = { login, services, status, logs, deploy, workspace, link, unlink, open, env, restart, stop, start, rollback, db, tokens, domains, ssh };
140
+
141
+ export async function run(argv) {
142
+ const { flags, positionals } = parseArgv(argv);
143
+
144
+ if (flags.version || flags.v) {
145
+ out(VERSION);
146
+ return 0;
147
+ }
148
+ // Asking for help SUCCEEDED at what was asked, with or without a command - `voroa --help` and
149
+ // `voroa services --help` must not disagree about that. Running the tool with nothing to do is the
150
+ // only usage error here.
151
+ setNoInput(flags.noInput || process.env.VOROA_NO_INPUT);
152
+ const name = positionals[0];
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
+ }
160
+ // Anything that is not a hand-written command is looked up among the customer-MCP tools
161
+ // (mcpTools.js). Those parse their own flags from their own schema, --help included. `tool <name>`
162
+ // reaches one whose short name a hand-written command already owns (restart, stop, start, rollback).
163
+ if (name && (name === 'tools' || name === 'tool' || !command)) {
164
+ const explicit = name === 'tool';
165
+ const toolName = explicit ? positionals[1] : name;
166
+ if (!toolName) throw new CliError('Which one? `voroa tools` lists them.');
167
+ const rest = argv.slice(argv.indexOf(toolName, explicit ? argv.indexOf('tool') + 1 : 0) + 1);
168
+ return runTool(readSettings({ apiUrl: flags.apiUrl, workspace: flags.workspace }), toolName, rest, { asJson: !!flags.json, explicit });
169
+ }
170
+ if (flags.help || flags.h) {
171
+ out(positionals[0] === 'env' ? ENV_HELP : USAGE);
172
+ return 0;
173
+ }
174
+ if (!name) {
175
+ out(USAGE);
176
+ return 1;
177
+ }
178
+
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
+
183
+ const settings = readSettings({ apiUrl: flags.apiUrl, workspace: flags.workspace });
184
+ const ctx = contextFrom(settings);
185
+ ctx.workspaceExplicit = !!flags.workspace;
186
+ return (await command(ctx, { positionals: positionals.slice(1), flags })) ?? 0;
187
+ }
188
+
189
+ // Only run when invoked as the binary, so tests can import `run`/`parseArgv`. pathToFileURL rather
190
+ // than a hand-built `file://` string - on Windows the drive letter makes those two differ.
191
+ const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
192
+ if (invokedDirectly) {
193
+ // `process.exitCode` and a natural end, NEVER `process.exit()`. Two reasons, both real:
194
+ // * stdout on a pipe is asynchronous, so a hard exit can truncate the last lines of output -
195
+ // which is precisely the case where output is being captured by a script.
196
+ // * on Windows, Node 24 aborts with a libuv assertion (UV_HANDLE_CLOSING in async.c) when
197
+ // process.exit() runs while fetch's pooled sockets are closing. The process then reports
198
+ // 0xC0000409 instead of our exit code - so `deploy --wait` would hand a pipeline a garbage
199
+ // status after correctly working out the right one. Verified locally on Node v24.14.0.
200
+ // Undici's idle sockets are unref'd, so nothing here keeps the process alive after the work ends.
201
+ run(process.argv.slice(2))
202
+ .then((code) => {
203
+ process.exitCode = code ?? 0;
204
+ })
205
+ .catch((e) => {
206
+ reportError(e, { asJson: process.argv.includes('--json') });
207
+ process.exitCode = e && Number.isInteger(e.exitCode) ? e.exitCode : 1;
208
+ })
209
+ // An open readline interface holds the event loop open. Closed centrally so no command has to
210
+ // remember, and so an error path cannot leave the process hanging with the exit code decided.
211
+ .finally(closePrompts);
212
+ }
@@ -0,0 +1,34 @@
1
+ // Hand the terminal to another program (psql, ssh) and return its exit code.
2
+ //
3
+ // One home because two commands need exactly this and a copy drifts (rule 10). While the child runs,
4
+ // Ctrl-C belongs to IT - cancelling a query, interrupting a remote command - so this process ignores
5
+ // SIGINT instead of dying and orphaning the child. Released on `exit` (where the result settles) or
6
+ // on `error`.
7
+ //
8
+ // No shell, ever: `args` are passed straight to the program, so nothing in them is interpreted.
9
+ import { spawn } from 'node:child_process';
10
+ import { CliError } from './errors.js';
11
+ import { suspendPrompts, resumePrompts } from './prompt.js';
12
+
13
+ export function runInteractive(cmd, args, { env, notFound } = {}) {
14
+ const ignoreSigint = () => {};
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();
19
+ return new Promise((resolve, reject) => {
20
+ // The CLI's own credential never reaches the child (ssh can forward variables with SendEnv).
21
+ const base = env || Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^VOROA_/i.test(k)));
22
+ const child = spawn(cmd, args, { stdio: 'inherit', env: base });
23
+ child.on('error', (e) => {
24
+ process.off('SIGINT', ignoreSigint);
25
+ resumePrompts();
26
+ reject(new CliError(e && e.code === 'ENOENT' ? notFound || `${cmd} was not found.` : `Could not start ${cmd}.`));
27
+ });
28
+ child.on('exit', (code) => {
29
+ process.off('SIGINT', ignoreSigint);
30
+ resumePrompts();
31
+ resolve(code ?? 1);
32
+ });
33
+ });
34
+ }
package/src/link.js ADDED
@@ -0,0 +1,72 @@
1
+ // `.voroa.json` - binds a folder to a service so `voroa deploy` / `logs` / `status` need no name.
2
+ //
3
+ // WHAT IT HOLDS: a service id, its name (for messages only) and the workspace id it was linked in.
4
+ // All three are NON-SECRET identifiers - the file is meant to be safe to commit, and nothing that
5
+ // authenticates anyone is ever written here (golden rule 13; the session lives in ~/.voroa, 0600).
6
+ //
7
+ // WHAT IT IS NOT: an ownership claim. The id is only a needle handed to `resolveService`, which
8
+ // still matches it against the server's own scoped listing on every command (golden rule 2,
9
+ // bug #14), and by uuid ONLY - never by name - so a `.voroa.json` copied from someone else's repo
10
+ // resolves to "not in this workspace" even when you own a service whose name equals its id.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import os from 'node:os';
14
+ import { CliError } from './errors.js';
15
+
16
+ export const LINK_FILE = '.voroa.json';
17
+
18
+ // Walk up from `from` like git does, so the link works from a subfolder of the project. Two stops:
19
+ // * the FIRST `.voroa.json` found decides. A broken one is an error, never skipped - skipping it
20
+ // would silently hand the decision to a parent folder's link and deploy a different service.
21
+ // * the home folder and the filesystem root are never read, so a stray `.voroa.json` there cannot
22
+ // choose for every project below it. Other shared parents (e.g. /tmp) ARE read - a project can
23
+ // legitimately live there; what makes a planted link harmless is that it matches by uuid only and
24
+ // the server still checks ownership.
25
+ // Windows paths compare case-insensitively: `c:\users\x` and `C:\Users\x` are the same folder, and a
26
+ // case-sensitive compare let the walk go straight past home.
27
+ export function samePath(a, b, platform = process.platform) {
28
+ return platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
29
+ }
30
+
31
+ export function findLink(from = process.cwd()) {
32
+ const home = path.resolve(os.homedir());
33
+ let dir = path.resolve(from);
34
+ for (;;) {
35
+ const parent = path.dirname(dir);
36
+ if (samePath(dir, home) || parent === dir) return null;
37
+ const file = path.join(dir, LINK_FILE);
38
+ if (fs.existsSync(file)) {
39
+ let parsed = null;
40
+ try {
41
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
42
+ } catch {
43
+ /* reported below */
44
+ }
45
+ if (!parsed || typeof parsed.serviceId !== 'string' || !parsed.serviceId) {
46
+ throw new CliError(`${file} is not a valid link. Run \`voroa link <service>\` again, or delete it.`);
47
+ }
48
+ return {
49
+ file,
50
+ serviceId: parsed.serviceId,
51
+ serviceName: typeof parsed.serviceName === 'string' ? parsed.serviceName : null,
52
+ workspaceId: typeof parsed.workspaceId === 'string' && parsed.workspaceId ? parsed.workspaceId : null,
53
+ };
54
+ }
55
+ dir = parent;
56
+ }
57
+ }
58
+
59
+ export function writeLink({ serviceId, serviceName, workspaceId }, dir = process.cwd()) {
60
+ const file = path.join(dir, LINK_FILE);
61
+ fs.writeFileSync(file, `${JSON.stringify({ serviceId, serviceName, workspaceId: workspaceId || null }, null, 2)}\n`);
62
+ return file;
63
+ }
64
+
65
+ // Removes the link in THIS folder only. Walking up to delete a parent's file from a subfolder is
66
+ // the kind of helpfulness that deletes something the user was not looking at.
67
+ export function removeLink(dir = process.cwd()) {
68
+ const file = path.join(dir, LINK_FILE);
69
+ if (!fs.existsSync(file)) return null;
70
+ fs.rmSync(file);
71
+ return file;
72
+ }
@@ -0,0 +1,72 @@
1
+ // Builds the kernel with all tools + the shared HTTP client wired in. Kept separate from server.js so
2
+ // tests can exercise the kernel without the MCP SDK (transport) and with an injected fetch/logger - no
3
+ // network, no credentials.
4
+ //
5
+ // env / fetchImpl / logger are injectable for tests: pass a mock fetch to assert the bearer is attached
6
+ // and never logged, with no real HTTP.
7
+
8
+ import { createKernel } from './kernel/index.js';
9
+ import { createLogger } from './kernel/logger.js';
10
+ import { loadConfig } from './lib/config.js';
11
+ import { createHttpClient } from './lib/http.js';
12
+ import { isReversible } from './lib/reversibility.js';
13
+
14
+ import { listServices } from './tools/listServices.js';
15
+ import { getServiceStatus } from './tools/getServiceStatus.js';
16
+ import { getDeployHistory } from './tools/getDeployHistory.js';
17
+ import { getBuildLog } from './tools/getBuildLog.js';
18
+ import { getRuntimeLogs } from './tools/getRuntimeLogs.js';
19
+ import { listDomains } from './tools/listDomains.js';
20
+ import { listDatabases } from './tools/listDatabases.js';
21
+ import { getUsage } from './tools/getUsage.js';
22
+ import { redeploy, restart, stop, start, rollback, cancelDeploy, purgeEdgeCache, scanDisk } from './tools/actions.js';
23
+ import { READ_TOOLS } from './tools/reads.js';
24
+ import { WRITE_TOOLS } from './tools/writes.js';
25
+ import { PROPOSE_TOOLS } from './tools/propose.js';
26
+
27
+ export function buildKernel({ env = process.env, fetchImpl, logger = createLogger('voroa-customer-mcp') } = {}) {
28
+ const kernel = createKernel({ logger });
29
+
30
+ // Read config fresh on each call so a rotated token is picked up without a restart.
31
+ const getConfig = () => loadConfig(env);
32
+ const http = createHttpClient({ getConfig, logger, fetchImpl });
33
+
34
+ kernel.setContext({ http, getConfig, env, logger, isReversible });
35
+
36
+ // READ tier (services:read)
37
+ kernel.registerTool(listServices);
38
+ kernel.registerTool(getServiceStatus);
39
+ kernel.registerTool(getDeployHistory);
40
+ kernel.registerTool(getBuildLog);
41
+ kernel.registerTool(getRuntimeLogs);
42
+ kernel.registerTool(listDomains);
43
+ kernel.registerTool(listDatabases);
44
+ kernel.registerTool(getUsage);
45
+
46
+ // Phase B read parity - grouped in tools/reads.js (see its header for why one file, and for the
47
+ // conservative projection rule every one of them follows).
48
+ for (const t of READ_TOOLS) kernel.registerTool(t);
49
+
50
+ // Phase C configuration tools (services:write), plus set_maintenance_mode which is services:act - see
51
+ // tools/writes.js for why the tier is "destroys no RESOURCE" rather than "reversible".
52
+ for (const t of WRITE_TOOLS) kernel.registerTool(t);
53
+
54
+ // Phase D (services:admin). Both are reversible:true because PROPOSING changes nothing - the
55
+ // irreversible thing they ask for happens only when a person approves it, on a surface this token
56
+ // cannot reach.
57
+ for (const t of PROPOSE_TOOLS) kernel.registerTool(t);
58
+
59
+ // ACTION tier (services:act) - every one REVERSIBLE (ADR-0060 D8). Nothing that deletes a resource,
60
+ // writes an environment variable, or touches billing exists on this surface at all.
61
+ kernel.registerTool(redeploy);
62
+ kernel.registerTool(restart);
63
+ kernel.registerTool(stop);
64
+ kernel.registerTool(start);
65
+ kernel.registerTool(rollback);
66
+ kernel.registerTool(cancelDeploy);
67
+ // Wave 2 (plan 2026-10-27): the edge cache refills, the disk scan reads.
68
+ kernel.registerTool(purgeEdgeCache);
69
+ kernel.registerTool(scanDisk);
70
+
71
+ return kernel;
72
+ }
@@ -0,0 +1,59 @@
1
+ // Kernel: a thin tool registry. Knows nothing about specific tools or the HTTP
2
+ // transport. Adding a tool = new file in src/tools/ + one registry line in build.js.
3
+ //
4
+ // DELIBERATE DUPLICATE of admin-mcp/src/kernel/index.js (rule #10 requires the reason
5
+ // be written down, so: these are two independently-installable npm packages with
6
+ // deliberately SEPARATE blast radii - the same separation ADR-0013 chose when it
7
+ // refused to add write tools to the read-only mcp/ package. A shared kernel would
8
+ // mean a customer's laptop installs a package that also carries the staff ops
9
+ // server's code, and a cross-package import between two `bin` entrypoints with no
10
+ // build step is a packaging problem, not a centralisation win. The file is 50 lines
11
+ // with no branching; if it ever grows logic worth sharing, extract it then.)
12
+
13
+ export function createKernel({ logger }) {
14
+ const tools = new Map();
15
+ let ctx = { logger };
16
+
17
+ return {
18
+ logger,
19
+
20
+ // Shared context handed to every tool handler (http client, logger,
21
+ // isReversible). Set once in build.js.
22
+ setContext(extra) {
23
+ ctx = { ...ctx, ...extra };
24
+ },
25
+
26
+ registerTool(tool) {
27
+ if (tools.has(tool.name)) {
28
+ throw new Error(`tool already registered: ${tool.name}`);
29
+ }
30
+ tools.set(tool.name, tool);
31
+ },
32
+
33
+ listTools() {
34
+ return [...tools.values()].map((t) => ({
35
+ name: t.name,
36
+ description: t.description,
37
+ inputSchema: t.inputSchema,
38
+ }));
39
+ },
40
+
41
+ getTool(name) {
42
+ return tools.get(name) || null;
43
+ },
44
+
45
+ toolNames() {
46
+ return [...tools.keys()];
47
+ },
48
+
49
+ async callTool(name, args) {
50
+ const tool = tools.get(name);
51
+ if (!tool) {
52
+ const err = new Error(`unknown tool: ${name}`);
53
+ err.code = 'NOT_FOUND';
54
+ throw err;
55
+ }
56
+ return tool.handler(args ?? {}, ctx);
57
+ },
58
+ };
59
+ }