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.
- package/LICENSE +21 -0
- package/README.md +193 -2
- package/package.json +41 -4
- package/src/api.js +552 -0
- package/src/commands/db.js +99 -0
- package/src/commands/deploy.js +104 -0
- package/src/commands/domains.js +122 -0
- package/src/commands/env.js +143 -0
- package/src/commands/lifecycle.js +40 -0
- package/src/commands/link.js +92 -0
- package/src/commands/login.js +104 -0
- package/src/commands/logs.js +285 -0
- package/src/commands/services.js +72 -0
- package/src/commands/ssh.js +46 -0
- package/src/commands/status.js +36 -0
- package/src/commands/tokens.js +81 -0
- package/src/commands/workspace.js +63 -0
- package/src/config.js +108 -0
- package/src/errors.js +10 -0
- package/src/flags.js +32 -0
- package/src/index.js +190 -0
- package/src/interactive.js +28 -0
- package/src/link.js +72 -0
- package/src/mcp/build.js +72 -0
- package/src/mcp/kernel/index.js +59 -0
- package/src/mcp/kernel/logger.js +28 -0
- package/src/mcp/lib/config.js +72 -0
- package/src/mcp/lib/credentials.js +51 -0
- package/src/mcp/lib/http.js +136 -0
- package/src/mcp/lib/response.js +14 -0
- package/src/mcp/lib/reversibility.js +33 -0
- package/src/mcp/reversibilityMap.json +77 -0
- package/src/mcp/tools/_action.js +49 -0
- package/src/mcp/tools/_ids.js +42 -0
- package/src/mcp/tools/_shape.js +318 -0
- package/src/mcp/tools/actions.js +94 -0
- package/src/mcp/tools/getBuildLog.js +39 -0
- package/src/mcp/tools/getDeployHistory.js +42 -0
- package/src/mcp/tools/getRuntimeLogs.js +44 -0
- package/src/mcp/tools/getServiceStatus.js +41 -0
- package/src/mcp/tools/getUsage.js +34 -0
- package/src/mcp/tools/listDatabases.js +25 -0
- package/src/mcp/tools/listDomains.js +33 -0
- package/src/mcp/tools/listServices.js +20 -0
- package/src/mcp/tools/propose.js +124 -0
- package/src/mcp/tools/reads.js +756 -0
- package/src/mcp/tools/writes.js +632 -0
- package/src/mcpTools.js +265 -0
- package/src/output.js +56 -0
- package/src/prompt.js +127 -0
- package/src/region.js +5 -0
- package/src/resolve.js +64 -0
- package/src/status.js +178 -0
- 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
|
+
}
|