voroa-cli 0.0.0-stage → 0.2.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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +193 -2
  3. package/package.json +41 -4
  4. package/src/api.js +552 -0
  5. package/src/commands/db.js +99 -0
  6. package/src/commands/deploy.js +104 -0
  7. package/src/commands/domains.js +122 -0
  8. package/src/commands/env.js +143 -0
  9. package/src/commands/lifecycle.js +40 -0
  10. package/src/commands/link.js +92 -0
  11. package/src/commands/login.js +104 -0
  12. package/src/commands/logs.js +285 -0
  13. package/src/commands/services.js +72 -0
  14. package/src/commands/ssh.js +46 -0
  15. package/src/commands/status.js +36 -0
  16. package/src/commands/tokens.js +81 -0
  17. package/src/commands/workspace.js +63 -0
  18. package/src/config.js +108 -0
  19. package/src/errors.js +10 -0
  20. package/src/flags.js +32 -0
  21. package/src/index.js +190 -0
  22. package/src/interactive.js +28 -0
  23. package/src/link.js +72 -0
  24. package/src/mcp/build.js +72 -0
  25. package/src/mcp/kernel/index.js +59 -0
  26. package/src/mcp/kernel/logger.js +28 -0
  27. package/src/mcp/lib/config.js +72 -0
  28. package/src/mcp/lib/credentials.js +51 -0
  29. package/src/mcp/lib/http.js +136 -0
  30. package/src/mcp/lib/response.js +14 -0
  31. package/src/mcp/lib/reversibility.js +33 -0
  32. package/src/mcp/reversibilityMap.json +77 -0
  33. package/src/mcp/tools/_action.js +49 -0
  34. package/src/mcp/tools/_ids.js +42 -0
  35. package/src/mcp/tools/_shape.js +318 -0
  36. package/src/mcp/tools/actions.js +94 -0
  37. package/src/mcp/tools/getBuildLog.js +39 -0
  38. package/src/mcp/tools/getDeployHistory.js +42 -0
  39. package/src/mcp/tools/getRuntimeLogs.js +44 -0
  40. package/src/mcp/tools/getServiceStatus.js +41 -0
  41. package/src/mcp/tools/getUsage.js +34 -0
  42. package/src/mcp/tools/listDatabases.js +25 -0
  43. package/src/mcp/tools/listDomains.js +33 -0
  44. package/src/mcp/tools/listServices.js +20 -0
  45. package/src/mcp/tools/propose.js +124 -0
  46. package/src/mcp/tools/reads.js +756 -0
  47. package/src/mcp/tools/writes.js +632 -0
  48. package/src/mcpTools.js +265 -0
  49. package/src/output.js +56 -0
  50. package/src/prompt.js +127 -0
  51. package/src/region.js +5 -0
  52. package/src/resolve.js +64 -0
  53. package/src/status.js +178 -0
  54. 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,10 @@
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
+ export class CliError extends Error {
5
+ constructor(message, exitCode = 1) {
6
+ super(message);
7
+ this.name = 'CliError';
8
+ this.exitCode = exitCode;
9
+ }
10
+ }
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,190 @@
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 { err, out } from './output.js';
17
+ import { closePrompts } 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
+
34
+ // Read from the manifest rather than repeated here. A hardcoded copy goes stale at the first
35
+ // release nobody remembers to edit in two places, and `--version` reporting the wrong number is the
36
+ // kind of small lie that costs an hour in a bug report. package.json ships in every npm tarball, so
37
+ // this resolves after a global install too.
38
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
39
+
40
+ const USAGE = `voroa - deploy and inspect your Voroa services
41
+
42
+ Usage
43
+ voroa login [--password] [--recovery] sign in (a 6-digit code by email, or a password)
44
+ voroa services the services in the active workspace
45
+ voroa status <service> one service's live state
46
+ voroa logs <service> [--lines N] the running container's output
47
+ voroa logs <service> --search TEXT only lines containing TEXT (last 24 hours)
48
+ voroa logs <service> --follow keep tailing it
49
+ voroa logs <service> --build [id] a build's log (the latest build with no id)
50
+ voroa deploy <service> [--wait] trigger a deploy
51
+ voroa deploy <service> --follow trigger a deploy, stream its build log, wait for the outcome
52
+ voroa restart|stop|start <service> restart, stop or start the running service
53
+ voroa rollback <service> [--to <id>] serve the previous version (or a chosen deploy) again
54
+ voroa env list|set|unset environment variables - see voroa env --help
55
+ voroa link <service> bind this folder to a service; <service> is then optional here
56
+ voroa unlink remove that binding
57
+ voroa open [service] [--print] the service's dashboard page (--print: only show the URL)
58
+ voroa db list databases in the active workspace
59
+ voroa db connect <database> open psql (asks you to confirm it is you); --show-url prints it
60
+ voroa tokens list API tokens for CI and AI clients
61
+ voroa tokens create --name <n> --scope <a,b> create one - the value is shown once
62
+ voroa tokens create ... --save keep it on this machine for the voroa tools commands instead
63
+ voroa tokens revoke <id> stop a token working immediately
64
+ voroa domains list [service] custom domains and their state
65
+ voroa domains add <domain> [service] add one and print the DNS record to create
66
+ voroa domains verify <domain> [--fix] check DNS and certificate (exit 2 until live)
67
+ voroa ssh [service] [--print] a shell in the running service
68
+ voroa workspace list your workspaces
69
+ voroa workspace set <id> choose the workspace commands act on
70
+ voroa tools every other command (projects, cron, backups, buckets, ...)
71
+ voroa <command> --help what one of those takes
72
+
73
+ Flags
74
+ --workspace <id> act on this workspace, for this command only
75
+ --api-url <url> talk to a different Voroa API
76
+ --json machine-readable output
77
+ --timeout <min> with deploy --wait, how long to wait (default 10)
78
+ --lines <n> with logs, how many lines to fetch (default 100, max 1000)
79
+ --search <text> with logs, only lines containing this text (case-insensitive)
80
+ --help --version
81
+
82
+ Exit codes
83
+ 0 succeeded (with deploy --wait: the service is live)
84
+ 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
86
+
87
+ <service> is a service name or its id, resolved inside the active workspace only.
88
+
89
+ CI
90
+ Set VOROA_TOKEN to an API token created in the dashboard (scoped, revocable, pinned to one
91
+ workspace). Sign-in, workspace switching and live build streaming need a login session instead.`;
92
+
93
+ // Flags that consume the next argument.
94
+ const VALUE_FLAGS = new Set(['--workspace', '--api-url', '--timeout', '--lines', '--search', '--to', '--name', '--scope']);
95
+ // Flags whose value is optional: `--build` alone means "the latest build".
96
+ const OPTIONAL_VALUE_FLAGS = new Set(['--build']);
97
+
98
+ export function parseArgv(argv) {
99
+ const flags = {};
100
+ const positionals = [];
101
+ for (let i = 0; i < argv.length; i++) {
102
+ const token = argv[i];
103
+ if (!token.startsWith('-')) {
104
+ positionals.push(token);
105
+ continue;
106
+ }
107
+ const [name, inline] = token.includes('=') ? [token.slice(0, token.indexOf('=')), token.slice(token.indexOf('=') + 1)] : [token, undefined];
108
+ const key = name.replace(/^--?/, '').replace(/-([a-z])/g, (_, c) => c.toUpperCase());
109
+ if (VALUE_FLAGS.has(name)) {
110
+ const value = inline !== undefined ? inline : argv[++i];
111
+ if (value === undefined) throw new CliError(`${name} needs a value.`);
112
+ flags[key] = value;
113
+ } else if (OPTIONAL_VALUE_FLAGS.has(name)) {
114
+ // Take the next token only if it is not itself a flag. A service name must therefore come
115
+ // before --build, which the usage text shows.
116
+ const next = inline !== undefined ? inline : argv[i + 1];
117
+ if (inline === undefined && next !== undefined && !next.startsWith('-')) i++;
118
+ flags[key] = inline !== undefined ? inline : next !== undefined && !next.startsWith('-') ? next : true;
119
+ } else {
120
+ flags[key] = inline !== undefined ? inline : true;
121
+ }
122
+ }
123
+ return { flags, positionals };
124
+ }
125
+
126
+ // Exported for test/mcpTools.test.js, which pins exactly which tool names these shadow.
127
+ export const COMMANDS = { login, services, status, logs, deploy, workspace, link, unlink, open, env, restart, stop, start, rollback, db, tokens, domains, ssh };
128
+
129
+ export async function run(argv) {
130
+ const { flags, positionals } = parseArgv(argv);
131
+
132
+ if (flags.version || flags.v) {
133
+ out(VERSION);
134
+ return 0;
135
+ }
136
+ // Asking for help SUCCEEDED at what was asked, with or without a command - `voroa --help` and
137
+ // `voroa services --help` must not disagree about that. Running the tool with nothing to do is the
138
+ // only usage error here.
139
+ const name = positionals[0];
140
+ const command = COMMANDS[name];
141
+ // Anything that is not a hand-written command is looked up among the customer-MCP tools
142
+ // (mcpTools.js). Those parse their own flags from their own schema, --help included. `tool <name>`
143
+ // reaches one whose short name a hand-written command already owns (restart, stop, start, rollback).
144
+ if (name && (name === 'tools' || name === 'tool' || !command)) {
145
+ const explicit = name === 'tool';
146
+ const toolName = explicit ? positionals[1] : name;
147
+ if (!toolName) throw new CliError('Which one? `voroa tools` lists them.');
148
+ const rest = argv.slice(argv.indexOf(toolName, explicit ? argv.indexOf('tool') + 1 : 0) + 1);
149
+ return runTool(readSettings({ apiUrl: flags.apiUrl, workspace: flags.workspace }), toolName, rest, { asJson: !!flags.json, explicit });
150
+ }
151
+ if (flags.help || flags.h) {
152
+ out(positionals[0] === 'env' ? ENV_HELP : USAGE);
153
+ return 0;
154
+ }
155
+ if (!name) {
156
+ out(USAGE);
157
+ return 1;
158
+ }
159
+
160
+
161
+ const settings = readSettings({ apiUrl: flags.apiUrl, workspace: flags.workspace });
162
+ const ctx = contextFrom(settings);
163
+ ctx.workspaceExplicit = !!flags.workspace;
164
+ return (await command(ctx, { positionals: positionals.slice(1), flags })) ?? 0;
165
+ }
166
+
167
+ // Only run when invoked as the binary, so tests can import `run`/`parseArgv`. pathToFileURL rather
168
+ // than a hand-built `file://` string - on Windows the drive letter makes those two differ.
169
+ const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
170
+ if (invokedDirectly) {
171
+ // `process.exitCode` and a natural end, NEVER `process.exit()`. Two reasons, both real:
172
+ // * stdout on a pipe is asynchronous, so a hard exit can truncate the last lines of output -
173
+ // which is precisely the case where output is being captured by a script.
174
+ // * on Windows, Node 24 aborts with a libuv assertion (UV_HANDLE_CLOSING in async.c) when
175
+ // process.exit() runs while fetch's pooled sockets are closing. The process then reports
176
+ // 0xC0000409 instead of our exit code - so `deploy --wait` would hand a pipeline a garbage
177
+ // status after correctly working out the right one. Verified locally on Node v24.14.0.
178
+ // Undici's idle sockets are unref'd, so nothing here keeps the process alive after the work ends.
179
+ run(process.argv.slice(2))
180
+ .then((code) => {
181
+ process.exitCode = code ?? 0;
182
+ })
183
+ .catch((e) => {
184
+ err(e && e.message ? e.message : 'Something went wrong.');
185
+ process.exitCode = e && Number.isInteger(e.exitCode) ? e.exitCode : 1;
186
+ })
187
+ // An open readline interface holds the event loop open. Closed centrally so no command has to
188
+ // remember, and so an error path cannot leave the process hanging with the exit code decided.
189
+ .finally(closePrompts);
190
+ }
@@ -0,0 +1,28 @@
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
+
12
+ export function runInteractive(cmd, args, { env, notFound } = {}) {
13
+ const ignoreSigint = () => {};
14
+ process.on('SIGINT', ignoreSigint);
15
+ return new Promise((resolve, reject) => {
16
+ // The CLI's own credential never reaches the child (ssh can forward variables with SendEnv).
17
+ const base = env || Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^VOROA_/i.test(k)));
18
+ const child = spawn(cmd, args, { stdio: 'inherit', env: base });
19
+ child.on('error', (e) => {
20
+ process.off('SIGINT', ignoreSigint);
21
+ reject(new CliError(e && e.code === 'ENOENT' ? notFound || `${cmd} was not found.` : `Could not start ${cmd}.`));
22
+ });
23
+ child.on('exit', (code) => {
24
+ process.off('SIGINT', ignoreSigint);
25
+ resolve(code ?? 1);
26
+ });
27
+ });
28
+ }
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
+ }
@@ -0,0 +1,28 @@
1
+ // Structured stderr logger - stdout is reserved for MCP protocol.
2
+ // Never log to stdout, ever. That breaks the transport.
3
+ // Never log a secret (the token) either (golden rule #13) - callers pass only
4
+ // non-secret context (method, path, tool name).
5
+ //
6
+ // Deliberate duplicate of admin-mcp/src/kernel/logger.js - see kernel/index.js for why.
7
+
8
+ function fmt(level, msg, ctx) {
9
+ const line = { ts: new Date().toISOString(), level, msg, ...ctx };
10
+ return JSON.stringify(line);
11
+ }
12
+
13
+ export function createLogger(name = 'voroa-customer-mcp') {
14
+ return {
15
+ info(ctxOrMsg, maybeMsg) {
16
+ const [msg, ctx] = typeof ctxOrMsg === 'string' ? [ctxOrMsg, {}] : [maybeMsg, ctxOrMsg];
17
+ process.stderr.write(fmt('info', msg, { logger: name, ...ctx }) + '\n');
18
+ },
19
+ warn(ctxOrMsg, maybeMsg) {
20
+ const [msg, ctx] = typeof ctxOrMsg === 'string' ? [ctxOrMsg, {}] : [maybeMsg, ctxOrMsg];
21
+ process.stderr.write(fmt('warn', msg, { logger: name, ...ctx }) + '\n');
22
+ },
23
+ error(ctxOrMsg, maybeMsg) {
24
+ const [msg, ctx] = typeof ctxOrMsg === 'string' ? [ctxOrMsg, {}] : [maybeMsg, ctxOrMsg];
25
+ process.stderr.write(fmt('error', msg, { logger: name, ...ctx }) + '\n');
26
+ },
27
+ };
28
+ }