@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 +81 -7
- package/bin/lib/oauth-page.mjs +65 -0
- package/bin/lib/oauth.mjs +152 -0
- package/bin/lib/profile-store.mjs +56 -0
- package/bin/zenifra.mjs +476 -37
- package/package.json +2 -2
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
|
|
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
|
|
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=
|
|
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
|
|
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`
|
|
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=
|
|
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 {
|
|
4
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
|
-
import {
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: '
|
|
460
|
-
jsonOutput: '{"type":"
|
|
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:
|
|
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
|
-
|
|
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
|
|
830
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
2381
|
-
{ label: 'Mes', value: (plan) =>
|
|
2382
|
-
{ label: 'Ano', value: (plan) =>
|
|
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) =>
|
|
2393
|
-
{ label: 'Mes', value: (plan) =>
|
|
2394
|
-
{ label: 'Ano', value: (plan) =>
|
|
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.
|
|
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"
|