@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 CHANGED
@@ -43,7 +43,7 @@ zenifra auth logout
43
43
  zenifra auth logout --revoke
44
44
  zenifra profile list
45
45
  zenifra profile show
46
- zenifra profile add --name staging --description Homologacao --api-base https://api-stg.zenifra.com/v1 --mode api-key --key znf_sua_chave
46
+ zenifra profile add --name staging --description Homologacao --api-base https://api.example.test/v1 --mode api-key --key znf_sua_chave
47
47
  zenifra profile use staging
48
48
  zenifra profile edit staging --description "Homologacao interna"
49
49
  zenifra profile remove staging
@@ -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-stg.zenifra.com/v1`
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=30000`
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-stg.zenifra.com/v1 --mode login
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` tambem invalida as sessoes de login do usuario no servidor
243
+ - `auth logout` remove somente a autenticacao local; `auth logout --revoke` revoga somente a conexao do perfil OAuth, ou as sessoes do usuario para login por senha
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=https://api-stg.zenifra.com/v1
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 { mkdir, readFile, rm, writeFile, chmod } from 'node:fs/promises';
4
+ import { readFile, rm } from 'node:fs/promises';
5
5
  import { existsSync } from 'node:fs';
6
- import { dirname, join } from 'node:path';
6
+ import { join } from 'node:path';
7
7
  import { homedir } from 'node:os';
8
8
  import process from 'node:process';
9
9
  import readline from 'node:readline/promises';
10
+ import { oauthLogin, openBrowser, validateOAuthProfile, refreshOAuth, revokeOAuth } from './lib/oauth.mjs';
11
+ import { withProfileLock, atomicPrivateWrite } from './lib/profile-store.mjs';
10
12
 
11
13
  const DEFAULT_API_BASE_URL = 'https://api.zenifra.com/v1';
12
14
  const DOCS_BASE_URL = 'https://docs.zenifra.com/pt';
@@ -23,9 +25,12 @@ const SESSION_FILE = join(SESSION_DIR, 'session.json');
23
25
  const PROFILES_FILE = join(SESSION_DIR, 'profiles.json');
24
26
  const PROFILE_STORE_VERSION = 1;
25
27
  const DEFAULT_PROFILE_NAME = 'default';
26
- const DEFAULT_HTTP_TIMEOUT_MS = 30_000;
28
+ const DEFAULT_HTTP_TIMEOUT_MS = 300_000;
27
29
  const KNOWN_FLAG_NAMES = new Set([
28
30
  'apiBase',
31
+ 'oauth',
32
+ 'readOnly',
33
+ 'noBrowser',
29
34
  'branch',
30
35
  'build',
31
36
  'challengeToken',
@@ -58,6 +63,7 @@ const KNOWN_FLAG_NAMES = new Set([
58
63
  'operation',
59
64
  'page',
60
65
  'password',
66
+ 'path',
61
67
  'paymentMode',
62
68
  'plan',
63
69
  'profile',
@@ -158,7 +164,7 @@ function usage() {
158
164
 
159
165
  Usage:
160
166
  zenifra help <command>
161
- zenifra auth login [--profile <name>] [--api-base <url>] [--code <code>]
167
+ zenifra auth login [--profile <name>] [--api-base <url>] [--code <code>] [--oauth] [--read-only] [--no-browser]
162
168
  zenifra auth api-key --key <znf_key> [--profile <name>] [--api-base <url>]
163
169
  zenifra auth logout [--profile <name>] [--revoke]
164
170
  zenifra profile list [--json]
@@ -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 de usuario no servidor antes de limpar o perfil.'],
262
+ flags: ['--profile <name> Limpa outro perfil sem trocar o perfil ativo.', '--revoke Revoga a conexao OAuth deste perfil, ou as sessoes do login por senha, antes de limpar o perfil.'],
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-stg.zenifra.com/v1 --mode api-key --key znf_0123...'],
271
+ examples: ['zenifra profile', 'zenifra profile list', 'zenifra profile add --name staging --description Homologacao --api-base https://api.example.test/v1 --mode api-key --key znf_0123...'],
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-stg.zenifra.com/v1 no Homologacao',
281
+ output: 'Nome Tipo API base Ativo Descricao\nprod api_key https://api.zenifra.com/v1 yes Producao\nstaging access_token https://api.example.test/v1 no Homologacao',
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-stg.zenifra.com/v1 --mode api-key --key znf_0123...', 'zenifra profile add'],
298
+ examples: ['zenifra profile add --name staging --description Homologacao --api-base https://api.example.test/v1 --mode api-key --key znf_0123...', 'zenifra profile add'],
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: profile.authMode === 'access_token' ? 'access_token' : 'api_key',
878
+ authMode: ['oauth', 'access_token'].includes(profile.authMode) ? profile.authMode : 'api_key',
879
+ oauth: profile.authMode === 'oauth' && isRecord(profile.oauth) ? {
880
+ issuer: profile.oauth.issuer, resource: profile.oauth.resource, clientId: profile.oauth.clientId,
881
+ refreshToken: profile.oauth.refreshToken, expiresAt: profile.oauth.expiresAt, scope: profile.oauth.scope,
882
+ } : undefined,
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
- async function writeProfileStore(store) {
906
+ let profileBaseline = emptyProfileStore();
907
+ const sameProfileValue = (a, b) => JSON.stringify(a) === JSON.stringify(b);
908
+ async function readCurrentProfileStore() {
909
+ try { return sanitizeProfileStore(JSON.parse(await readFile(PROFILES_FILE, 'utf8'))); }
910
+ catch (error) { if (error.code === 'ENOENT') return emptyProfileStore(); throw new CliError('Arquivo de perfis invalido. Verifique sua configuracao local.'); }
911
+ }
912
+ async function saveProfileStore(store, { signal } = {}) {
844
913
  const normalized = sanitizeProfileStore(store);
845
- await mkdir(dirname(PROFILES_FILE), { recursive: true });
846
- await writeFile(PROFILES_FILE, `${JSON.stringify(normalized, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
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
- return sanitizeProfileStore(JSON.parse(await readFile(PROFILES_FILE, 'utf8')));
971
+ const store = sanitizeProfileStore(JSON.parse(await readFile(PROFILES_FILE, 'utf8')));
972
+ profileBaseline = structuredClone(store);
973
+ return store;
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
- const credential = credentialOverride || resolveCredential(session);
1529
+ if (tokenRequired && !credentialOverride && session.authMode === 'oauth') {
1530
+ if (process.env.ZENIFRA_API_KEY) {
1531
+ if (!session.__oauthShadowWarned) process.stderr.write('Aviso: ZENIFRA_API_KEY tem prioridade sobre o perfil OAuth.\n');
1532
+ session.__oauthShadowWarned = true;
1533
+ } else await prepareOAuthCredential(session, flags);
1534
+ }
1535
+ let credential = credentialOverride || resolveCredential(session);
1536
+ if (!tokenRequired && session.authMode === 'oauth' && credential?.source === 'profile') credential = null;
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.2.6",
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"