@zenifra/cli 0.2.6 → 0.3.1
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 +58 -8
- 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/lib/public-ux.mjs +40 -0
- package/bin/zenifra.mjs +333 -27
- 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
|
|
@@ -148,14 +148,55 @@ Fluxos:
|
|
|
148
148
|
- `zenifra deploy`: dispara o build/deploy GitHub e retorna o `build_id`
|
|
149
149
|
- `zenifra deploy watch`: usa esse `build_id` para acompanhar o build em tempo real e imprimir os logs incrementais ate o fim
|
|
150
150
|
|
|
151
|
+
Antes de uma mutacao, confirme o perfil, a API e a organizacao ativa. Para criacoes, confirme tambem o catalogo, plano, pagamento, exposicao, dominio, origem do deploy, porta, instancias e ambientes. Depois, leia o projeto de volta e acompanhe o build/deployment ate um estado terminal.
|
|
152
|
+
|
|
153
|
+
O dominio principal e um dominio personalizado sao campos diferentes. Nao repita o dominio principal em `config.custom_domains`; depois de adicionar um dominio personalizado, valide DNS/TLS e leia a URL final antes de considerar a operacao concluida.
|
|
154
|
+
|
|
151
155
|
Se voce rodar apenas `zenifra deploy`, a CLI mostra a ajuda especifica do comando com uso, flags, exemplos e exemplo de retorno.
|
|
152
156
|
|
|
157
|
+
## Login pelo navegador (OAuth)
|
|
158
|
+
|
|
159
|
+
Enderecos em `example.test` sao ficticios; substitua pela API autorizada do seu ambiente.
|
|
160
|
+
|
|
161
|
+
Use uma API com o login OAuth da CLI habilitado. O comando abre o Console para voce
|
|
162
|
+
entrar, concluir a verificacao de seguranca e aprovar o acesso da CLI a sua conta:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
zenifra auth login --oauth --profile staging --api-base https://api.example.test/v1
|
|
166
|
+
zenifra orgs
|
|
167
|
+
zenifra org set --org <id>
|
|
168
|
+
zenifra projects
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Para executar a copia local deste repositorio, substitua `zenifra` por
|
|
172
|
+
`node bin/zenifra.mjs`. Para uma API local, use seu endereco loopback, por exemplo
|
|
173
|
+
`--api-base http://127.0.0.1:3000/v1`. Enderecos externos exigem HTTPS.
|
|
174
|
+
|
|
175
|
+
- `--read-only` solicita somente leitura; as permissoes da sua organizacao continuam valendo.
|
|
176
|
+
- `--no-browser` mostra o endereco para abrir manualmente no navegador da mesma maquina.
|
|
177
|
+
- O retorno usa uma porta local temporaria em `127.0.0.1`; nao precisa cadastrar uma porta fixa.
|
|
178
|
+
- O login aguarda ate tres minutos. Use `Ctrl+C` para cancelar.
|
|
179
|
+
- Os tokens ficam no perfil local privado e sao renovados automaticamente antes de expirar.
|
|
180
|
+
- Nao altere `--api-base` ou `ZENIFRA_API_URL` para reutilizar o token em outra API: crie outro perfil.
|
|
181
|
+
- `ZENIFRA_API_KEY` continua tendo prioridade e gera um aviso quando substitui um perfil OAuth.
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
zenifra auth logout # remove apenas a autenticacao local
|
|
185
|
+
zenifra auth logout --revoke # revoga somente a conexao OAuth deste perfil
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Sem `--oauth`, `auth login` mantem o login por email, senha e verificacao.
|
|
189
|
+
Uma falha ao renovar a sessao exige novo login; a CLI nao repete automaticamente
|
|
190
|
+
operacoes de alteracao. Se um comando for encerrado abruptamente e deixar
|
|
191
|
+
`profiles.lock`, verifique o PID indicado nesse arquivo e remova o lock somente
|
|
192
|
+
depois de confirmar que o comando terminou.
|
|
193
|
+
|
|
153
194
|
## Configuracao
|
|
154
195
|
|
|
155
196
|
- API padrao: `https://api.zenifra.com/v1`
|
|
156
|
-
- Override de API: `ZENIFRA_API_URL=https://api
|
|
197
|
+
- Override de API: `ZENIFRA_API_URL=https://api.example.test/v1`
|
|
157
198
|
- API key global: `ZENIFRA_API_KEY=znf_sua_chave`
|
|
158
|
-
- Timeout de cada request HTTP: `ZENIFRA_HTTP_TIMEOUT_MS=
|
|
199
|
+
- Timeout padrao de cada request HTTP: 5 minutos (`ZENIFRA_HTTP_TIMEOUT_MS=300000`). A variavel permite ajustar esse limite.
|
|
159
200
|
- Store local de perfis: `~/.config/zenifra-cli/profiles.json`
|
|
160
201
|
- Override do diretorio local: `ZENIFRA_CONFIG_DIR=/path/custom`
|
|
161
202
|
|
|
@@ -185,12 +226,13 @@ A CLI agora trabalha com um perfil ativo. Cada perfil pode ter:
|
|
|
185
226
|
- credencial:
|
|
186
227
|
- `api_key` para automacao organizacional
|
|
187
228
|
- `access_token` para login pessoal, com `selectedOrganizationId`
|
|
229
|
+
- `oauth` para login pelo navegador, com renovacao automatica e organizacao selecionada
|
|
188
230
|
|
|
189
231
|
Exemplos:
|
|
190
232
|
|
|
191
233
|
```bash
|
|
192
234
|
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
|
|
235
|
+
zenifra profile add --name staging --description Homologacao --api-base https://api.example.test/v1 --mode login
|
|
194
236
|
zenifra profile list
|
|
195
237
|
zenifra profile show staging
|
|
196
238
|
zenifra profile use prod
|
|
@@ -202,7 +244,7 @@ Regras de precedencia:
|
|
|
202
244
|
- `ZENIFRA_API_URL` sobrescreve a API base do perfil ativo apenas para a execucao atual
|
|
203
245
|
- `auth login` e `auth api-key` operam no perfil ativo por padrao
|
|
204
246
|
- `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`
|
|
247
|
+
- `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
248
|
- `auth logout --revoke` exige um perfil autenticado por login e nao revoga API keys
|
|
207
249
|
|
|
208
250
|
Migracao:
|
|
@@ -279,15 +321,23 @@ Observacoes do wizard:
|
|
|
279
321
|
- em projetos de banco, o wizard nao pergunta `username`, `password` nem `database name`
|
|
280
322
|
- em projetos de banco, a CLI preenche apenas campos tecnicos minimos exigidos pela validacao atual da API
|
|
281
323
|
- em projetos Valkey, a capacidade é definida pelo plano e a CLI não pergunta instâncias, imagem, variáveis de ambiente ou exposição HTTP
|
|
282
|
-
- a conexão mascarada pode ser consultada a qualquer momento;
|
|
324
|
+
- a conexão mascarada pode ser consultada a qualquer momento; uma rotação concluída pode salvar a conexão utilizável em arquivo privado com `--connection-file <path>`
|
|
283
325
|
- `valkey credentials rotate` retorna uma operação assíncrona; use `--wait` ou `valkey credentials status` para acompanhar
|
|
284
326
|
|
|
327
|
+
Exemplo de entrega segura para automação local:
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
zenifra valkey credentials rotate --project <project-id> --wait --connection-file /caminho/privado/redis-url.txt --json
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
O arquivo é criado com permissão privada; a conexão não aparece na saída do comando quando essa opção é usada.
|
|
334
|
+
|
|
285
335
|
## Regressao manual de auto-scaling em staging
|
|
286
336
|
|
|
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.
|
|
337
|
+
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
338
|
|
|
289
339
|
```bash
|
|
290
|
-
export ZENIFRA_API_URL_STG=
|
|
340
|
+
export ZENIFRA_API_URL_STG=http://127.0.0.1:3000/v1
|
|
291
341
|
export ZENIFRA_API_KEY_STG=znf_sua_chave_de_staging
|
|
292
342
|
export ZENIFRA_STAGING_ALLOW_MUTATIONS=1
|
|
293
343
|
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
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { atomicPrivateWrite } from './profile-store.mjs';
|
|
2
|
+
|
|
3
|
+
const MASKED_SECRET_PATTERN = /\*{4,}|\[redacted\]|<redacted>/i;
|
|
4
|
+
|
|
5
|
+
function normalizeDomain(value) {
|
|
6
|
+
return String(value || '').trim().replace(/\.+$/, '').toLowerCase();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function normalizeCustomDomains(domains, { primaryDomain } = {}) {
|
|
10
|
+
if (!Array.isArray(domains)) return [];
|
|
11
|
+
const normalized = domains.map(normalizeDomain).filter(Boolean);
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
for (const domain of normalized) {
|
|
14
|
+
if (seen.has(domain)) throw new Error(`Duplicate custom domain: ${domain}`);
|
|
15
|
+
seen.add(domain);
|
|
16
|
+
}
|
|
17
|
+
const primary = normalizeDomain(primaryDomain);
|
|
18
|
+
if (primary && seen.has(primary)) {
|
|
19
|
+
throw new Error(`Custom domain conflicts with the primary domain: ${primary}`);
|
|
20
|
+
}
|
|
21
|
+
return normalized;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function validateValkeyConnection(value) {
|
|
25
|
+
const connection = String(value ?? '');
|
|
26
|
+
if (!connection || connection.trim() === '' || MASKED_SECRET_PATTERN.test(connection)) {
|
|
27
|
+
throw new Error('The Valkey connection is masked. Rotate credentials and save the one-time connection securely.');
|
|
28
|
+
}
|
|
29
|
+
return connection;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function writeValkeyConnectionFile(path, value) {
|
|
33
|
+
const connection = validateValkeyConnection(value);
|
|
34
|
+
await atomicPrivateWrite(path, connection);
|
|
35
|
+
return connection;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isMaskedSecret(value) {
|
|
39
|
+
return MASKED_SECRET_PATTERN.test(String(value || ''));
|
|
40
|
+
}
|
package/bin/zenifra.mjs
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
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';
|
|
12
|
+
import { normalizeCustomDomains, writeValkeyConnectionFile } from './lib/public-ux.mjs';
|
|
10
13
|
|
|
11
14
|
const DEFAULT_API_BASE_URL = 'https://api.zenifra.com/v1';
|
|
12
15
|
const DOCS_BASE_URL = 'https://docs.zenifra.com/pt';
|
|
@@ -23,13 +26,17 @@ const SESSION_FILE = join(SESSION_DIR, 'session.json');
|
|
|
23
26
|
const PROFILES_FILE = join(SESSION_DIR, 'profiles.json');
|
|
24
27
|
const PROFILE_STORE_VERSION = 1;
|
|
25
28
|
const DEFAULT_PROFILE_NAME = 'default';
|
|
26
|
-
const DEFAULT_HTTP_TIMEOUT_MS =
|
|
29
|
+
const DEFAULT_HTTP_TIMEOUT_MS = 300_000;
|
|
27
30
|
const KNOWN_FLAG_NAMES = new Set([
|
|
28
31
|
'apiBase',
|
|
32
|
+
'oauth',
|
|
33
|
+
'readOnly',
|
|
34
|
+
'noBrowser',
|
|
29
35
|
'branch',
|
|
30
36
|
'build',
|
|
31
37
|
'challengeToken',
|
|
32
38
|
'code',
|
|
39
|
+
'connectionFile',
|
|
33
40
|
'commitSha',
|
|
34
41
|
'config',
|
|
35
42
|
'count',
|
|
@@ -58,6 +65,7 @@ const KNOWN_FLAG_NAMES = new Set([
|
|
|
58
65
|
'operation',
|
|
59
66
|
'page',
|
|
60
67
|
'password',
|
|
68
|
+
'path',
|
|
61
69
|
'paymentMode',
|
|
62
70
|
'plan',
|
|
63
71
|
'profile',
|
|
@@ -158,7 +166,7 @@ function usage() {
|
|
|
158
166
|
|
|
159
167
|
Usage:
|
|
160
168
|
zenifra help <command>
|
|
161
|
-
zenifra auth login [--profile <name>] [--api-base <url>] [--code <code>]
|
|
169
|
+
zenifra auth login [--profile <name>] [--api-base <url>] [--code <code>] [--oauth] [--read-only] [--no-browser]
|
|
162
170
|
zenifra auth api-key --key <znf_key> [--profile <name>] [--api-base <url>]
|
|
163
171
|
zenifra auth logout [--profile <name>] [--revoke]
|
|
164
172
|
zenifra profile list [--json]
|
|
@@ -182,6 +190,10 @@ Usage:
|
|
|
182
190
|
zenifra project logs --project <id> [--instance <id>] [--json]
|
|
183
191
|
zenifra project metrics --project <id> [--instance <id>] [--json]
|
|
184
192
|
zenifra project metrics capabilities --project <id> [--json]
|
|
193
|
+
zenifra project healthcheck get --project <id> [--json]
|
|
194
|
+
zenifra project healthcheck set --project <id> --path /health [--json]
|
|
195
|
+
zenifra project healthcheck disable --project <id> [--json]
|
|
196
|
+
zenifra project healthcheck failures --project <id> [--page <n>] [--limit <n>] [--json]
|
|
185
197
|
zenifra project network --project <id> [--view <summary|status-codes|routes|user-agents|request-events|source-ips>] [--json]
|
|
186
198
|
zenifra project image set --project <id> --image <image> [--json]
|
|
187
199
|
zenifra project exposure set --project <id> --exposure <public|private> [--json]
|
|
@@ -214,7 +226,7 @@ Run "zenifra help <command>" or "zenifra <command> --help" for command-specific
|
|
|
214
226
|
const HELP_SPECS = [
|
|
215
227
|
{
|
|
216
228
|
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]',
|
|
229
|
+
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
230
|
description: 'Gerencia a autenticacao dos perfis locais da CLI.',
|
|
219
231
|
examples: ['zenifra auth', 'zenifra auth login --profile staging', 'zenifra auth api-key --profile prod --key znf_0123456789abcdef01234567_abcd...'],
|
|
220
232
|
output: 'Zenifra CLI - auth',
|
|
@@ -222,9 +234,12 @@ const HELP_SPECS = [
|
|
|
222
234
|
},
|
|
223
235
|
{
|
|
224
236
|
command: 'auth login',
|
|
225
|
-
usage: 'zenifra auth login [--profile <name>] [--api-base <url>] [--code <code>]',
|
|
237
|
+
usage: 'zenifra auth login [--profile <name>] [--api-base <url>] [--code <code>] [--oauth] [--read-only] [--no-browser]',
|
|
226
238
|
description: 'Autentica um usuario Zenifra no perfil ativo ou em um perfil especifico.',
|
|
227
239
|
flags: [
|
|
240
|
+
'--oauth Entra pelo navegador usando OAuth e PKCE.',
|
|
241
|
+
'--read-only Solicita somente leitura com --oauth.',
|
|
242
|
+
'--no-browser Mostra o endereco sem abrir o navegador com --oauth.',
|
|
228
243
|
'--profile <name> Atualiza ou cria o perfil informado e o torna ativo.',
|
|
229
244
|
'--api-base <url> Usa uma API diferente da producao.',
|
|
230
245
|
'--code <code> Informa o codigo de desafio sem prompt interativo.',
|
|
@@ -246,7 +261,7 @@ const HELP_SPECS = [
|
|
|
246
261
|
command: 'auth logout',
|
|
247
262
|
usage: 'zenifra auth logout [--profile <name>] [--revoke]',
|
|
248
263
|
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
|
|
264
|
+
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
265
|
examples: ['zenifra auth logout', 'zenifra auth logout --profile staging', 'zenifra auth logout --revoke'],
|
|
251
266
|
output: 'Autenticacao removida do perfil active.',
|
|
252
267
|
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 +270,7 @@ const HELP_SPECS = [
|
|
|
255
270
|
command: 'profile',
|
|
256
271
|
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
272
|
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
|
|
273
|
+
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
274
|
output: 'Zenifra CLI - profile',
|
|
260
275
|
notes: ['Use "zenifra help profile <subcomando>" para detalhes de list, show, add, edit, use e remove.'],
|
|
261
276
|
},
|
|
@@ -265,7 +280,7 @@ const HELP_SPECS = [
|
|
|
265
280
|
description: 'Lista os perfis locais configurados e indica o perfil ativo.',
|
|
266
281
|
flags: ['--json Imprime a resposta em JSON.'],
|
|
267
282
|
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
|
|
283
|
+
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
284
|
jsonOutput: '[{"name":"prod","auth_mode":"api_key","api_base_url":"https://api.zenifra.com/v1","active":true}]',
|
|
270
285
|
},
|
|
271
286
|
{
|
|
@@ -282,7 +297,7 @@ const HELP_SPECS = [
|
|
|
282
297
|
usage: 'zenifra profile add [--name <name>] [--description <text>] [--api-base <url>] [--mode <api-key|login>] [--key <znf_key>] [--json]',
|
|
283
298
|
description: 'Cria um novo perfil e opcionalmente autentica via API key ou login de usuario.',
|
|
284
299
|
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
|
|
300
|
+
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
301
|
output: 'Perfil staging salvo e definido como ativo.',
|
|
287
302
|
jsonOutput: '{"name":"staging","auth_mode":"api_key","active":true}',
|
|
288
303
|
},
|
|
@@ -381,18 +396,18 @@ const HELP_SPECS = [
|
|
|
381
396
|
},
|
|
382
397
|
{
|
|
383
398
|
command: 'valkey credentials rotate',
|
|
384
|
-
usage: 'zenifra valkey credentials rotate --project <id> [--idempotency-key <key>] [--wait] [--interval <seconds>] [--timeout <seconds>] [--json]',
|
|
399
|
+
usage: 'zenifra valkey credentials rotate --project <id> [--idempotency-key <key>] [--wait] [--interval <seconds>] [--timeout <seconds>] [--connection-file <path>] [--json]',
|
|
385
400
|
description: 'Solicita a renovacao da credencial e retorna uma operacao acompanhavel.',
|
|
386
|
-
flags: ['--project <id> ID do projeto.', '--idempotency-key <key> Chave para repetir a mesma solicitacao.', '--wait Aguarda a conclusao.', '--interval <seconds> Intervalo do polling. Padrao: 2.', '--timeout <seconds> Timeout total. Padrao: 900.', '--json Imprime a resposta em JSON.'],
|
|
401
|
+
flags: ['--project <id> ID do projeto.', '--idempotency-key <key> Chave para repetir a mesma solicitacao.', '--wait Aguarda a conclusao.', '--interval <seconds> Intervalo do polling. Padrao: 2.', '--timeout <seconds> Timeout total. Padrao: 900.', '--connection-file <path> Salva a conexao concluida em arquivo privado.', '--json Imprime a resposta em JSON.'],
|
|
387
402
|
examples: ['zenifra valkey credentials rotate --project proj_1', 'zenifra valkey credentials rotate --project proj_1 --wait --timeout 120'],
|
|
388
403
|
output: 'Operacao aceita: rotation_123\nEstado: accepted',
|
|
389
404
|
notes: ['Salve a nova string de conexao quando a operacao atingir completed.'],
|
|
390
405
|
},
|
|
391
406
|
{
|
|
392
407
|
command: 'valkey credentials status',
|
|
393
|
-
usage: 'zenifra valkey credentials status --project <id> --operation <id> [--json]',
|
|
408
|
+
usage: 'zenifra valkey credentials status --project <id> --operation <id> [--connection-file <path>] [--json]',
|
|
394
409
|
description: 'Consulta o estado de uma renovacao de credencial Valkey.',
|
|
395
|
-
flags: ['--project <id> ID do projeto.', '--operation <id> ID da operacao.', '--json Imprime a resposta em JSON.'],
|
|
410
|
+
flags: ['--project <id> ID do projeto.', '--operation <id> ID da operacao.', '--connection-file <path> Salva a conexao concluida em arquivo privado.', '--json Imprime a resposta em JSON.'],
|
|
396
411
|
examples: ['zenifra valkey credentials status --project proj_1 --operation rotation_123'],
|
|
397
412
|
output: 'Operacao: rotation_123\nEstado: completed',
|
|
398
413
|
},
|
|
@@ -417,7 +432,7 @@ const HELP_SPECS = [
|
|
|
417
432
|
},
|
|
418
433
|
{
|
|
419
434
|
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]',
|
|
435
|
+
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
436
|
description: 'Agrupa comandos operacionais e de introspecao sobre um projeto especifico.',
|
|
422
437
|
examples: ['zenifra project', 'zenifra project info --project proj_1', 'zenifra project env add --project proj_1 --name NODE_ENV --value production'],
|
|
423
438
|
output: 'Zenifra CLI - project',
|
|
@@ -475,6 +490,52 @@ const HELP_SPECS = [
|
|
|
475
490
|
jsonOutput: '{"access":"snapshot","groups":["resources","capacity","clients","activity","key_lifecycle","profile","reliability"],"refresh_seconds":60,"history":null}',
|
|
476
491
|
notes: ['A API permanece como fonte de autorizacao. A CLI nao mantem uma lista local de planos.'],
|
|
477
492
|
},
|
|
493
|
+
{
|
|
494
|
+
command: 'project healthcheck',
|
|
495
|
+
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]',
|
|
496
|
+
description: 'Consulta e gerencia a verificacao de saude de um projeto HTTP.',
|
|
497
|
+
examples: ['zenifra project healthcheck get --project 507f1f77bcf86cd799439012', 'zenifra project healthcheck failures --project 507f1f77bcf86cd799439012'],
|
|
498
|
+
output: 'Zenifra CLI - project healthcheck',
|
|
499
|
+
notes: ['Use "zenifra help project healthcheck <subcomando>" para detalhes de get, set, disable e failures.'],
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
command: 'project healthcheck get',
|
|
503
|
+
usage: 'zenifra project healthcheck get --project <id> [--json]',
|
|
504
|
+
description: 'Mostra a configuracao e o estado atual da verificacao de saude do projeto.',
|
|
505
|
+
flags: ['--project <id> ID do projeto.', '--json Imprime a resposta publica em JSON.'],
|
|
506
|
+
examples: ['zenifra project healthcheck get --project 507f1f77bcf86cd799439012'],
|
|
507
|
+
output: 'Campo Valor\n---------- ----------\nStatus ativo\nRota /health\nVerificacao configurada',
|
|
508
|
+
jsonOutput: '{"available":true,"healthcheck":{"enabled":true,"path":"/health"},"interval_seconds":60,"retention_days":30}',
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
command: 'project healthcheck set',
|
|
512
|
+
usage: 'zenifra project healthcheck set --project <id> --path /health [--json]',
|
|
513
|
+
description: 'Ativa a verificacao de saude do projeto usando a rota informada.',
|
|
514
|
+
flags: ['--project <id> ID do projeto.', '--path <path> Rota HTTP absoluta da aplicacao.', '--json Imprime a resposta publica em JSON.'],
|
|
515
|
+
examples: ['zenifra project healthcheck set --project 507f1f77bcf86cd799439012 --path /health'],
|
|
516
|
+
output: 'Verificacao de saude ativada.\nRota: /health',
|
|
517
|
+
jsonOutput: '{"healthcheck":{"enabled":true,"path":"/health"},"interval_seconds":60,"retention_days":30}',
|
|
518
|
+
notes: ['A rota deve comecar com / e nao pode conter host, query string ou fragmento.'],
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
command: 'project healthcheck disable',
|
|
522
|
+
usage: 'zenifra project healthcheck disable --project <id> [--json]',
|
|
523
|
+
description: 'Desativa a verificacao de saude do projeto sem apagar a rota configurada.',
|
|
524
|
+
flags: ['--project <id> ID do projeto.', '--json Imprime a resposta publica em JSON.'],
|
|
525
|
+
examples: ['zenifra project healthcheck disable --project 507f1f77bcf86cd799439012'],
|
|
526
|
+
output: 'Verificacao de saude desativada.',
|
|
527
|
+
jsonOutput: '{"healthcheck":{"enabled":false,"path":"/health"},"interval_seconds":60,"retention_days":30}',
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
command: 'project healthcheck failures',
|
|
531
|
+
usage: 'zenifra project healthcheck failures --project <id> [--page <n>] [--limit <n>] [--json]',
|
|
532
|
+
description: 'Lista as falhas publicas de verificacao de saude dos ultimos 30 dias.',
|
|
533
|
+
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.'],
|
|
534
|
+
examples: ['zenifra project healthcheck failures --project 507f1f77bcf86cd799439012', 'zenifra project healthcheck failures --project 507f1f77bcf86cd799439012 --page 2 --limit 10'],
|
|
535
|
+
output: 'Quando Status\n2026-08-31T12:00:00.000Z 503',
|
|
536
|
+
jsonOutput: '{"failures":[{"occurred_at":"2026-08-31T12:00:00.000Z","status_code":503}],"pagination":{"page":1,"limit":10,"total":1,"total_pages":1}}',
|
|
537
|
+
notes: ['O historico e limitado aos 30 dias anteriores.'],
|
|
538
|
+
},
|
|
478
539
|
{
|
|
479
540
|
command: 'project network',
|
|
480
541
|
usage: 'zenifra project network --project <id> [--view <summary|status-codes|routes|user-agents|request-events|source-ips>] [--json]',
|
|
@@ -816,7 +877,11 @@ function normalizeProfileRecord(name, profile = {}) {
|
|
|
816
877
|
return {
|
|
817
878
|
name,
|
|
818
879
|
description: profile.description ? String(profile.description) : '',
|
|
819
|
-
authMode:
|
|
880
|
+
authMode: ['oauth', 'access_token'].includes(profile.authMode) ? profile.authMode : 'api_key',
|
|
881
|
+
oauth: profile.authMode === 'oauth' && isRecord(profile.oauth) ? {
|
|
882
|
+
issuer: profile.oauth.issuer, resource: profile.oauth.resource, clientId: profile.oauth.clientId,
|
|
883
|
+
refreshToken: profile.oauth.refreshToken, expiresAt: profile.oauth.expiresAt, scope: profile.oauth.scope,
|
|
884
|
+
} : undefined,
|
|
820
885
|
apiBaseUrl: normalizeApiBaseUrl(profile.apiBaseUrl),
|
|
821
886
|
apiKey: profile.apiKey ? String(profile.apiKey) : undefined,
|
|
822
887
|
accessToken: profile.accessToken ? String(profile.accessToken) : undefined,
|
|
@@ -840,13 +905,36 @@ function sanitizeProfileStore(store) {
|
|
|
840
905
|
return next;
|
|
841
906
|
}
|
|
842
907
|
|
|
843
|
-
|
|
908
|
+
let profileBaseline = emptyProfileStore();
|
|
909
|
+
const sameProfileValue = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
910
|
+
async function readCurrentProfileStore() {
|
|
911
|
+
try { return sanitizeProfileStore(JSON.parse(await readFile(PROFILES_FILE, 'utf8'))); }
|
|
912
|
+
catch (error) { if (error.code === 'ENOENT') return emptyProfileStore(); throw new CliError('Arquivo de perfis invalido. Verifique sua configuracao local.'); }
|
|
913
|
+
}
|
|
914
|
+
async function saveProfileStore(store, { signal } = {}) {
|
|
844
915
|
const normalized = sanitizeProfileStore(store);
|
|
845
|
-
await
|
|
846
|
-
|
|
847
|
-
await chmod(PROFILES_FILE, 0o600).catch(() => undefined);
|
|
916
|
+
await atomicPrivateWrite(PROFILES_FILE, `${JSON.stringify(normalized, null, 2)}\n`, { signal });
|
|
917
|
+
profileBaseline = structuredClone(normalized);
|
|
848
918
|
return normalized;
|
|
849
919
|
}
|
|
920
|
+
async function writeProfileStore(store, { signal } = {}) {
|
|
921
|
+
const proposed = sanitizeProfileStore(store);
|
|
922
|
+
const baseline = profileBaseline;
|
|
923
|
+
return withProfileLock(SESSION_DIR, async () => {
|
|
924
|
+
const current = await readCurrentProfileStore();
|
|
925
|
+
for (const name of new Set([...Object.keys(baseline.profiles), ...Object.keys(proposed.profiles)])) {
|
|
926
|
+
const before = baseline.profiles[name], after = proposed.profiles[name];
|
|
927
|
+
if (sameProfileValue(before, after)) continue;
|
|
928
|
+
if (!sameProfileValue(current.profiles[name], before)) {
|
|
929
|
+
throw new CliError('O perfil foi alterado por outro comando. Repita o comando para usar a configuracao atual.');
|
|
930
|
+
}
|
|
931
|
+
if (after) current.profiles[name] = after; else delete current.profiles[name];
|
|
932
|
+
}
|
|
933
|
+
if (proposed.activeProfile !== baseline.activeProfile) current.activeProfile = proposed.activeProfile;
|
|
934
|
+
signal?.throwIfAborted();
|
|
935
|
+
return saveProfileStore(current, { signal });
|
|
936
|
+
}, { signal });
|
|
937
|
+
}
|
|
850
938
|
|
|
851
939
|
async function readLegacySession() {
|
|
852
940
|
if (!existsSync(SESSION_FILE)) return null;
|
|
@@ -882,7 +970,9 @@ async function migrateLegacySession() {
|
|
|
882
970
|
async function readProfileStore() {
|
|
883
971
|
if (existsSync(PROFILES_FILE)) {
|
|
884
972
|
try {
|
|
885
|
-
|
|
973
|
+
const store = sanitizeProfileStore(JSON.parse(await readFile(PROFILES_FILE, 'utf8')));
|
|
974
|
+
profileBaseline = structuredClone(store);
|
|
975
|
+
return store;
|
|
886
976
|
} catch {
|
|
887
977
|
throw new CliError(`Store de perfis invalido em ${PROFILES_FILE}. Corrija o arquivo ou remova-o para recriar.`);
|
|
888
978
|
}
|
|
@@ -919,17 +1009,17 @@ function getProfileRecord(session, explicitName) {
|
|
|
919
1009
|
return store.profiles[name] ? { name, profile: store.profiles[name], store } : null;
|
|
920
1010
|
}
|
|
921
1011
|
|
|
922
|
-
async function persistSession(session) {
|
|
1012
|
+
async function persistSession(session, { signal } = {}) {
|
|
923
1013
|
const store = getStore(session);
|
|
924
1014
|
const profileName = getProfileName(session);
|
|
925
1015
|
if (!profileName) {
|
|
926
|
-
return writeProfileStore(store);
|
|
1016
|
+
return writeProfileStore(store, { signal });
|
|
927
1017
|
}
|
|
928
1018
|
store.profiles[profileName] = normalizeProfileRecord(profileName, session);
|
|
929
1019
|
if (!store.activeProfile || store.activeProfile === profileName) {
|
|
930
1020
|
store.activeProfile = profileName;
|
|
931
1021
|
}
|
|
932
|
-
const normalized = await writeProfileStore(store);
|
|
1022
|
+
const normalized = await writeProfileStore(store, { signal });
|
|
933
1023
|
session.__store = normalized;
|
|
934
1024
|
session.__profileName = profileName;
|
|
935
1025
|
return normalized;
|
|
@@ -1438,7 +1528,14 @@ async function request(session, flags, method, path, {
|
|
|
1438
1528
|
credential: credentialOverride,
|
|
1439
1529
|
headers: extraHeaders = {},
|
|
1440
1530
|
} = {}) {
|
|
1441
|
-
|
|
1531
|
+
if (tokenRequired && !credentialOverride && session.authMode === 'oauth') {
|
|
1532
|
+
if (process.env.ZENIFRA_API_KEY) {
|
|
1533
|
+
if (!session.__oauthShadowWarned) process.stderr.write('Aviso: ZENIFRA_API_KEY tem prioridade sobre o perfil OAuth.\n');
|
|
1534
|
+
session.__oauthShadowWarned = true;
|
|
1535
|
+
} else await prepareOAuthCredential(session, flags);
|
|
1536
|
+
}
|
|
1537
|
+
let credential = credentialOverride || resolveCredential(session);
|
|
1538
|
+
if (!tokenRequired && session.authMode === 'oauth' && credential?.source === 'profile') credential = null;
|
|
1442
1539
|
if (tokenRequired && !credential) {
|
|
1443
1540
|
throw new CliError('Voce precisa autenticar primeiro: zenifra auth login, zenifra auth api-key --key <znf_key> ou ZENIFRA_API_KEY.');
|
|
1444
1541
|
}
|
|
@@ -1470,6 +1567,7 @@ async function request(session, flags, method, path, {
|
|
|
1470
1567
|
headers,
|
|
1471
1568
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
1472
1569
|
signal: AbortSignal.timeout(timeoutMs),
|
|
1570
|
+
...(session.authMode === 'oauth' && credential?.source === 'profile' ? { redirect: 'error' } : {}),
|
|
1473
1571
|
});
|
|
1474
1572
|
} catch (error) {
|
|
1475
1573
|
if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
|
|
@@ -1696,7 +1794,7 @@ function buildLogLineOf(log) {
|
|
|
1696
1794
|
}
|
|
1697
1795
|
|
|
1698
1796
|
function requireProjectId(flags, commandKey) {
|
|
1699
|
-
if (!flags.project) {
|
|
1797
|
+
if (!flags.project || flags.project === true) {
|
|
1700
1798
|
printCommandHelpAndFail(commandKey)
|
|
1701
1799
|
return null
|
|
1702
1800
|
}
|
|
@@ -1816,7 +1914,55 @@ async function resolveOrgId(session, flags, { interactive = true } = {}) {
|
|
|
1816
1914
|
return session.selectedOrganizationId;
|
|
1817
1915
|
}
|
|
1818
1916
|
|
|
1917
|
+
async function handleOAuthLogin(session, flags) {
|
|
1918
|
+
if (['email', 'password', 'code', 'totp', 'challengeToken', 'key'].some(key => flags[key])) {
|
|
1919
|
+
throw new CliError('Use --oauth sem senha, codigo ou API key.');
|
|
1920
|
+
}
|
|
1921
|
+
const target = ensureProfile(session, flags.profile, { activate: true });
|
|
1922
|
+
const base = apiBaseUrl(target, flags);
|
|
1923
|
+
const controller = new AbortController();
|
|
1924
|
+
const cancel = () => controller.abort();
|
|
1925
|
+
process.once('SIGINT', cancel); process.once('SIGTERM', cancel);
|
|
1926
|
+
try {
|
|
1927
|
+
const login = await oauthLogin(base, { readOnly: Boolean(flags.readOnly), signal: controller.signal,
|
|
1928
|
+
onAuthorize: async url => {
|
|
1929
|
+
process.stdout.write(`Abra este endereco no navegador para entrar:\n${url}\n`);
|
|
1930
|
+
if (!flags.noBrowser) {
|
|
1931
|
+
const opened = await openBrowser(url);
|
|
1932
|
+
if (!opened) process.stderr.write('Nao foi possivel abrir o navegador. Abra o endereco acima manualmente.\n');
|
|
1933
|
+
}
|
|
1934
|
+
},
|
|
1935
|
+
});
|
|
1936
|
+
if (controller.signal.aborted) throw new CliError('Login OAuth cancelado.');
|
|
1937
|
+
Object.assign(target, login, { apiBaseUrl: base, selectedOrganizationId: undefined, updatedAt: new Date().toISOString() });
|
|
1938
|
+
await persistSession(target, { signal: controller.signal });
|
|
1939
|
+
if (controller.signal.aborted) throw new CliError('Login OAuth cancelado.');
|
|
1940
|
+
Object.assign(session, buildSession(target.__store));
|
|
1941
|
+
process.stdout.write('Login OAuth realizado com sucesso.\n');
|
|
1942
|
+
if (process.env.ZENIFRA_API_KEY) process.stderr.write('Aviso: ZENIFRA_API_KEY tem prioridade sobre este perfil OAuth nos comandos.\n');
|
|
1943
|
+
} finally { process.removeListener('SIGINT', cancel); process.removeListener('SIGTERM', cancel); }
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
async function prepareOAuthCredential(session, flags) {
|
|
1947
|
+
validateOAuthProfile(session.oauth, apiBaseUrl(session, flags));
|
|
1948
|
+
if (!Number.isFinite(session.oauth.expiresAt)) throw new CliError('Perfil OAuth invalido. Faca login novamente.');
|
|
1949
|
+
await withProfileLock(SESSION_DIR, async () => {
|
|
1950
|
+
const store = await readCurrentProfileStore();
|
|
1951
|
+
const current = store.profiles[getProfileName(session)];
|
|
1952
|
+
if (current?.authMode !== 'oauth' || !current.accessToken) throw new CliError('Sessao OAuth encerrada. Faca login novamente.');
|
|
1953
|
+
validateOAuthProfile(current.oauth, apiBaseUrl(session, flags));
|
|
1954
|
+
if (!Number.isFinite(current.oauth.expiresAt)) throw new CliError('Perfil OAuth invalido. Faca login novamente.');
|
|
1955
|
+
if (current.oauth.expiresAt <= Date.now() + 30_000) {
|
|
1956
|
+
Object.assign(current, await refreshOAuth(current.oauth), { updatedAt: new Date().toISOString() });
|
|
1957
|
+
await saveProfileStore(store);
|
|
1958
|
+
} else profileBaseline = structuredClone(store);
|
|
1959
|
+
Object.assign(session, current, { __store: store });
|
|
1960
|
+
});
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1819
1963
|
async function handleLogin(session, flags) {
|
|
1964
|
+
if (flags.oauth) return handleOAuthLogin(session, flags);
|
|
1965
|
+
if (flags.readOnly || flags.noBrowser) throw new CliError('--read-only e --no-browser exigem --oauth.');
|
|
1820
1966
|
const targetSession = ensureProfile(session, flags.profile, { activate: Boolean(flags.profile) || !getProfileName(session) });
|
|
1821
1967
|
const email = String(flags.email || await prompt('Email'));
|
|
1822
1968
|
const password = String(flags.password || await promptHidden('Senha'));
|
|
@@ -1908,6 +2054,20 @@ async function handleLogout(session, flags) {
|
|
|
1908
2054
|
throw new CliError('Nenhum perfil ativo configurado. Crie um perfil com "zenifra profile add" ou autentique com "zenifra auth login".');
|
|
1909
2055
|
}
|
|
1910
2056
|
const target = requireExistingProfile(session, targetName);
|
|
2057
|
+
if (target.authMode === 'oauth') {
|
|
2058
|
+
await withProfileLock(SESSION_DIR, async () => {
|
|
2059
|
+
const store = await readCurrentProfileStore();
|
|
2060
|
+
const current = store.profiles[getProfileName(target)];
|
|
2061
|
+
if (current?.authMode !== 'oauth') throw new CliError('Perfil alterado. Repita o logout.');
|
|
2062
|
+
if (flags.revoke) await revokeOAuth(current.oauth, apiBaseUrl(target, flags));
|
|
2063
|
+
Object.assign(current, { oauth: undefined, accessToken: undefined, apiKey: undefined,
|
|
2064
|
+
selectedOrganizationId: undefined, authMode: 'api_key', updatedAt: new Date().toISOString() });
|
|
2065
|
+
session.__store = await saveProfileStore(store);
|
|
2066
|
+
if (target.__profileName === getProfileName(session)) Object.assign(session, buildSession(session.__store));
|
|
2067
|
+
});
|
|
2068
|
+
process.stdout.write(`Autenticacao removida do perfil ${target.__profileName}${flags.revoke ? ' e conexao OAuth revogada' : ''}.\n`);
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
1911
2071
|
if (flags.revoke) {
|
|
1912
2072
|
if (!target.accessToken) {
|
|
1913
2073
|
throw new CliError('A revogacao exige um perfil autenticado por login de usuario; API keys devem ser revogadas na organizacao.');
|
|
@@ -1920,6 +2080,7 @@ async function handleLogout(session, flags) {
|
|
|
1920
2080
|
},
|
|
1921
2081
|
});
|
|
1922
2082
|
}
|
|
2083
|
+
target.oauth = undefined;
|
|
1923
2084
|
target.accessToken = undefined;
|
|
1924
2085
|
target.apiKey = undefined;
|
|
1925
2086
|
target.selectedOrganizationId = undefined;
|
|
@@ -1942,7 +2103,7 @@ function profileOutput(profile, activeName) {
|
|
|
1942
2103
|
return {
|
|
1943
2104
|
name: profile.name,
|
|
1944
2105
|
description: profile.description || '',
|
|
1945
|
-
auth_mode: profile.accessToken ? 'access_token' : profile.apiKey ? 'api_key' : profile.authMode || 'api_key',
|
|
2106
|
+
auth_mode: profile.authMode === 'oauth' ? 'oauth' : profile.accessToken ? 'access_token' : profile.apiKey ? 'api_key' : profile.authMode || 'api_key',
|
|
1946
2107
|
api_base_url: profile.apiBaseUrl || DEFAULT_API_BASE_URL,
|
|
1947
2108
|
selected_organization_id: profile.selectedOrganizationId,
|
|
1948
2109
|
has_api_key: Boolean(profile.apiKey),
|
|
@@ -3332,6 +3493,9 @@ async function handleProjectCreate(session, flags) {
|
|
|
3332
3493
|
const plan = wizardPayload?.plan || flags.plan || await prompt('Plano');
|
|
3333
3494
|
const paymentMode = wizardPayload?.payment_mode || flags.paymentMode || await prompt('Modo de pagamento');
|
|
3334
3495
|
const config = wizardPayload?.config || await parseConfig(flags.config || await prompt('Config JSON ou @arquivo'));
|
|
3496
|
+
if (Array.isArray(config?.custom_domains)) {
|
|
3497
|
+
config.custom_domains = normalizeCustomDomains(config.custom_domains, { primaryDomain: config.domain });
|
|
3498
|
+
}
|
|
3335
3499
|
const typeProject = normalizeTypeProject(config?.type_project);
|
|
3336
3500
|
const valkeyCatalog = typeProject === 'valkey'
|
|
3337
3501
|
? requireValkeyCatalog(unwrapData(await request(session, flags, 'GET', '/managed-services/catalog')))
|
|
@@ -3850,6 +4014,118 @@ async function handleProjectAutoscalingEvents(session, flags) {
|
|
|
3850
4014
|
printAutoscalingEvents(data);
|
|
3851
4015
|
}
|
|
3852
4016
|
|
|
4017
|
+
function publicHealthcheckValue(value) {
|
|
4018
|
+
if (value === undefined || value === null || value === '') return '-';
|
|
4019
|
+
if (typeof value === 'object') return '-';
|
|
4020
|
+
return String(value);
|
|
4021
|
+
}
|
|
4022
|
+
|
|
4023
|
+
function healthcheckForOutput(data) {
|
|
4024
|
+
if (data?.healthcheck && typeof data.healthcheck === 'object' && !Array.isArray(data.healthcheck)) {
|
|
4025
|
+
return data.healthcheck;
|
|
4026
|
+
}
|
|
4027
|
+
return data || {};
|
|
4028
|
+
}
|
|
4029
|
+
|
|
4030
|
+
function printHealthcheck(data) {
|
|
4031
|
+
const healthcheck = healthcheckForOutput(data);
|
|
4032
|
+
const rows = [
|
|
4033
|
+
{ field: 'Status', value: healthcheck.enabled === true ? 'ativo' : healthcheck.enabled === false ? 'inativo' : 'indisponivel' },
|
|
4034
|
+
{ field: 'Rota', value: publicHealthcheckValue(healthcheck.path) },
|
|
4035
|
+
{ field: 'Verificacao', value: publicHealthcheckValue(healthcheck.status || healthcheck.state || (healthcheck.enabled ? 'configurada' : 'desativada')) },
|
|
4036
|
+
];
|
|
4037
|
+
if (healthcheck.last_check_at !== undefined) rows.push({ field: 'Ultima verificacao', value: publicHealthcheckValue(healthcheck.last_check_at) });
|
|
4038
|
+
if (healthcheck.last_failure_at !== undefined) rows.push({ field: 'Ultima falha', value: publicHealthcheckValue(healthcheck.last_failure_at) });
|
|
4039
|
+
printTable(rows, [
|
|
4040
|
+
{ label: 'Campo', value: (row) => row.field },
|
|
4041
|
+
{ label: 'Valor', value: (row) => row.value },
|
|
4042
|
+
]);
|
|
4043
|
+
}
|
|
4044
|
+
|
|
4045
|
+
function validateHealthcheckPath(value) {
|
|
4046
|
+
const path = String(value || '').trim();
|
|
4047
|
+
if (!path.startsWith('/')
|
|
4048
|
+
|| path.startsWith('//')
|
|
4049
|
+
|| path.includes('?')
|
|
4050
|
+
|| path.includes('#')
|
|
4051
|
+
|| path.includes('\\')
|
|
4052
|
+
|| /\s/.test(path)) {
|
|
4053
|
+
throw new CliError('Informe --path como uma rota absoluta, iniciada por /, sem host, query string ou fragmento.');
|
|
4054
|
+
}
|
|
4055
|
+
return path;
|
|
4056
|
+
}
|
|
4057
|
+
|
|
4058
|
+
function healthcheckFailuresOf(data) {
|
|
4059
|
+
if (Array.isArray(data)) return data;
|
|
4060
|
+
return Array.isArray(data?.failures) ? data.failures : [];
|
|
4061
|
+
}
|
|
4062
|
+
|
|
4063
|
+
function printHealthcheckFailures(data) {
|
|
4064
|
+
const failures = healthcheckFailuresOf(data);
|
|
4065
|
+
printTable(failures, [
|
|
4066
|
+
{ label: 'Quando', value: (failure) => publicHealthcheckValue(failure.occurred_at || failure.observed_at) },
|
|
4067
|
+
{ label: 'Status', value: (failure) => publicHealthcheckValue(failure.status_code) },
|
|
4068
|
+
]);
|
|
4069
|
+
|
|
4070
|
+
const pagination = data?.pagination;
|
|
4071
|
+
if (pagination && pagination.total_pages === undefined && pagination.pages !== undefined) {
|
|
4072
|
+
printPagination({ ...pagination, total_pages: pagination.pages }, 'falha');
|
|
4073
|
+
return;
|
|
4074
|
+
}
|
|
4075
|
+
printPagination(pagination, 'falha');
|
|
4076
|
+
}
|
|
4077
|
+
|
|
4078
|
+
async function handleProjectHealthcheckGet(session, flags) {
|
|
4079
|
+
const projectId = requireProjectId(flags, 'project healthcheck get');
|
|
4080
|
+
if (!projectId) return;
|
|
4081
|
+
const orgId = await resolveOrgId(session, flags);
|
|
4082
|
+
const data = unwrapData(await request(session, flags, 'GET', `/project/${projectId}/healthcheck`, { orgId }));
|
|
4083
|
+
if (flags.json) return printJson(data);
|
|
4084
|
+
printHealthcheck(data);
|
|
4085
|
+
}
|
|
4086
|
+
|
|
4087
|
+
async function handleProjectHealthcheckSet(session, flags) {
|
|
4088
|
+
const projectId = requireProjectId(flags, 'project healthcheck set');
|
|
4089
|
+
if (!projectId) return;
|
|
4090
|
+
if (flags.path === undefined || flags.path === true || flags.path === '') {
|
|
4091
|
+
printCommandHelpAndFail('project healthcheck set');
|
|
4092
|
+
return;
|
|
4093
|
+
}
|
|
4094
|
+
const path = validateHealthcheckPath(flags.path);
|
|
4095
|
+
const orgId = await resolveOrgId(session, flags);
|
|
4096
|
+
const payload = await request(session, flags, 'PATCH', `/project/${projectId}/healthcheck`, {
|
|
4097
|
+
orgId,
|
|
4098
|
+
body: { healthcheck: { enabled: true, path } },
|
|
4099
|
+
});
|
|
4100
|
+
const data = unwrapData(payload);
|
|
4101
|
+
if (flags.json) return printJson(data);
|
|
4102
|
+
process.stdout.write(`Verificacao de saude ativada.\nRota: ${path}\n`);
|
|
4103
|
+
}
|
|
4104
|
+
|
|
4105
|
+
async function handleProjectHealthcheckDisable(session, flags) {
|
|
4106
|
+
const projectId = requireProjectId(flags, 'project healthcheck disable');
|
|
4107
|
+
if (!projectId) return;
|
|
4108
|
+
const orgId = await resolveOrgId(session, flags);
|
|
4109
|
+
const payload = await request(session, flags, 'PATCH', `/project/${projectId}/healthcheck`, {
|
|
4110
|
+
orgId,
|
|
4111
|
+
body: { healthcheck: { enabled: false } },
|
|
4112
|
+
});
|
|
4113
|
+
const data = unwrapData(payload);
|
|
4114
|
+
if (flags.json) return printJson(data);
|
|
4115
|
+
process.stdout.write('Verificacao de saude desativada.\n');
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
async function handleProjectHealthcheckFailures(session, flags) {
|
|
4119
|
+
const projectId = requireProjectId(flags, 'project healthcheck failures');
|
|
4120
|
+
if (!projectId) return;
|
|
4121
|
+
validateProjectListFlags(flags, { maxLimit: 100 });
|
|
4122
|
+
const orgId = await resolveOrgId(session, flags);
|
|
4123
|
+
const query = buildQuery(flags, ['page', 'limit']);
|
|
4124
|
+
const data = unwrapData(await request(session, flags, 'GET', `/project/${projectId}/healthcheck/failures${query}`, { orgId }));
|
|
4125
|
+
if (flags.json) return printJson(data);
|
|
4126
|
+
printHealthcheckFailures(data);
|
|
4127
|
+
}
|
|
4128
|
+
|
|
3853
4129
|
async function handleProjectBillingUsage(session, flags) {
|
|
3854
4130
|
const projectId = requireProjectId(flags, 'project billing usage');
|
|
3855
4131
|
if (!projectId) return;
|
|
@@ -3956,6 +4232,17 @@ async function handleValkeyCredentialStatus(session, flags) {
|
|
|
3956
4232
|
`/managed-services/${projectId}/credential-rotations/${flags.operation}`,
|
|
3957
4233
|
{ orgId },
|
|
3958
4234
|
));
|
|
4235
|
+
if (flags.connectionFile) {
|
|
4236
|
+
await writeValkeyConnectionFile(String(flags.connectionFile), data.connection_string);
|
|
4237
|
+
if (flags.json) return printJson({
|
|
4238
|
+
operation_id: data.operation_id,
|
|
4239
|
+
state: data.state,
|
|
4240
|
+
connection_saved: true,
|
|
4241
|
+
connection_file: String(flags.connectionFile),
|
|
4242
|
+
});
|
|
4243
|
+
process.stdout.write(`Credencial concluida e salva em ${flags.connectionFile}.\n`);
|
|
4244
|
+
return;
|
|
4245
|
+
}
|
|
3959
4246
|
if (flags.json) return printJson(data);
|
|
3960
4247
|
printValkeyRotation(data);
|
|
3961
4248
|
}
|
|
@@ -3978,6 +4265,17 @@ async function handleValkeyCredentialRotate(session, flags) {
|
|
|
3978
4265
|
|
|
3979
4266
|
if (flags.wait) {
|
|
3980
4267
|
const completed = await waitForValkeyRotation(session, flags, projectId, accepted.operation_id, orgId);
|
|
4268
|
+
if (flags.connectionFile) {
|
|
4269
|
+
await writeValkeyConnectionFile(String(flags.connectionFile), completed.connection_string);
|
|
4270
|
+
if (flags.json) return printJson({
|
|
4271
|
+
operation_id: completed.operation_id,
|
|
4272
|
+
state: completed.state,
|
|
4273
|
+
connection_saved: true,
|
|
4274
|
+
connection_file: String(flags.connectionFile),
|
|
4275
|
+
});
|
|
4276
|
+
process.stdout.write(`Credencial concluida e salva em ${flags.connectionFile}.\n`);
|
|
4277
|
+
return;
|
|
4278
|
+
}
|
|
3981
4279
|
if (flags.json) return printJson(completed);
|
|
3982
4280
|
printValkeyRotation(completed);
|
|
3983
4281
|
return;
|
|
@@ -4221,6 +4519,14 @@ async function main() {
|
|
|
4221
4519
|
if (command === 'valkey' && subcommand === 'connection') return handleValkeyConnection(session, flags);
|
|
4222
4520
|
if (command === 'valkey' && subcommand === 'credentials' && positional[2] === 'rotate') return handleValkeyCredentialRotate(session, flags);
|
|
4223
4521
|
if (command === 'valkey' && subcommand === 'credentials' && positional[2] === 'status') return handleValkeyCredentialStatus(session, flags);
|
|
4522
|
+
if (command === 'project' && subcommand === 'healthcheck' && positional.length === 2) {
|
|
4523
|
+
process.stdout.write(commandHelp(['project', 'healthcheck']));
|
|
4524
|
+
return;
|
|
4525
|
+
}
|
|
4526
|
+
if (command === 'project' && subcommand === 'healthcheck' && positional[2] === 'get') return handleProjectHealthcheckGet(session, flags);
|
|
4527
|
+
if (command === 'project' && subcommand === 'healthcheck' && positional[2] === 'set') return handleProjectHealthcheckSet(session, flags);
|
|
4528
|
+
if (command === 'project' && subcommand === 'healthcheck' && positional[2] === 'disable') return handleProjectHealthcheckDisable(session, flags);
|
|
4529
|
+
if (command === 'project' && subcommand === 'healthcheck' && positional[2] === 'failures') return handleProjectHealthcheckFailures(session, flags);
|
|
4224
4530
|
if (command === 'project' && subcommand === 'info') return handleProjectInfo(session, flags);
|
|
4225
4531
|
if (command === 'project' && subcommand === 'url') return handleProjectUrl(session, flags);
|
|
4226
4532
|
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.1",
|
|
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"
|