@zenifra/cli 0.2.3 → 0.2.4

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
@@ -40,6 +40,7 @@ zenifra auth login --profile staging --code 123456
40
40
  zenifra auth api-key --key znf_sua_chave
41
41
  zenifra auth api-key --profile prod --key znf_sua_chave
42
42
  zenifra auth logout
43
+ zenifra auth logout --revoke
43
44
  zenifra profile list
44
45
  zenifra profile show
45
46
  zenifra profile add --name staging --description Homologacao --api-base https://api-stg.zenifra.com/v1 --mode api-key --key znf_sua_chave
@@ -54,6 +55,7 @@ zenifra plans --type storage --json
54
55
  zenifra create project
55
56
  zenifra create project --name <name> --plan free --payment-mode hourly --config @examples/http-project.json
56
57
  zenifra create project --name <name> --plan basic --payment-mode hourly --config @examples/http-github-project.json
58
+ zenifra create project --name <name> --plan premium --payment-mode hourly --config @examples/http-autoscaling-project.json
57
59
  zenifra create project --name <name> --plan db-basic --payment-mode monthly --config @examples/postgresql-project.json
58
60
  zenifra create project --name <name> --plan db-basic --payment-mode monthly --config @examples/mariadb-project.json
59
61
  zenifra projects --type http --page 1 --limit 15
@@ -67,6 +69,11 @@ zenifra project envs --project <project-id>
67
69
  zenifra project env add --project <project-id> --name NODE_ENV --value production
68
70
  zenifra project env update --project <project-id> --name NODE_ENV --value staging
69
71
  zenifra project env remove --project <project-id> --name NODE_ENV
72
+ zenifra project autoscaling --project <project-id>
73
+ zenifra project autoscaling set --project <project-id> --min 2 --max 8 --cpu 70 --memory 80
74
+ zenifra project autoscaling disable --project <project-id>
75
+ zenifra project autoscaling events --project <project-id> --direction scale_up --page 1 --limit 10
76
+ zenifra project billing usage --project <project-id> --from 2026-06-01T00:00:00Z --limit 20
70
77
  zenifra project instances --project <project-id>
71
78
  zenifra project instances set --project <project-id> --count 3
72
79
  zenifra builds --project <project-id>
@@ -103,6 +110,7 @@ Se voce rodar apenas `zenifra deploy`, a CLI mostra a ajuda especifica do comand
103
110
  - API padrao: `https://api.zenifra.com/v1`
104
111
  - Override de API: `ZENIFRA_API_URL=https://api-stg.zenifra.com/v1`
105
112
  - API key global: `ZENIFRA_API_KEY=znf_sua_chave`
113
+ - Timeout de cada request HTTP: `ZENIFRA_HTTP_TIMEOUT_MS=30000`
106
114
  - Store local de perfis: `~/.config/zenifra-cli/profiles.json`
107
115
  - Override do diretorio local: `ZENIFRA_CONFIG_DIR=/path/custom`
108
116
 
@@ -149,10 +157,12 @@ Regras de precedencia:
149
157
  - `ZENIFRA_API_URL` sobrescreve a API base do perfil ativo apenas para a execucao atual
150
158
  - `auth login` e `auth api-key` operam no perfil ativo por padrao
151
159
  - `auth login --profile <name>` e `auth api-key --profile <name>` atualizam ou criam outro perfil e o tornam ativo
160
+ - `auth logout` remove somente a autenticacao local; `auth logout --revoke` tambem invalida as sessoes de login do usuario no servidor
161
+ - `auth logout --revoke` exige um perfil autenticado por login e nao revoga API keys
152
162
 
153
163
  Migracao:
154
164
 
155
- - se existir `session.json` legado e ainda nao existir `profiles.json`, a CLI migra automaticamente a sessao antiga para um perfil `default`
165
+ - se existir `session.json` legado e ainda nao existir `profiles.json`, a CLI migra automaticamente a sessao antiga para um perfil `default` e remove o arquivo legado
156
166
 
157
167
  ## Automacao com API key
158
168
 
@@ -180,6 +190,7 @@ Use os arquivos em `examples/` como base para `zenifra create project`:
180
190
 
181
191
  - `examples/http-project.json`: projeto HTTP com imagem OCI publica
182
192
  - `examples/http-github-project.json`: projeto HTTP com build via GitHub
193
+ - `examples/http-autoscaling-project.json`: projeto HTTP pago criado com auto-scaling
183
194
  - `examples/postgresql-project.json`: projeto PostgreSQL
184
195
  - `examples/mariadb-project.json`: projeto MariaDB
185
196
 
@@ -196,21 +207,39 @@ O wizard atual cobre:
196
207
  - projetos `mariadb`
197
208
 
198
209
  `zenifra create project` nao assume valores default para `--plan` e `--payment-mode`.
210
+ Configs HTTP nao interativas tambem devem informar `config.exposure`; use `public` para criar rota/dominio publico ou `private` para manter a aplicacao sem exposicao na internet.
199
211
  Antes de escolher um plano com o usuario, compare os catalogos com `zenifra plans` para evitar suposicoes sobre custo.
200
212
 
201
213
  Valores aceitos:
202
214
 
203
215
  - `payment_mode`: `hourly`, `monthly`, `yearly`
204
216
  - `type_project` no `config`: `http`, `postgresql`, `mariadb`
217
+ - `exposure` no `config` HTTP: `public`, `private`
205
218
  - `plan`: `free`, `static`, `basic`, `premium`, `premium_plus`, `business`, `deep_learning_basic`, `deep_learning_premium`, `db-free`, `db-starter`, `db-basic`, `db-premium`, `db-enterprise`
206
219
  - `config.github.runtime` (quando houver GitHub em projeto HTTP): `nodejs` ou `python`
220
+ - `config.autoscaling` (somente HTTP pago): `enabled: true`, `max_instances` maior ou igual a `config.instances` e alvos opcionais de CPU/memoria entre 1 e 100
207
221
 
208
222
  Observacoes do wizard:
209
223
 
210
224
  - conflitos entre documentacao e API sao validados pelo contrato real aceito pela API
225
+ - o wizard oferece auto-scaling apenas quando o plano selecionado informa essa disponibilidade
226
+ - na criacao com auto-scaling, `config.instances` e o minimo inicial e `config.autoscaling.max_instances` e o maximo
211
227
  - em projetos de banco, o wizard nao pergunta `username`, `password` nem `database name`
212
228
  - em projetos de banco, a CLI preenche apenas campos tecnicos minimos exigidos pela validacao atual da API
213
229
 
230
+ ## Regressao manual de auto-scaling em staging
231
+
232
+ 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.
233
+
234
+ ```bash
235
+ export ZENIFRA_API_URL_STG=https://api-stg.zenifra.com/v1
236
+ export ZENIFRA_API_KEY_STG=znf_sua_chave_de_staging
237
+ export ZENIFRA_STAGING_ALLOW_MUTATIONS=1
238
+ npm run test:staging:autoscaling
239
+ ```
240
+
241
+ Use `ZENIFRA_STAGING_SKIP_HOURLY_WAIT=1` para pular a espera pelo fechamento da janela horaria. Quando a espera esta habilitada, o teste verifica moeda, totais de computacao e armazenamento, instancia-horas e armazenamento persistente faturavel. A execucao pode consumir capacidade e gerar custos no ambiente de staging; o resumo redigido fica em `artifacts/`, que nao e versionado.
242
+
214
243
  ## Publicacao no npm
215
244
 
216
245
  Este pacote e publicado pelo GitHub Actions usando npm Trusted Publishing. O pacote precisa existir no npm antes de configurar Trusted Publishing.
package/bin/zenifra.mjs CHANGED
@@ -21,6 +21,53 @@ const SESSION_FILE = join(SESSION_DIR, 'session.json');
21
21
  const PROFILES_FILE = join(SESSION_DIR, 'profiles.json');
22
22
  const PROFILE_STORE_VERSION = 1;
23
23
  const DEFAULT_PROFILE_NAME = 'default';
