@zenifra/cli 0.2.6 → 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 +45 -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 +302 -23
- 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
|
|
@@ -150,12 +150,49 @@ Fluxos:
|
|
|
150
150
|
|
|
151
151
|
Se voce rodar apenas `zenifra deploy`, a CLI mostra a ajuda especifica do comando com uso, flags, exemplos e exemplo de retorno.
|
|
152
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
|
+
|
|
153
190
|
## Configuracao
|
|
154
191
|
|
|
155
192
|
- API padrao: `https://api.zenifra.com/v1`
|
|
156
|
-
- Override de API: `ZENIFRA_API_URL=https://api
|
|
193
|
+
- Override de API: `ZENIFRA_API_URL=https://api.example.test/v1`
|
|
157
194
|
- API key global: `ZENIFRA_API_KEY=znf_sua_chave`
|
|
158
|
-
- 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.
|
|
159
196
|
- Store local de perfis: `~/.config/zenifra-cli/profiles.json`
|
|
160
197
|
- Override do diretorio local: `ZENIFRA_CONFIG_DIR=/path/custom`
|
|
161
198
|
|
|
@@ -185,12 +222,13 @@ A CLI agora trabalha com um perfil ativo. Cada perfil pode ter:
|
|
|
185
222
|
- credencial:
|
|
186
223
|
- `api_key` para automacao organizacional
|
|
187
224
|
- `access_token` para login pessoal, com `selectedOrganizationId`
|
|
225
|
+
- `oauth` para login pelo navegador, com renovacao automatica e organizacao selecionada
|
|
188
226
|
|
|
189
227
|
Exemplos:
|
|
190
228
|
|
|
191
229
|
```bash
|
|
192
230
|
zenifra profile add --name prod --description Producao --api-base https://api.zenifra.com/v1 --mode api-key --key znf_sua_chave
|
|
193
|
-
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
|
|
194
232
|
zenifra profile list
|
|
195
233
|
zenifra profile show staging
|
|
196
234
|
zenifra profile use prod
|
|
@@ -202,7 +240,7 @@ Regras de precedencia:
|
|
|
202
240
|
- `ZENIFRA_API_URL` sobrescreve a API base do perfil ativo apenas para a execucao atual
|
|
203
241
|
- `auth login` e `auth api-key` operam no perfil ativo por padrao
|
|
204
242
|
- `auth login --profile <name>` e `auth api-key --profile <name>` atualizam ou criam outro perfil e o tornam ativo
|
|
205
|
-
- `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
|
|
206
244
|
- `auth logout --revoke` exige um perfil autenticado por login e nao revoga API keys
|
|
207
245
|
|
|
208
246
|
Migracao:
|
|
@@ -284,10 +322,10 @@ Observacoes do wizard:
|
|
|
284
322
|
|
|
285
323
|
## Regressao manual de auto-scaling em staging
|
|
286
324
|
|
|
287
|
-
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.
|
|
288
326
|
|
|
289
327
|
```bash
|
|
290
|
-
export ZENIFRA_API_URL_STG=
|
|
328
|
+
export ZENIFRA_API_URL_STG=http://127.0.0.1:3000/v1
|
|
291
329
|
export ZENIFRA_API_KEY_STG=znf_sua_chave_de_staging
|
|
292
330
|
export ZENIFRA_STAGING_ALLOW_MUTATIONS=1
|
|
293
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]
|
|
@@ -182,6 +188,10 @@ Usage:
|
|
|
182
188
|
zenifra project logs --project <id> [--instance <id>] [--json]
|
|
183
189
|
zenifra project metrics --project <id> [--instance <id>] [--json]
|
|
184
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]
|
|
185
195
|
zenifra project network --project <id> [--view <summary|status-codes|routes|user-agents|request-events|source-ips>] [--json]
|
|
186
196
|
zenifra project image set --project <id> --image <image> [--json]
|
|
187
197
|
zenifra project exposure set --project <id> --exposure <public|private> [--json]
|
|
@@ -214,7 +224,7 @@ Run "zenifra help <command>" or "zenifra <command> --help" for command-specific
|
|
|
214
224
|
const HELP_SPECS = [
|
|
215
225
|
{
|
|
216
226
|
command: 'auth',
|
|
217
|
-
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]',
|
|
218
228
|
description: 'Gerencia a autenticacao dos perfis locais da CLI.',
|
|
219
229
|
examples: ['zenifra auth', 'zenifra auth login --profile staging', 'zenifra auth api-key --profile prod --key znf_0123456789abcdef01234567_abcd...'],
|
|
220
230
|
output: 'Zenifra CLI - auth',
|
|
@@ -222,9 +232,12 @@ const HELP_SPECS = [
|
|
|
222
232
|
},
|
|
223
233
|
{
|
|
224
234
|
command: 'auth login',
|
|
225
|
-
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]',
|
|
226
236
|
description: 'Autentica um usuario Zenifra no perfil ativo ou em um perfil especifico.',
|
|
227
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.',
|
|
228
241
|
'--profile <name> Atualiza ou cria o perfil informado e o torna ativo.',
|
|
229
242
|
'--api-base <url> Usa uma API diferente da producao.',
|
|
230
243
|
'--code <code> Informa o codigo de desafio sem prompt interativo.',
|
|
@@ -246,7 +259,7 @@ const HELP_SPECS = [
|
|
|
246
259
|
command: 'auth logout',
|
|
247
260
|
usage: 'zenifra auth logout [--profile <name>] [--revoke]',
|
|
248
261
|
description: 'Remove a autenticacao do perfil ativo ou do perfil informado.',
|
|
249
|
-
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.'],
|
|
250
263
|
examples: ['zenifra auth logout', 'zenifra auth logout --profile staging', 'zenifra auth logout --revoke'],
|
|
251
264
|
output: 'Autenticacao removida do perfil active.',
|
|
252
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.'],
|
|
@@ -255,7 +268,7 @@ const HELP_SPECS = [
|
|
|
255
268
|
command: 'profile',
|
|
256
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]',
|
|
257
270
|
description: 'Gerencia perfis de ambiente locais, incluindo API base, descricao e credenciais.',
|
|
258
|
-
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...'],
|
|
259
272
|
output: 'Zenifra CLI - profile',
|
|
260
273
|
notes: ['Use "zenifra help profile <subcomando>" para detalhes de list, show, add, edit, use e remove.'],
|
|
261
274
|
},
|
|
@@ -265,7 +278,7 @@ const HELP_SPECS = [
|
|
|
265
278
|
description: 'Lista os perfis locais configurados e indica o perfil ativo.',
|
|
266
279
|
flags: ['--json Imprime a resposta em JSON.'],
|
|
267
280
|
examples: ['zenifra profile list', 'zenifra profile list --json'],
|
|
268
|
-
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',
|
|
269
282
|
jsonOutput: '[{"name":"prod","auth_mode":"api_key","api_base_url":"https://api.zenifra.com/v1","active":true}]',
|
|
270
283
|
},
|
|
271
284
|
{
|
|
@@ -282,7 +295,7 @@ const HELP_SPECS = [
|
|
|
282
295
|
usage: 'zenifra profile add [--name <name>] [--description <text>] [--api-base <url>] [--mode <api-key|login>] [--key <znf_key>] [--json]',
|
|
283
296
|
description: 'Cria um novo perfil e opcionalmente autentica via API key ou login de usuario.',
|
|
284
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.'],
|
|
285
|
-
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'],
|
|
286
299
|
output: 'Perfil staging salvo e definido como ativo.',
|
|
287
300
|
jsonOutput: '{"name":"staging","auth_mode":"api_key","active":true}',
|
|
288
301
|
},
|
|
@@ -417,7 +430,7 @@ const HELP_SPECS = [
|
|
|
417
430
|
},
|
|
418
431
|
{
|
|
419
432
|
command: 'project',
|
|
420
|
-
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 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]',
|
|
421
434
|
description: 'Agrupa comandos operacionais e de introspecao sobre um projeto especifico.',
|
|
422
435
|
examples: ['zenifra project', 'zenifra project info --project proj_1', 'zenifra project env add --project proj_1 --name NODE_ENV --value production'],
|
|
423
436
|
output: 'Zenifra CLI - project',
|
|
@@ -475,6 +488,52 @@ const HELP_SPECS = [
|
|
|
475
488
|
jsonOutput: '{"access":"snapshot","groups":["resources","capacity","clients","activity","key_lifecycle","profile","reliability"],"refresh_seconds":60,"history":null}',
|
|
476
489
|
notes: ['A API permanece como fonte de autorizacao. A CLI nao mantem uma lista local de planos.'],
|
|
477
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.'],
|
|
536
|
+
},
|
|
478
537
|
{
|
|
479
538
|
command: 'project network',
|
|
480
539
|
usage: 'zenifra project network --project <id> [--view <summary|status-codes|routes|user-agents|request-events|source-ips>] [--json]',
|
|
@@ -816,7 +875,11 @@ function normalizeProfileRecord(name, profile = {}) {
|
|
|
816
875
|
return {
|
|
817
876
|
name,
|
|
818
877
|
description: profile.description ? String(profile.description) : '',
|
|
819
|
-
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,
|
|
820
883
|
apiBaseUrl: normalizeApiBaseUrl(profile.apiBaseUrl),
|
|
821
884
|
apiKey: profile.apiKey ? String(profile.apiKey) : undefined,
|
|
822
885
|
accessToken: profile.accessToken ? String(profile.accessToken) : undefined,
|
|
@@ -840,13 +903,36 @@ function sanitizeProfileStore(store) {
|
|
|
840
903
|
return next;
|
|
841
904
|
}
|
|
842
905
|
|
|
843
|
-
|
|
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 } = {}) {
|
|
844
913
|
const normalized = sanitizeProfileStore(store);
|
|
845
|
-
await
|
|
846
|
-
|
|
847
|
-
await chmod(PROFILES_FILE, 0o600).catch(() => undefined);
|
|
914
|
+
await atomicPrivateWrite(PROFILES_FILE, `${JSON.stringify(normalized, null, 2)}\n`, { signal });
|
|
915
|
+
profileBaseline = structuredClone(normalized);
|
|
848
916
|
return normalized;
|
|
849
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
|
+
}
|
|
850
936
|
|
|
851
937
|
async function readLegacySession() {
|
|
852
938
|
if (!existsSync(SESSION_FILE)) return null;
|
|
@@ -882,7 +968,9 @@ async function migrateLegacySession() {
|
|
|
882
968
|
async function readProfileStore() {
|
|
883
969
|
if (existsSync(PROFILES_FILE)) {
|
|
884
970
|
try {
|
|
885
|
-
|
|
971
|
+
const store = sanitizeProfileStore(JSON.parse(await readFile(PROFILES_FILE, 'utf8')));
|
|
972
|
+
profileBaseline = structuredClone(store);
|
|
973
|
+
return store;
|
|
886
974
|
} catch {
|
|
887
975
|
throw new CliError(`Store de perfis invalido em ${PROFILES_FILE}. Corrija o arquivo ou remova-o para recriar.`);
|
|
888
976
|
}
|
|
@@ -919,17 +1007,17 @@ function getProfileRecord(session, explicitName) {
|
|
|
919
1007
|
return store.profiles[name] ? { name, profile: store.profiles[name], store } : null;
|
|
920
1008
|
}
|
|
921
1009
|
|
|
922
|
-
async function persistSession(session) {
|
|
1010
|
+
async function persistSession(session, { signal } = {}) {
|
|
923
1011
|
const store = getStore(session);
|
|
924
1012
|
const profileName = getProfileName(session);
|
|
925
1013
|
if (!profileName) {
|
|
926
|
-
return writeProfileStore(store);
|
|
1014
|
+
return writeProfileStore(store, { signal });
|
|
927
1015
|
}
|
|
928
1016
|
store.profiles[profileName] = normalizeProfileRecord(profileName, session);
|
|
929
1017
|
if (!store.activeProfile || store.activeProfile === profileName) {
|
|
930
1018
|
store.activeProfile = profileName;
|
|
931
1019
|
}
|
|
932
|
-
const normalized = await writeProfileStore(store);
|
|
1020
|
+
const normalized = await writeProfileStore(store, { signal });
|
|
933
1021
|
session.__store = normalized;
|
|
934
1022
|
session.__profileName = profileName;
|
|
935
1023
|
return normalized;
|
|
@@ -1438,7 +1526,14 @@ async function request(session, flags, method, path, {
|
|
|
1438
1526
|
credential: credentialOverride,
|
|
1439
1527
|
headers: extraHeaders = {},
|
|
1440
1528
|
} = {}) {
|
|
1441
|
-
|
|
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;
|
|
1442
1537
|
if (tokenRequired && !credential) {
|
|
1443
1538
|
throw new CliError('Voce precisa autenticar primeiro: zenifra auth login, zenifra auth api-key --key <znf_key> ou ZENIFRA_API_KEY.');
|
|
1444
1539
|
}
|
|
@@ -1470,6 +1565,7 @@ async function request(session, flags, method, path, {
|
|
|
1470
1565
|
headers,
|
|
1471
1566
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
1472
1567
|
signal: AbortSignal.timeout(timeoutMs),
|
|
1568
|
+
...(session.authMode === 'oauth' && credential?.source === 'profile' ? { redirect: 'error' } : {}),
|
|
1473
1569
|
});
|
|
1474
1570
|
} catch (error) {
|
|
1475
1571
|
if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
|
|
@@ -1696,7 +1792,7 @@ function buildLogLineOf(log) {
|
|
|
1696
1792
|
}
|
|
1697
1793
|
|
|
1698
1794
|
function requireProjectId(flags, commandKey) {
|
|
1699
|
-
if (!flags.project) {
|
|
1795
|
+
if (!flags.project || flags.project === true) {
|
|
1700
1796
|
printCommandHelpAndFail(commandKey)
|
|
1701
1797
|
return null
|
|
1702
1798
|
}
|
|
@@ -1816,7 +1912,55 @@ async function resolveOrgId(session, flags, { interactive = true } = {}) {
|
|
|
1816
1912
|
return session.selectedOrganizationId;
|
|
1817
1913
|
}
|
|
1818
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
|
+
|
|
1819
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.');
|
|
1820
1964
|
const targetSession = ensureProfile(session, flags.profile, { activate: Boolean(flags.profile) || !getProfileName(session) });
|
|
1821
1965
|
const email = String(flags.email || await prompt('Email'));
|
|
1822
1966
|
const password = String(flags.password || await promptHidden('Senha'));
|
|
@@ -1908,6 +2052,20 @@ async function handleLogout(session, flags) {
|
|
|
1908
2052
|
throw new CliError('Nenhum perfil ativo configurado. Crie um perfil com "zenifra profile add" ou autentique com "zenifra auth login".');
|
|
1909
2053
|
}
|
|
1910
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
|
+
}
|
|
1911
2069
|
if (flags.revoke) {
|
|
1912
2070
|
if (!target.accessToken) {
|
|
1913
2071
|
throw new CliError('A revogacao exige um perfil autenticado por login de usuario; API keys devem ser revogadas na organizacao.');
|
|
@@ -1920,6 +2078,7 @@ async function handleLogout(session, flags) {
|
|
|
1920
2078
|
},
|
|
1921
2079
|
});
|
|
1922
2080
|
}
|
|
2081
|
+
target.oauth = undefined;
|
|
1923
2082
|
target.accessToken = undefined;
|
|
1924
2083
|
target.apiKey = undefined;
|
|
1925
2084
|
target.selectedOrganizationId = undefined;
|
|
@@ -1942,7 +2101,7 @@ function profileOutput(profile, activeName) {
|
|
|
1942
2101
|
return {
|
|
1943
2102
|
name: profile.name,
|
|
1944
2103
|
description: profile.description || '',
|
|
1945
|
-
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',
|
|
1946
2105
|
api_base_url: profile.apiBaseUrl || DEFAULT_API_BASE_URL,
|
|
1947
2106
|
selected_organization_id: profile.selectedOrganizationId,
|
|
1948
2107
|
has_api_key: Boolean(profile.apiKey),
|
|
@@ -3850,6 +4009,118 @@ async function handleProjectAutoscalingEvents(session, flags) {
|
|
|
3850
4009
|
printAutoscalingEvents(data);
|
|
3851
4010
|
}
|
|
3852
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
|
+
|
|
3853
4124
|
async function handleProjectBillingUsage(session, flags) {
|
|
3854
4125
|
const projectId = requireProjectId(flags, 'project billing usage');
|
|
3855
4126
|
if (!projectId) return;
|
|
@@ -4221,6 +4492,14 @@ async function main() {
|
|
|
4221
4492
|
if (command === 'valkey' && subcommand === 'connection') return handleValkeyConnection(session, flags);
|
|
4222
4493
|
if (command === 'valkey' && subcommand === 'credentials' && positional[2] === 'rotate') return handleValkeyCredentialRotate(session, flags);
|
|
4223
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);
|
|
4224
4503
|
if (command === 'project' && subcommand === 'info') return handleProjectInfo(session, flags);
|
|
4225
4504
|
if (command === 'project' && subcommand === 'url') return handleProjectUrl(session, flags);
|
|
4226
4505
|
if (command === 'project' && subcommand === 'logs') return handleProjectLogs(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"
|