@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 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
@@ -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-stg.zenifra.com/v1`
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=30000`
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-stg.zenifra.com/v1 --mode login
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` tambem invalida as sessoes de login do usuario no servidor
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; a credencial completa aparece apenas na criação ou em uma rotação concluída
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=https://api-stg.zenifra.com/v1
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 { 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';
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 = 30_000;
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 de usuario no servidor antes de limpar o perfil.'],
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-stg.zenifra.com/v1 --mode api-key --key znf_0123...'],
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-stg.zenifra.com/v1 no Homologacao',
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-stg.zenifra.com/v1 --mode api-key --key znf_0123...', 'zenifra profile add'],
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: profile.authMode === 'access_token' ? 'access_token' : 'api_key',
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
- async function writeProfileStore(store) {
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 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);
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
- return sanitizeProfileStore(JSON.parse(await readFile(PROFILES_FILE, 'utf8')));
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
- const credential = credentialOverride || resolveCredential(session);
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.2.6",
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"