24
+ const DEFAULT_HTTP_TIMEOUT_MS = 30_000;
25
+ const KNOWN_FLAG_NAMES = new Set([
26
+ 'apiBase',
27
+ 'branch',
28
+ 'build',
29
+ 'challengeToken',
30
+ 'code',
31
+ 'commitSha',
32
+ 'config',
33
+ 'count',
34
+ 'cpu',
35
+ 'cursor',
36
+ 'description',
37
+ 'direction',
38
+ 'email',
39
+ 'exposure',
40
+ 'follow',
41
+ 'from',
42
+ 'help',
43
+ 'image',
44
+ 'instance',
45
+ 'interval',
46
+ 'json',
47
+ 'key',
48
+ 'limit',
49
+ 'max',
50
+ 'memory',
51
+ 'min',
52
+ 'mode',
53
+ 'name',
54
+ 'org',
55
+ 'page',
56
+ 'password',
57
+ 'paymentMode',
58
+ 'plan',
59
+ 'profile',
60
+ 'project',
61
+ 'revoke',
62
+ 'showValues',
63
+ 'status',
64
+ 'timeout',
65
+ 'to',
66
+ 'totp',
67
+ 'type',
68
+ 'value',
69
+ 'view',
70
+ ]);
24
71
  const ALLOWED_PLAN_VALUES = new Set([
25
72
  'free',
26
73
  'static',
@@ -39,6 +86,7 @@ const ALLOWED_PLAN_VALUES = new Set([
39
86
  const ALLOWED_PAYMENT_MODE_VALUES = new Set(['hourly', 'monthly', 'yearly']);
40
87
  const ALLOWED_TYPE_PROJECT_VALUES = new Set(['http', 'postgresql', 'mariadb']);
41
88
  const ALLOWED_RUNTIME_VALUES = new Set(['nodejs', 'python']);
89
+ const ALLOWED_EXPOSURE_VALUES = new Set(['public', 'private']);
42
90
  const GITHUB_RUNTIME_VERSIONS = {
43
91
  nodejs: ['24', '22', '20'],
44
92
  python: ['3.13', '3.12', '3.11'],
@@ -68,6 +116,7 @@ const WIZARD_DATABASE_PLAN_OPTIONS = [
68
116
  ];
69
117
  const WIZARD_PAYMENT_MODE_OPTIONS = ['hourly', 'monthly', 'yearly'];
70
118
  const WIZARD_TYPE_PROJECT_OPTIONS = ['http', 'postgresql', 'mariadb'];
119
+ const WIZARD_HTTP_EXPOSURE_OPTIONS = ['public', 'private'];
71
120
  let promptInterface;
72
121
  let pipedInputPromise;
73
122
  let pipedInputIndex = 0;
@@ -95,7 +144,7 @@ Usage:
95
144
  zenifra help <command>
96
145
  zenifra auth login [--profile <name>] [--api-base <url>] [--code <code>]
97
146
  zenifra auth api-key --key <znf_key> [--profile <name>] [--api-base <url>]
98
- zenifra auth logout [--profile <name>]
147
+ zenifra auth logout [--profile <name>] [--revoke]
99
148
  zenifra profile list [--json]
100
149
  zenifra profile show [<name>] [--json]
101
150
  zenifra profile add [--name <name>] [--description <text>] [--api-base <url>] [--mode <api-key|login>] [--key <znf_key>] [--json]
@@ -114,10 +163,16 @@ Usage:
114
163
  zenifra project metrics --project <id> [--instance <id>] [--json]
115
164
  zenifra project network --project <id> [--view <summary|status-codes|routes|user-agents|request-events|source-ips>] [--json]
116
165
  zenifra project image set --project <id> --image <image> [--json]
166
+ zenifra project exposure set --project <id> --exposure <public|private> [--json]
117
167
  zenifra project envs --project <id> [--json] [--show-values]
118
168
  zenifra project env add --project <id> --name <name> --value <value> [--json]
119
169
  zenifra project env update --project <id> --name <name> --value <value> [--json]
120
170
  zenifra project env remove --project <id> --name <name> [--json]
171
+ zenifra project autoscaling --project <id> [--json]
172
+ zenifra project autoscaling set --project <id> --min <n> --max <n> [--cpu <percent>] [--memory <percent>] [--json]
173
+ zenifra project autoscaling disable --project <id> [--json]
174
+ zenifra project autoscaling events --project <id> [--direction <scale_up|scale_down>] [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]
175
+ zenifra project billing usage --project <id> [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]
121
176
  zenifra project instances --project <id> [--json]
122
177
  zenifra project instances set --project <id> --count <n> [--json]
123
178
  zenifra builds --project <id> [--page <n>] [--limit <n>] [--branch <name>] [--status <status>] [--json]
@@ -138,7 +193,7 @@ Run "zenifra help <command>" or "zenifra <command> --help" for command-specific
138
193
  const HELP_SPECS = [
139
194
  {
140
195
  command: 'auth',
141
- 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>]',
196
+ 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]',
142
197
  description: 'Gerencia a autenticacao dos perfis locais da CLI.',
143
198
  examples: ['zenifra auth', 'zenifra auth login --profile staging', 'zenifra auth api-key --profile prod --key znf_0123456789abcdef01234567_abcd...'],
144
199
  output: 'Zenifra CLI - auth',
@@ -168,11 +223,12 @@ const HELP_SPECS = [
168
223
  },
169
224
  {
170
225
  command: 'auth logout',
171
- usage: 'zenifra auth logout [--profile <name>]',
226
+ usage: 'zenifra auth logout [--profile <name>] [--revoke]',
172
227
  description: 'Remove a autenticacao do perfil ativo ou do perfil informado.',
173
- flags: ['--profile <name> Limpa outro perfil sem trocar o perfil ativo.'],
174
- examples: ['zenifra auth logout', 'zenifra auth logout --profile staging'],
228
+ flags: ['--profile <name> Limpa outro perfil sem trocar o perfil ativo.', '--revoke Revoga as sessoes de usuario no servidor antes de limpar o perfil.'],
229
+ examples: ['zenifra auth logout', 'zenifra auth logout --profile staging', 'zenifra auth logout --revoke'],
175
230
  output: 'Autenticacao removida do perfil active.',
231
+ notes: ['Sem --revoke, remove somente a credencial local. A revogacao invalida as sessoes de login do usuario e nao se aplica a API keys.'],
176
232
  },
177
233
  {
178
234
  command: 'profile',
@@ -291,11 +347,11 @@ const HELP_SPECS = [
291
347
  },
292
348
  {
293
349
  command: 'project',
294
- usage: 'zenifra project\n zenifra project info --project <id> [--json]\n zenifra project url --project <id> [--json]\n zenifra project logs --project <id> [--instance <id>] [--json]\n zenifra project metrics --project <id> [--instance <id>] [--json]\n zenifra project network --project <id> [--view <summary|status-codes|routes|user-agents|request-events|source-ips>] [--json]\n zenifra project image set --project <id> --image <image> [--json]\n zenifra project 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 instances --project <id> [--json]\n zenifra project instances set --project <id> --count <n> [--json]',
350
+ usage: 'zenifra project\n zenifra project info --project <id> [--json]\n zenifra project url --project <id> [--json]\n zenifra project logs --project <id> [--instance <id>] [--json]\n zenifra project metrics --project <id> [--instance <id>] [--json]\n zenifra project network --project <id> [--view <summary|status-codes|routes|user-agents|request-events|source-ips>] [--json]\n zenifra project image set --project <id> --image <image> [--json]\n zenifra project exposure set --project <id> --exposure <public|private> [--json]\n zenifra project envs --project <id> [--json] [--show-values]\n zenifra project env add --project <id> --name <name> --value <value> [--json]\n zenifra project env update --project <id> --name <name> --value <value> [--json]\n zenifra project env remove --project <id> --name <name> [--json]\n zenifra project autoscaling --project <id> [--json]\n zenifra project autoscaling set --project <id> --min <n> --max <n> [--cpu <percent>] [--memory <percent>] [--json]\n zenifra project autoscaling disable --project <id> [--json]\n zenifra project autoscaling events --project <id> [--direction <scale_up|scale_down>] [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]\n zenifra project billing usage --project <id> [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]\n zenifra project instances --project <id> [--json]\n zenifra project instances set --project <id> --count <n> [--json]',
295
351
  description: 'Agrupa comandos operacionais e de introspecao sobre um projeto especifico.',
296
352
  examples: ['zenifra project', 'zenifra project info --project proj_1', 'zenifra project env add --project proj_1 --name NODE_ENV --value production'],
297
353
  output: 'Zenifra CLI - project',
298
- notes: ['Use "zenifra help project <subcomando>" para detalhes de info, url, logs, metrics, network, image, envs e instances.'],
354
+ notes: ['Use "zenifra help project <subcomando>" para detalhes de info, url, logs, metrics, network, image, exposure, autoscaling, billing, envs e instances.'],
299
355
  },
300
356
  {
301
357
  command: 'project info',
@@ -352,6 +408,16 @@ const HELP_SPECS = [
352
408
  output: 'Imagem atualizada com sucesso.',
353
409
  jsonOutput: '{"status":"success","message":"updated with success"}',
354
410
  },
411
+ {
412
+ command: 'project exposure set',
413
+ usage: 'zenifra project exposure set --project <id> --exposure <public|private> [--json]',
414
+ description: 'Altera se um projeto HTTP expoe rota publica e dominio.',
415
+ flags: ['--project <id> ID do projeto.', '--exposure <mode> Use public para criar rota publica ou private para remover exposicao.', '--json Imprime a resposta em JSON.'],
416
+ examples: ['zenifra project exposure set --project 507f1f77bcf86cd799439012 --exposure private', 'zenifra project exposure set --project 507f1f77bcf86cd799439012 --exposure public --json'],
417
+ output: 'Campo Valor\n--------- ----------------------------------\nExposicao private\nDominio -',
418
+ jsonOutput: '{"status":"success","message":"project exposure updated with success","exposure":"private","custom_domains":[]}',
419
+ notes: ['Ao mudar para private, a API remove a rota publica e dominios personalizados do projeto.'],
420
+ },
355
421
  {
356
422
  command: 'project envs',
357
423
  usage: 'zenifra project envs --project <id> [--json] [--show-values]',
@@ -392,10 +458,55 @@ const HELP_SPECS = [
392
458
  jsonOutput: '{"status":"success","message":"updated with success","envs":[]}',
393
459
  notes: ['Falha se a variavel nao existir.'],
394
460
  },
461
+ {
462
+ command: 'project autoscaling',
463
+ usage: 'zenifra project autoscaling --project <id> [--json]',
464
+ description: 'Mostra a configuracao de auto-scaling HTTP do projeto.',
465
+ flags: ['--project <id> ID do projeto.', '--json Imprime a resposta em JSON.'],
466
+ examples: ['zenifra project autoscaling --project 507f1f77bcf86cd799439012'],
467
+ output: 'Campo Valor\n---------- -----\nStatus ativo\nMinimo 2\nMaximo 8\nCPU alvo 70%\nMemoria 80%',
468
+ jsonOutput: '{"enabled":true,"min_instances":2,"max_instances":8,"target_cpu_utilization_percent":70,"target_memory_utilization_percent":80}',
469
+ },
470
+ {
471
+ command: 'project autoscaling set',
472
+ usage: 'zenifra project autoscaling set --project <id> --min <n> --max <n> [--cpu <percent>] [--memory <percent>] [--json]',
473
+ description: 'Ativa ou atualiza o auto-scaling HTTP do projeto.',
474
+ flags: ['--project <id> ID do projeto.', '--min <n> Minimo de instancias reservadas.', '--max <n> Maximo de instancias permitidas.', '--cpu <percent> CPU alvo. Padrao da API: 70.', '--memory <percent> Memoria alvo. Padrao da API: 80.', '--json Imprime a resposta em JSON.'],
475
+ examples: ['zenifra project autoscaling set --project 507f1f77bcf86cd799439012 --min 2 --max 8 --cpu 70 --memory 80'],
476
+ output: 'Auto-scaling atualizado: 2-8 instancias.',
477
+ jsonOutput: '{"status":"success","message":"project autoscaling updated with success","data":{"autoscaling":{"enabled":true,"min_instances":2,"max_instances":8}}}',
478
+ },
479
+ {
480
+ command: 'project autoscaling disable',
481
+ usage: 'zenifra project autoscaling disable --project <id> [--json]',
482
+ description: 'Desativa o auto-scaling HTTP e volta para instancias fixas no minimo configurado.',
483
+ flags: ['--project <id> ID do projeto.', '--json Imprime a resposta em JSON.'],
484
+ examples: ['zenifra project autoscaling disable --project 507f1f77bcf86cd799439012'],
485
+ output: 'Auto-scaling desativado.',
486
+ jsonOutput: '{"status":"success","message":"project autoscaling updated with success","data":{"autoscaling":{"enabled":false}}}',
487
+ },
488
+ {
489
+ command: 'project autoscaling events',
490
+ usage: 'zenifra project autoscaling events --project <id> [--direction <scale_up|scale_down>] [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]',
491
+ description: 'Lista o historico de scale up e scale down do auto-scaling HTTP.',
492
+ flags: ['--project <id> ID do projeto.', '--direction <valor> Filtra por scale_up ou scale_down.', '--from <iso> Inicio do periodo.', '--to <iso> Fim do periodo.', '--page <n> Pagina. Padrao da API: 1.', '--limit <n> Itens por pagina. Maximo da API: 100.', '--json Imprime a resposta em JSON.'],
493
+ examples: ['zenifra project autoscaling events --project 507f1f77bcf86cd799439012 --direction scale_up'],
494
+ output: 'Quando Direcao Instancias Limites Motivo\n05/06/2026, 18:40 aumento 2 -> 5 CPU 91%/70% capacidade aumentada',
495
+ jsonOutput: '{"events":[{"direction":"scale_up","previous_instances":2,"new_instances":5,"occurred_at":"2026-06-05T21:40:00.000Z"}],"pagination":{"page":1,"limit":10,"total":1,"total_pages":1}}',
496
+ },
497
+ {
498
+ command: 'project billing usage',
499
+ usage: 'zenifra project billing usage --project <id> [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]',
500
+ description: 'Lista o consumo horario consolidado de um projeto.',
501
+ flags: ['--project <id> ID do projeto.', '--from <iso> Inicio do periodo.', '--to <iso> Fim do periodo.', '--page <n> Pagina. Padrao da API: 1.', '--limit <n> Itens por pagina. Maximo: 50.', '--json Imprime a resposta em JSON.'],
502
+ examples: ['zenifra project billing usage --project 507f1f77bcf86cd799439012 --from 2026-06-01T00:00:00Z --limit 20'],
503
+ output: 'Inicio Fim Instancia-horas Armazenamento Total\n2026-06-01T10:00:00.000Z 2026-06-01T11:00:00.000Z 2 1.5 GB-h R$ 0,30',
504
+ jsonOutput: '{"hours":[],"summary":{"currency":"brl","compute_amount":0,"storage_amount":0,"total_amount":0},"pagination":{"page":1,"limit":10,"total":0,"total_pages":0}}',
505
+ },
395
506
  {
396
507
  command: 'project instances',
397
508
  usage: 'zenifra project instances --project <id> [--json]',
398
- description: 'Lista instancias/pods do projeto.',
509
+ description: 'Lista instancias do projeto.',
399
510
  flags: ['--project <id> ID do projeto.', '--json Imprime a resposta em JSON.'],
400
511
  examples: ['zenifra project instances --project 507f1f77bcf86cd799439012'],
401
512
  output: 'Instancia\nweb-1',
@@ -426,11 +537,11 @@ const HELP_SPECS = [
426
537
  flags: [
427
538
  '--project <id> ID do projeto.',
428
539
  '--build <id> ID do build.',
429
- '--cursor <n> Cursor inicial. Padrao: 0.',
430
- '--limit <n> Quantidade de logs por request. Padrao: 200.',
540
+ '--cursor <n> Cursor inicial inteiro, maior ou igual a 0. Padrao: 0.',
541
+ '--limit <n> Quantidade de logs por request, entre 1 e 500. Padrao: 200.',
431
542
  '--follow Continua em polling ate o build finalizar.',
432
- '--interval <seconds> Intervalo de polling com --follow. Padrao: 5.',
433
- '--timeout <seconds> Timeout total com --follow. Padrao: 900.',
543
+ '--interval <seconds> Intervalo de polling com --follow, minimo 0.1. Padrao: 5.',
544
+ '--timeout <seconds> Timeout total com --follow, minimo 1. Padrao: 900.',
434
545
  '--json Imprime a resposta em JSON.',
435
546
  ],
436
547
  examples: ['zenifra builds logs --project 507f1f77bcf86cd799439012 --build build_123', 'zenifra builds logs --project 507f1f77bcf86cd799439012 --build build_123 --follow'],
@@ -466,7 +577,7 @@ const HELP_SPECS = [
466
577
  command: 'deploy watch',
467
578
  usage: 'zenifra deploy watch --project <id> --build <id> [--interval <seconds>] [--timeout <seconds>] [--json]',
468
579
  description: 'Acompanha um build em tempo real, fazendo polling do status e imprimindo os logs incrementais do build ate o estado terminal.',
469
- flags: ['--project <id> ID do projeto.', '--build <id> ID do build.', '--interval <seconds> Intervalo de polling. Padrao: 5.', '--timeout <seconds> Timeout total. Padrao: 900.', '--json Imprime cada resposta em JSON.'],
580
+ flags: ['--project <id> ID do projeto.', '--build <id> ID do build.', '--interval <seconds> Intervalo de polling, minimo 0.1. Padrao: 5.', '--timeout <seconds> Timeout total, minimo 1. Padrao: 900.', '--json Imprime cada resposta em JSON.'],
470
581
  examples: [
471
582
  'zenifra deploy watch --project 507f1f77bcf86cd799439012 --build build_123',
472
583
  'zenifra deploy watch --project 507f1f77bcf86cd799439012 --build build_123 --interval 2',
@@ -578,6 +689,17 @@ function parseArgs(argv) {
578
689
  return { positional, flags };
579
690
  }
580
691
 
692
+ function flagNameForDisplay(value) {
693
+ return `--${String(value).replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`)}`;
694
+ }
695
+
696
+ function validateKnownFlags(flags) {
697
+ const unknown = Object.keys(flags).filter((name) => !KNOWN_FLAG_NAMES.has(name));
698
+ if (unknown.length > 0) {
699
+ throw new CliError(`Flag desconhecida: ${unknown.map(flagNameForDisplay).join(', ')}.`);
700
+ }
701
+ }
702
+
581
703
  function emptyProfileStore() {
582
704
  return {
583
705
  version: PROFILE_STORE_VERSION,
@@ -667,7 +789,9 @@ async function migrateLegacySession() {
667
789
  });
668
790
  store.profiles[DEFAULT_PROFILE_NAME] = migratedProfile;
669
791
  store.activeProfile = DEFAULT_PROFILE_NAME;
670
- return writeProfileStore(store);
792
+ const normalized = await writeProfileStore(store);
793
+ await rm(SESSION_FILE, { force: true });
794
+ return normalized;
671
795
  }
672
796
 
673
797
  async function readProfileStore() {
@@ -861,9 +985,16 @@ function estimateWizardTotal(state = {}) {
861
985
  if (state.httpStoragePersistent) total += 1;
862
986
  }
863
987
 
988
+ if (state.planAllowsAutoscaling) {
989
+ total += 1;
990
+ if (state.httpAutoscalingEnabled) total += 3;
991
+ }
992
+
864
993
  total += 1;
865
994
  if (state.envs?.enabled) total += state.envs.count * 3;
866
995
 
996
+ total += 1;
997
+
867
998
  if (state.httpSource === 'github') {
868
999
  total += 9;
869
1000
  } else if (state.httpSource === 'oci') {
@@ -877,13 +1008,13 @@ function estimateWizardTotal(state = {}) {
877
1008
  total += 1;
878
1009
  }
879
1010
 
880
- if (state.planAllowsBlockIp) {
1011
+ if (state.httpExposure !== 'private' && state.planAllowsBlockIp) {
881
1012
  total += 2;
882
1013
  if (state.whitelist?.enabled) total += state.whitelist.count * 3;
883
1014
  if (state.blacklist?.enabled) total += state.blacklist.count * 3;
884
1015
  }
885
1016
 
886
- if (state.planAllowsSubdomain) total += 1;
1017
+ if (state.httpExposure !== 'private' && state.planAllowsSubdomain) total += 1;
887
1018
  return total;
888
1019
  }
889
1020
 
@@ -1199,8 +1330,24 @@ async function promptHidden(question) {
1199
1330
  });
1200
1331
  }
1201
1332
 
1202
- async function request(session, flags, method, path, { body, orgId, tokenRequired = true } = {}) {
1203
- const credential = resolveCredential(session);
1333
+ function httpTimeoutMs() {
1334
+ const configured = process.env.ZENIFRA_HTTP_TIMEOUT_MS;
1335
+ if (configured === undefined) return DEFAULT_HTTP_TIMEOUT_MS;
1336
+
1337
+ const value = Number(configured);
1338
+ if (!Number.isInteger(value) || value <= 0) {
1339
+ throw new CliError('ZENIFRA_HTTP_TIMEOUT_MS deve ser um inteiro positivo em milissegundos.');
1340
+ }
1341
+ return value;
1342
+ }
1343
+
1344
+ async function request(session, flags, method, path, {
1345
+ body,
1346
+ orgId,
1347
+ tokenRequired = true,
1348
+ credential: credentialOverride,
1349
+ } = {}) {
1350
+ const credential = credentialOverride || resolveCredential(session);
1204
1351
  if (tokenRequired && !credential) {
1205
1352
  throw new CliError('Voce precisa autenticar primeiro: zenifra auth login, zenifra auth api-key --key <znf_key> ou ZENIFRA_API_KEY.');
1206
1353
  }
@@ -1216,11 +1363,21 @@ async function request(session, flags, method, path, { body, orgId, tokenRequire
1216
1363
  headers['x-organization-id'] = orgId;
1217
1364
  }
1218
1365
 
1219
- const response = await fetch(`${apiBaseUrl(session, flags)}${path}`, {
1220
- method,
1221
- headers,
1222
- body: body === undefined ? undefined : JSON.stringify(body),
1223
- });
1366
+ const timeoutMs = httpTimeoutMs();
1367
+ let response;
1368
+ try {
1369
+ response = await fetch(`${apiBaseUrl(session, flags)}${path}`, {
1370
+ method,
1371
+ headers,
1372
+ body: body === undefined ? undefined : JSON.stringify(body),
1373
+ signal: AbortSignal.timeout(timeoutMs),
1374
+ });
1375
+ } catch (error) {
1376
+ if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
1377
+ throw new CliError(`A API da Zenifra nao respondeu em ate ${timeoutMs} ms.`);
1378
+ }
1379
+ throw new CliError(`Nao foi possivel conectar a API da Zenifra: ${error?.message || 'erro de rede'}.`);
1380
+ }
1224
1381
 
1225
1382
  const text = await response.text();
1226
1383
  let payload = {};
@@ -1234,12 +1391,18 @@ async function request(session, flags, method, path, { body, orgId, tokenRequire
1234
1391
 
1235
1392
  if (!response.ok) {
1236
1393
  const message = payload?.message || payload?.error || `Zenifra API retornou HTTP ${response.status}`;
1237
- if (response.status === 401 || response.status === 403) {
1394
+ if (response.status === 401) {
1238
1395
  if (credential?.type === 'api_key') {
1239
- throw new CliError(`${message}. Verifique se a API key esta ativa, possui permissao RBAC e se o IP atual esta permitido.`);
1396
+ throw new CliError(`${message}. Verifique se a API key esta ativa e se o IP atual esta permitido.`);
1240
1397
  }
1241
1398
  throw new CliError(`${message}. Rode "zenifra auth login" para renovar sua sessao.`);
1242
1399
  }
1400
+ if (response.status === 403) {
1401
+ throw new CliError(message);
1402
+ }
1403
+ if (response.status === 429 && Number.isFinite(Number(payload?.retry_after_seconds))) {
1404
+ throw new CliError(`${message} Tente novamente em ${Number(payload.retry_after_seconds)} segundo(s).`);
1405
+ }
1243
1406
  if (String(message).includes('missing x-organization-id')) {
1244
1407
  throw new CliError('Organizacao nao selecionada. Rode "zenifra org set" ou use --org <id>.');
1245
1408
  }
@@ -1351,6 +1514,7 @@ function printProject(project) {
1351
1514
  { label: 'Nome', value: project.name || '-' },
1352
1515
  { label: 'Status', value: project.status || '-' },
1353
1516
  { label: 'Tipo', value: project.type_project || '-' },
1517
+ { label: 'Exposicao', value: project.exposure || '-' },
1354
1518
  { label: 'Plano', value: project.plan || '-' },
1355
1519
  { label: 'URL', value: url || '-' },
1356
1520
  { label: 'Imagem', value: project.image || '-' },
@@ -1507,6 +1671,18 @@ async function handleLogout(session, flags) {
1507
1671
  throw new CliError('Nenhum perfil ativo configurado. Crie um perfil com "zenifra profile add" ou autentique com "zenifra auth login".');
1508
1672
  }
1509
1673
  const target = requireExistingProfile(session, targetName);
1674
+ if (flags.revoke) {
1675
+ if (!target.accessToken) {
1676
+ throw new CliError('A revogacao exige um perfil autenticado por login de usuario; API keys devem ser revogadas na organizacao.');
1677
+ }
1678
+ await request(target, flags, 'DELETE', '/authentication', {
1679
+ credential: {
1680
+ token: target.accessToken,
1681
+ type: 'user',
1682
+ source: 'profile',
1683
+ },
1684
+ });
1685
+ }
1510
1686
  target.accessToken = undefined;
1511
1687
  target.apiKey = undefined;
1512
1688
  target.selectedOrganizationId = undefined;
@@ -1516,7 +1692,7 @@ async function handleLogout(session, flags) {
1516
1692
  if (target.__profileName === getProfileName(session)) {
1517
1693
  Object.assign(session, buildSession(target.__store));
1518
1694
  }
1519
- process.stdout.write(`Autenticacao removida do perfil ${target.__profileName}.\n`);
1695
+ process.stdout.write(`Autenticacao removida do perfil ${target.__profileName}${flags.revoke ? ' e sessoes de usuario revogadas' : ''}.\n`);
1520
1696
  }
1521
1697
 
1522
1698
  function maskApiKey(value) {
@@ -1801,6 +1977,22 @@ function normalizeTypeProject(value) {
1801
1977
  return aliases.get(raw) || raw;
1802
1978
  }
1803
1979
 
1980
+ function normalizeExposure(value) {
1981
+ const raw = normalizeFreeText(value);
1982
+ const aliases = new Map([
1983
+ ['public', 'public'],
1984
+ ['publico', 'public'],
1985
+ ['público', 'public'],
1986
+ ['exposto', 'public'],
1987
+ ['sim', 'public'],
1988
+ ['private', 'private'],
1989
+ ['privado', 'private'],
1990
+ ['nao', 'private'],
1991
+ ['não', 'private'],
1992
+ ]);
1993
+ return aliases.get(raw) || raw;
1994
+ }
1995
+
1804
1996
  function normalizeRuntime(value) {
1805
1997
  const raw = normalizeFreeText(value);
1806
1998
  const aliases = new Map([
@@ -1842,6 +2034,13 @@ function isIpAccessEnabled(planCatalog, planId) {
1842
2034
  return planHasFeature(planCatalog, planId, 'Bloqueio de IPs');
1843
2035
  }
1844
2036
 
2037
+ function isAutoscalingEnabledForPlan(planCatalog, planId) {
2038
+ const plan = Array.isArray(planCatalog)
2039
+ ? planCatalog.find((item) => item?.plan === planId)
2040
+ : undefined;
2041
+ return plan?.permissions?.allow_autoscaling === 'true';
2042
+ }
2043
+
1845
2044
  function getMaxReplicasFromFeatures(features) {
1846
2045
  const replicaFeature = features.find((feature) => /réplicas?/i.test(String(feature)));
1847
2046
  if (!replicaFeature) return 5;
@@ -2016,6 +2215,52 @@ function formatAllowedValues(values) {
2016
2215
  return [...values].join(', ');
2017
2216
  }
2018
2217
 
2218
+ function validateCreateAutoscaling(config, plan, typeProject) {
2219
+ if (config?.autoscaling === undefined) return config;
2220
+ if (typeProject !== 'http') {
2221
+ throw new CliError('config.autoscaling deve ser informado apenas para projetos HTTP.');
2222
+ }
2223
+ if (plan === 'free') {
2224
+ throw new CliError('Auto-scaling nao esta disponivel para projetos HTTP no plano free.');
2225
+ }
2226
+
2227
+ const autoscaling = config.autoscaling;
2228
+ if (!autoscaling || typeof autoscaling !== 'object' || Array.isArray(autoscaling)) {
2229
+ throw new CliError('config.autoscaling deve ser um objeto.');
2230
+ }
2231
+ if (autoscaling.enabled !== true) {
2232
+ throw new CliError('config.autoscaling.enabled deve ser true na criacao do projeto.');
2233
+ }
2234
+ if (autoscaling.min_instances !== undefined) {
2235
+ throw new CliError('Use config.instances como minimo inicial; config.autoscaling.min_instances nao e aceito na criacao.');
2236
+ }
2237
+
2238
+ const instances = Number(config.instances);
2239
+ if (!Number.isInteger(instances) || instances <= 0) {
2240
+ throw new CliError('config.instances deve ser um inteiro positivo ao criar um projeto com auto-scaling.');
2241
+ }
2242
+
2243
+ const maxInstances = Number(autoscaling.max_instances);
2244
+ if (!Number.isInteger(maxInstances) || maxInstances < instances) {
2245
+ throw new CliError('config.autoscaling.max_instances deve ser um inteiro maior ou igual a config.instances.');
2246
+ }
2247
+
2248
+ const nextAutoscaling = {
2249
+ enabled: true,
2250
+ max_instances: maxInstances,
2251
+ };
2252
+ for (const key of ['target_cpu_utilization_percent', 'target_memory_utilization_percent']) {
2253
+ if (autoscaling[key] === undefined) continue;
2254
+ const value = Number(autoscaling[key]);
2255
+ if (!Number.isInteger(value) || value < 1 || value > 100) {
2256
+ throw new CliError(`config.autoscaling.${key} deve ser um inteiro entre 1 e 100.`);
2257
+ }
2258
+ nextAutoscaling[key] = value;
2259
+ }
2260
+
2261
+ return { ...config, autoscaling: nextAutoscaling };
2262
+ }
2263
+
2019
2264
  function validateCreateInput({ plan, paymentMode, config }) {
2020
2265
  const nextPlan = normalizePlan(plan);
2021
2266
  if (!nextPlan || !ALLOWED_PLAN_VALUES.has(nextPlan)) {
@@ -2041,10 +2286,22 @@ function validateCreateInput({ plan, paymentMode, config }) {
2041
2286
  nextConfig.github = { ...nextConfig.github, runtime: nextRuntime };
2042
2287
  }
2043
2288
 
2289
+ if (nextTypeProject === 'http') {
2290
+ const nextExposure = normalizeExposure(nextConfig.exposure);
2291
+ if (!nextExposure || !ALLOWED_EXPOSURE_VALUES.has(nextExposure)) {
2292
+ throw new CliError(`exposure invalido: "${nextConfig.exposure || ''}". Valores aceitos: ${formatAllowedValues(ALLOWED_EXPOSURE_VALUES)}. Docs: ${DOCS_CREATE_HTTP_URL}`);
2293
+ }
2294
+ nextConfig.exposure = nextExposure;
2295
+ } else if (nextConfig.exposure !== undefined) {
2296
+ throw new CliError('exposure deve ser informado apenas para projetos HTTP.');
2297
+ }
2298
+
2299
+ const validatedConfig = validateCreateAutoscaling(nextConfig, nextPlan, nextTypeProject);
2300
+
2044
2301
  return {
2045
2302
  plan: nextPlan,
2046
2303
  paymentMode: nextPaymentMode,
2047
- config: nextConfig,
2304
+ config: validatedConfig,
2048
2305
  };
2049
2306
  }
2050
2307
 
@@ -2072,7 +2329,10 @@ function printWizardSummary(payload) {
2072
2329
  if (masked.description) lines.push(` descricao: ${masked.description}`);
2073
2330
 
2074
2331
  if (masked.config.type_project === 'http') {
2075
- lines.push(` http: porta ${masked.config.port} | instancias ${masked.config.instances} | storage ${masked.config.storage.capacity}Gi (${masked.config.storage.persistent ? 'persistente' : 'efemero'})`);
2332
+ lines.push(` http: exposicao ${masked.config.exposure} | porta ${masked.config.port} | instancias ${masked.config.instances} | storage ${masked.config.storage.capacity}Gi (${masked.config.storage.persistent ? 'persistente' : 'efemero'})`);
2333
+ if (masked.config.autoscaling?.enabled) {
2334
+ lines.push(` auto-scaling: ${masked.config.instances}-${masked.config.autoscaling.max_instances} instancias | CPU ${masked.config.autoscaling.target_cpu_utilization_percent}% | memoria ${masked.config.autoscaling.target_memory_utilization_percent}%`);
2335
+ }
2076
2336
  if (masked.config.github) {
2077
2337
  lines.push(` github: ${masked.config.github.repository_owner}/${masked.config.github.repository_name}@${masked.config.github.branch} | runtime ${masked.config.github.runtime}@${masked.config.github.version}`);
2078
2338
  } else if (masked.config.image) {
@@ -2277,6 +2537,42 @@ async function buildHttpConfig({ plan, httpPlans, availableInstances }) {
2277
2537
  max: Math.max(1, Number(availableInstances?.[plan] || 1)),
2278
2538
  });
2279
2539
 
2540
+ let autoscaling;
2541
+ if (isAutoscalingEnabledForPlan(httpPlans, plan)) {
2542
+ const enabled = await promptWizardBoolean({
2543
+ label: 'Ativar auto-scaling',
2544
+ docs: DOCS_CONFIGURATION_URL,
2545
+ examples: ['sim', 'nao'],
2546
+ });
2547
+ promptWizardBoolean.ui.state.httpAutoscalingEnabled = enabled;
2548
+ if (enabled) {
2549
+ autoscaling = {
2550
+ enabled: true,
2551
+ max_instances: await promptWizardNumber({
2552
+ label: 'Maximo de instancias do auto-scaling',
2553
+ docs: DOCS_CONFIGURATION_URL,
2554
+ examples: [String(Math.max(instances, 2))],
2555
+ min: instances,
2556
+ max: Math.max(instances, Number(availableInstances?.[plan] || instances)),
2557
+ }),
2558
+ target_cpu_utilization_percent: await promptWizardNumber({
2559
+ label: 'CPU alvo do auto-scaling (%)',
2560
+ docs: DOCS_CONFIGURATION_URL,
2561
+ examples: ['70'],
2562
+ min: 1,
2563
+ max: 100,
2564
+ }),
2565
+ target_memory_utilization_percent: await promptWizardNumber({
2566
+ label: 'Memoria alvo do auto-scaling (%)',
2567
+ docs: DOCS_CONFIGURATION_URL,
2568
+ examples: ['80'],
2569
+ min: 1,
2570
+ max: 100,
2571
+ }),
2572
+ };
2573
+ }
2574
+ }
2575
+
2280
2576
  let storage;
2281
2577
  if (plan === 'free') {
2282
2578
  storage = { persistent: false, capacity: 1 };
@@ -2320,8 +2616,17 @@ async function buildHttpConfig({ plan, httpPlans, availableInstances }) {
2320
2616
  ],
2321
2617
  });
2322
2618
 
2619
+ const exposure = await promptWizardSelect({
2620
+ label: 'Exposicao HTTP',
2621
+ docs: DOCS_CREATE_HTTP_URL,
2622
+ examples: WIZARD_HTTP_EXPOSURE_OPTIONS,
2623
+ options: WIZARD_HTTP_EXPOSURE_OPTIONS.map((value) => ({ value })),
2624
+ });
2625
+ promptWizardSelect.ui.state.httpExposure = exposure;
2626
+
2627
+ const isPublicExposure = exposure === 'public';
2323
2628
  const allowBlockIp = isIpAccessEnabled(httpPlans, plan);
2324
- const customWhitelist = allowBlockIp
2629
+ const customWhitelist = isPublicExposure && allowBlockIp
2325
2630
  ? await promptWizardPairs({
2326
2631
  introLabel: 'Deseja configurar whitelist personalizada de entrada?',
2327
2632
  docs: DOCS_CONFIGURATION_URL,
@@ -2334,7 +2639,7 @@ async function buildHttpConfig({ plan, httpPlans, availableInstances }) {
2334
2639
  })
2335
2640
  : [];
2336
2641
 
2337
- const ingress_black_list = allowBlockIp
2642
+ const ingress_black_list = isPublicExposure && allowBlockIp
2338
2643
  ? await promptWizardPairs({
2339
2644
  introLabel: 'Deseja adicionar blacklist de entrada?',
2340
2645
  docs: DOCS_CONFIGURATION_URL,
@@ -2347,7 +2652,7 @@ async function buildHttpConfig({ plan, httpPlans, availableInstances }) {
2347
2652
  })
2348
2653
  : [];
2349
2654
 
2350
- const subdomain = isSubdomainCustomizable(httpPlans, plan)
2655
+ const subdomain = isPublicExposure && isSubdomainCustomizable(httpPlans, plan)
2351
2656
  ? await promptWizardText({
2352
2657
  label: 'Subdomain personalizado',
2353
2658
  examples: ['minha-api'],
@@ -2357,9 +2662,11 @@ async function buildHttpConfig({ plan, httpPlans, availableInstances }) {
2357
2662
 
2358
2663
  return {
2359
2664
  type_project: 'http',
2665
+ exposure,
2360
2666
  ...(source === 'github' ? { github: await buildHttpGithubConfig() } : { image: await buildHttpImageConfig() }),
2361
2667
  port,
2362
2668
  instances,
2669
+ ...(autoscaling ? { autoscaling } : {}),
2363
2670
  storage,
2364
2671
  envs,
2365
2672
  ...(subdomain ? { subdomain } : {}),
@@ -2475,6 +2782,7 @@ async function runProjectCreateWizard(session, flags) {
2475
2782
  if (typeProject === 'http') {
2476
2783
  ui.state.planAllowsBlockIp = isIpAccessEnabled(catalogs.httpPlans, plan);
2477
2784
  ui.state.planAllowsSubdomain = isSubdomainCustomizable(catalogs.httpPlans, plan);
2785
+ ui.state.planAllowsAutoscaling = isAutoscalingEnabledForPlan(catalogs.httpPlans, plan);
2478
2786
  }
2479
2787
  const paymentMode = await promptWizardSelect({
2480
2788
  label: 'Modo de pagamento',
@@ -2654,6 +2962,33 @@ async function handleProjectImageSet(session, flags) {
2654
2962
  process.stdout.write('Imagem atualizada com sucesso.\n');
2655
2963
  }
2656
2964
 
2965
+ async function handleProjectExposureSet(session, flags) {
2966
+ const projectId = requireProjectId(flags, 'project exposure set');
2967
+ if (!projectId) return
2968
+ const exposure = normalizeExposure(flags.exposure);
2969
+
2970
+ if (!ALLOWED_EXPOSURE_VALUES.has(exposure)) {
2971
+ throw new CliError('exposure invalido. Use public ou private.');
2972
+ }
2973
+
2974
+ const orgId = await resolveOrgId(session, flags);
2975
+ const payload = await request(session, flags, 'PATCH', `/project/${projectId}/exposure`, {
2976
+ orgId,
2977
+ body: { exposure },
2978
+ });
2979
+
2980
+ if (flags.json) return printJson(payload);
2981
+
2982
+ printTable([
2983
+ { field: 'Exposicao', value: payload.exposure || exposure },
2984
+ { field: 'Dominio', value: payload.domain ? formatPublicUrl(payload.domain) : '-' },
2985
+ { field: 'Dominios customizados', value: asArray(payload.custom_domains).join(', ') || '-' },
2986
+ ], [
2987
+ { label: 'Campo', value: (row) => row.field },
2988
+ { label: 'Valor', value: (row) => row.value },
2989
+ ]);
2990
+ }
2991
+
2657
2992
  async function getProjectEnvs(session, flags, projectId, orgId) {
2658
2993
  return asArray(unwrapData(await request(session, flags, 'GET', `/project/${projectId}/envs`, { orgId })));
2659
2994
  }
@@ -2760,6 +3095,241 @@ async function handleProjectInstancesSet(session, flags) {
2760
3095
  process.stdout.write(`Quantidade de instancias atualizada para ${instances}.\n`);
2761
3096
  }
2762
3097
 
3098
+ function parseAutoscalingPositiveInteger(value, flagName) {
3099
+ const parsed = Number(value);
3100
+ if (!Number.isInteger(parsed) || parsed <= 0) {
3101
+ throw new CliError(`Informe ${flagName} <n> com um inteiro positivo.`);
3102
+ }
3103
+ return parsed;
3104
+ }
3105
+
3106
+ function parseAutoscalingTargetPercent(value, flagName) {
3107
+ const parsed = parseAutoscalingPositiveInteger(value, flagName);
3108
+ if (parsed > 100) {
3109
+ throw new CliError(`Informe ${flagName} entre 1 e 100.`);
3110
+ }
3111
+ return parsed;
3112
+ }
3113
+
3114
+ function autoscalingFromProject(project) {
3115
+ return project?.additional_info?.autoscaling || project?.autoscaling || { enabled: false };
3116
+ }
3117
+
3118
+ function printAutoscaling(autoscaling) {
3119
+ printTable([
3120
+ {
3121
+ status: autoscaling.enabled ? 'ativo' : 'inativo',
3122
+ min: autoscaling.min_instances || '-',
3123
+ max: autoscaling.max_instances || '-',
3124
+ cpu: autoscaling.target_cpu_utilization_percent ? `${autoscaling.target_cpu_utilization_percent}%` : '-',
3125
+ memory: autoscaling.target_memory_utilization_percent ? `${autoscaling.target_memory_utilization_percent}%` : '-',
3126
+ },
3127
+ ], [
3128
+ { label: 'Status', value: (item) => item.status },
3129
+ { label: 'Minimo', value: (item) => item.min },
3130
+ { label: 'Maximo', value: (item) => item.max },
3131
+ { label: 'CPU alvo', value: (item) => item.cpu },
3132
+ { label: 'Memoria alvo', value: (item) => item.memory },
3133
+ ]);
3134
+ }
3135
+
3136
+ async function handleProjectAutoscaling(session, flags) {
3137
+ const projectId = requireProjectId(flags, 'project autoscaling');
3138
+ if (!projectId) return
3139
+ const orgId = await resolveOrgId(session, flags);
3140
+ const project = await getProject(session, flags, projectId, orgId);
3141
+ const autoscaling = autoscalingFromProject(project);
3142
+
3143
+ if (flags.json) return printJson(autoscaling);
3144
+ printAutoscaling(autoscaling);
3145
+ }
3146
+
3147
+ async function handleProjectAutoscalingSet(session, flags) {
3148
+ const projectId = requireProjectId(flags, 'project autoscaling set');
3149
+ if (!projectId) return
3150
+ if (flags.min === undefined || flags.max === undefined) {
3151
+ printCommandHelpAndFail('project autoscaling set')
3152
+ return
3153
+ }
3154
+
3155
+ const minInstances = parseAutoscalingPositiveInteger(flags.min, '--min');
3156
+ const maxInstances = parseAutoscalingPositiveInteger(flags.max, '--max');
3157
+ if (maxInstances < minInstances) {
3158
+ throw new CliError('Informe --max maior ou igual a --min.');
3159
+ }
3160
+
3161
+ const body = {
3162
+ enabled: true,
3163
+ min_instances: minInstances,
3164
+ max_instances: maxInstances,
3165
+ };
3166
+
3167
+ if (flags.cpu !== undefined) {
3168
+ body.target_cpu_utilization_percent = parseAutoscalingTargetPercent(flags.cpu, '--cpu');
3169
+ }
3170
+ if (flags.memory !== undefined) {
3171
+ body.target_memory_utilization_percent = parseAutoscalingTargetPercent(flags.memory, '--memory');
3172
+ }
3173
+
3174
+ const orgId = await resolveOrgId(session, flags);
3175
+ const payload = await request(session, flags, 'PATCH', `/project/${projectId}/autoscaling`, {
3176
+ orgId,
3177
+ body,
3178
+ });
3179
+
3180
+ if (flags.json) return printJson(payload);
3181
+ process.stdout.write(`Auto-scaling atualizado: ${minInstances}-${maxInstances} instancias.\n`);
3182
+ }
3183
+
3184
+ async function handleProjectAutoscalingDisable(session, flags) {
3185
+ const projectId = requireProjectId(flags, 'project autoscaling disable');
3186
+ if (!projectId) return
3187
+
3188
+ const orgId = await resolveOrgId(session, flags);
3189
+ const payload = await request(session, flags, 'PATCH', `/project/${projectId}/autoscaling`, {
3190
+ orgId,
3191
+ body: { enabled: false },
3192
+ });
3193
+
3194
+ if (flags.json) return printJson(payload);
3195
+ process.stdout.write('Auto-scaling desativado.\n');
3196
+ }
3197
+
3198
+ function printAutoscalingEvents(data) {
3199
+ const directions = { scale_up: 'aumento', scale_down: 'reducao' };
3200
+ const reasons = { increased_capacity: 'capacidade aumentada', reduced_capacity: 'capacidade reduzida' };
3201
+ const events = asArray(data?.events);
3202
+ printTable(events, [
3203
+ { label: 'Quando', value: (event) => event.occurred_at || '-' },
3204
+ { label: 'Direcao', value: (event) => directions[event.direction] || event.direction || '-' },
3205
+ {
3206
+ label: 'Instancias',
3207
+ value: (event) => `${event.previous_instances ?? '-'} -> ${event.new_instances ?? '-'}`,
3208
+ },
3209
+ {
3210
+ label: 'Limites',
3211
+ value: (event) => {
3212
+ const cpu = typeof event.current_cpu_utilization_percent === 'number' || typeof event.target_cpu_utilization_percent === 'number'
3213
+ ? `CPU ${event.current_cpu_utilization_percent ?? '-'}%/${event.target_cpu_utilization_percent ?? '-'}%`
3214
+ : null;
3215
+ const memory = typeof event.current_memory_utilization_percent === 'number' || typeof event.target_memory_utilization_percent === 'number'
3216
+ ? `MEM ${event.current_memory_utilization_percent ?? '-'}%/${event.target_memory_utilization_percent ?? '-'}%`
3217
+ : null;
3218
+ return [cpu, memory].filter(Boolean).join(' ');
3219
+ },
3220
+ },
3221
+ { label: 'Motivo', value: (event) => reasons[event.reason] || event.reason || event.trigger_metric || '-' },
3222
+ ]);
3223
+
3224
+ printPagination(data?.pagination, 'evento');
3225
+ }
3226
+
3227
+ function isIsoDate(value) {
3228
+ const text = String(value);
3229
+ const match = text.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/);
3230
+ if (!match || Number.isNaN(Date.parse(text))) return false;
3231
+
3232
+ const [, year, month, day, hour = '00', minute = '00', second = '00'] = match;
3233
+ const calendarDate = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
3234
+ return calendarDate.getUTCFullYear() === Number(year)
3235
+ && calendarDate.getUTCMonth() === Number(month) - 1
3236
+ && calendarDate.getUTCDate() === Number(day)
3237
+ && Number(hour) <= 23
3238
+ && Number(minute) <= 59
3239
+ && Number(second) <= 59;
3240
+ }
3241
+
3242
+ function validateProjectListFlags(flags, { maxLimit }) {
3243
+ for (const key of ['from', 'to']) {
3244
+ if (flags[key] !== undefined && !isIsoDate(flags[key])) {
3245
+ throw new CliError(`Informe --${key} <iso> com uma data ISO valida.`);
3246
+ }
3247
+ }
3248
+ if (flags.from !== undefined && flags.to !== undefined && Date.parse(String(flags.from)) > Date.parse(String(flags.to))) {
3249
+ throw new CliError('Informe --from anterior ou igual a --to.');
3250
+ }
3251
+ for (const key of ['page', 'limit']) {
3252
+ if (flags[key] === undefined) continue;
3253
+ const value = Number(flags[key]);
3254
+ if (!Number.isInteger(value) || value <= 0) {
3255
+ throw new CliError(`Informe --${key} <n> com um inteiro positivo.`);
3256
+ }
3257
+ if (key === 'limit' && value > maxLimit) {
3258
+ throw new CliError(`Informe --limit entre 1 e ${maxLimit}.`);
3259
+ }
3260
+ }
3261
+ }
3262
+
3263
+ function printPagination(pagination, singular = 'item') {
3264
+ if (!pagination) return;
3265
+ const { page, total_pages: totalPages, total } = pagination;
3266
+ process.stdout.write(`Pagina ${page} de ${totalPages || 1} - ${total} ${singular}(s)\n`);
3267
+ }
3268
+
3269
+ function formatMoney(value, currency = 'brl') {
3270
+ const amount = Number(value);
3271
+ if (!Number.isFinite(amount)) return '-';
3272
+ try {
3273
+ return new Intl.NumberFormat('pt-BR', {
3274
+ style: 'currency',
3275
+ currency: String(currency || 'brl').toUpperCase(),
3276
+ }).format(amount);
3277
+ } catch {
3278
+ return `${String(currency || 'brl').toUpperCase()} ${amount.toFixed(2)}`;
3279
+ }
3280
+ }
3281
+
3282
+ function printBillingUsage(data) {
3283
+ const hours = asArray(data?.hours);
3284
+ printTable(hours, [
3285
+ { label: 'Inicio', value: (item) => item.hour_start || '-' },
3286
+ { label: 'Fim', value: (item) => item.hour_end || '-' },
3287
+ { label: 'Instancia-horas', value: (item) => item.compute_instance_hours ?? '-' },
3288
+ { label: 'Armazenamento', value: (item) => item.storage_gb_hours === undefined ? '-' : `${item.storage_gb_hours} GB-h` },
3289
+ { label: 'Computacao', value: (item) => formatMoney(item.compute_amount, item.currency) },
3290
+ { label: 'Storage', value: (item) => formatMoney(item.storage_amount, item.currency) },
3291
+ { label: 'Total', value: (item) => formatMoney(item.total_amount, item.currency) },
3292
+ { label: 'Status', value: (item) => item.status || '-' },
3293
+ ]);
3294
+
3295
+ if (data?.summary) {
3296
+ const summary = data.summary;
3297
+ process.stdout.write(`Resumo: computacao ${formatMoney(summary.compute_amount, summary.currency)}, armazenamento ${formatMoney(summary.storage_amount, summary.currency)}, total ${formatMoney(summary.total_amount, summary.currency)}\n`);
3298
+ }
3299
+ printPagination(data?.pagination, 'hora');
3300
+ }
3301
+
3302
+
3303
+ async function handleProjectAutoscalingEvents(session, flags) {
3304
+ const projectId = requireProjectId(flags, 'project autoscaling events');
3305
+ if (!projectId) return
3306
+
3307
+ if (flags.direction !== undefined && !['scale_up', 'scale_down'].includes(String(flags.direction))) {
3308
+ throw new CliError('direction invalido. Use scale_up ou scale_down.');
3309
+ }
3310
+
3311
+ validateProjectListFlags(flags, { maxLimit: 100 });
3312
+
3313
+ const orgId = await resolveOrgId(session, flags);
3314
+ const query = buildQuery(flags, ['direction', 'from', 'to', 'page', 'limit']);
3315
+ const data = unwrapData(await request(session, flags, 'GET', `/project/${projectId}/autoscaling/events${query}`, { orgId }));
3316
+
3317
+ if (flags.json) return printJson(data);
3318
+ printAutoscalingEvents(data);
3319
+ }
3320
+
3321
+ async function handleProjectBillingUsage(session, flags) {
3322
+ const projectId = requireProjectId(flags, 'project billing usage');
3323
+ if (!projectId) return;
3324
+ validateProjectListFlags(flags, { maxLimit: 50 });
3325
+ const orgId = await resolveOrgId(session, flags);
3326
+ const query = buildQuery(flags, ['from', 'to', 'page', 'limit']);
3327
+ const data = unwrapData(await request(session, flags, 'GET', `/project/${projectId}/billing/hourly-usage${query}`, { orgId }));
3328
+
3329
+ if (flags.json) return printJson(data);
3330
+ printBillingUsage(data);
3331
+ }
3332
+
2763
3333
  function buildQuery(flags, allowedKeys) {
2764
3334
  const params = new URLSearchParams();
2765
3335
  for (const key of allowedKeys) {
@@ -2820,6 +3390,25 @@ async function getBuildLogs(session, flags, projectId, buildId, orgId, { cursor
2820
3390
  return unwrapData(await request(session, flags, 'GET', `/project/${projectId}/github/builds/${buildId}/logs${query}`, { orgId }))
2821
3391
  }
2822
3392
 
3393
+ function parseIntegerOption(value, flagName, { defaultValue, min, max }) {
3394
+ if (value === undefined) return defaultValue;
3395
+ const parsed = Number(value);
3396
+ if (!Number.isInteger(parsed) || parsed < min || (max !== undefined && parsed > max)) {
3397
+ const range = max === undefined ? `maior ou igual a ${min}` : `entre ${min} e ${max}`;
3398
+ throw new CliError(`Informe ${flagName} com um inteiro ${range}.`);
3399
+ }
3400
+ return parsed;
3401
+ }
3402
+
3403
+ function parseSecondsOption(value, flagName, { defaultValue, min }) {
3404
+ if (value === undefined) return defaultValue;
3405
+ const parsed = Number(value);
3406
+ if (!Number.isFinite(parsed) || parsed < min) {
3407
+ throw new CliError(`Informe ${flagName} com um numero maior ou igual a ${min}.`);
3408
+ }
3409
+ return parsed;
3410
+ }
3411
+
2823
3412
  function printBuildLogs(logs) {
2824
3413
  for (const log of asArray(logs)) {
2825
3414
  process.stdout.write(`${buildLogLineOf(log)}\n`)
@@ -2838,10 +3427,10 @@ async function followBuildLogs(session, flags, projectId, buildId, orgId, {
2838
3427
  intervalSeconds = 5,
2839
3428
  timeoutSeconds = 900,
2840
3429
  } = {}) {
2841
- let cursor = Math.max(Number(initialCursor || 0), 0)
2842
- const pollLimit = Math.max(Number(limit || 200), 1)
2843
- const intervalMs = Math.max(Number(intervalSeconds || 5), 0.1) * 1000
2844
- const timeoutMs = Math.max(Number(timeoutSeconds || 900), 1) * 1000
3430
+ let cursor = parseIntegerOption(initialCursor, '--cursor', { defaultValue: 0, min: 0 })
3431
+ const pollLimit = parseIntegerOption(limit, '--limit', { defaultValue: 200, min: 1, max: 500 })
3432
+ const intervalMs = parseSecondsOption(intervalSeconds, '--interval', { defaultValue: 5, min: 0.1 }) * 1000
3433
+ const timeoutMs = parseSecondsOption(timeoutSeconds, '--timeout', { defaultValue: 900, min: 1 }) * 1000
2845
3434
  const startedAt = Date.now()
2846
3435
 
2847
3436
  while (true) {
@@ -2887,15 +3476,15 @@ async function handleBuildLogs(session, flags) {
2887
3476
  }
2888
3477
 
2889
3478
  const orgId = await resolveOrgId(session, flags);
2890
- const cursor = Math.max(Number(flags.cursor || 0), 0)
2891
- const limit = Math.max(Number(flags.limit || 200), 1)
3479
+ const cursor = parseIntegerOption(flags.cursor, '--cursor', { defaultValue: 0, min: 0 })
3480
+ const limit = parseIntegerOption(flags.limit, '--limit', { defaultValue: 200, min: 1, max: 500 })
2892
3481
 
2893
3482
  if (flags.follow) {
2894
3483
  return followBuildLogs(session, flags, projectId, buildId, orgId, {
2895
3484
  initialCursor: cursor,
2896
3485
  limit,
2897
- intervalSeconds: Number(flags.interval || 5),
2898
- timeoutSeconds: Number(flags.timeout || 900),
3486
+ intervalSeconds: parseSecondsOption(flags.interval, '--interval', { defaultValue: 5, min: 0.1 }),
3487
+ timeoutSeconds: parseSecondsOption(flags.timeout, '--timeout', { defaultValue: 900, min: 1 }),
2899
3488
  })
2900
3489
  }
2901
3490
 
@@ -2916,13 +3505,14 @@ async function handleDeployWatch(session, flags) {
2916
3505
  return followBuildLogs(session, flags, projectId, buildId, orgId, {
2917
3506
  initialCursor: 0,
2918
3507
  limit: 200,
2919
- intervalSeconds: Number(flags.interval || 5),
2920
- timeoutSeconds: Number(flags.timeout || 900),
3508
+ intervalSeconds: parseSecondsOption(flags.interval, '--interval', { defaultValue: 5, min: 0.1 }),
3509
+ timeoutSeconds: parseSecondsOption(flags.timeout, '--timeout', { defaultValue: 900, min: 1 }),
2921
3510
  })
2922
3511
  }
2923
3512
 
2924
3513
  async function main() {
2925
3514
  const { positional, flags } = parseArgs(process.argv.slice(2));
3515
+ validateKnownFlags(flags);
2926
3516
  const [command, subcommand] = positional;
2927
3517
  const session = await readSession();
2928
3518
  try {
@@ -2972,10 +3562,16 @@ async function main() {
2972
3562
  if (command === 'project' && subcommand === 'metrics') return handleProjectMetrics(session, flags);
2973
3563
  if (command === 'project' && subcommand === 'network') return handleProjectNetwork(session, flags);
2974
3564
  if (command === 'project' && subcommand === 'image' && positional[2] === 'set') return handleProjectImageSet(session, flags);
3565
+ if (command === 'project' && subcommand === 'exposure' && positional[2] === 'set') return handleProjectExposureSet(session, flags);
2975
3566
  if (command === 'project' && subcommand === 'envs') return handleProjectEnvs(session, flags);
2976
3567
  if (command === 'project' && subcommand === 'env' && positional[2] === 'add') return handleProjectEnvMutation(session, flags, 'add');
2977
3568
  if (command === 'project' && subcommand === 'env' && positional[2] === 'update') return handleProjectEnvMutation(session, flags, 'update');
2978
3569
  if (command === 'project' && subcommand === 'env' && positional[2] === 'remove') return handleProjectEnvMutation(session, flags, 'remove');
3570
+ if (command === 'project' && subcommand === 'autoscaling' && positional[2] === 'set') return handleProjectAutoscalingSet(session, flags);
3571
+ if (command === 'project' && subcommand === 'autoscaling' && positional[2] === 'disable') return handleProjectAutoscalingDisable(session, flags);
3572
+ if (command === 'project' && subcommand === 'autoscaling' && positional[2] === 'events') return handleProjectAutoscalingEvents(session, flags);
3573
+ if (command === 'project' && subcommand === 'autoscaling') return handleProjectAutoscaling(session, flags);
3574
+ if (command === 'project' && subcommand === 'billing' && positional[2] === 'usage') return handleProjectBillingUsage(session, flags);
2979
3575
  if (command === 'project' && subcommand === 'instances' && positional[2] === 'set') return handleProjectInstancesSet(session, flags);
2980
3576
  if (command === 'project' && subcommand === 'instances') return handleProjectInstances(session, flags);
2981
3577
  if (command === 'builds' && subcommand === 'logs') return handleBuildLogs(session, flags);
@@ -0,0 +1,30 @@
1
+ {
2
+ "type_project": "http",
3
+ "exposure": "public",
4
+ "image": {
5
+ "url": "docker.io/nginx:perl",
6
+ "is_public": true
7
+ },
8
+ "port": 80,
9
+ "instances": 2,
10
+ "autoscaling": {
11
+ "enabled": true,
12
+ "max_instances": 8,
13
+ "target_cpu_utilization_percent": 70,
14
+ "target_memory_utilization_percent": 80
15
+ },
16
+ "storage": {
17
+ "persistent": false,
18
+ "capacity": 1
19
+ },
20
+ "envs": [],
21
+ "network_access": {
22
+ "ingress_white_list": [
23
+ {
24
+ "cidr": "0.0.0.0/0",
25
+ "description": "Allow all"
26
+ }
27
+ ],
28
+ "ingress_black_list": []
29
+ }
30
+ }
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "type_project": "http",
3
+ "exposure": "public",
3
4
  "github": {
4
5
  "repository_owner": "zenifra",
5
6
  "repository_name": "zenifra-cli",
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "type_project": "http",
3
+ "exposure": "public",
3
4
  "image": {
4
5
  "url": "docker.io/nginx:perl",
5
6
  "is_public": true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenifra/cli",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Official Zenifra command line interface.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,7 +31,9 @@
31
31
  "README.md"
32
32
  ],
33
33
  "scripts": {
34
- "check": "node --check bin/zenifra.mjs",
34
+ "check": "node --check bin/zenifra.mjs && npm run check:staging-autoscaling-regression",
35
+ "check:staging-autoscaling-regression": "node --check scripts/staging-autoscaling-regression.mjs",
36
+ "test:staging:autoscaling": "node scripts/staging-autoscaling-regression.mjs",
35
37
  "test": "node --test"
36
38
  }
37
39
  }