@zenifra/cli 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,7 +43,7 @@ zenifra auth logout
43
43
  zenifra auth logout --revoke
44
44
  zenifra profile list
45
45
  zenifra profile show
46
- zenifra profile add --name staging --description Homologacao --api-base https://api-stg.zenifra.com/v1 --mode api-key --key znf_sua_chave
46
+ zenifra profile add --name staging --description Homologacao --api-base https://api.example.test/v1 --mode api-key --key znf_sua_chave
47
47
  zenifra profile use staging
48
48
  zenifra profile edit staging --description "Homologacao interna"
49
49
  zenifra profile remove staging
@@ -72,6 +72,7 @@ zenifra valkey credentials status --project <project-id> --operation <operation-
72
72
  zenifra project url --project <project-id>
73
73
  zenifra project logs --project <project-id> --instance <instance-id>
74
74
  zenifra project metrics --project <project-id> --instance <instance-id>
75
+ zenifra project metrics capabilities --project <project-id>
75
76
  zenifra project network --project <project-id> --view summary
76
77
  zenifra project image set --project <project-id> --image ghcr.io/zenifra/app:tag
77
78
  zenifra project envs --project <project-id>
@@ -93,6 +94,41 @@ zenifra deploy --project <project-id> --branch main
93
94
  zenifra deploy watch --project <project-id> --build <build-id>
94
95
  ```
95
96
 
97
+ ## Metricas de projetos Valkey
98
+
99
+ O comando `zenifra project metrics` mostra as metricas do projeto e, para projetos Valkey, o snapshot da instancia selecionada:
100
+
101
+ ```bash
102
+ zenifra project metrics --project <project-id> --instance instance-1
103
+ ```
104
+
105
+ A saida legivel inclui:
106
+
107
+ - recursos: CPU e memoria da instancia;
108
+ - capacidade: memoria usada, pico, limite e fragmentacao;
109
+ - clientes conectados e bloqueados;
110
+ - atividade: operacoes por segundo e trafego de entrada/saida;
111
+ - ciclo de vida: chaves expiradas e removidas;
112
+ - uptime e perfil (`cache`, `key-value` ou `queue`);
113
+ - confiabilidade: replicacao e persistencia, quando disponiveis.
114
+
115
+ Perfis especificos podem incluir:
116
+
117
+ - `cache`: hits, misses e hit ratio;
118
+ - `key-value`: total de chaves e chaves com expiracao;
119
+ - `queue`: metricas comuns, sem estimar profundidade quando ela nao esta disponivel.
120
+
121
+ Para consultar o nivel de acesso e os grupos disponiveis antes de buscar um snapshot:
122
+
123
+ ```bash
124
+ zenifra project metrics capabilities --project <project-id>
125
+ zenifra project metrics capabilities --project <project-id> --json
126
+ ```
127
+
128
+ `--json` preserva a resposta publica da API. Valores ausentes sao exibidos como `indisponivel` no modo legivel; `0` continua sendo um valor valido. O historico nao esta disponivel neste momento.
129
+
130
+ ---
131
+
96
132
  ## Builds GitHub
97
133
 
98
134
  Use `zenifra builds` para listar o historico de builds e `zenifra builds logs` para ler os logs do pipeline GitHub de um build especifico.
@@ -114,12 +150,49 @@ Fluxos:
114
150
 
115
151
  Se voce rodar apenas `zenifra deploy`, a CLI mostra a ajuda especifica do comando com uso, flags, exemplos e exemplo de retorno.
116
152
 
153
+ ## Login pelo navegador (OAuth)
154
+
155
+ Enderecos em `example.test` sao ficticios; substitua pela API autorizada do seu ambiente.
156
+
157
+ Use uma API com o login OAuth da CLI habilitado. O comando abre o Console para voce
158
+ entrar, concluir a verificacao de seguranca e aprovar o acesso da CLI a sua conta:
159
+
160
+ ```bash
161
+ zenifra auth login --oauth --profile staging --api-base https://api.example.test/v1
162
+ zenifra orgs
163
+ zenifra org set --org <id>
164
+ zenifra projects
165
+ ```
166
+
167
+ Para executar a copia local deste repositorio, substitua `zenifra` por
168
+ `node bin/zenifra.mjs`. Para uma API local, use seu endereco loopback, por exemplo
169
+ `--api-base http://127.0.0.1:3000/v1`. Enderecos externos exigem HTTPS.
170
+
171
+ - `--read-only` solicita somente leitura; as permissoes da sua organizacao continuam valendo.
172
+ - `--no-browser` mostra o endereco para abrir manualmente no navegador da mesma maquina.
173
+ - O retorno usa uma porta local temporaria em `127.0.0.1`; nao precisa cadastrar uma porta fixa.
174
+ - O login aguarda ate tres minutos. Use `Ctrl+C` para cancelar.
175
+ - Os tokens ficam no perfil local privado e sao renovados automaticamente antes de expirar.
176
+ - Nao altere `--api-base` ou `ZENIFRA_API_URL` para reutilizar o token em outra API: crie outro perfil.
177
+ - `ZENIFRA_API_KEY` continua tendo prioridade e gera um aviso quando substitui um perfil OAuth.
178
+
179
+ ```bash
180
+ zenifra auth logout # remove apenas a autenticacao local
181
+ zenifra auth logout --revoke # revoga somente a conexao OAuth deste perfil
182
+ ```
183
+
184
+ Sem `--oauth`, `auth login` mantem o login por email, senha e verificacao.
185
+ Uma falha ao renovar a sessao exige novo login; a CLI nao repete automaticamente
186
+ operacoes de alteracao. Se um comando for encerrado abruptamente e deixar
187
+ `profiles.lock`, verifique o PID indicado nesse arquivo e remova o lock somente
188
+ depois de confirmar que o comando terminou.
189
+
117
190
  ## Configuracao
118
191
 
119
192
  - API padrao: `https://api.zenifra.com/v1`
120
- - Override de API: `ZENIFRA_API_URL=https://api-stg.zenifra.com/v1`
193
+ - Override de API: `ZENIFRA_API_URL=https://api.example.test/v1`
121
194
  - API key global: `ZENIFRA_API_KEY=znf_sua_chave`
122
- - Timeout de cada request HTTP: `ZENIFRA_HTTP_TIMEOUT_MS=30000`
195
+ - Timeout padrao de cada request HTTP: 5 minutos (`ZENIFRA_HTTP_TIMEOUT_MS=300000`). A variavel permite ajustar esse limite.
123
196
  - Store local de perfis: `~/.config/zenifra-cli/profiles.json`
124
197
  - Override do diretorio local: `ZENIFRA_CONFIG_DIR=/path/custom`
125
198
 
@@ -149,12 +222,13 @@ A CLI agora trabalha com um perfil ativo. Cada perfil pode ter:
149
222
  - credencial:
150
223
  - `api_key` para automacao organizacional
151
224
  - `access_token` para login pessoal, com `selectedOrganizationId`
225
+ - `oauth` para login pelo navegador, com renovacao automatica e organizacao selecionada
152
226
 
153
227
  Exemplos:
154
228
 
155
229
  ```bash
156
230
  zenifra profile add --name prod --description Producao --api-base https://api.zenifra.com/v1 --mode api-key --key znf_sua_chave
157
- zenifra profile add --name staging --description Homologacao --api-base https://api-stg.zenifra.com/v1 --mode login
231
+ zenifra profile add --name staging --description Homologacao --api-base https://api.example.test/v1 --mode login
158
232
  zenifra profile list
159
233
  zenifra profile show staging
160
234
  zenifra profile use prod
@@ -166,7 +240,7 @@ Regras de precedencia:
166
240
  - `ZENIFRA_API_URL` sobrescreve a API base do perfil ativo apenas para a execucao atual
167
241
  - `auth login` e `auth api-key` operam no perfil ativo por padrao
168
242
  - `auth login --profile <name>` e `auth api-key --profile <name>` atualizam ou criam outro perfil e o tornam ativo
169
- - `auth logout` remove somente a autenticacao local; `auth logout --revoke` tambem invalida as sessoes de login do usuario no servidor
243
+ - `auth logout` remove somente a autenticacao local; `auth logout --revoke` revoga somente a conexao do perfil OAuth, ou as sessoes do usuario para login por senha
170
244
  - `auth logout --revoke` exige um perfil autenticado por login e nao revoga API keys
171
245
 
172
246
  Migracao:
@@ -248,10 +322,10 @@ Observacoes do wizard:
248
322
 
249
323
  ## Regressao manual de auto-scaling em staging
250
324
 
251
- O teste de staging cria projetos, gera trafego, consulta consumo e remove somente os projetos criados pela propria execucao. Ele rejeita a API de producao e exige habilitacao explicita das mutacoes.
325
+ O teste de staging cria projetos, gera trafego, consulta consumo e remove somente os projetos criados pela propria execucao. Ele exige uma API de teste explicita, rejeita a API de producao e exige habilitacao explicita das mutacoes. O exemplo abaixo usa uma API local de teste.
252
326
 
253
327
  ```bash
254
- export ZENIFRA_API_URL_STG=https://api-stg.zenifra.com/v1
328
+ export ZENIFRA_API_URL_STG=http://127.0.0.1:3000/v1
255
329
  export ZENIFRA_API_KEY_STG=znf_sua_chave_de_staging
256
330
  export ZENIFRA_STAGING_ALLOW_MUTATIONS=1
257
331
  npm run test:staging:autoscaling
