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
@@ -0,0 +1,104 @@
1
+ // `voroa deploy <service> [--wait] [--timeout <minutes>]`
2
+ //
3
+ // THE EXIT CODE IS THE PRODUCT HERE. A CLI that a pipeline calls has to answer "did it work" with a
4
+ // number, and two of the three answers are easy to get wrong:
5
+ //
6
+ // 0 the service is live
7
+ // 1 the deploy failed, or built and then failed to start
8
+ // 2 we ran out of time while it was still going
9
+ //
10
+ // 2 is deliberately NOT 0. A timeout means we stopped watching; it does not mean the deploy worked,
11
+ // and a pipeline that treats it as success ships on a coin flip.
12
+ //
13
+ // The poller reads the honest `verdict` field and nothing else. It never looks at the raw build
14
+ // status: 'live' there means the image built and the start job returned, which is precisely the lie
15
+ // known bug #24 exists to record - two of three deploys once showed "Deploy failed" while the
16
+ // container was healthy and serving, and earlier a container crash-looped for the better part of an
17
+ // hour behind a green badge.
18
+ import { redeploy, appStatus, ApiError, triggeredDeploymentUuid } from '../api.js';
19
+ import { showBuildLog } from './logs.js';
20
+ import { verdictOutcome, EXIT_TIMEOUT } from '../status.js';
21
+ import { resolveService } from '../resolve.js';
22
+ import { numberFlag } from '../flags.js';
23
+ import { out } from '../output.js';
24
+ import { CliError } from '../errors.js';
25
+
26
+ const DEFAULT_TIMEOUT_MIN = 10;
27
+ const POLL_MS = 5000;
28
+
29
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30
+
31
+ export async function deploy(ctx, { positionals, flags }) {
32
+ // VALIDATE BEFORE ANY SIDE EFFECT. `--timeout abc` used to be caught further down, after the
33
+ // deploy had already been triggered - so a typo in a flag about WAITING would still start a real
34
+ // deploy and then fail, which is worse than the silent default it replaced. A refusal has to come
35
+ // before the thing it might refuse. (Refuses rather than defaulting - see cli/src/flags.js.)
36
+ const minutes = numberFlag('--timeout', flags.timeout, { fallback: DEFAULT_TIMEOUT_MIN, min: 0 });
37
+
38
+ const app = await resolveService(ctx, positionals[0]);
39
+
40
+ const triggered = await redeploy(ctx, app.uuid);
41
+ out(`Deploy triggered for ${app.name}.`);
42
+
43
+ // --follow = the build log as it happens, then the same verdict wait as --wait (same exit codes).
44
+ // Only the uuid THIS trigger returned is followed: "the latest deploy" could be the previous one for
45
+ // a moment, and streaming someone the wrong build log is worse than streaming none.
46
+ // The deadline starts at the trigger and covers the log phase too, so --timeout means one thing.
47
+ const deadline = Date.now() + minutes * 60_000;
48
+
49
+ if (flags.follow) {
50
+ const deploymentUuid = triggeredDeploymentUuid(triggered);
51
+ if (deploymentUuid) {
52
+ // Ctrl-C while following ends the command - the deploy itself keeps running on the server.
53
+ if (await showBuildLog(ctx, app, deploymentUuid, { deadline })) {
54
+ out(`Stopped following. The deploy is still running - check \`voroa status ${app.name}\`.`);
55
+ return 0;
56
+ }
57
+ } else {
58
+ out('The build log is not available yet for this deploy - waiting for the outcome instead.');
59
+ }
60
+ }
61
+
62
+ if (!flags.wait && !flags.follow) {
63
+ out('Run `voroa status ' + app.name + '` to check on it, or add --wait next time.');
64
+ return 0;
65
+ }
66
+
67
+ out('Waiting for the deploy to finish...');
68
+
69
+ for (;;) {
70
+ // A read failure mid-wait is not a verdict - a network blip must not be reported as a failed
71
+ // deploy. But "we could not ask" and "it has not resolved yet" are DIFFERENT answers, and
72
+ // collapsing them is the mistake bug #39 records: swallowing an expired session here would spin
73
+ // out the full timeout and then report exit 2, telling a pipeline the deploy was still running
74
+ // when in fact we stopped being allowed to look. So a permanent refusal stops with its own
75
+ // sentence, and only the transient failures (network, 429, 5xx) keep the poll going.
76
+ let verdict = null;
77
+ try {
78
+ const st = await appStatus(ctx, app.uuid);
79
+ verdict = st ? st.verdict : null;
80
+ } catch (e) {
81
+ if (e instanceof ApiError && (e.httpStatus === 401 || e.httpStatus === 403 || e.httpStatus === 404)) {
82
+ throw new CliError(`${e.message} The deploy was triggered and is still running - check it from the dashboard.`);
83
+ }
84
+ verdict = null;
85
+ }
86
+
87
+ const outcome = verdictOutcome(verdict);
88
+ if (outcome.done) {
89
+ out(outcome.message);
90
+ return outcome.code;
91
+ }
92
+
93
+ const remaining = deadline - Date.now();
94
+ if (remaining <= 0) {
95
+ out(`Timed out after ${minutes} minute${minutes === 1 ? '' : 's'}. The deploy has not finished.`);
96
+ out(`This is not a failure and not a success - check \`voroa status ${app.name}\`.`);
97
+ return EXIT_TIMEOUT;
98
+ }
99
+
100
+ // Never sleep past the deadline: a --timeout is a promise about when we stop, and overshooting
101
+ // it by a poll interval makes the number in the flag a lie.
102
+ await sleep(Math.min(POLL_MS, remaining));
103
+ }
104
+ }
@@ -0,0 +1,122 @@
1
+ // `voroa domains list|add|verify [service]`
2
+ import { listDomains, addDomain, reprovisionDomain } from '../api.js';
3
+ import { resolveService } from '../resolve.js';
4
+ import { renderTable, out, json } from '../output.js';
5
+ import { CliError } from '../errors.js';
6
+
7
+ // verify: 0 live, 2 not live YET - distinct from 1 (a real error), so a script can wait on 2 only.
8
+ const EXIT_NOT_LIVE = 2;
9
+
10
+ function stateOf(d) {
11
+ // A pending wildcard routes nothing until its ownership record is checked (dashboard Verify).
12
+ if (d.ownership === 'pending') return 'waiting for ownership check';
13
+ if (d.serving) return 'serving';
14
+ if (d.routing === 'unrouted') return 'DNS ok, not serving yet';
15
+ return 'waiting for DNS';
16
+ }
17
+
18
+ // A domain's own edge state (ADR-0264), in the dashboard's words. White-label: "edge" only.
19
+ const EDGE_LABEL = { protected: 'protected', switching: 'switching on', waiting_for_dns: 'ready - change DNS',
20
+ pending: 'preparing', leaving: 'off - change DNS back', off: 'off' };
21
+ function edgeOf(d) {
22
+ return (d.edgeProtection && EDGE_LABEL[d.edgeProtection.state]) || '-';
23
+ }
24
+
25
+ function recordLine(r, suffix = '') {
26
+ // A record with no value (the service has no default address yet) would print a literal "null".
27
+ return r && r.value ? ` ${r.type} ${r.name} ${r.value}${suffix}` : null;
28
+ }
29
+
30
+ function dnsLines(d) {
31
+ if (!d.dns) return [];
32
+ // A wildcard needs TWO records (ownership + traffic) and both are required.
33
+ if (Array.isArray(d.dns)) {
34
+ const lines = d.dns.map((r) => recordLine(r)).filter(Boolean);
35
+ return lines.length ? lines : [' The DNS records are not available yet - try again once the service has deployed.'];
36
+ }
37
+ // An apex domain gets an ALIAS as the record that never goes stale; the A record is for providers without one.
38
+ const lines = [recordLine(d.dns.alias, ' (preferred, if your DNS provider supports it)'), recordLine(d.dns)].filter(Boolean);
39
+ return lines.length ? lines : [' The DNS record is not available yet - try again once the service has deployed.'];
40
+ }
41
+
42
+ export async function domains(ctx, { positionals, flags }) {
43
+ const sub = positionals[0];
44
+
45
+ if (!sub || sub === 'list') {
46
+ const app = await resolveService(ctx, positionals[1]);
47
+ const d = await listDomains(ctx, app.uuid);
48
+ if (flags.json) {
49
+ json({ service: app.name, ...d });
50
+ return 0;
51
+ }
52
+ if (d.defaultDomain) out(`Default: https://${d.defaultDomain}`);
53
+ if (!d.custom.length) out('No custom domains.');
54
+ // EDGE column only once any domain has an edge state (ADR-0264) - output is unchanged until then.
55
+ else if (d.custom.some((c) => c.edgeProtection)) {
56
+ out(renderTable(['DOMAIN', 'STATE', 'EDGE', 'ID'], d.custom.map((c) => [c.domain, stateOf(c), edgeOf(c), c.id])));
57
+ } else out(renderTable(['DOMAIN', 'STATE', 'ID'], d.custom.map((c) => [c.domain, stateOf(c), c.id])));
58
+ return 0;
59
+ }
60
+
61
+ if (sub === 'add') {
62
+ const name = positionals[1];
63
+ if (!name) throw new CliError('Which domain? `voroa domains add <domain> [service]`.');
64
+ const app = await resolveService(ctx, positionals[2]);
65
+ const added = await addDomain(ctx, app.uuid, name);
66
+ if (flags.json) {
67
+ json({ service: app.name, ...added });
68
+ return 0;
69
+ }
70
+ out(`Added ${added.domain} to ${app.name}. Create this DNS record at your DNS provider:`);
71
+ for (const l of dnsLines(added)) out(l);
72
+ out(`Then run \`voroa domains verify ${added.domain}\`. The certificate is issued automatically once DNS points here.`);
73
+ return 0;
74
+ }
75
+
76
+ if (sub === 'verify') {
77
+ const name = positionals[1];
78
+ if (!name) throw new CliError('Which domain? `voroa domains verify <domain> [service]`.');
79
+ const app = await resolveService(ctx, positionals[2]);
80
+ const d = await listDomains(ctx, app.uuid);
81
+ const row = d.custom.find((c) => c.domain.toLowerCase() === name.toLowerCase());
82
+ if (!row) throw new CliError(`${name} is not a custom domain on ${app.name}. Run \`voroa domains list\`.`);
83
+
84
+ // DNS already points here but nothing serves it: re-apply the routing and certificate, once, when
85
+ // asked. Otherwise this state never resolves on its own and verify would report it for ever.
86
+ let reprovisioned = false;
87
+ // A pending wildcard claim has no route to re-apply - its id is not a domain id - so --fix never
88
+ // touches it. Verifying ownership is a dashboard action.
89
+ if (row.ownership === 'pending') {
90
+ if (flags.json) {
91
+ json({ service: app.name, ...row, state: stateOf(row), reprovisioned });
92
+ return EXIT_NOT_LIVE;
93
+ }
94
+ out(`${row.domain}: ${stateOf(row)}`);
95
+ out('Create both records at your DNS provider, then press Verify next to the domain in your Voroa dashboard:');
96
+ for (const l of dnsLines(row)) out(l);
97
+ return EXIT_NOT_LIVE;
98
+ }
99
+ if (row.routing === 'unrouted' && flags.fix) {
100
+ await reprovisionDomain(ctx, app.uuid, row.id);
101
+ reprovisioned = true;
102
+ }
103
+ const code = row.serving ? 0 : EXIT_NOT_LIVE;
104
+ if (flags.json) {
105
+ json({ service: app.name, ...row, state: stateOf(row), reprovisioned });
106
+ return code;
107
+ }
108
+ out(`${row.domain}: ${stateOf(row)}`);
109
+ if (row.routing === 'dns_pending') {
110
+ out('DNS does not point here yet. The record to create:');
111
+ for (const l of dnsLines(row)) out(l);
112
+ out('DNS changes can take a while to spread. Run this again in a few minutes.');
113
+ } else if (reprovisioned) {
114
+ out('Routing and certificate re-applied, and the service redeploys. Run this again in a minute or two.');
115
+ } else if (row.routing === 'unrouted') {
116
+ out(`DNS is correct but the domain is not being served. Run \`voroa domains verify ${row.domain} --fix\` to re-apply it.`);
117
+ }
118
+ return code;
119
+ }
120
+
121
+ throw new CliError(`Unknown domains command "${sub}". Try \`voroa domains list\`, \`add <domain>\` or \`verify <domain>\`.`);
122
+ }
@@ -0,0 +1,143 @@
1
+ // `voroa env list|set|unset [service]`
2
+ //
3
+ // GOLDEN RULE 4 SHAPES THIS WHOLE FILE. A stored value is encrypted and write-only: it can be
4
+ // replaced, never read back - not by the dashboard, not by an API token, not by this CLI. So:
5
+ // * `list` shows NAMES. There is no value to show.
6
+ // * there is no `env pull`, and the help says why instead of leaving people to look for it.
7
+ // * `set` never takes the value from argv. A value on the command line lands in shell history and
8
+ // in the process list for every user on the box. It comes from stdin (CI) or a hidden prompt.
9
+ // * replacing an existing key needs --overwrite. Once replaced, the old value is gone for everyone.
10
+ import fs from 'node:fs';
11
+ import { listEnv, setEnv, deleteEnv, isApiToken } from '../api.js';
12
+ import { resolveService } from '../resolve.js';
13
+ import { askHidden } from '../prompt.js';
14
+ import { renderTable, out, json } from '../output.js';
15
+ import { CliError } from '../errors.js';
16
+
17
+ export const ENV_HELP = `voroa env - environment variables for a service
18
+
19
+ voroa env list [service] variable names (values are never readable)
20
+ voroa env set <KEY> [service] [--overwrite] value from stdin, or a hidden prompt
21
+ [--build-time] also available while the app builds
22
+ voroa env unset <KEY> [service]
23
+
24
+ Setting or removing a variable redeploys the service.
25
+
26
+ Values are write-only by design: they can be replaced, never displayed or downloaded.
27
+ There is no \`env pull\`. Keep the source of truth for a value wherever you created it.
28
+
29
+ echo "$DATABASE_URL" | voroa env set DATABASE_URL --overwrite`;
30
+
31
+ // The platform's own name rule (backend lib/envVars.js envEntryProblem) and no stricter - a stricter
32
+ // one would make an existing, valid variable impossible to remove from here.
33
+ function keyArg(raw) {
34
+ if (!raw) throw new CliError('Which variable? `voroa env set <KEY>`.');
35
+ if (raw.includes('=')) {
36
+ throw new CliError('Do not put the value on the command line - it would be saved in your shell history. Pipe it in: `echo "$VALUE" | voroa env set KEY`.');
37
+ }
38
+ if (/\s/.test(raw) || raw.startsWith('#')) throw new CliError('That is not a valid variable name: no spaces, and it cannot start with "#".');
39
+ return raw;
40
+ }
41
+
42
+ // The platform sets these itself and silently ignores a write to them (golden rule 7). Refused here so
43
+ // the CLI never reports "Set PORT" for a change that did not happen.
44
+ const RESERVED_KEYS = new Set(['PORT', 'NIXPACKS_NODE_VERSION']);
45
+
46
+ // Piped or redirected -> stdin, minus ONE trailing newline that `echo` adds. A terminal -> a hidden
47
+ // prompt, so the value is never echoed. Anything else (Git Bash's mintty reports "not a terminal"
48
+ // while a person is typing; a CI runner can leave stdin open with no writer) would block for ever on a
49
+ // read, so it is refused with the way forward instead.
50
+ async function readValue(key) {
51
+ if (process.stdin.isTTY) return askHidden(`Value for ${key}: `, { timeoutMs: 5 * 60_000 });
52
+ let kind;
53
+ try {
54
+ const st = fs.fstatSync(0);
55
+ // Raw mode bits, not isFIFO(): on Windows a pipe reports mode S_IFIFO (0o010000) while isFIFO() is
56
+ // false (measured on Node 24).
57
+ const type = st.mode & 0o170000;
58
+ // A socket counts too: Node's child_process stdin pipe on Linux/macOS is a socketpair, and so is
59
+ // stdin under some CI runners - refusing it failed every piped value there (caught by Linux CI).
60
+ kind = type === 0o010000 || type === 0o140000 || st.isFile() ? 'readable' : 'other';
61
+ } catch {
62
+ kind = 'other';
63
+ }
64
+ if (kind !== 'readable') {
65
+ throw new CliError(`No value to read. Pipe it in: \`echo "$VALUE" | voroa env set ${key}\` (in Git Bash, run it through \`winpty\` to be prompted instead).`);
66
+ }
67
+ let raw;
68
+ try {
69
+ raw = fs.readFileSync(0, 'utf8');
70
+ } catch (e) {
71
+ if (e && e.code === 'EOF') raw = ''; // Windows reports an empty pipe this way
72
+ else throw new CliError('Could not read the value from stdin.');
73
+ }
74
+ return raw.replace(/\r?\n$/, '');
75
+ }
76
+
77
+ export async function env(ctx, { positionals, flags }) {
78
+ const sub = positionals[0];
79
+
80
+ if (!sub || flags.help) {
81
+ out(ENV_HELP);
82
+ return sub ? 0 : 1;
83
+ }
84
+ if (sub === 'pull') {
85
+ throw new CliError('There is no `env pull`: stored values are write-only and cannot be read back. Run `voroa env --help`.');
86
+ }
87
+
88
+ if (sub === 'list') {
89
+ const app = await resolveService(ctx, positionals[1]);
90
+ const rows = await listEnv(ctx, app.uuid);
91
+ if (flags.json) {
92
+ json({ service: app.name, variables: rows.map((r) => ({ key: r.key, buildTime: r.buildTime, hasValue: true })) });
93
+ return 0;
94
+ }
95
+ if (!rows.length) {
96
+ out(`${app.name} has no environment variables.`);
97
+ return 0;
98
+ }
99
+ out(renderTable(['NAME', 'BUILD TIME'], rows.map((r) => [r.key, r.buildTime ? 'yes' : ''])));
100
+ return 0;
101
+ }
102
+
103
+ if (sub === 'set') {
104
+ const key = keyArg(positionals[1]);
105
+ if (RESERVED_KEYS.has(key)) throw new CliError(`${key} is set by the platform and cannot be changed.`);
106
+ const app = await resolveService(ctx, positionals[2]);
107
+ // Check-then-write: a key created by someone else between this read and the write below is replaced
108
+ // on a login session (the dashboard's route has no overwrite check). The API-token door does check,
109
+ // server-side. Accepted - the window is one round trip.
110
+ const existing = await listEnv(ctx, app.uuid);
111
+ const current = existing.find((r) => r.key === key);
112
+ if (current && current.platformManaged) throw new CliError(`${key} is managed by the platform and cannot be set here.`);
113
+ if (current && !flags.overwrite) {
114
+ throw new CliError(`${key} already has a value on ${app.name}. Replacing it discards the old value for good - add --overwrite if that is what you want.`);
115
+ }
116
+ const value = await readValue(key);
117
+ if (value === '') throw new CliError('No value given - nothing was changed.');
118
+ // The platform strips line breaks from a variable and still answers OK, so a multi-line value (a
119
+ // private key, pretty-printed JSON) would be stored mangled - and with --overwrite, the good one lost.
120
+ if (/[\r\n]/.test(value)) {
121
+ throw new CliError('That value spans more than one line, which an environment variable cannot hold. Store it as a secret file in the dashboard instead.');
122
+ }
123
+ await setEnv(ctx, app.uuid, [{ key, value, isBuildTime: !!flags.buildTime }], { overwrite: !!current });
124
+ if (flags.json) json({ service: app.name, key, replaced: !!current, redeployed: true });
125
+ else out(`${current ? 'Replaced' : 'Set'} ${key} on ${app.name}. A redeploy has started.`);
126
+ return 0;
127
+ }
128
+
129
+ if (sub === 'unset') {
130
+ const key = keyArg(positionals[1]);
131
+ // Removing a variable is not among what an API token may do.
132
+ if (isApiToken(ctx.token)) throw new CliError('An API token cannot remove variables. Sign in with `voroa login` to do that.');
133
+ const app = await resolveService(ctx, positionals[2]);
134
+ const row = (await listEnv(ctx, app.uuid)).find((r) => r.key === key);
135
+ if (!row) throw new CliError(`${app.name} has no variable called ${key}.`);
136
+ await deleteEnv(ctx, app.uuid, row.id);
137
+ if (flags.json) json({ service: app.name, key, removed: true });
138
+ else out(`Removed ${key} from ${app.name}.`);
139
+ return 0;
140
+ }
141
+
142
+ throw new CliError(`Unknown env command "${sub}". Run \`voroa env --help\`.`);
143
+ }
@@ -0,0 +1,40 @@
1
+ // `voroa restart|stop|start [service]` and `voroa rollback [service] [--to <deploy id>]`
2
+ import { lifecycle, rollback as rollbackCall, deployHistory } from '../api.js';
3
+ import { resolveService } from '../resolve.js';
4
+ import { out, json } from '../output.js';
5
+ import { CliError } from '../errors.js';
6
+
7
+ const DONE = { restart: 'Restarted', stop: 'Stopped', start: 'Started' };
8
+
9
+ function action(name) {
10
+ return async function run(ctx, { positionals, flags }) {
11
+ const app = await resolveService(ctx, positionals[0]);
12
+ await lifecycle(ctx, app.uuid, name);
13
+ if (flags.json) json({ service: app.name, action: name, ok: true });
14
+ else out(`${DONE[name]} ${app.name}.`);
15
+ return 0;
16
+ };
17
+ }
18
+
19
+ export const restart = action('restart');
20
+ export const stop = action('stop');
21
+ export const start = action('start');
22
+
23
+ export async function rollback(ctx, { positionals, flags }) {
24
+ const app = await resolveService(ctx, positionals[0]);
25
+ let target = null;
26
+ if (flags.to !== undefined) {
27
+ if (flags.to === true || !flags.to) throw new CliError('--to needs a deploy id. See `voroa logs <service> --build` or the dashboard.');
28
+ // Checked against this service's own history, so a typo is a sentence, not a server round-trip -
29
+ // the server still resolves the artifact from its own row either way.
30
+ const history = await deployHistory(ctx, app.uuid);
31
+ if (!history.some((d) => d.deploymentUuid === flags.to)) {
32
+ throw new CliError(`No deploy "${flags.to}" in ${app.name}'s history.`);
33
+ }
34
+ target = flags.to;
35
+ }
36
+ await rollbackCall(ctx, app.uuid, target);
37
+ if (flags.json) json({ service: app.name, rolledBackTo: target || 'previous', ok: true });
38
+ else out(`Rolling ${app.name} back to ${target ? `deploy ${target}` : 'the previous version'}. Check it with \`voroa status\`.`);
39
+ return 0;
40
+ }
@@ -0,0 +1,92 @@
1
+ // `voroa link <service>` / `voroa unlink` / `voroa open [service]`
2
+ import { spawn } from 'node:child_process';
3
+ import { resolveService } from '../resolve.js';
4
+ import { findLink, writeLink, removeLink, LINK_FILE } from '../link.js';
5
+ import { DEFAULT_API_URL } from '../config.js';
6
+ import { isApiToken } from '../api.js';
7
+ import { out, json } from '../output.js';
8
+ import { CliError } from '../errors.js';
9
+
10
+ export async function link(ctx, { positionals, flags }) {
11
+ if (!positionals[0]) throw new CliError('Which service? `voroa link <service>` - run `voroa services` to see them.');
12
+ // Resolved through the server's scoped listing, so only a service the caller owns here can be linked.
13
+ const app = await resolveService(ctx, positionals[0], { useLink: false });
14
+ // A token carries no workspace of its own on the client, so nothing is recorded for it.
15
+ const workspaceId = isApiToken(ctx.token) ? null : ctx.workspaceId;
16
+ const file = writeLink({ serviceId: app.uuid, serviceName: app.name, workspaceId });
17
+ if (flags.json) {
18
+ json({ linked: true, serviceId: app.uuid, serviceName: app.name, file });
19
+ } else {
20
+ out(`Linked this folder to ${app.name}. Commands here no longer need a service name.`);
21
+ out(`${LINK_FILE} holds ids only - it is safe to commit.`);
22
+ }
23
+ return 0;
24
+ }
25
+
26
+ export async function unlink(ctx, { flags }) {
27
+ const file = removeLink();
28
+ if (flags.json) {
29
+ json({ unlinked: !!file });
30
+ return 0;
31
+ }
32
+ if (file) {
33
+ out('Unlinked this folder.');
34
+ return 0;
35
+ }
36
+ const above = findLink();
37
+ out(above ? `No ${LINK_FILE} in this folder. The link in effect is ${above.file} - run \`voroa unlink\` there.` : 'This folder is not linked.');
38
+ return 0;
39
+ }
40
+
41
+ const DEFAULT_DASHBOARD_URL = 'https://app.getvoroa.com';
42
+
43
+ // The dashboard address is known only for the default API. For any other endpoint, guessing one
44
+ // would send the user's browser to a host we made up - so it must be given.
45
+ function dashboardUrl(ctx) {
46
+ const explicit = process.env.VOROA_DASHBOARD_URL;
47
+ if (explicit) {
48
+ let parsed;
49
+ try {
50
+ parsed = new URL(explicit);
51
+ } catch {
52
+ /* refused below */
53
+ }
54
+ if (!parsed || !['https:', 'http:'].includes(parsed.protocol)) {
55
+ throw new CliError('VOROA_DASHBOARD_URL must be an http(s) address.');
56
+ }
57
+ return explicit.replace(/\/+$/, '');
58
+ }
59
+ if (String(ctx.apiUrl).replace(/\/+$/, '').toLowerCase() === DEFAULT_API_URL) return DEFAULT_DASHBOARD_URL;
60
+ throw new CliError('The dashboard address for this API is not known. Set VOROA_DASHBOARD_URL.');
61
+ }
62
+
63
+ function launch(url) {
64
+ // NO SHELL on any platform. Windows uses the URL handler directly rather than `cmd /c start`: through
65
+ // cmd, an `&` in the URL (VOROA_DASHBOARD_URL is user-supplied) is a command separator.
66
+ const [cmd, args] =
67
+ process.platform === 'win32'
68
+ ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
69
+ : process.platform === 'darwin'
70
+ ? ['open', [url]]
71
+ : ['xdg-open', [url]];
72
+ try {
73
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
74
+ child.on('error', () => {}); // no browser / headless box: the printed URL is the answer
75
+ child.unref();
76
+ } catch {
77
+ /* same */
78
+ }
79
+ }
80
+
81
+ export async function open(ctx, { positionals, flags }) {
82
+ const app = await resolveService(ctx, positionals[0]);
83
+ // The uuid is from the server's scoped listing and URL-encoded, so nothing a user typed reaches the shell.
84
+ const url = `${dashboardUrl(ctx)}/services/${encodeURIComponent(app.uuid)}`;
85
+ if (flags.json) {
86
+ json({ url });
87
+ return 0;
88
+ }
89
+ out(url);
90
+ if (!flags.print) launch(url);
91
+ return 0;
92
+ }
@@ -0,0 +1,104 @@
1
+ // `voroa login` - email + a 6-digit code by default, email + password with --password.
2
+ //
3
+ // HONESTY ABOUT THROTTLING: the code-request endpoint is enumeration-safe. Whether the address has
4
+ // an account, whether the send was throttled, whether mail delivery is even configured - it answers
5
+ // the same generic 200 every time, on purpose. So the CLI cannot tell the user "sent" and be sure;
6
+ // what it can do is say what a missing code most likely means, which is why the send limit is named
7
+ // in the copy instead of leaving someone staring at an empty inbox blaming their spam filter.
8
+ import { ask, askHidden, PromptTimeout } from '../prompt.js';
9
+ import { otpAvailable, requestOtp, verifyOtp, passwordLogin, mfaVerify, ApiError } from '../api.js';
10
+ import { saveSettings, readConfig } from '../config.js';
11
+ import { out } from '../output.js';
12
+ import { CliError } from '../errors.js';
13
+
14
+ // The code lives for 10 minutes server-side. Waiting longer than half that is waiting for a code
15
+ // that will have expired before it can be typed.
16
+ const CODE_WAIT_MS = 5 * 60 * 1000;
17
+
18
+ export async function login(ctx, { flags }) {
19
+ const email = (await ask('Email: ')).trim();
20
+ if (!email) throw new CliError('An email address is required.');
21
+
22
+ let session;
23
+ if (flags.password) {
24
+ const password = await askHidden('Password: ');
25
+ if (!password) throw new CliError('A password is required.');
26
+ session = await passwordLogin(ctx, email, password);
27
+ } else {
28
+ if (!(await otpAvailable(ctx))) {
29
+ throw new CliError('Sign-in codes are not available right now. Try `voroa login --password`.');
30
+ }
31
+ await requestOtp(ctx, email);
32
+ out(`We have asked Voroa to email a 6-digit code to ${email}. It expires in 10 minutes.`);
33
+ out('No code? Check the spam folder - or you may have hit the send limit, in which case wait a');
34
+ out('few minutes and try again.');
35
+ out('');
36
+
37
+ let code;
38
+ try {
39
+ code = await ask('Code: ', { timeoutMs: CODE_WAIT_MS });
40
+ } catch (e) {
41
+ if (e instanceof PromptTimeout) {
42
+ throw new CliError('Stopped waiting for the code. Run `voroa login` again when you have it.');
43
+ }
44
+ throw e;
45
+ }
46
+ if (!code) throw new CliError('A code is required.');
47
+ session = await verifyOtp(ctx, email, code);
48
+ }
49
+
50
+ // Two-step verification: the first step returned a CHALLENGE, not a session. Complete it with an
51
+ // authenticator or recovery code. A passkey needs a browser, so a passkey-only account is told that
52
+ // plainly rather than being sent round the loop again.
53
+ if (session && session.mfaRequired && session.challengeToken) {
54
+ const methods = Array.isArray(session.methods) ? session.methods : [];
55
+ const method = flags.recovery ? 'recovery' : methods.includes('totp') ? 'totp' : methods.includes('recovery') ? 'recovery' : null;
56
+ if (!method || !methods.includes(method)) {
57
+ if (flags.recovery && methods.includes('totp')) {
58
+ throw new CliError('Your account has no recovery codes left. Run `voroa login` without --recovery and use your authenticator app.');
59
+ }
60
+ throw new CliError(
61
+ methods.includes('recovery')
62
+ ? 'Your account confirms sign-in with a passkey, which a terminal cannot use. Run `voroa login --recovery` with a recovery code.'
63
+ : 'Your account confirms sign-in with a passkey, which a terminal cannot use. Sign in to the dashboard and create an API token for the CLI instead.'
64
+ );
65
+ }
66
+ const code = await askHidden(method === 'totp' ? 'Authenticator code: ' : 'Recovery code: ');
67
+ if (!code) throw new CliError('A code is required.');
68
+ try {
69
+ session = await mfaVerify(ctx, session.challengeToken, method, code);
70
+ } catch (e) {
71
+ // The generic 401 text says 'your session has expired' - wrong in the middle of signing in.
72
+ if (e instanceof ApiError && e.httpStatus === 401) throw new CliError('That code was not accepted, or sign-in took too long. Run `voroa login` again.');
73
+ if (e instanceof ApiError && e.httpStatus === 429) {
74
+ throw new CliError('Too many attempts with an authenticator code. Use a recovery code (`voroa login --recovery`), or try again later.');
75
+ }
76
+ throw e;
77
+ }
78
+ }
79
+
80
+ if (!session || !session.token) throw new CliError('Sign-in did not return a session. Try again.');
81
+
82
+ const who = (session.user && session.user.email) || email;
83
+
84
+ // Store the token and the endpoint it belongs to. A token is only valid against the API that
85
+ // issued it, so persisting them together stops a later `--api-url` change from silently sending
86
+ // this credential to a different host.
87
+ //
88
+ // AND drop the selected workspace when the account or the endpoint changes. A workspace id belongs
89
+ // to the account that could see it: keeping it across a login as somebody else sends a foreign id
90
+ // in X-Workspace-Id, and the server rightly answers 403 - an opaque one, because nothing in it
91
+ // says "the problem is a stale workspace from your last login". Clearing it falls back to the
92
+ // server's own default for the new account, which is always a valid answer.
93
+ const before = readConfig();
94
+ const sameAccount = before.email && before.email === who && before.apiUrl && before.apiUrl === ctx.apiUrl;
95
+ saveSettings({
96
+ apiUrl: ctx.apiUrl,
97
+ token: session.token,
98
+ email: who,
99
+ workspaceId: sameAccount ? before.workspaceId : undefined,
100
+ });
101
+ out(`Signed in as ${who}.`);
102
+ out('Run `voroa services` to see what is running, or `voroa workspace list` to switch workspace.');
103
+ return 0;
104
+ }