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/status.js ADDED
@@ -0,0 +1,178 @@
1
+ // Status classify - the CLI's copy of the SPA's status vocabulary.
2
+ //
3
+ // WHY A COPY AND NOT AN IMPORT: `frontend/src/status.js` is a Vite/ESM module inside the SPA's
4
+ // package; this package ships with zero dependencies and is installed on its own. It cannot reach
5
+ // across into the frontend tree at runtime. So the two `classify` / `statusLabel` bodies below are
6
+ // a DELIBERATE, PINNED duplicate - the same arrangement `shared/planGate.mjs` has with
7
+ // `frontend/src/lib/planGate.js`.
8
+ //
9
+ // PINNED REGION - `classify` and `statusLabel` are byte-identical to the SPA's, and
10
+ // `backend/tests/cliStatusParity.test.js` fails CI the moment one side drifts from the other. It
11
+ // pins them two ways: the extracted function bodies must match character for character, AND both
12
+ // implementations must answer an adversarial case table identically. If you change a status label,
13
+ // change it in `frontend/src/status.js` and copy it here in the SAME commit.
14
+ //
15
+ // The SPA's other exports (`isStoppedStatus`, `serviceBucket`, `parseDomains`, `primaryDomain`) are
16
+ // deliberately NOT copied - the CLI has no tab filter and no domain chip row, and an unused copy is
17
+ // a copy that drifts unnoticed.
18
+ //
19
+ // Anything outside this file that decides a state from a raw status or verdict token is a rule #10
20
+ // violation and is caught by the deny-list in the same test. Import from here.
21
+
22
+ // Platform raw status "state:health" format me deta hai (e.g. "running:healthy",
23
+ // "running:unknown", "exited:unhealthy"). Raw string user ko dikhana confusing hai
24
+ // (jaise "running:unknown" -> lagta hai kuch galat hai, jabki app theek chal raha).
25
+ // Isliye saaf human label + rang me map karte hain.
26
+ //
27
+ // deploymentStatus (backend se) agar 'queued'/'in_progress' hai to build chal raha hai -
28
+ // container ka transient status (jaise "exited:unhealthy") galat hota hai, isliye usko
29
+ // override karke "Deploying..." dikhate hain.
30
+ export function classify(status, deploymentStatus, verdict) {
31
+ if (deploymentStatus === 'in_progress' || deploymentStatus === 'queued') {
32
+ return { label: 'Deploying…', tone: 'amber', live: true };
33
+ }
34
+
35
+ // Honest Deploy Verdict (build finish ke baad ka runtime gate). CONTRACT-AGNOSTIC by design: verdict
36
+ // chahe alag `verdict` field me aaye ya EXISTING `status` field me overlay ho - dono handle. `v` =
37
+ // verdict (agar diya) warna status; extended enum building|live|failed|starting|failed_to_start.
38
+ // 'starting'/'failed_to_start'/'failed' ko explicit map karo, state:health split se PEHLE.
39
+ // Render-style: runtime-fail (build theek par app up nahi hua) = "Failed" (subject = deploy);
40
+ // build-stage fail = "Build failed". Dono distinct, warna dono includes('fail') -> generic "Failed".
41
+ const v = verdict || status;
42
+ // Idle-sleep (ADR-0010): ek SLEEPABLE free app idle par so gaya (backend `verdict:'sleeping'` bhejta jab
43
+ // apps.slept_at set). NEUTRAL "Sleeping" - crash/fail NAHI (red nahi). Agle request par khud jaag jaata.
44
+ if (v === 'sleeping') return { label: 'Sleeping', tone: 'gray', live: true };
45
+ // Crash-loop suspend (ADR-0124): humne rok di kyunki wo baar-baar mar rahi thi. Ye 'failed_to_start'
46
+ // (laal) se alag hai aur 'sleeping' (gray, khud jaag jaata) se bhi - isliye AMBER: kuch toota nahi hai
47
+ // is waqt, par jab tak customer khud start nahi karta tab tak kuch chalega bhi nahi. Ek hi laal badge me
48
+ // "aapka app crash hua" aur "humne aapka app rok diya" ko mila dena customer se agla kadam chhupa deta.
49
+ if (v === 'suspended') return { label: 'Stopped by Voroa', tone: 'amber' };
50
+ if (v === 'paused_free_hours') return { label: 'Paused: free hours used', tone: 'amber' };
51
+ // ADR-0314 zero setup: service ka pehla deploy uske add-on database ka intezaar kar raha hai. Amber -
52
+ // kuch toota nahi, ek kadam baaki hai (payment ya database ka banna); service page par do button.
53
+ if (v === 'waiting_for_addon') return { label: 'Waiting for database', tone: 'amber' };
54
+ // `unknown` (ADR-0243) - EXPLICIT, not a fallthrough. The deploy is past every deadline and the
55
+ // container that could have answered is gone, so we cannot say whether the service came up. It looks
56
+ // the same as the catch-all below and must not BE the catch-all: `status.test.js` forbids any backend
57
+ // verdict reaching that line, because a verdict nobody wrote a branch for renders as an unexplained
58
+ // grey badge. This one is explained - grey and honest, not amber (nothing is pending) and not red
59
+ // (nothing is wrong). A container status, when we have one, still wins over it below.
60
+ if (v === 'unknown') return { label: 'Unknown', tone: 'gray' };
61
+ if (v === 'starting') return { label: 'Starting…', tone: 'amber', live: true };
62
+ if (v === 'failed_to_start') return { label: 'Failed', tone: 'red' };
63
+ if (v === 'failed') return { label: 'Build failed', tone: 'red' };
64
+
65
+ // Koi container status hai hi nahi. Ye NORMAL hai: platform ka apna runtime read fail ho sakta
66
+ // hai (ya container abhi swap ho raha ho) aur tab status null aata hai - wo best-effort hai.
67
+ // `verdict` ek ALAG axis pe banta hai, isliye wo abhi bhi sach hota hai - use padho, "Unknown"
68
+ // mat bolo. (Wording yahan jaan-boojh ke SPA se alag hai: ye file customer ki disk pe as-is
69
+ // install hoti hai, to yahan platform ke substrate ka naam nahi jaa sakta - golden rule 6.)
70
+ // NOTE: ye branch SIRF tab chalti hai jab status missing ho. Ek MAUJOOD status hamesha jeetta hai
71
+ // (neeche state:health parse) - warna verdict:'live' ek 'restarting:unhealthy' container ko green
72
+ // "Running" bana deta aur bug #30 ki poori honesty ulti ho jaati.
73
+ if (!status) {
74
+ if (v === 'live') return { label: 'Running', tone: 'green' };
75
+ if (v === 'building') return { label: 'Deploying…', tone: 'amber', live: true };
76
+ return { label: 'Unknown', tone: 'gray' };
77
+ }
78
+
79
+ // trim() bhi (backend lib/runtimeState.js jaisa) - engine spacing me consistent nahi hai, aur ek
80
+ // untrimmed ' unhealthy ' crash-loop branch ko chupchaap miss kar deta tha (parity guard ne pakda).
81
+ const [stateRaw, healthRaw] = String(status).split(':');
82
+ const state = (stateRaw || '').trim().toLowerCase();
83
+ const health = (healthRaw || '').trim().toLowerCase();
84
+
85
+ if (state.includes('running')) {
86
+ // "unknown"/missing health ka matlab sirf health-check off hai - app chal raha hai.
87
+ if (health === 'unhealthy') return { label: 'Unhealthy', tone: 'amber' };
88
+ return { label: 'Running', tone: 'green' };
89
+ }
90
+ if (state.includes('start') || state.includes('restart')) {
91
+ // Crash-loop honestly (bug #30): ek container jo BAAR-BAAR restart ho raha hai wo "Starting…" nahi
92
+ // hai - wo gir raha hai. Backend ise `restarting:unhealthy` bhejta hai (native container ka apna
93
+ // healthcheck FAIL, ya restart-flap detect hua). Sirf tab red - `restarting:unknown` (ek transient
94
+ // restart, health-check off) pehle jaisa "Starting…" hi rehta hai.
95
+ if (state.includes('restart') && health === 'unhealthy') {
96
+ return { label: 'Crash looping', tone: 'red' };
97
+ }
98
+ return { label: 'Starting…', tone: 'amber', live: true };
99
+ }
100
+ if (state.includes('build') || state.includes('deploy') || state.includes('queue')) {
101
+ return { label: 'Deploying…', tone: 'amber', live: true };
102
+ }
103
+ if (state.includes('degraded')) {
104
+ return { label: 'Degraded', tone: 'red' };
105
+ }
106
+ if (state.includes('error') || state.includes('fail')) {
107
+ return { label: 'Failed', tone: 'red' };
108
+ }
109
+ if (state.includes('exit') || state.includes('stop') || state.includes('dead')) {
110
+ return { label: 'Stopped', tone: 'gray' };
111
+ }
112
+ // Anjaan state - raw dikha do par clean
113
+ return { label: stateRaw || 'Unknown', tone: 'gray' };
114
+ }
115
+
116
+ // Pure label mapper (bug #3) - raw "state:health" (e.g. "running:unknown") ko kabhi user ko
117
+ // NAHI dikhate; hamesha saaf label. classify() pe delegate karta hai (single source of truth),
118
+ // sirf string label deta hai. Regression test isi ko pin karta hai.
119
+ export function statusLabel(status, deploymentStatus) {
120
+ return classify(status, deploymentStatus).label;
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------------------------
124
+ // END OF THE PINNED REGION. Everything below is CLI-only and has no counterpart in the SPA - the
125
+ // parity test extracts the two functions above by name, so additions here never affect it.
126
+ // ---------------------------------------------------------------------------------------------
127
+
128
+ // Verdict -> process exit code. This is the CLI's published contract (README "Exit codes") and it
129
+ // lives here, next to the vocabulary it reads, so no command file ever compares a verdict token
130
+ // itself.
131
+ //
132
+ // The input is ALWAYS the honest `verdict` field, NEVER the raw build `status` (bug #24: `status`
133
+ // stays the raw build state - 'live' there means "the image built and the start job returned", which
134
+ // is exactly the lie the verdict field was added to stop telling).
135
+ //
136
+ // done:true -> stop waiting, exit with `code`
137
+ // done:false -> the deploy has not resolved yet; keep polling
138
+ //
139
+ // A verdict we do not recognise is treated as unresolved rather than as success. The failure mode
140
+ // then is "we waited and timed out", which is honest, instead of "we called an unknown state a
141
+ // green deploy".
142
+ export const EXIT_OK = 0;
143
+ export const EXIT_FAILED = 1;
144
+ export const EXIT_TIMEOUT = 2;
145
+
146
+ // "Is there more build log coming?" This reads the RAW build status on purpose, and only for that
147
+ // one question - the build stream ends when the build ends, which is exactly what the raw field
148
+ // describes. It is never turned into a state shown to a user; `classify` and `verdictOutcome` own
149
+ // that, and the difference is the whole point of bug #24.
150
+ // isBuildRunning - the OTHER half of isBuildFinished, and deliberately not its negation. `!isBuildFinished`
151
+ // is TRUE for a null status, which is the "we could not read it" answer - a caller that follows on that
152
+ // waits out its own deadline instead of saying the honest thing. Callers that must keep following a live
153
+ // build (the build-log follow loop) need "we KNOW it is running", which is only this.
154
+ export function isBuildRunning(buildStatus) {
155
+ return buildStatus === 'building';
156
+ }
157
+
158
+ export function isBuildFinished(buildStatus) {
159
+ return buildStatus != null && buildStatus !== 'building';
160
+ }
161
+
162
+ export function verdictOutcome(verdict) {
163
+ if (verdict === 'live') return { done: true, code: EXIT_OK, message: 'Live.' };
164
+ if (verdict === 'failed_to_start') {
165
+ return { done: true, code: EXIT_FAILED, message: 'The build finished but the service did not start.' };
166
+ }
167
+ if (verdict === 'failed') return { done: true, code: EXIT_FAILED, message: 'The build failed.' };
168
+ // UNKNOWN IS TERMINAL, and that is the whole point of handling it here. The fallback below is
169
+ // `{done:false}`, so without this line `--wait` would poll forever on a deploy we can never judge - and
170
+ // that is not a corner case: a long build plus a wait crosses the verdict window routinely.
171
+ // EXIT_OK, not EXIT_FAILED: the same aged-out deploy exits 0 today, and failing a customer's pipeline
172
+ // over OUR missing evidence would turn our gap into their red build. The message must not reuse "Live."
173
+ // - saying nothing happened is exactly the claim we cannot make.
174
+ if (verdict === 'unknown') {
175
+ return { done: true, code: EXIT_OK, message: 'Deploy finished. We could not confirm the service came up.' };
176
+ }
177
+ return { done: false, code: null, message: null };
178
+ }
package/src/stepup.js ADDED
@@ -0,0 +1,69 @@
1
+ // "Confirm it is you" from a terminal.
2
+ //
3
+ // A few routes (revealing a database password, minting an API token) need a FRESH proof of identity
4
+ // on the session - ten minutes old at most - on top of being signed in. The server answers
5
+ // 403 { code: 'step_up_required' } when it is missing. We react to exactly that answer, ask for the
6
+ // proof, and retry the call ONCE. We never ask up front: the server decides what needs it, and a
7
+ // prompt the server did not ask for teaches people to type secrets on reflex.
8
+ //
9
+ // What can be used here, in the server's own order (services/mfa.js verifyForStepUp):
10
+ // * 2FA on -> an authenticator code, or a recovery code with --recovery. A passkey cannot be used
11
+ // from a terminal - the dashboard is the answer for a passkey-only account.
12
+ // * 2FA off -> the password, or an emailed code for an account without one.
13
+ // None of these is ever echoed, and none is kept after the call.
14
+ import { ApiError, isApiToken, securityState, reauth, reauthEmailCode } from './api.js';
15
+ import { askHidden } from './prompt.js';
16
+ import { err } from './output.js';
17
+ import { CliError } from './errors.js';
18
+
19
+ const PROMPT_MS = 5 * 60_000;
20
+
21
+ function isStepUpRequired(e) {
22
+ return e instanceof ApiError && e.httpStatus === 403 && e.code === 'step_up_required';
23
+ }
24
+
25
+ async function confirmIdentity(ctx, { recovery = false } = {}) {
26
+ const state = await securityState(ctx);
27
+ const mfa = (state && state.mfa) || {};
28
+ // stderr: stdout may be JSON or a URL a script is capturing.
29
+ err('Please confirm it is you to continue.');
30
+
31
+ let body;
32
+ if (mfa.enabled) {
33
+ if (recovery) {
34
+ body = { method: 'recovery', code: await askHidden('Recovery code: ', { timeoutMs: PROMPT_MS }) };
35
+ } else if (mfa.totp && mfa.totp.confirmed) {
36
+ body = { method: 'totp', code: await askHidden('Authenticator code: ', { timeoutMs: PROMPT_MS }) };
37
+ } else {
38
+ throw new CliError('Your account confirms with a passkey, which cannot be used from a terminal. Use --recovery with a recovery code, or do this in the dashboard.');
39
+ }
40
+ } else if (mfa.hasPassword) {
41
+ body = { method: 'password', password: await askHidden('Password: ', { timeoutMs: PROMPT_MS }) };
42
+ } else {
43
+ await reauthEmailCode(ctx);
44
+ err('We have emailed you a 6-digit code.');
45
+ body = { method: 'email_code', code: await askHidden('Code: ', { timeoutMs: PROMPT_MS }) };
46
+ }
47
+ if (!body.code && !body.password) throw new CliError('Nothing was entered - stopped.');
48
+ await reauth(ctx, body);
49
+ }
50
+
51
+ export async function withStepUp(ctx, fn, { recovery = false } = {}) {
52
+ try {
53
+ return await fn();
54
+ } catch (e) {
55
+ if (!isStepUpRequired(e)) throw e;
56
+ // A token has no session to step up on. The server refuses these routes to a token anyway; this
57
+ // just says it in words.
58
+ if (isApiToken(ctx.token)) throw new CliError('This needs a signed-in session, not an API token. Run `voroa login`.');
59
+ await confirmIdentity(ctx, { recovery });
60
+ try {
61
+ return await fn();
62
+ } catch (again) {
63
+ // Retried exactly once. A second demand means the confirmation did not stick - say that, not the
64
+ // generic "you do not have access", which would read as a permissions problem.
65
+ if (isStepUpRequired(again)) throw new CliError('The confirmation did not go through. Please try again.');
66
+ throw again;
67
+ }
68
+ }
69
+ }