@@ -0,0 +1,65 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const style = `
4
+ * { box-sizing: border-box; }
5
+ :root { color-scheme: dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
6
+ body { margin: 0; min-height: 100vh; min-height: 100svh; display: grid; place-items: center; background: #09090b; color: #fafafa; padding: 32px 20px; }
7
+ .page { width: min(100%, 480px); }
8
+ .brand { display: flex; align-items: center; justify-content: center; gap: 9px; margin-bottom: 36px; font-size: 23px; font-weight: 650; letter-spacing: -.8px; }
9
+ .brand svg { width: 34px; height: 34px; fill: currentColor; }
10
+ .card { padding: 42px 36px 32px; border: 1px solid #29292f; border-radius: 24px; background: #111114; box-shadow: 0 24px 80px #0005; text-align: center; }
11
+ .status { display: grid; place-items: center; width: 76px; height: 76px; margin: 0 auto 28px; border-radius: 50%; color: #6ee7b7; background: #142d25; border: 1px solid #2a5344; box-shadow: 0 0 0 8px #142d2545; }
12
+ .status svg { width: 34px; height: 34px; }
13
+ .cancelled .status { color: #d4d4d8; background: #232329; border-color: #3f3f46; box-shadow: 0 0 0 8px #23232965; }
14
+ .eyebrow { margin: 0 0 12px; color: #a1a1aa; font-size: 11px; font-weight: 650; letter-spacing: 2px; text-transform: uppercase; }
15
+ h1 { margin: 0; font-size: clamp(27px, 5vw, 34px); line-height: 1.2; font-weight: 650; letter-spacing: -1.1px; }
16
+ .description { margin: 16px auto 28px; max-width: 340px; color: #a1a1aa; font-size: 15px; line-height: 1.7; }
17
+ .next { display: flex; align-items: center; gap: 14px; padding: 18px; text-align: left; background: #1a1a1f; border: 1px solid #303036; border-radius: 12px; }
18
+ .terminal { flex: 0 0 auto; display: grid; place-items: center; width: 40px; height: 40px; border: 1px solid #3a3a42; border-radius: 9px; background: #24242b; }
19
+ .terminal svg { width: 21px; height: 21px; }
20
+ .next strong { display: block; font-size: 14px; font-weight: 600; }
21
+ .next p { margin: 5px 0 0; color: #a1a1aa; font-size: 12px; line-height: 1.6; }
22
+ .close { margin: 25px 0 0; color: #a1a1aa; font-size: 12px; line-height: 1.6; }
23
+ .footer { margin: 24px 0 0; text-align: center; color: #a1a1aa; font-size: 12px; }
24
+ @media (max-width: 420px) { .card { padding: 34px 22px 26px; } .brand { margin-bottom: 28px; } .next { padding: 14px; gap: 11px; } }
25
+ `;
26
+
27
+ export const oauthPagePolicy = `default-src 'none'; style-src 'sha256-${createHash('sha256').update(style).digest('base64')}'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`;
28
+
29
+ export function oauthCallbackPage(cancelled = false) {
30
+ const title = cancelled ? 'Login cancelado' : 'Autorização recebida';
31
+ const description = cancelled
32
+ ? 'Você cancelou a autorização. Nenhum novo acesso foi concedido ao CLI.'
33
+ : 'Sua autorização foi enviada ao Zenifra CLI. Continue no terminal para concluir o login.';
34
+ const icon = cancelled ? '<path d="m8 8 8 8M16 8l-8 8"/>' : '<path d="m5 12 4 4L19 6"/>';
35
+ return `<!doctype html>
36
+ <html lang="pt-BR">
37
+ <head>
38
+ <meta charset="utf-8">
39
+ <meta name="viewport" content="width=device-width, initial-scale=1">
40
+ <meta name="color-scheme" content="dark">
41
+ <title>${title} · Zenifra CLI</title>
42
+ <style>${style}</style>
43
+ </head>
44
+ <body>
45
+ <main class="page${cancelled ? ' cancelled' : ''}">
46
+ <div class="brand" aria-label="Zenifra">
47
+ <svg viewBox="0 0 1000 1000" aria-hidden="true"><path d="M298.27,353.7l-82.91,229.13c82.68-78.72,163.4-112.86,290.82-112.86h3.22c126.61-1.58,264.38-96.29,323.98-218.5h-395.9c-56.45,0-118.78,45.77-139.21,102.22Z"/><path d="M493.82,530.02h-3.22c-126.61,1.58-264.38,96.29-323.98,218.5h395.9c56.45,0,118.78-45.77,139.21-102.22l82.91-229.13c-82.68,78.72-163.4,112.86-290.82,112.86Z"/></svg>
48
+ <span>zenifra</span>
49
+ </div>
50
+ <section class="card" aria-labelledby="title">
51
+ <div class="status" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${icon}</svg></div>
52
+ <p class="eyebrow">Zenifra CLI</p>
53
+ <h1 id="title">${title}</h1>
54
+ <p class="description">${description}</p>
55
+ <div class="next">
56
+ <span class="terminal" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="m5 7 5 5-5 5M13 17h6"/></svg></span>
57
+ <div><strong>Volte ao seu terminal</strong><p>${cancelled ? 'Inicie um novo login quando quiser.' : 'A confirmação do login aparecerá por lá.'}</p></div>
58
+ </div>
59
+ <p class="close">Você já pode fechar esta aba.</p>
60
+ </section>
61
+ <p class="footer">Seu próximo passo está no terminal.</p>
62
+ </main>
63
+ </body>
64
+ </html>`;
65
+ }
@@ -0,0 +1,152 @@
1
+ import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { spawn } from 'node:child_process';
4
+ import { oauthCallbackPage, oauthPagePolicy } from './oauth-page.mjs';
5
+
6
+ const CLIENT_ID = 'zenifra-cli';
7
+ const fail = () => new Error('Resposta OAuth invalida. Tente fazer login novamente.');
8
+ export function oauthTarget(apiBase) {
9
+ let url;
10
+ try { url = new URL(apiBase); } catch { throw fail(); }
11
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/v1'
12
+ || !(url.protocol === 'https:' || (url.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(url.hostname)))) {
13
+ throw new Error('OAuth exige uma API HTTPS com caminho /v1 (HTTP permitido somente em loopback local).');
14
+ }
15
+ return { issuer: url.origin, resource: `${url.origin}/v1`, clientId: CLIENT_ID };
16
+ }
17
+ export function validateOAuthProfile(oauth, apiBase) {
18
+ const target = oauthTarget(apiBase);
19
+ if (!oauth || oauth.issuer !== target.issuer || oauth.resource !== target.resource || oauth.clientId !== CLIENT_ID) {
20
+ throw new Error('O perfil OAuth pertence a outra API. Use a API original ou faca login em outro perfil.');
21
+ }
22
+ return target;
23
+ }
24
+ function checkCancelled(signal) {
25
+ if (signal?.aborted) throw new Error('Login OAuth cancelado.');
26
+ }
27
+ async function jsonRequest(url, options = {}, signal) {
28
+ checkCancelled(signal);
29
+ const controller = new AbortController();
30
+ const cancel = () => controller.abort();
31
+ const timer = setTimeout(cancel, 30_000);
32
+ signal?.addEventListener('abort', cancel, { once: true });
33
+ try {
34
+ let response;
35
+ try { response = await fetch(url, { ...options, redirect: 'error', signal: controller.signal }); }
36
+ catch { checkCancelled(signal); throw new Error('Nao foi possivel conectar ao OAuth. Tente novamente.'); }
37
+ checkCancelled(signal);
38
+ if (!response.ok) {
39
+ await response.body?.cancel();
40
+ throw new Error('OAuth recusou a solicitacao. Faca login novamente; nenhuma operacao foi repetida.');
41
+ }
42
+ const reader = response.body.getReader(); let length = 0; const chunks = [];
43
+ try {
44
+ while (true) {
45
+ const { done, value } = await reader.read(); if (done) break;
46
+ length += value.length; if (length > 65536) throw fail(); chunks.push(value);
47
+ }
48
+ checkCancelled(signal);
49
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
50
+ } catch { await reader.cancel().catch(() => {}); checkCancelled(signal); throw fail(); }
51
+ } finally {
52
+ clearTimeout(timer);
53
+ signal?.removeEventListener('abort', cancel);
54
+ }
55
+ }
56
+
57
+ function tokens(value, target, previousScope) {
58
+ const scope = value?.scope ?? previousScope;
59
+ if (!value || typeof value.access_token !== 'string' || !value.access_token || value.access_token.length > 16384
60
+ || /\s/.test(value.access_token) || typeof value.refresh_token !== 'string' || !value.refresh_token || value.refresh_token.length > 16384
61
+ || /\s/.test(value.refresh_token) || String(value.token_type).toLowerCase() !== 'bearer'
62
+ || !Number.isSafeInteger(value.expires_in) || value.expires_in <= 0 || value.expires_in > 86400
63
+ || typeof scope !== 'string' || !scope.split(' ').includes('cli:read')
64
+ || scope.split(' ').some(s => !['cli:read', 'cli:write', 'offline_access'].includes(s))) throw fail();
65
+ return { authMode: 'oauth', accessToken: value.access_token, apiKey: undefined,
66
+ oauth: { ...target, refreshToken: value.refresh_token, expiresAt: Date.now() + value.expires_in * 1000, scope } };
67
+ }
68
+ async function tokenRequest(target, parameters, previousScope, signal) {
69
+ const value = await jsonRequest(`${target.issuer}/v1/oauth/token`, {
70
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
71
+ body: new URLSearchParams({ client_id: CLIENT_ID, resource: target.resource, ...parameters }),
72
+ }, signal);
73
+ checkCancelled(signal);
74
+ const result = tokens(value, target, previousScope);
75
+ if (previousScope && result.oauth.scope.split(' ').some(s => !previousScope.split(' ').includes(s))) throw fail();
76
+ return result;
77
+ }
78
+ export async function refreshOAuth(oauth) {
79
+ const target = validateOAuthProfile(oauth, oauth.resource);
80
+ if (!oauth.refreshToken) throw fail();
81
+ return tokenRequest(target, { grant_type: 'refresh_token', refresh_token: oauth.refreshToken }, oauth.scope);
82
+ }
83
+ export async function revokeOAuth(oauth, apiBase) {
84
+ const target = validateOAuthProfile(oauth, apiBase);
85
+ let response;
86
+ try {
87
+ response = await fetch(`${target.issuer}/v1/oauth/revoke`, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30_000),
88
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
89
+ body: new URLSearchParams({ client_id: CLIENT_ID, token: oauth.refreshToken, token_type_hint: 'refresh_token' }) });
90
+ } catch { throw new Error('Nao foi possivel revogar a conexao OAuth. O perfil foi preservado.'); }
91
+ await response.body?.cancel();
92
+ if (!response.ok) throw new Error('A revogacao OAuth falhou. O perfil foi preservado.');
93
+ }
94
+ export function openBrowser(url) {
95
+ const executable = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32.exe' : 'xdg-open';
96
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
97
+ return new Promise(resolve => {
98
+ const child = spawn(executable, args, { shell: false, stdio: 'ignore', windowsHide: true });
99
+ const timer = setTimeout(() => { child.unref(); resolve(false); }, 3000);
100
+ child.once('error', () => { clearTimeout(timer); resolve(false); }); child.once('exit', code => { clearTimeout(timer); resolve(code === 0); });
101
+ });
102
+ }
103
+ export async function oauthLogin(apiBase, { readOnly = false, onAuthorize, timeoutMs = 180_000, signal } = {}) {
104
+ const target = oauthTarget(apiBase);
105
+ const metadata = await jsonRequest(`${target.issuer}/.well-known/oauth-authorization-server`, {}, signal);
106
+ if (metadata.issuer !== target.issuer || metadata.authorization_endpoint !== `${target.issuer}/v1/oauth/authorize`
107
+ || metadata.token_endpoint !== `${target.issuer}/v1/oauth/token` || metadata.revocation_endpoint !== `${target.issuer}/v1/oauth/revoke`
108
+ || !metadata.code_challenge_methods_supported?.includes('S256') || metadata.authorization_response_iss_parameter_supported !== true) throw fail();
109
+ const state = randomBytes(32).toString('base64url'); const verifier = randomBytes(48).toString('base64url');
110
+ const scope = readOnly ? 'cli:read offline_access' : 'cli:read cli:write offline_access';
111
+ let resolveCode, rejectCode, accepted = false, port;
112
+ const pending = new Promise((resolve, reject) => { resolveCode = resolve; rejectCode = reject; });
113
+ // Attach a handler before invoking asynchronous browser launch.
114
+ pending.catch(() => {});
115
+ const server = createServer({ maxHeaderSize: 8192 }, (req, res) => {
116
+ res.setHeader('Cache-Control', 'no-store'); res.setHeader('Content-Type', 'text/plain; charset=utf-8');
117
+ res.setHeader('Content-Security-Policy', "default-src 'none'"); res.setHeader('Referrer-Policy', 'no-referrer');
118
+ const reject = () => { res.writeHead(400); res.end('Callback OAuth invalido.'); };
119
+ if (accepted || req.method !== 'GET' || req.headers.host !== `127.0.0.1:${port}` || !req.url?.startsWith('/oauth/callback?') || req.url.length > 8192) return reject();
120
+ const url = new URL(req.url, `http://127.0.0.1:${port}`); const p = url.searchParams;
121
+ if (url.pathname !== '/oauth/callback' || [...p.keys()].some(k => !['code','state','iss','error','error_description','error_uri'].includes(k) || p.getAll(k).length !== 1)) return reject();
122
+ const candidate = Buffer.from(p.get('state') || '');
123
+ if (candidate.length !== state.length || !timingSafeEqual(candidate, Buffer.from(state)) || p.get('iss') !== target.issuer
124
+ || (p.has('code') === p.has('error')) || (p.has('code') && !/^[\x21-\x7e]{1,2048}$/.test(p.get('code')))) return reject();
125
+ accepted = true;
126
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
127
+ res.setHeader('Content-Security-Policy', oauthPagePolicy);
128
+ res.setHeader('X-Content-Type-Options', 'nosniff');
129
+ res.end(oauthCallbackPage(p.has('error')));
130
+ if (p.has('error')) rejectCode(new Error('Login OAuth cancelado ou recusado.')); else resolveCode(p.get('code'));
131
+ });
132
+ const sockets = new Set();
133
+ server.on('connection', socket => { sockets.add(socket); socket.once('close', () => sockets.delete(socket)); });
134
+ server.requestTimeout = 10_000; server.headersTimeout = 10_000;
135
+ const cancel = () => rejectCode(new Error('Login OAuth cancelado.'));
136
+ let timer;
137
+ try {
138
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
139
+ port = server.address().port;
140
+ const redirectUri = `http://127.0.0.1:${port}/oauth/callback`;
141
+ const authorize = new URL(metadata.authorization_endpoint);
142
+ authorize.search = new URLSearchParams({ client_id: CLIENT_ID, response_type: 'code', redirect_uri: redirectUri, resource: target.resource,
143
+ scope, state, code_challenge: createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256' }).toString();
144
+ timer = setTimeout(() => rejectCode(new Error('Tempo de login OAuth esgotado. Tente novamente.')), timeoutMs);
145
+ signal?.addEventListener('abort', cancel, { once: true }); if (signal?.aborted) cancel();
146
+ await onAuthorize(authorize.toString());
147
+ const code = await pending;
148
+ return await tokenRequest(target, { grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: redirectUri }, scope, signal);
149
+ } finally {
150
+ clearTimeout(timer); signal?.removeEventListener('abort', cancel); for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(resolve));
151
+ }
152
+ }
@@ -0,0 +1,56 @@
1
+ import { mkdir, chmod, open, rename, unlink } from 'node:fs/promises';
2
+ import { unlinkSync, lstatSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+
6
+ export async function withProfileLock(directory, action, { timeoutMs = 35_000, signal } = {}) {
7
+ signal?.throwIfAborted();
8
+ await mkdir(directory, { recursive: true, mode: 0o700 });
9
+ await chmod(directory, 0o700);
10
+ const path = join(directory, 'profiles.lock');
11
+ const deadline = Date.now() + timeoutMs;
12
+ let handle;
13
+ while (!handle) {
14
+ signal?.throwIfAborted();
15
+ try { handle = await open(path, 'wx', 0o600); }
16
+ catch (error) {
17
+ if (error.code !== 'EEXIST') throw error;
18
+ if (Date.now() >= deadline) throw new Error(`Perfis ocupados: ${path}. Aguarde o outro comando. Se ele terminou, verifique o PID no arquivo e remova somente esse lock antes de tentar novamente.`);
19
+ await new Promise(resolve => setTimeout(resolve, 50));
20
+ }
21
+ }
22
+ const identity = await handle.stat();
23
+ const cleanup = () => {
24
+ try {
25
+ const current = lstatSync(path);
26
+ if (current.ino === identity.ino && current.dev === identity.dev) unlinkSync(path);
27
+ } catch {}
28
+ };
29
+ const interrupt = () => { cleanup(); process.exit(130); };
30
+ process.once('exit', cleanup);
31
+ if (!signal) { process.once('SIGINT', interrupt); process.once('SIGTERM', interrupt); }
32
+ try {
33
+ signal?.throwIfAborted();
34
+ await handle.writeFile(String(process.pid));
35
+ signal?.throwIfAborted();
36
+ return await action();
37
+ }
38
+ finally {
39
+ process.removeListener('exit', cleanup); process.removeListener('SIGINT', interrupt); process.removeListener('SIGTERM', interrupt);
40
+ cleanup(); await handle.close();
41
+ }
42
+ }
43
+ export async function atomicPrivateWrite(path, content, { signal } = {}) {
44
+ signal?.throwIfAborted();
45
+ const temporary = `${path}.${randomUUID()}.tmp`;
46
+ const handle = await open(temporary, 'wx', 0o600);
47
+ try {
48
+ signal?.throwIfAborted();
49
+ await handle.writeFile(content, 'utf8');
50
+ await handle.sync();
51
+ await handle.close();
52
+ signal?.throwIfAborted();
53
+ await rename(temporary, path);
54
+ }
55
+ finally { await handle.close().catch(() => {}); await unlink(temporary).catch(() => {}); }
56
+ }
package/bin/zenifra.mjs CHANGED
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { randomUUID } from 'node:crypto';
4
- import { mkdir, readFile, rm, writeFile, chmod } from 'node:fs/promises';
4
+ import { readFile, rm } from 'node:fs/promises';
5
5
  import { existsSync } from 'node:fs';
6
- import { dirname, join } from 'node:path';
6
+ import { join } from 'node:path';
7
7
  import { homedir } from 'node:os';
8
8
  import process from 'node:process';
9
9
  import readline from 'node:readline/promises';
10
+ import { oauthLogin, openBrowser, validateOAuthProfile, refreshOAuth, revokeOAuth } from './lib/oauth.mjs';
11
+ import { withProfileLock, atomicPrivateWrite } from './lib/profile-store.mjs';
10
12
 
11
13
  const DEFAULT_API_BASE_URL = 'https://api.zenifra.com/v1';
12
14
  const DOCS_BASE_URL = 'https://docs.zenifra.com/pt';
@@ -23,9 +25,12 @@ const SESSION_FILE = join(SESSION_DIR, 'session.json');
23
25
  const PROFILES_FILE = join(SESSION_DIR, 'profiles.json');
24
26
  const PROFILE_STORE_VERSION = 1;
25
27
  const DEFAULT_PROFILE_NAME = 'default';
26
- const DEFAULT_HTTP_TIMEOUT_MS = 30_000;
28
+ const DEFAULT_HTTP_TIMEOUT_MS = 300_000;
27
29
  const KNOWN_FLAG_NAMES = new Set([
28
30
  'apiBase',
31
+ 'oauth',
32
+ 'readOnly',
33
+ 'noBrowser',
29
34
  'branch',
30
35
  'build',
31
36
  'challengeToken',
@@ -58,6 +63,7 @@ const KNOWN_FLAG_NAMES = new Set([
58
63
  'operation',
59
64
  'page',
60
65
  'password',
66
+ 'path',
61
67
  'paymentMode',
62
68
  'plan',
63
69
  'profile',
@@ -158,7 +164,7 @@ function usage() {
158
164
 
159
165
  Usage:
160
166
  zenifra help <command>
161
- zenifra auth login [--profile <name>] [--api-base <url>] [--code <code>]
167
+ zenifra auth login [--profile <name>] [--api-base <url>] [--code <code>] [--oauth] [--read-only] [--no-browser]
162
168
  zenifra auth api-key --key <znf_key> [--profile <name>] [--api-base <url>]
163
169
  zenifra auth logout [--profile <name>] [--revoke]
164
170
  zenifra profile list [--json]
@@ -181,6 +187,11 @@ Usage:
181
187
  zenifra project url --project <id> [--json]
182
188
  zenifra project logs --project <id> [--instance <id>] [--json]
183
189
  zenifra project metrics --project <id> [--instance <id>] [--json]
190
+ zenifra project metrics capabilities --project <id> [--json]
191
+ zenifra project healthcheck get --project <id> [--json]
192
+ zenifra project healthcheck set --project <id> --path /health [--json]
193
+ zenifra project healthcheck disable --project <id> [--json]
194
+ zenifra project healthcheck failures --project <id> [--page <n>] [--limit <n>] [--json]
184
195
  zenifra project network --project <id> [--view <summary|status-codes|routes|user-agents|request-events|source-ips>] [--json]
185
196
  zenifra project image set --project <id> --image <image> [--json]
186
197
  zenifra project exposure set --project <id> --exposure <public|private> [--json]
@@ -213,7 +224,7 @@ Run "zenifra help <command>" or "zenifra <command> --help" for command-specific
213
224
  const HELP_SPECS = [
214
225
  {
215
226
  command: 'auth',
216
- usage: 'zenifra auth\n zenifra auth login [--profile <name>] [--api-base <url>] [--code <code>]\n zenifra auth api-key --key <znf_key> [--profile <name>] [--api-base <url>]\n zenifra auth logout [--profile <name>] [--revoke]',
227
+ usage: 'zenifra auth\n zenifra auth login [--profile <name>] [--api-base <url>] [--code <code>] [--oauth] [--read-only] [--no-browser]\n zenifra auth api-key --key <znf_key> [--profile <name>] [--api-base <url>]\n zenifra auth logout [--profile <name>] [--revoke]',
217
228
  description: 'Gerencia a autenticacao dos perfis locais da CLI.',
218
229
  examples: ['zenifra auth', 'zenifra auth login --profile staging', 'zenifra auth api-key --profile prod --key znf_0123456789abcdef01234567_abcd...'],
219
230
  output: 'Zenifra CLI - auth',
@@ -221,9 +232,12 @@ const HELP_SPECS = [
221
232
  },
222
233
  {
223
234
  command: 'auth login',
224
- usage: 'zenifra auth login [--profile <name>] [--api-base <url>] [--code <code>]',
235
+ usage: 'zenifra auth login [--profile <name>] [--api-base <url>] [--code <code>] [--oauth] [--read-only] [--no-browser]',
225
236
  description: 'Autentica um usuario Zenifra no perfil ativo ou em um perfil especifico.',
226
237
  flags: [
238
+ '--oauth Entra pelo navegador usando OAuth e PKCE.',
239
+ '--read-only Solicita somente leitura com --oauth.',
240
+ '--no-browser Mostra o endereco sem abrir o navegador com --oauth.',
227
241
  '--profile <name> Atualiza ou cria o perfil informado e o torna ativo.',
228
242
  '--api-base <url> Usa uma API diferente da producao.',
229
243
  '--code <code> Informa o codigo de desafio sem prompt interativo.',
@@ -245,7 +259,7 @@ const HELP_SPECS = [
245
259
  command: 'auth logout',
246
260
  usage: 'zenifra auth logout [--profile <name>] [--revoke]',
247
261
  description: 'Remove a autenticacao do perfil ativo ou do perfil informado.',
248
- flags: ['--profile <name> Limpa outro perfil sem trocar o perfil ativo.', '--revoke Revoga as sessoes de usuario no servidor antes de limpar o perfil.'],
262
+ flags: ['--profile <name> Limpa outro perfil sem trocar o perfil ativo.', '--revoke Revoga a conexao OAuth deste perfil, ou as sessoes do login por senha, antes de limpar o perfil.'],
249
263
  examples: ['zenifra auth logout', 'zenifra auth logout --profile staging', 'zenifra auth logout --revoke'],
250
264
  output: 'Autenticacao removida do perfil active.',
251
265
  notes: ['Sem --revoke, remove somente a credencial local. A revogacao invalida as sessoes de login do usuario e nao se aplica a API keys.'],
@@ -254,7 +268,7 @@ const HELP_SPECS = [
254
268
  command: 'profile',
255
269
  usage: 'zenifra profile\n zenifra profile list [--json]\n zenifra profile show [<name>] [--json]\n zenifra profile add [--name <name>] [--description <text>] [--api-base <url>] [--mode <api-key|login>] [--key <znf_key>] [--json]\n zenifra profile edit <name> [--description <text>] [--api-base <url>] [--json]\n zenifra profile use <name> [--json]\n zenifra profile remove <name> [--json]',
256
270
  description: 'Gerencia perfis de ambiente locais, incluindo API base, descricao e credenciais.',
257
- examples: ['zenifra profile', 'zenifra profile list', 'zenifra profile add --name staging --description Homologacao --api-base https://api-stg.zenifra.com/v1 --mode api-key --key znf_0123...'],
271
+ examples: ['zenifra profile', 'zenifra profile list', 'zenifra profile add --name staging --description Homologacao --api-base https://api.example.test/v1 --mode api-key --key znf_0123...'],
258
272
  output: 'Zenifra CLI - profile',
259
273
  notes: ['Use "zenifra help profile <subcomando>" para detalhes de list, show, add, edit, use e remove.'],
260
274
  },
@@ -264,7 +278,7 @@ const HELP_SPECS = [
264
278
  description: 'Lista os perfis locais configurados e indica o perfil ativo.',
265
279
  flags: ['--json Imprime a resposta em JSON.'],
266
280
  examples: ['zenifra profile list', 'zenifra profile list --json'],
267
- output: 'Nome Tipo API base Ativo Descricao\nprod api_key https://api.zenifra.com/v1 yes Producao\nstaging access_token https://api-stg.zenifra.com/v1 no Homologacao',
281
+ output: 'Nome Tipo API base Ativo Descricao\nprod api_key https://api.zenifra.com/v1 yes Producao\nstaging access_token https://api.example.test/v1 no Homologacao',
268
282
  jsonOutput: '[{"name":"prod","auth_mode":"api_key","api_base_url":"https://api.zenifra.com/v1","active":true}]',
269
283
  },
270
284
  {
@@ -281,7 +295,7 @@ const HELP_SPECS = [
281
295
  usage: 'zenifra profile add [--name <name>] [--description <text>] [--api-base <url>] [--mode <api-key|login>] [--key <znf_key>] [--json]',
282
296
  description: 'Cria um novo perfil e opcionalmente autentica via API key ou login de usuario.',
283
297
  flags: ['--name <name> Nome do perfil.', '--description <text> Descricao opcional.', '--api-base <url> API base do perfil.', '--mode <mode> api-key ou login.', '--key <znf_key> API key para modo api-key.', '--json Imprime a resposta em JSON.'],
284
- examples: ['zenifra profile add --name staging --description Homologacao --api-base https://api-stg.zenifra.com/v1 --mode api-key --key znf_0123...', 'zenifra profile add'],
298
+ examples: ['zenifra profile add --name staging --description Homologacao --api-base https://api.example.test/v1 --mode api-key --key znf_0123...', 'zenifra profile add'],
285
299
  output: 'Perfil staging salvo e definido como ativo.',
286
300
  jsonOutput: '{"name":"staging","auth_mode":"api_key","active":true}',
287
301
  },
@@ -416,11 +430,11 @@ const HELP_SPECS = [
416
430
  },
417
431
  {
418
432
  command: 'project',
419
- usage: 'zenifra project\n zenifra project info --project <id> [--json]\n zenifra project url --project <id> [--json]\n zenifra project logs --project <id> [--instance <id>] [--json]\n zenifra project metrics --project <id> [--instance <id>] [--json]\n zenifra project network --project <id> [--view <summary|status-codes|routes|user-agents|request-events|source-ips>] [--json]\n zenifra project image set --project <id> --image <image> [--json]\n zenifra project exposure set --project <id> --exposure <public|private> [--json]\n zenifra project envs --project <id> [--json] [--show-values]\n zenifra project env add --project <id> --name <name> --value <value> [--json]\n zenifra project env update --project <id> --name <name> --value <value> [--json]\n zenifra project env remove --project <id> --name <name> [--json]\n zenifra project autoscaling --project <id> [--json]\n zenifra project autoscaling set --project <id> --min <n> --max <n> [--cpu <percent>] [--memory <percent>] [--json]\n zenifra project autoscaling disable --project <id> [--json]\n zenifra project autoscaling events --project <id> [--direction <scale_up|scale_down>] [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]\n zenifra project billing usage --project <id> [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]\n zenifra project instances --project <id> [--json]\n zenifra project instances set --project <id> --count <n> [--json]',
433
+ usage: 'zenifra project\n zenifra project info --project <id> [--json]\n zenifra project url --project <id> [--json]\n zenifra project logs --project <id> [--instance <id>] [--json]\n zenifra project metrics --project <id> [--instance <id>] [--json]\n zenifra project metrics capabilities --project <id> [--json]\n zenifra project healthcheck get --project <id> [--json]\n zenifra project healthcheck set --project <id> --path /health [--json]\n zenifra project healthcheck disable --project <id> [--json]\n zenifra project healthcheck failures --project <id> [--page <n>] [--limit <n>] [--json]\n zenifra project network --project <id> [--view <summary|status-codes|routes|user-agents|request-events|source-ips>] [--json]\n zenifra project image set --project <id> --image <image> [--json]\n zenifra project exposure set --project <id> --exposure <public|private> [--json]\n zenifra project envs --project <id> [--json] [--show-values]\n zenifra project env add --project <id> --name <name> --value <value> [--json]\n zenifra project env update --project <id> --name <name> --value <value> [--json]\n zenifra project env remove --project <id> --name <name> [--json]\n zenifra project autoscaling --project <id> [--json]\n zenifra project autoscaling set --project <id> --min <n> --max <n> [--cpu <percent>] [--memory <percent>] [--json]\n zenifra project autoscaling disable --project <id> [--json]\n zenifra project autoscaling events --project <id> [--direction <scale_up|scale_down>] [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]\n zenifra project billing usage --project <id> [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]\n zenifra project instances --project <id> [--json]\n zenifra project instances set --project <id> --count <n> [--json]',
420
434
  description: 'Agrupa comandos operacionais e de introspecao sobre um projeto especifico.',
421
435
  examples: ['zenifra project', 'zenifra project info --project proj_1', 'zenifra project env add --project proj_1 --name NODE_ENV --value production'],
422
436
  output: 'Zenifra CLI - project',
423
- notes: ['Use "zenifra help project <subcomando>" para detalhes de info, url, logs, metrics, network, image, exposure, autoscaling, billing, envs e instances.'],
437
+ notes: ['Use "zenifra help project <subcomando>" para detalhes de info, url, logs, metrics, capabilities, network, image, exposure, autoscaling, billing, envs e instances.'],
424
438
  },
425
439
  {
426
440
  command: 'project info',
@@ -453,11 +467,72 @@ const HELP_SPECS = [
453
467
  {
454
468
  command: 'project metrics',
455
469
  usage: 'zenifra project metrics --project <id> [--instance <id>] [--json]',
456
- description: 'Mostra CPU, memoria e dados basicos de rede do projeto.',
457
- flags: ['--project <id> ID do projeto.', '--instance <id> Filtra uma instancia.', '--json Imprime a resposta em JSON.'],
458
- examples: ['zenifra project metrics --project 507f1f77bcf86cd799439012 --instance web-1'],
459
- output: 'Instancia Tipo CPU Memoria\nweb-1 application 0.2 64',
460
- jsonOutput: '{"type":"application","instance":"web-1","cpu":0.2,"memory":64,"network":{"requests":120}}',
470
+ description: 'Mostra metricas de recursos do projeto e, para Valkey, o snapshot nativo da instancia.',
471
+ flags: ['--project <id> ID do projeto.', '--instance <id> Filtra uma instancia.', '--json Imprime a resposta em JSON sem formatacao adicional.'],
472
+ examples: ['zenifra project metrics --project 507f1f77bcf86cd799439012 --instance web-1', 'zenifra project metrics --project 507f1f77bcf86cd799439012 --instance instance-1'],
473
+ output: 'Campo Valor\n---------- ----------------\nPerfil cache\nDisponibilidade available\nMemoria usada 100 MB',
474
+ jsonOutput: '{"instance":"instance-1","type":"valkey","availability":"available","valkey":{"schema_version":1,"profile":"cache","memory":{"used_bytes":104857600}}}',
475
+ notes: [
476
+ 'Para projetos Valkey, a saida legivel inclui recursos, capacidade, clientes, atividade, ciclo de vida de chaves, perfil e confiabilidade.',
477
+ 'Valores ausentes sao exibidos como indisponivel; zero continua sendo um valor valido.',
478
+ 'Use "zenifra project metrics capabilities --project <id>" para consultar o acesso e os grupos de metricas disponiveis.',
479
+ ],
480
+ },
481
+ {
482
+ command: 'project metrics capabilities',
483
+ usage: 'zenifra project metrics capabilities --project <id> [--json]',
484
+ description: 'Mostra os grupos de metricas e o nivel de acesso disponivel para o projeto.',
485
+ flags: ['--project <id> ID do projeto.', '--json Imprime a resposta em JSON.'],
486
+ examples: ['zenifra project metrics capabilities --project 507f1f77bcf86cd799439012'],
487
+ output: 'Campo Valor\n---------- ------------------------------------------------------------\nAcesso snapshot\nAtualizacao 60 s\nHistorico indisponivel',
488
+ jsonOutput: '{"access":"snapshot","groups":["resources","capacity","clients","activity","key_lifecycle","profile","reliability"],"refresh_seconds":60,"history":null}',
489
+ notes: ['A API permanece como fonte de autorizacao. A CLI nao mantem uma lista local de planos.'],
490
+ },
491
+ {
492
+ command: 'project healthcheck',
493
+ usage: 'zenifra project healthcheck\n zenifra project healthcheck get --project <id> [--json]\n zenifra project healthcheck set --project <id> --path /health [--json]\n zenifra project healthcheck disable --project <id> [--json]\n zenifra project healthcheck failures --project <id> [--page <n>] [--limit <n>] [--json]',
494
+ description: 'Consulta e gerencia a verificacao de saude de um projeto HTTP.',
495
+ examples: ['zenifra project healthcheck get --project 507f1f77bcf86cd799439012', 'zenifra project healthcheck failures --project 507f1f77bcf86cd799439012'],
496
+ output: 'Zenifra CLI - project healthcheck',
497
+ notes: ['Use "zenifra help project healthcheck <subcomando>" para detalhes de get, set, disable e failures.'],
498
+ },
499
+ {
500
+ command: 'project healthcheck get',
501
+ usage: 'zenifra project healthcheck get --project <id> [--json]',
502
+ description: 'Mostra a configuracao e o estado atual da verificacao de saude do projeto.',
503
+ flags: ['--project <id> ID do projeto.', '--json Imprime a resposta publica em JSON.'],
504
+ examples: ['zenifra project healthcheck get --project 507f1f77bcf86cd799439012'],
505
+ output: 'Campo Valor\n---------- ----------\nStatus ativo\nRota /health\nVerificacao configurada',
506
+ jsonOutput: '{"available":true,"healthcheck":{"enabled":true,"path":"/health"},"interval_seconds":60,"retention_days":30}',
507
+ },
508
+ {
509
+ command: 'project healthcheck set',
510
+ usage: 'zenifra project healthcheck set --project <id> --path /health [--json]',
511
+ description: 'Ativa a verificacao de saude do projeto usando a rota informada.',
512
+ flags: ['--project <id> ID do projeto.', '--path <path> Rota HTTP absoluta da aplicacao.', '--json Imprime a resposta publica em JSON.'],
513
+ examples: ['zenifra project healthcheck set --project 507f1f77bcf86cd799439012 --path /health'],
514
+ output: 'Verificacao de saude ativada.\nRota: /health',
515
+ jsonOutput: '{"healthcheck":{"enabled":true,"path":"/health"},"interval_seconds":60,"retention_days":30}',
516
+ notes: ['A rota deve comecar com / e nao pode conter host, query string ou fragmento.'],
517
+ },
518
+ {
519
+ command: 'project healthcheck disable',
520
+ usage: 'zenifra project healthcheck disable --project <id> [--json]',
521
+ description: 'Desativa a verificacao de saude do projeto sem apagar a rota configurada.',
522
+ flags: ['--project <id> ID do projeto.', '--json Imprime a resposta publica em JSON.'],
523
+ examples: ['zenifra project healthcheck disable --project 507f1f77bcf86cd799439012'],
524
+ output: 'Verificacao de saude desativada.',
525
+ jsonOutput: '{"healthcheck":{"enabled":false,"path":"/health"},"interval_seconds":60,"retention_days":30}',
526
+ },
527
+ {
528
+ command: 'project healthcheck failures',
529
+ usage: 'zenifra project healthcheck failures --project <id> [--page <n>] [--limit <n>] [--json]',
530
+ description: 'Lista as falhas publicas de verificacao de saude dos ultimos 30 dias.',
531
+ flags: ['--project <id> ID do projeto.', '--page <n> Pagina. Padrao da API: 1.', '--limit <n> Itens por pagina. Maximo: 100.', '--json Imprime a resposta publica em JSON.'],
532
+ examples: ['zenifra project healthcheck failures --project 507f1f77bcf86cd799439012', 'zenifra project healthcheck failures --project 507f1f77bcf86cd799439012 --page 2 --limit 10'],
533
+ output: 'Quando Status\n2026-08-31T12:00:00.000Z 503',
534
+ jsonOutput: '{"failures":[{"occurred_at":"2026-08-31T12:00:00.000Z","status_code":503}],"pagination":{"page":1,"limit":10,"total":1,"total_pages":1}}',
535
+ notes: ['O historico e limitado aos 30 dias anteriores.'],
461
536
  },
462
537
  {
463
538
  command: 'project network',
@@ -800,7 +875,11 @@ function normalizeProfileRecord(name, profile = {}) {
800
875
  return {
801
876
  name,
802
877
  description: profile.description ? String(profile.description) : '',
803
- authMode: profile.authMode === 'access_token' ? 'access_token' : 'api_key',
878
+ authMode: ['oauth', 'access_token'].includes(profile.authMode) ? profile.authMode : 'api_key',
879
+ oauth: profile.authMode === 'oauth' && isRecord(profile.oauth) ? {
880
+ issuer: profile.oauth.issuer, resource: profile.oauth.resource, clientId: profile.oauth.clientId,
881
+ refreshToken: profile.oauth.refreshToken, expiresAt: profile.oauth.expiresAt, scope: profile.oauth.scope,
882
+ } : undefined,
804
883
  apiBaseUrl: normalizeApiBaseUrl(profile.apiBaseUrl),
805
884
  apiKey: profile.apiKey ? String(profile.apiKey) : undefined,
806
885
  accessToken: profile.accessToken ? String(profile.accessToken) : undefined,
@@ -824,13 +903,36 @@ function sanitizeProfileStore(store) {
824
903
  return next;
825
904
  }
826
905
 
827
- async function writeProfileStore(store) {
906
+ let profileBaseline = emptyProfileStore();
907
+ const sameProfileValue = (a, b) => JSON.stringify(a) === JSON.stringify(b);
908
+ async function readCurrentProfileStore() {
909
+ try { return sanitizeProfileStore(JSON.parse(await readFile(PROFILES_FILE, 'utf8'))); }
910
+ catch (error) { if (error.code === 'ENOENT') return emptyProfileStore(); throw new CliError('Arquivo de perfis invalido. Verifique sua configuracao local.'); }
911
+ }
912
+ async function saveProfileStore(store, { signal } = {}) {
828
913
  const normalized = sanitizeProfileStore(store);
829
- await mkdir(dirname(PROFILES_FILE), { recursive: true });
830
- await writeFile(PROFILES_FILE, `${JSON.stringify(normalized, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
831
- await chmod(PROFILES_FILE, 0o600).catch(() => undefined);
914
+ await atomicPrivateWrite(PROFILES_FILE, `${JSON.stringify(normalized, null, 2)}\n`, { signal });
915
+ profileBaseline = structuredClone(normalized);
832
916
  return normalized;
833
917
  }
918
+ async function writeProfileStore(store, { signal } = {}) {
919
+ const proposed = sanitizeProfileStore(store);
920
+ const baseline = profileBaseline;
921
+ return withProfileLock(SESSION_DIR, async () => {
922
+ const current = await readCurrentProfileStore();
923
+ for (const name of new Set([...Object.keys(baseline.profiles), ...Object.keys(proposed.profiles)])) {
924
+ const before = baseline.profiles[name], after = proposed.profiles[name];
925
+ if (sameProfileValue(before, after)) continue;
926
+ if (!sameProfileValue(current.profiles[name], before)) {
927
+ throw new CliError('O perfil foi alterado por outro comando. Repita o comando para usar a configuracao atual.');
928
+ }
929
+ if (after) current.profiles[name] = after; else delete current.profiles[name];
930
+ }
931
+ if (proposed.activeProfile !== baseline.activeProfile) current.activeProfile = proposed.activeProfile;
932
+ signal?.throwIfAborted();
933
+ return saveProfileStore(current, { signal });
934
+ }, { signal });
935
+ }
834
936
 
835
937
  async function readLegacySession() {
836
938
  if (!existsSync(SESSION_FILE)) return null;
@@ -866,7 +968,9 @@ async function migrateLegacySession() {
866
968
  async function readProfileStore() {
867
969
  if (existsSync(PROFILES_FILE)) {
868
970
  try {
869
- return sanitizeProfileStore(JSON.parse(await readFile(PROFILES_FILE, 'utf8')));
971
+ const store = sanitizeProfileStore(JSON.parse(await readFile(PROFILES_FILE, 'utf8')));
972
+ profileBaseline = structuredClone(store);
973
+ return store;
870
974
  } catch {
871
975
  throw new CliError(`Store de perfis invalido em ${PROFILES_FILE}. Corrija o arquivo ou remova-o para recriar.`);
872
976
  }
@@ -903,17 +1007,17 @@ function getProfileRecord(session, explicitName) {
903
1007
  return store.profiles[name] ? { name, profile: store.profiles[name], store } : null;
904
1008
  }
905
1009
 
906
- async function persistSession(session) {
1010
+ async function persistSession(session, { signal } = {}) {
907
1011
  const store = getStore(session);
908
1012
  const profileName = getProfileName(session);
909
1013
  if (!profileName) {
910
- return writeProfileStore(store);
1014
+ return writeProfileStore(store, { signal });
911
1015
  }
912
1016
  store.profiles[profileName] = normalizeProfileRecord(profileName, session);
913
1017
  if (!store.activeProfile || store.activeProfile === profileName) {
914
1018
  store.activeProfile = profileName;
915
1019
  }
916
- const normalized = await writeProfileStore(store);
1020
+ const normalized = await writeProfileStore(store, { signal });
917
1021
  session.__store = normalized;
918
1022
  session.__profileName = profileName;
919
1023
  return normalized;
@@ -1422,7 +1526,14 @@ async function request(session, flags, method, path, {
1422
1526
  credential: credentialOverride,
1423
1527
  headers: extraHeaders = {},
1424
1528
  } = {}) {
1425
- const credential = credentialOverride || resolveCredential(session);
1529
+ if (tokenRequired && !credentialOverride && session.authMode === 'oauth') {
1530
+ if (process.env.ZENIFRA_API_KEY) {
1531
+ if (!session.__oauthShadowWarned) process.stderr.write('Aviso: ZENIFRA_API_KEY tem prioridade sobre o perfil OAuth.\n');
1532
+ session.__oauthShadowWarned = true;
1533
+ } else await prepareOAuthCredential(session, flags);
1534
+ }
1535
+ let credential = credentialOverride || resolveCredential(session);
1536
+ if (!tokenRequired && session.authMode === 'oauth' && credential?.source === 'profile') credential = null;
1426
1537
  if (tokenRequired && !credential) {
1427
1538
  throw new CliError('Voce precisa autenticar primeiro: zenifra auth login, zenifra auth api-key --key <znf_key> ou ZENIFRA_API_KEY.');
1428
1539
  }
@@ -1454,6 +1565,7 @@ async function request(session, flags, method, path, {
1454
1565
  headers,
1455
1566
  body: body === undefined ? undefined : JSON.stringify(body),
1456
1567
  signal: AbortSignal.timeout(timeoutMs),
1568
+ ...(session.authMode === 'oauth' && credential?.source === 'profile' ? { redirect: 'error' } : {}),
1457
1569
  });
1458
1570
  } catch (error) {
1459
1571
  if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
@@ -1486,6 +1598,9 @@ async function request(session, flags, method, path, {
1486
1598
  if (response.status === 429 && Number.isFinite(Number(payload?.retry_after_seconds))) {
1487
1599
  throw new CliError(`${message} Tente novamente em ${Number(payload.retry_after_seconds)} segundo(s).`);
1488
1600
  }
1601
+ if (response.status === 402 && path.includes('/metrics') && /support metrics/i.test(String(message))) {
1602
+ throw new CliError('Este projeto nao possui acesso a metricas.');
1603
+ }
1489
1604
  if (String(message).includes('missing x-organization-id')) {
1490
1605
  throw new CliError('Organizacao nao selecionada. Rode "zenifra org set" ou use --org <id>.');
1491
1606
  }
@@ -1526,6 +1641,128 @@ function printTable(rows, columns) {
1526
1641
  }
1527
1642
  }
1528
1643
 
1644
+ function formatValkeyNumber(value) {
1645
+ if (value === null || value === undefined || !Number.isFinite(Number(value))) return 'indisponivel';
1646
+ return String(value);
1647
+ }
1648
+
1649
+ function formatValkeyBytes(value, suffix = '') {
1650
+ if (value === null || value === undefined || !Number.isFinite(Number(value))) return 'indisponivel';
1651
+
1652
+ const numericValue = Number(value);
1653
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
1654
+ let unitIndex = 0;
1655
+ let normalizedValue = numericValue;
1656
+ while (normalizedValue >= 1024 && unitIndex < units.length - 1) {
1657
+ normalizedValue /= 1024;
1658
+ unitIndex += 1;
1659
+ }
1660
+
1661
+ const digits = normalizedValue >= 100 ? 0 : normalizedValue >= 10 ? 1 : 2;
1662
+ const formattedValue = Number(normalizedValue.toFixed(digits)).toString();
1663
+ return `${formattedValue} ${units[unitIndex]}${suffix}`;
1664
+ }
1665
+
1666
+ function formatValkeyPercent(value) {
1667
+ if (value === null || value === undefined || !Number.isFinite(Number(value))) return 'indisponivel';
1668
+ return `${Number((Number(value) * 100).toFixed(2))}%`;
1669
+ }
1670
+
1671
+ function valkeyMetricRows(metrics) {
1672
+ const native = metrics?.valkey;
1673
+ const rows = [
1674
+ { field: 'Instancia', value: metrics?.instance || '-' },
1675
+ { field: 'Disponibilidade', value: metrics?.availability || 'indisponivel' },
1676
+ { field: 'Observado em', value: metrics?.observed_at || 'indisponivel' },
1677
+ { field: 'CPU', value: formatValkeyNumber(metrics?.cpu) },
1678
+ { field: 'Memoria', value: formatValkeyBytes(metrics?.memory) },
1679
+ ];
1680
+
1681
+ if (!native) {
1682
+ rows.push({ field: 'Metricas Valkey', value: 'indisponivel' });
1683
+ return rows;
1684
+ }
1685
+
1686
+ rows.push(
1687
+ { field: 'Perfil', value: native.profile || '-' },
1688
+ { field: 'Memoria usada', value: formatValkeyBytes(native.memory?.used_bytes) },
1689
+ { field: 'Pico de memoria', value: formatValkeyBytes(native.memory?.peak_bytes) },
1690
+ { field: 'Limite de memoria', value: formatValkeyBytes(native.memory?.limit_bytes) },
1691
+ { field: 'Fragmentacao', value: formatValkeyNumber(native.memory?.fragmentation_ratio) },
1692
+ { field: 'Clientes conectados', value: formatValkeyNumber(native.clients?.connected) },
1693
+ { field: 'Clientes bloqueados', value: formatValkeyNumber(native.clients?.blocked) },
1694
+ { field: 'Operacoes por segundo', value: formatValkeyNumber(native.activity?.operations_per_second) },
1695
+ { field: 'Entrada', value: formatValkeyBytes(native.activity?.input_bytes_per_second, '/s') },
1696
+ { field: 'Saida', value: formatValkeyBytes(native.activity?.output_bytes_per_second, '/s') },
1697
+ { field: 'Chaves expiradas', value: formatValkeyNumber(native.keys?.expired_total) },
1698
+ { field: 'Chaves removidas', value: formatValkeyNumber(native.keys?.evicted_total) },
1699
+ { field: 'Uptime', value: `${formatValkeyNumber(native.uptime_seconds)} s` },
1700
+ );
1701
+
1702
+ if (native.cache) {
1703
+ rows.push(
1704
+ { field: 'Cache hits', value: formatValkeyNumber(native.cache.hits_total) },
1705
+ { field: 'Cache misses', value: formatValkeyNumber(native.cache.misses_total) },
1706
+ { field: 'Hit ratio', value: formatValkeyPercent(native.cache.hit_ratio) },
1707
+ );
1708
+ }
1709
+
1710
+ if (native.key_value) {
1711
+ rows.push(
1712
+ { field: 'Total de chaves', value: formatValkeyNumber(native.key_value.keys_total) },
1713
+ { field: 'Chaves com expiracao', value: formatValkeyNumber(native.key_value.keys_with_expiration) },
1714
+ );
1715
+ }
1716
+
1717
+ if (native.reliability) {
1718
+ const replication = native.reliability.replication;
1719
+ const persistence = native.reliability.persistence;
1720
+ if (replication) {
1721
+ rows.push(
1722
+ { field: 'Replicacao', value: replication.status || 'indisponivel' },
1723
+ { field: 'Repl. disponiveis', value: formatValkeyNumber(replication.replicas_available) },
1724
+ { field: 'Repl. esperadas', value: formatValkeyNumber(replication.replicas_expected) },
1725
+ { field: 'Lag', value: replication.lag_seconds === null || replication.lag_seconds === undefined ? 'indisponivel' : `${replication.lag_seconds} s` },
1726
+ );
1727
+ }
1728
+ if (persistence) {
1729
+ rows.push(
1730
+ { field: 'Persistencia', value: persistence.status || 'indisponivel' },
1731
+ { field: 'Persistencia ativa', value: persistence.enabled === null || persistence.enabled === undefined ? 'indisponivel' : persistence.enabled ? 'sim' : 'nao' },
1732
+ { field: 'Ultimo sucesso', value: persistence.last_success_at || 'indisponivel' },
1733
+ );
1734
+ }
1735
+ }
1736
+
1737
+ return rows;
1738
+ }
1739
+
1740
+ function printValkeyMetrics(metrics) {
1741
+ printTable(valkeyMetricRows(metrics), [
1742
+ { label: 'Campo', value: (row) => row.field },
1743
+ { label: 'Valor', value: (row) => row.value },
1744
+ ]);
1745
+ }
1746
+
1747
+ function printMetricsCapabilities(capabilities) {
1748
+ const groups = Array.isArray(capabilities?.groups) && capabilities.groups.length > 0
1749
+ ? capabilities.groups.join(', ')
1750
+ : 'nenhum';
1751
+ const history = capabilities?.history
1752
+ ? `${capabilities.history.retention_seconds} s de retencao`
1753
+ : 'indisponivel';
1754
+
1755
+ printTable([
1756
+ { field: 'Acesso', value: capabilities?.access || 'indisponivel' },
1757
+ { field: 'Grupos', value: groups },
1758
+ { field: 'Atualizacao', value: capabilities?.refresh_seconds === null || capabilities?.refresh_seconds === undefined ? 'indisponivel' : `${capabilities.refresh_seconds} s` },
1759
+ { field: 'Historico', value: history },
1760
+ ], [
1761
+ { label: 'Campo', value: (row) => row.field },
1762
+ { label: 'Valor', value: (row) => row.value },
1763
+ ]);
1764
+ }
1765
+
1529
1766
  function asArray(value) {
1530
1767
  if (Array.isArray(value)) return value;
1531
1768
  if (Array.isArray(value?.projects)) return value.projects;
@@ -1555,7 +1792,7 @@ function buildLogLineOf(log) {
1555
1792
  }
1556
1793
 
1557
1794
  function requireProjectId(flags, commandKey) {
1558
- if (!flags.project) {
1795
+ if (!flags.project || flags.project === true) {
1559
1796
  printCommandHelpAndFail(commandKey)
1560
1797
  return null
1561
1798
  }
@@ -1675,7 +1912,55 @@ async function resolveOrgId(session, flags, { interactive = true } = {}) {
1675
1912
  return session.selectedOrganizationId;
1676
1913
  }
1677
1914
 
1915
+ async function handleOAuthLogin(session, flags) {
1916
+ if (['email', 'password', 'code', 'totp', 'challengeToken', 'key'].some(key => flags[key])) {
1917
+ throw new CliError('Use --oauth sem senha, codigo ou API key.');
1918
+ }
1919
+ const target = ensureProfile(session, flags.profile, { activate: true });
1920
+ const base = apiBaseUrl(target, flags);
1921
+ const controller = new AbortController();
1922
+ const cancel = () => controller.abort();
1923
+ process.once('SIGINT', cancel); process.once('SIGTERM', cancel);
1924
+ try {
1925
+ const login = await oauthLogin(base, { readOnly: Boolean(flags.readOnly), signal: controller.signal,
1926
+ onAuthorize: async url => {
1927
+ process.stdout.write(`Abra este endereco no navegador para entrar:\n${url}\n`);
1928
+ if (!flags.noBrowser) {
1929
+ const opened = await openBrowser(url);
1930
+ if (!opened) process.stderr.write('Nao foi possivel abrir o navegador. Abra o endereco acima manualmente.\n');
1931
+ }
1932
+ },
1933
+ });
1934
+ if (controller.signal.aborted) throw new CliError('Login OAuth cancelado.');
1935
+ Object.assign(target, login, { apiBaseUrl: base, selectedOrganizationId: undefined, updatedAt: new Date().toISOString() });
1936
+ await persistSession(target, { signal: controller.signal });
1937
+ if (controller.signal.aborted) throw new CliError('Login OAuth cancelado.');
1938
+ Object.assign(session, buildSession(target.__store));
1939
+ process.stdout.write('Login OAuth realizado com sucesso.\n');
1940
+ if (process.env.ZENIFRA_API_KEY) process.stderr.write('Aviso: ZENIFRA_API_KEY tem prioridade sobre este perfil OAuth nos comandos.\n');
1941
+ } finally { process.removeListener('SIGINT', cancel); process.removeListener('SIGTERM', cancel); }
1942
+ }
1943
+
1944
+ async function prepareOAuthCredential(session, flags) {
1945
+ validateOAuthProfile(session.oauth, apiBaseUrl(session, flags));
1946
+ if (!Number.isFinite(session.oauth.expiresAt)) throw new CliError('Perfil OAuth invalido. Faca login novamente.');
1947
+ await withProfileLock(SESSION_DIR, async () => {
1948
+ const store = await readCurrentProfileStore();
1949
+ const current = store.profiles[getProfileName(session)];
1950
+ if (current?.authMode !== 'oauth' || !current.accessToken) throw new CliError('Sessao OAuth encerrada. Faca login novamente.');
1951
+ validateOAuthProfile(current.oauth, apiBaseUrl(session, flags));
1952
+ if (!Number.isFinite(current.oauth.expiresAt)) throw new CliError('Perfil OAuth invalido. Faca login novamente.');
1953
+ if (current.oauth.expiresAt <= Date.now() + 30_000) {
1954
+ Object.assign(current, await refreshOAuth(current.oauth), { updatedAt: new Date().toISOString() });
1955
+ await saveProfileStore(store);
1956
+ } else profileBaseline = structuredClone(store);
1957
+ Object.assign(session, current, { __store: store });
1958
+ });
1959
+ }
1960
+
1678
1961
  async function handleLogin(session, flags) {
1962
+ if (flags.oauth) return handleOAuthLogin(session, flags);
1963
+ if (flags.readOnly || flags.noBrowser) throw new CliError('--read-only e --no-browser exigem --oauth.');
1679
1964
  const targetSession = ensureProfile(session, flags.profile, { activate: Boolean(flags.profile) || !getProfileName(session) });
1680
1965
  const email = String(flags.email || await prompt('Email'));
1681
1966
  const password = String(flags.password || await promptHidden('Senha'));
@@ -1767,6 +2052,20 @@ async function handleLogout(session, flags) {
1767
2052
  throw new CliError('Nenhum perfil ativo configurado. Crie um perfil com "zenifra profile add" ou autentique com "zenifra auth login".');
1768
2053
  }
1769
2054
  const target = requireExistingProfile(session, targetName);
2055
+ if (target.authMode === 'oauth') {
2056
+ await withProfileLock(SESSION_DIR, async () => {
2057
+ const store = await readCurrentProfileStore();
2058
+ const current = store.profiles[getProfileName(target)];
2059
+ if (current?.authMode !== 'oauth') throw new CliError('Perfil alterado. Repita o logout.');
2060
+ if (flags.revoke) await revokeOAuth(current.oauth, apiBaseUrl(target, flags));
2061
+ Object.assign(current, { oauth: undefined, accessToken: undefined, apiKey: undefined,
2062
+ selectedOrganizationId: undefined, authMode: 'api_key', updatedAt: new Date().toISOString() });
2063
+ session.__store = await saveProfileStore(store);
2064
+ if (target.__profileName === getProfileName(session)) Object.assign(session, buildSession(session.__store));
2065
+ });
2066
+ process.stdout.write(`Autenticacao removida do perfil ${target.__profileName}${flags.revoke ? ' e conexao OAuth revogada' : ''}.\n`);
2067
+ return;
2068
+ }
1770
2069
  if (flags.revoke) {
1771
2070
  if (!target.accessToken) {
1772
2071
  throw new CliError('A revogacao exige um perfil autenticado por login de usuario; API keys devem ser revogadas na organizacao.');
@@ -1779,6 +2078,7 @@ async function handleLogout(session, flags) {
1779
2078
  },
1780
2079
  });
1781
2080
  }
2081
+ target.oauth = undefined;
1782
2082
  target.accessToken = undefined;
1783
2083
  target.apiKey = undefined;
1784
2084
  target.selectedOrganizationId = undefined;
@@ -1801,7 +2101,7 @@ function profileOutput(profile, activeName) {
1801
2101
  return {
1802
2102
  name: profile.name,
1803
2103
  description: profile.description || '',
1804
- auth_mode: profile.accessToken ? 'access_token' : profile.apiKey ? 'api_key' : profile.authMode || 'api_key',
2104
+ auth_mode: profile.authMode === 'oauth' ? 'oauth' : profile.accessToken ? 'access_token' : profile.apiKey ? 'api_key' : profile.authMode || 'api_key',
1805
2105
  api_base_url: profile.apiBaseUrl || DEFAULT_API_BASE_URL,
1806
2106
  selected_organization_id: profile.selectedOrganizationId,
1807
2107
  has_api_key: Boolean(profile.apiKey),
@@ -2230,6 +2530,11 @@ function formatBrl(value) {
2230
2530
  }).format(Number.isFinite(number) ? number : 0);
2231
2531
  }
2232
2532
 
2533
+ function formatBrlFromCents(value) {
2534
+ const number = Number(value ?? 0);
2535
+ return formatBrl(Number.isFinite(number) ? number / 100 : 0);
2536
+ }
2537
+
2233
2538
  function humanizeStorageName(value) {
2234
2539
  const raw = String(value || '').trim();
2235
2540
  if (!raw) return { storage: '-', type: '-' };
@@ -2377,9 +2682,9 @@ function printPlansCatalogs(payload, type) {
2377
2682
  process.stdout.write('HTTP\n');
2378
2683
  printTable(asArray(payload.http), [
2379
2684
  { label: 'Plano', value: (plan) => plan.plan || '-' },
2380
- { label: 'Hora', value: (plan) => formatBrl(plan.prices?.hourly) },
2381
- { label: 'Mes', value: (plan) => formatBrl(plan.prices?.monthly) },
2382
- { label: 'Ano', value: (plan) => formatBrl(plan.prices?.yearly) },
2685
+ { label: 'Hora', value: (plan) => formatBrlFromCents(plan.prices?.hourly) },
2686
+ { label: 'Mes', value: (plan) => formatBrlFromCents(plan.prices?.monthly) },
2687
+ { label: 'Ano', value: (plan) => formatBrlFromCents(plan.prices?.yearly) },
2383
2688
  { label: 'Recursos', value: (plan) => asArray(plan.features).join(', ') || '-' },
2384
2689
  ]);
2385
2690
  }
@@ -2389,9 +2694,9 @@ function printPlansCatalogs(payload, type) {
2389
2694
  process.stdout.write('PostgreSQL / MariaDB\n');
2390
2695
  printTable(asArray(payload.database), [
2391
2696
  { label: 'Plano', value: (plan) => plan.plan || '-' },
2392
- { label: 'Hora', value: (plan) => formatBrl(plan.prices?.hourly) },
2393
- { label: 'Mes', value: (plan) => formatBrl(plan.prices?.monthly) },
2394
- { label: 'Ano', value: (plan) => formatBrl(plan.prices?.yearly) },
2697
+ { label: 'Hora', value: (plan) => formatBrlFromCents(plan.prices?.hourly) },
2698
+ { label: 'Mes', value: (plan) => formatBrlFromCents(plan.prices?.monthly) },
2699
+ { label: 'Ano', value: (plan) => formatBrlFromCents(plan.prices?.yearly) },
2395
2700
  { label: 'Recursos', value: (plan) => asArray(plan.features).join(', ') || '-' },
2396
2701
  ]);
2397
2702
  }
@@ -3281,6 +3586,16 @@ async function handleProjectLogs(session, flags) {
3281
3586
  printLogs(logs, flags);
3282
3587
  }
3283
3588
 
3589
+ async function handleProjectMetricsCapabilities(session, flags) {
3590
+ const projectId = requireProjectId(flags, 'project metrics capabilities');
3591
+ if (!projectId) return;
3592
+ const orgId = await resolveOrgId(session, flags);
3593
+ const capabilities = unwrapData(await request(session, flags, 'GET', `/project/${projectId}/metrics/capabilities`, { orgId }));
3594
+
3595
+ if (flags.json) return printJson(capabilities);
3596
+ printMetricsCapabilities(capabilities);
3597
+ }
3598
+
3284
3599
  async function handleProjectMetrics(session, flags) {
3285
3600
  const projectId = requireProjectId(flags, 'project metrics');
3286
3601
  if (!projectId) return
@@ -3289,6 +3604,9 @@ async function handleProjectMetrics(session, flags) {
3289
3604
  const metrics = unwrapData(await request(session, flags, 'GET', `/project/${projectId}/metrics${query}`, { orgId }));
3290
3605
 
3291
3606
  if (flags.json) return printJson(metrics);
3607
+ if (metrics?.type === 'valkey') {
3608
+ return printValkeyMetrics(metrics);
3609
+ }
3292
3610
  if (Array.isArray(metrics)) {
3293
3611
  return printTable(metrics, [
3294
3612
  { label: 'Instancia', value: (metric) => metric.instance || '-' },
@@ -3691,6 +4009,118 @@ async function handleProjectAutoscalingEvents(session, flags) {
3691
4009
  printAutoscalingEvents(data);
3692
4010
  }
3693
4011
 
4012
+ function publicHealthcheckValue(value) {
4013
+ if (value === undefined || value === null || value === '') return '-';
4014
+ if (typeof value === 'object') return '-';
4015
+ return String(value);
4016
+ }
4017
+
4018
+ function healthcheckForOutput(data) {
4019
+ if (data?.healthcheck && typeof data.healthcheck === 'object' && !Array.isArray(data.healthcheck)) {
4020
+ return data.healthcheck;
4021
+ }
4022
+ return data || {};
4023
+ }
4024
+
4025
+ function printHealthcheck(data) {
4026
+ const healthcheck = healthcheckForOutput(data);
4027
+ const rows = [
4028
+ { field: 'Status', value: healthcheck.enabled === true ? 'ativo' : healthcheck.enabled === false ? 'inativo' : 'indisponivel' },
4029
+ { field: 'Rota', value: publicHealthcheckValue(healthcheck.path) },
4030
+ { field: 'Verificacao', value: publicHealthcheckValue(healthcheck.status || healthcheck.state || (healthcheck.enabled ? 'configurada' : 'desativada')) },
4031
+ ];
4032
+ if (healthcheck.last_check_at !== undefined) rows.push({ field: 'Ultima verificacao', value: publicHealthcheckValue(healthcheck.last_check_at) });
4033
+ if (healthcheck.last_failure_at !== undefined) rows.push({ field: 'Ultima falha', value: publicHealthcheckValue(healthcheck.last_failure_at) });
4034
+ printTable(rows, [
4035
+ { label: 'Campo', value: (row) => row.field },
4036
+ { label: 'Valor', value: (row) => row.value },
4037
+ ]);
4038
+ }
4039
+
4040
+ function validateHealthcheckPath(value) {
4041
+ const path = String(value || '').trim();
4042
+ if (!path.startsWith('/')
4043
+ || path.startsWith('//')
4044
+ || path.includes('?')
4045
+ || path.includes('#')
4046
+ || path.includes('\\')
4047
+ || /\s/.test(path)) {
4048
+ throw new CliError('Informe --path como uma rota absoluta, iniciada por /, sem host, query string ou fragmento.');
4049
+ }
4050
+ return path;
4051
+ }
4052
+
4053
+ function healthcheckFailuresOf(data) {
4054
+ if (Array.isArray(data)) return data;
4055
+ return Array.isArray(data?.failures) ? data.failures : [];
4056
+ }
4057
+
4058
+ function printHealthcheckFailures(data) {
4059
+ const failures = healthcheckFailuresOf(data);
4060
+ printTable(failures, [
4061
+ { label: 'Quando', value: (failure) => publicHealthcheckValue(failure.occurred_at || failure.observed_at) },
4062
+ { label: 'Status', value: (failure) => publicHealthcheckValue(failure.status_code) },
4063
+ ]);
4064
+
4065
+ const pagination = data?.pagination;
4066
+ if (pagination && pagination.total_pages === undefined && pagination.pages !== undefined) {
4067
+ printPagination({ ...pagination, total_pages: pagination.pages }, 'falha');
4068
+ return;
4069
+ }
4070
+ printPagination(pagination, 'falha');
4071
+ }
4072
+
4073
+ async function handleProjectHealthcheckGet(session, flags) {
4074
+ const projectId = requireProjectId(flags, 'project healthcheck get');
4075
+ if (!projectId) return;
4076
+ const orgId = await resolveOrgId(session, flags);
4077
+ const data = unwrapData(await request(session, flags, 'GET', `/project/${projectId}/healthcheck`, { orgId }));
4078
+ if (flags.json) return printJson(data);
4079
+ printHealthcheck(data);
4080
+ }
4081
+
4082
+ async function handleProjectHealthcheckSet(session, flags) {
4083
+ const projectId = requireProjectId(flags, 'project healthcheck set');
4084
+ if (!projectId) return;
4085
+ if (flags.path === undefined || flags.path === true || flags.path === '') {
4086
+ printCommandHelpAndFail('project healthcheck set');
4087
+ return;
4088
+ }
4089
+ const path = validateHealthcheckPath(flags.path);
4090
+ const orgId = await resolveOrgId(session, flags);
4091
+ const payload = await request(session, flags, 'PATCH', `/project/${projectId}/healthcheck`, {
4092
+ orgId,
4093
+ body: { healthcheck: { enabled: true, path } },
4094
+ });
4095
+ const data = unwrapData(payload);
4096
+ if (flags.json) return printJson(data);
4097
+ process.stdout.write(`Verificacao de saude ativada.\nRota: ${path}\n`);
4098
+ }
4099
+
4100
+ async function handleProjectHealthcheckDisable(session, flags) {
4101
+ const projectId = requireProjectId(flags, 'project healthcheck disable');
4102
+ if (!projectId) return;
4103
+ const orgId = await resolveOrgId(session, flags);
4104
+ const payload = await request(session, flags, 'PATCH', `/project/${projectId}/healthcheck`, {
4105
+ orgId,
4106
+ body: { healthcheck: { enabled: false } },
4107
+ });
4108
+ const data = unwrapData(payload);
4109
+ if (flags.json) return printJson(data);
4110
+ process.stdout.write('Verificacao de saude desativada.\n');
4111
+ }
4112
+
4113
+ async function handleProjectHealthcheckFailures(session, flags) {
4114
+ const projectId = requireProjectId(flags, 'project healthcheck failures');
4115
+ if (!projectId) return;
4116
+ validateProjectListFlags(flags, { maxLimit: 100 });
4117
+ const orgId = await resolveOrgId(session, flags);
4118
+ const query = buildQuery(flags, ['page', 'limit']);
4119
+ const data = unwrapData(await request(session, flags, 'GET', `/project/${projectId}/healthcheck/failures${query}`, { orgId }));
4120
+ if (flags.json) return printJson(data);
4121
+ printHealthcheckFailures(data);
4122
+ }
4123
+
3694
4124
  async function handleProjectBillingUsage(session, flags) {
3695
4125
  const projectId = requireProjectId(flags, 'project billing usage');
3696
4126
  if (!projectId) return;
@@ -4038,7 +4468,7 @@ async function main() {
4038
4468
  return;
4039
4469
  }
4040
4470
 
4041
- if (command === 'auth' && subcommand === 'login') return handleLogin(session, flags);
4471
+ if (command === 'auth' && subcommand === 'login') return await handleLogin(session, flags);
4042
4472
  if (command === 'auth' && subcommand === 'api-key') return handleApiKeyLogin(session, flags);
4043
4473
  if (command === 'auth' && subcommand === 'logout') return handleLogout(session, flags);
4044
4474
  if (command === 'profile' && subcommand === 'list') return handleProfileList(session, flags);
@@ -4047,7 +4477,7 @@ async function main() {
4047
4477
  if (command === 'profile' && subcommand === 'edit') return handleProfileEdit(session, flags, positional);
4048
4478
  if (command === 'profile' && subcommand === 'use') return handleProfileUse(session, flags, positional);
4049
4479
  if (command === 'profile' && subcommand === 'remove') return handleProfileRemove(session, flags, positional);
4050
- if (command === 'login') return handleLogin(session, flags);
4480
+ if (command === 'login') return await handleLogin(session, flags);
4051
4481
  if (command === 'logout') return handleLogout(session, flags);
4052
4482
  if (command === 'plans') return handlePlans(session, flags);
4053
4483
  if (command === 'create' && subcommand === 'project') return handleProjectCreate(session, flags);
@@ -4062,9 +4492,18 @@ async function main() {
4062
4492
  if (command === 'valkey' && subcommand === 'connection') return handleValkeyConnection(session, flags);
4063
4493
  if (command === 'valkey' && subcommand === 'credentials' && positional[2] === 'rotate') return handleValkeyCredentialRotate(session, flags);
4064
4494
  if (command === 'valkey' && subcommand === 'credentials' && positional[2] === 'status') return handleValkeyCredentialStatus(session, flags);
4495
+ if (command === 'project' && subcommand === 'healthcheck' && positional.length === 2) {
4496
+ process.stdout.write(commandHelp(['project', 'healthcheck']));
4497
+ return;
4498
+ }
4499
+ if (command === 'project' && subcommand === 'healthcheck' && positional[2] === 'get') return handleProjectHealthcheckGet(session, flags);
4500
+ if (command === 'project' && subcommand === 'healthcheck' && positional[2] === 'set') return handleProjectHealthcheckSet(session, flags);
4501
+ if (command === 'project' && subcommand === 'healthcheck' && positional[2] === 'disable') return handleProjectHealthcheckDisable(session, flags);
4502
+ if (command === 'project' && subcommand === 'healthcheck' && positional[2] === 'failures') return handleProjectHealthcheckFailures(session, flags);
4065
4503
  if (command === 'project' && subcommand === 'info') return handleProjectInfo(session, flags);
4066
4504
  if (command === 'project' && subcommand === 'url') return handleProjectUrl(session, flags);
4067
4505
  if (command === 'project' && subcommand === 'logs') return handleProjectLogs(session, flags);
4506
+ if (command === 'project' && subcommand === 'metrics' && positional[2] === 'capabilities') return handleProjectMetricsCapabilities(session, flags);
4068
4507
  if (command === 'project' && subcommand === 'metrics') return handleProjectMetrics(session, flags);
4069
4508
  if (command === 'project' && subcommand === 'network') return handleProjectNetwork(session, flags);
4070
4509
  if (command === 'project' && subcommand === 'image' && positional[2] === 'set') return handleProjectImageSet(session, flags);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenifra/cli",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "Official Zenifra command line interface.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,7 +31,7 @@
31
31
  "README.md"
32
32
  ],
33
33
  "scripts": {
34
- "check": "node --check bin/zenifra.mjs && npm run check:staging-autoscaling-regression",
34
+ "check": "node --check bin/zenifra.mjs && node --check bin/lib/oauth.mjs && node --check bin/lib/oauth-page.mjs && node --check bin/lib/profile-store.mjs && npm run check:staging-autoscaling-regression",
35
35
  "check:staging-autoscaling-regression": "node --check scripts/staging-autoscaling-regression.mjs",
36
36
  "test:staging:autoscaling": "node scripts/staging-autoscaling-regression.mjs",
37
37
  "test": "node --test"