@zenifra/cli 0.2.3 → 0.2.5

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/bin/zenifra.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { randomUUID } from 'node:crypto';
3
4
  import { mkdir, readFile, rm, writeFile, chmod } from 'node:fs/promises';
4
5
  import { existsSync } from 'node:fs';
5
6
  import { dirname, join } from 'node:path';
@@ -13,6 +14,7 @@ const DOCS_RUNTIME_URL = `${DOCS_BASE_URL}/docs/runtime`;
13
14
  const DOCS_CREATE_HTTP_URL = `${DOCS_BASE_URL}/docs/how-use-zenifra/http/how-to-create-a-http-project-at-zenifra-via-console`;
14
15
  const DOCS_CREATE_POSTGRESQL_URL = `${DOCS_BASE_URL}/docs/how-use-zenifra/database/how-to-create-a-postgresql-project-at-zenifra-via-console`;
15
16
  const DOCS_CREATE_MARIADB_URL = `${DOCS_BASE_URL}/docs/how-use-zenifra/database/how-to-create-a-mariadb-project-at-zenifra-via-console`;
17
+ const DOCS_CREATE_VALKEY_URL = `${DOCS_BASE_URL}/docs/managed-services/valkey`;
16
18
  const DOCS_CONFIGURATION_URL = `${DOCS_BASE_URL}/docs/configuration`;
17
19
  const DOCS_DATABASE_CONFIGURATION_URL = `${DOCS_BASE_URL}/docs/database/configuration/configuration`;
18
20
  const DOCS_PAYMENTS_URL = `${DOCS_BASE_URL}/docs/payments/payments`;
@@ -21,6 +23,56 @@ const SESSION_FILE = join(SESSION_DIR, 'session.json');
21
23
  const PROFILES_FILE = join(SESSION_DIR, 'profiles.json');
22
24
  const PROFILE_STORE_VERSION = 1;
23
25
  const DEFAULT_PROFILE_NAME = 'default';
26
+ const DEFAULT_HTTP_TIMEOUT_MS = 30_000;
27
+ const KNOWN_FLAG_NAMES = new Set([
28
+ 'apiBase',
29
+ 'branch',
30
+ 'build',
31
+ 'challengeToken',
32
+ 'code',
33
+ 'commitSha',
34
+ 'config',
35
+ 'count',
36
+ 'cpu',
37
+ 'cursor',
38
+ 'description',
39
+ 'direction',
40
+ 'email',
41
+ 'exposure',
42
+ 'follow',
43
+ 'from',
44
+ 'help',
45
+ 'image',
46
+ 'idempotencyKey',
47
+ 'instance',
48
+ 'interval',
49
+ 'json',
50
+ 'key',
51
+ 'limit',
52
+ 'max',
53
+ 'memory',
54
+ 'min',
55
+ 'mode',
56
+ 'name',
57
+ 'org',
58
+ 'operation',
59
+ 'page',
60
+ 'password',
61
+ 'paymentMode',
62
+ 'plan',
63
+ 'profile',
64
+ 'project',
65
+ 'revoke',
66
+ 'showValues',
67
+ 'status',
68
+ 'timeout',
69
+ 'to',
70
+ 'totp',
71
+ 'type',
72
+ 'value',
73
+ 'view',
74
+ 'wait',
75
+ ]);
24
76
  const ALLOWED_PLAN_VALUES = new Set([
25
77
  'free',
26
78
  'static',
@@ -35,10 +87,21 @@ const ALLOWED_PLAN_VALUES = new Set([
35
87
  'db-basic',
36
88
  'db-premium',
37
89
  'db-enterprise',
90
+ 'cache-free',
91
+ 'cache-starter',
92
+ 'cache-basic',
93
+ 'cache-premium',
94
+ 'cache-enterprise',
95
+ 'queue-free',
96
+ 'queue-starter',
97
+ 'queue-basic',
98
+ 'queue-premium',
99
+ 'queue-enterprise',
38
100
  ]);
39
101
  const ALLOWED_PAYMENT_MODE_VALUES = new Set(['hourly', 'monthly', 'yearly']);
40
- const ALLOWED_TYPE_PROJECT_VALUES = new Set(['http', 'postgresql', 'mariadb']);
102
+ const ALLOWED_TYPE_PROJECT_VALUES = new Set(['http', 'postgresql', 'mariadb', 'valkey']);
41
103
  const ALLOWED_RUNTIME_VALUES = new Set(['nodejs', 'python']);
104
+ const ALLOWED_EXPOSURE_VALUES = new Set(['public', 'private']);
42
105
  const GITHUB_RUNTIME_VERSIONS = {
43
106
  nodejs: ['24', '22', '20'],
44
107
  python: ['3.13', '3.12', '3.11'],
@@ -67,7 +130,9 @@ const WIZARD_DATABASE_PLAN_OPTIONS = [
67
130
  'db-free',
68
131
  ];
69
132
  const WIZARD_PAYMENT_MODE_OPTIONS = ['hourly', 'monthly', 'yearly'];
70
- const WIZARD_TYPE_PROJECT_OPTIONS = ['http', 'postgresql', 'mariadb'];
133
+ const WIZARD_TYPE_PROJECT_OPTIONS = ['http', 'postgresql', 'mariadb', 'valkey'];
134
+ const WIZARD_VALKEY_PROFILE_OPTIONS = ['key_value', 'cache', 'queue'];
135
+ const WIZARD_HTTP_EXPOSURE_OPTIONS = ['public', 'private'];
71
136
  let promptInterface;
72
137
  let pipedInputPromise;
73
138
  let pipedInputIndex = 0;
@@ -95,7 +160,7 @@ Usage:
95
160
  zenifra help <command>
96
161
  zenifra auth login [--profile <name>] [--api-base <url>] [--code <code>]
97
162
  zenifra auth api-key --key <znf_key> [--profile <name>] [--api-base <url>]
98
- zenifra auth logout [--profile <name>]
163
+ zenifra auth logout [--profile <name>] [--revoke]
99
164
  zenifra profile list [--json]
100
165
  zenifra profile show [<name>] [--json]
101
166
  zenifra profile add [--name <name>] [--description <text>] [--api-base <url>] [--mode <api-key|login>] [--key <znf_key>] [--json]
@@ -104,20 +169,30 @@ Usage:
104
169
  zenifra profile remove <name> [--json]
105
170
  zenifra orgs [--json]
106
171
  zenifra org set [--org <id>]
107
- zenifra plans [--type <all|http|database|storage>] [--json]
172
+ zenifra plans [--type <all|http|database|storage|valkey>] [--json]
108
173
  zenifra create project
109
174
  zenifra create project --name <name> --plan <plan> --payment-mode <mode> --config <json|@file> [--description <text>] [--org <id>] [--json]
110
- zenifra projects [--json] [--org <id>] [--type <http|postgresql|mariadb>] [--page <n>] [--limit <n>]
175
+ zenifra projects [--json] [--org <id>] [--type <http|postgresql|mariadb|valkey>] [--page <n>] [--limit <n>]
176
+ zenifra valkey status --project <id> [--json]
177
+ zenifra valkey connection --project <id> [--json]
178
+ zenifra valkey credentials rotate --project <id> [--idempotency-key <key>] [--wait] [--interval <seconds>] [--timeout <seconds>] [--json]
179
+ zenifra valkey credentials status --project <id> --operation <id> [--json]
111
180
  zenifra project info --project <id> [--json]
112
181
  zenifra project url --project <id> [--json]
113
182
  zenifra project logs --project <id> [--instance <id>] [--json]
114
183
  zenifra project metrics --project <id> [--instance <id>] [--json]
115
184
  zenifra project network --project <id> [--view <summary|status-codes|routes|user-agents|request-events|source-ips>] [--json]
116
185
  zenifra project image set --project <id> --image <image> [--json]
186
+ zenifra project exposure set --project <id> --exposure <public|private> [--json]
117
187
  zenifra project envs --project <id> [--json] [--show-values]
118
188
  zenifra project env add --project <id> --name <name> --value <value> [--json]
119
189
  zenifra project env update --project <id> --name <name> --value <value> [--json]
120
190
  zenifra project env remove --project <id> --name <name> [--json]
191
+ zenifra project autoscaling --project <id> [--json]
192
+ zenifra project autoscaling set --project <id> --min <n> --max <n> [--cpu <percent>] [--memory <percent>] [--json]
193
+ zenifra project autoscaling disable --project <id> [--json]
194
+ zenifra project autoscaling events --project <id> [--direction <scale_up|scale_down>] [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]
195
+ zenifra project billing usage --project <id> [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]
121
196
  zenifra project instances --project <id> [--json]
122
197
  zenifra project instances set --project <id> --count <n> [--json]
123
198
  zenifra builds --project <id> [--page <n>] [--limit <n>] [--branch <name>] [--status <status>] [--json]
@@ -138,7 +213,7 @@ Run "zenifra help <command>" or "zenifra <command> --help" for command-specific
138
213
  const HELP_SPECS = [
139
214
  {
140
215
  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>]',
216
+ 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
217
  description: 'Gerencia a autenticacao dos perfis locais da CLI.',
143
218
  examples: ['zenifra auth', 'zenifra auth login --profile staging', 'zenifra auth api-key --profile prod --key znf_0123456789abcdef01234567_abcd...'],
144
219
  output: 'Zenifra CLI - auth',
@@ -168,11 +243,12 @@ const HELP_SPECS = [
168
243
  },
169
244
  {
170
245
  command: 'auth logout',
171
- usage: 'zenifra auth logout [--profile <name>]',
246
+ usage: 'zenifra auth logout [--profile <name>] [--revoke]',
172
247
  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'],
248
+ flags: ['--profile <name> Limpa outro perfil sem trocar o perfil ativo.', '--revoke Revoga as sessoes de usuario no servidor antes de limpar o perfil.'],
249
+ examples: ['zenifra auth logout', 'zenifra auth logout --profile staging', 'zenifra auth logout --revoke'],
175
250
  output: 'Autenticacao removida do perfil active.',
251
+ 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
252
  },
177
253
  {
178
254
  command: 'profile',
@@ -263,16 +339,65 @@ const HELP_SPECS = [
263
339
  },
264
340
  {
265
341
  command: 'plans',
266
- usage: 'zenifra plans [--type <all|http|database|storage>] [--json]',
267
- description: 'Lista os catalogos publicos de preco de planos HTTP, banco e armazenamento.',
268
- flags: ['--type <type> Filtra o catalogo: all, http, database ou storage.', '--json Imprime a resposta em JSON.'],
269
- examples: ['zenifra plans', 'zenifra plans --type http', 'zenifra plans --type storage --json'],
342
+ usage: 'zenifra plans [--type <all|http|database|storage|valkey>] [--json]',
343
+ description: 'Lista os catalogos publicos de preco de planos HTTP, banco, armazenamento e Valkey.',
344
+ flags: ['--type <type> Filtra o catalogo: all, http, database, storage, valkey, key-value, cache ou queue.', '--json Imprime a resposta em JSON.'],
345
+ examples: ['zenifra plans', 'zenifra plans --type http', 'zenifra plans --type valkey --json'],
270
346
  output: 'HTTP\nPlano Hora Mes Ano Recursos\nfree R$ 0,00 R$ 0,00 R$ 0,00 1 GB Armazenamento Efemero',
271
347
  jsonOutput: '{"http":[{"plan":"free","prices":{"hourly":0,"monthly":0,"yearly":0},"features":["1 GB Armazenamento Efemero"]}],"database":[],"storage":[]}',
272
348
  },
349
+ {
350
+ command: 'valkey',
351
+ usage: 'zenifra valkey\n zenifra valkey status --project <id> [--json]\n zenifra valkey connection --project <id> [--json]\n zenifra valkey credentials rotate --project <id> [--idempotency-key <key>] [--wait] [--interval <seconds>] [--timeout <seconds>] [--json]\n zenifra valkey credentials status --project <id> --operation <id> [--json]',
352
+ description: 'Consulta projetos Valkey e gerencia suas conexoes e credenciais.',
353
+ examples: ['zenifra valkey status --project proj_1', 'zenifra valkey connection --project proj_1', 'zenifra valkey credentials rotate --project proj_1 --wait'],
354
+ output: 'Zenifra CLI - valkey',
355
+ notes: ['A string de conexao completa aparece somente quando uma criacao ou rotacao a disponibiliza.'],
356
+ },
357
+ {
358
+ command: 'valkey status',
359
+ usage: 'zenifra valkey status --project <id> [--json]',
360
+ description: 'Mostra estado, perfil, versao e persistencia de um projeto Valkey.',
361
+ flags: ['--project <id> ID do projeto.', '--json Imprime a resposta em JSON.'],
362
+ examples: ['zenifra valkey status --project proj_1'],
363
+ output: 'Status Perfil Versao Persistente Instancias\nrunning cache 9.1.1 nao 1',
364
+ },
365
+ {
366
+ command: 'valkey connection',
367
+ usage: 'zenifra valkey connection --project <id> [--json]',
368
+ description: 'Mostra os dados de conexao de um projeto Valkey sem revelar a credencial.',
369
+ flags: ['--project <id> ID do projeto.', '--json Imprime a resposta em JSON.'],
370
+ examples: ['zenifra valkey connection --project proj_1'],
371
+ output: 'Host Porta TLS Conexao\nvalkey.example.com 6380 sim valkeys://default:********@valkey.example.com:6380/0',
372
+ notes: ['Use a credencial recebida na criacao ou em uma rotacao concluida.'],
373
+ },
374
+ {
375
+ command: 'valkey credentials',
376
+ usage: 'zenifra valkey credentials\n zenifra valkey credentials rotate --project <id> [--idempotency-key <key>] [--wait] [--interval <seconds>] [--timeout <seconds>] [--json]\n zenifra valkey credentials status --project <id> --operation <id> [--json]',
377
+ description: 'Agrupa comandos de renovacao e acompanhamento de credenciais Valkey.',
378
+ examples: ['zenifra valkey credentials rotate --project proj_1', 'zenifra valkey credentials status --project proj_1 --operation operation_1'],
379
+ output: 'Zenifra CLI - valkey credentials',
380
+ },
381
+ {
382
+ command: 'valkey credentials rotate',
383
+ usage: 'zenifra valkey credentials rotate --project <id> [--idempotency-key <key>] [--wait] [--interval <seconds>] [--timeout <seconds>] [--json]',
384
+ description: 'Solicita a renovacao da credencial e retorna uma operacao acompanhavel.',
385
+ 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.'],
386
+ examples: ['zenifra valkey credentials rotate --project proj_1', 'zenifra valkey credentials rotate --project proj_1 --wait --timeout 120'],
387
+ output: 'Operacao aceita: rotation_123\nEstado: accepted',
388
+ notes: ['Salve a nova string de conexao quando a operacao atingir completed.'],
389
+ },
390
+ {
391
+ command: 'valkey credentials status',
392
+ usage: 'zenifra valkey credentials status --project <id> --operation <id> [--json]',
393
+ description: 'Consulta o estado de uma renovacao de credencial Valkey.',
394
+ flags: ['--project <id> ID do projeto.', '--operation <id> ID da operacao.', '--json Imprime a resposta em JSON.'],
395
+ examples: ['zenifra valkey credentials status --project proj_1 --operation rotation_123'],
396
+ output: 'Operacao: rotation_123\nEstado: completed',
397
+ },
273
398
  {
274
399
  command: 'projects',
275
- usage: 'zenifra projects [--json] [--org <id>] [--type <http|postgresql|mariadb>] [--page <n>] [--limit <n>]',
400
+ usage: 'zenifra projects [--json] [--org <id>] [--type <http|postgresql|mariadb|valkey>] [--page <n>] [--limit <n>]',
276
401
  description: 'Lista projetos da organizacao ativa ou da API key.',
277
402
  flags: ['--json Imprime a resposta em JSON.', '--org <id> Usa uma organizacao especifica com sessao de usuario.', '--type <type> Filtra por tipo de projeto.', '--page <n> Pagina. Padrao: 1.', '--limit <n> Itens por pagina. Padrao: 15.'],
278
403
  examples: ['zenifra projects --type http --page 1 --limit 15', 'zenifra projects --json'],
@@ -281,9 +406,9 @@ const HELP_SPECS = [
281
406
  },
282
407
  {
283
408
  command: 'create project',
284
- usage: 'zenifra create project\n zenifra create project --name <name> --plan <plan> --payment-mode <mode> --config <json|@file> [--description <text>] [--org <id>] [--json]',
409
+ usage: 'zenifra create project\n zenifra create project --name <name> --plan <plan> --payment-mode <mode> --config <json|@file> [--description <text>] [--idempotency-key <key>] [--org <id>] [--json]',
285
410
  description: 'Cria um projeto via wizard interativo ou via payload de configuracao JSON.',
286
- flags: ['--name <name> Nome do projeto.', '--plan <plan> Plano do projeto.', '--payment-mode <mode> Modo de pagamento.', '--config <json|@file> JSON inline ou arquivo.', '--description <text> Descricao opcional.', '--json Imprime a resposta em JSON.'],
411
+ flags: ['--name <name> Nome do projeto.', '--plan <plan> Plano do projeto.', '--payment-mode <mode> Modo de pagamento.', '--config <json|@file> JSON inline ou arquivo.', '--description <text> Descricao opcional.', '--idempotency-key <key> Chave para repetir a mesma criacao com seguranca.', '--json Imprime a resposta em JSON.'],
287
412
  examples: ['zenifra create project', 'zenifra create project --name api-web --plan free --payment-mode hourly --config @examples/http-project.json'],
288
413
  output: 'Campo Valor\n------- --------------------------------------\nProjeto 507f1f77bcf86cd799439012\nDominio https://api-web.client.zenifra.com',
289
414
  jsonOutput: '{"status":"success","data":{"id":"507f1f77bcf86cd799439012","name":"api-web"}}',
@@ -291,11 +416,11 @@ const HELP_SPECS = [
291
416
  },
292
417
  {
293
418
  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]',
419
+ 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
420
  description: 'Agrupa comandos operacionais e de introspecao sobre um projeto especifico.',
296
421
  examples: ['zenifra project', 'zenifra project info --project proj_1', 'zenifra project env add --project proj_1 --name NODE_ENV --value production'],
297
422
  output: 'Zenifra CLI - project',
298
- notes: ['Use "zenifra help project <subcomando>" para detalhes de info, url, logs, metrics, network, image, envs e instances.'],
423
+ notes: ['Use "zenifra help project <subcomando>" para detalhes de info, url, logs, metrics, network, image, exposure, autoscaling, billing, envs e instances.'],
299
424
  },
300
425
  {
301
426
  command: 'project info',
@@ -352,6 +477,16 @@ const HELP_SPECS = [
352
477
  output: 'Imagem atualizada com sucesso.',
353
478
  jsonOutput: '{"status":"success","message":"updated with success"}',
354
479
  },
480
+ {
481
+ command: 'project exposure set',
482
+ usage: 'zenifra project exposure set --project <id> --exposure <public|private> [--json]',
483
+ description: 'Altera se um projeto HTTP expoe rota publica e dominio.',
484
+ 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.'],
485
+ examples: ['zenifra project exposure set --project 507f1f77bcf86cd799439012 --exposure private', 'zenifra project exposure set --project 507f1f77bcf86cd799439012 --exposure public --json'],
486
+ output: 'Campo Valor\n--------- ----------------------------------\nExposicao private\nDominio -',
487
+ jsonOutput: '{"status":"success","message":"project exposure updated with success","exposure":"private","custom_domains":[]}',
488
+ notes: ['Ao mudar para private, a API remove a rota publica e dominios personalizados do projeto.'],
489
+ },
355
490
  {
356
491
  command: 'project envs',
357
492
  usage: 'zenifra project envs --project <id> [--json] [--show-values]',
@@ -392,10 +527,55 @@ const HELP_SPECS = [
392
527
  jsonOutput: '{"status":"success","message":"updated with success","envs":[]}',
393
528
  notes: ['Falha se a variavel nao existir.'],
394
529
  },
530
+ {
531
+ command: 'project autoscaling',
532
+ usage: 'zenifra project autoscaling --project <id> [--json]',
533
+ description: 'Mostra a configuracao de auto-scaling HTTP do projeto.',
534
+ flags: ['--project <id> ID do projeto.', '--json Imprime a resposta em JSON.'],
535
+ examples: ['zenifra project autoscaling --project 507f1f77bcf86cd799439012'],
536
+ output: 'Campo Valor\n---------- -----\nStatus ativo\nMinimo 2\nMaximo 8\nCPU alvo 70%\nMemoria 80%',
537
+ jsonOutput: '{"enabled":true,"min_instances":2,"max_instances":8,"target_cpu_utilization_percent":70,"target_memory_utilization_percent":80}',
538
+ },
539
+ {
540
+ command: 'project autoscaling set',
541
+ usage: 'zenifra project autoscaling set --project <id> --min <n> --max <n> [--cpu <percent>] [--memory <percent>] [--json]',
542
+ description: 'Ativa ou atualiza o auto-scaling HTTP do projeto.',
543
+ 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.'],
544
+ examples: ['zenifra project autoscaling set --project 507f1f77bcf86cd799439012 --min 2 --max 8 --cpu 70 --memory 80'],
545
+ output: 'Auto-scaling atualizado: 2-8 instancias.',
546
+ jsonOutput: '{"status":"success","message":"project autoscaling updated with success","data":{"autoscaling":{"enabled":true,"min_instances":2,"max_instances":8}}}',
547
+ },
548
+ {
549
+ command: 'project autoscaling disable',
550
+ usage: 'zenifra project autoscaling disable --project <id> [--json]',
551
+ description: 'Desativa o auto-scaling HTTP e volta para instancias fixas no minimo configurado.',
552
+ flags: ['--project <id> ID do projeto.', '--json Imprime a resposta em JSON.'],
553
+ examples: ['zenifra project autoscaling disable --project 507f1f77bcf86cd799439012'],
554
+ output: 'Auto-scaling desativado.',
555
+ jsonOutput: '{"status":"success","message":"project autoscaling updated with success","data":{"autoscaling":{"enabled":false}}}',
556
+ },
557
+ {
558
+ command: 'project autoscaling events',
559
+ usage: 'zenifra project autoscaling events --project <id> [--direction <scale_up|scale_down>] [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]',
560
+ description: 'Lista o historico de scale up e scale down do auto-scaling HTTP.',
561
+ 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.'],
562
+ examples: ['zenifra project autoscaling events --project 507f1f77bcf86cd799439012 --direction scale_up'],
563
+ output: 'Quando Direcao Instancias Limites Motivo\n05/06/2026, 18:40 aumento 2 -> 5 CPU 91%/70% capacidade aumentada',
564
+ 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}}',
565
+ },
566
+ {
567
+ command: 'project billing usage',
568
+ usage: 'zenifra project billing usage --project <id> [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]',
569
+ description: 'Lista o consumo horario consolidado de um projeto.',
570
+ 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.'],
571
+ examples: ['zenifra project billing usage --project 507f1f77bcf86cd799439012 --from 2026-06-01T00:00:00Z --limit 20'],
572
+ 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',
573
+ jsonOutput: '{"hours":[],"summary":{"currency":"brl","compute_amount":0,"storage_amount":0,"total_amount":0},"pagination":{"page":1,"limit":10,"total":0,"total_pages":0}}',
574
+ },
395
575
  {
396
576
  command: 'project instances',
397
577
  usage: 'zenifra project instances --project <id> [--json]',
398
- description: 'Lista instancias/pods do projeto.',
578
+ description: 'Lista instancias do projeto.',
399
579
  flags: ['--project <id> ID do projeto.', '--json Imprime a resposta em JSON.'],
400
580
  examples: ['zenifra project instances --project 507f1f77bcf86cd799439012'],
401
581
  output: 'Instancia\nweb-1',
@@ -426,11 +606,11 @@ const HELP_SPECS = [
426
606
  flags: [
427
607
  '--project <id> ID do projeto.',
428
608
  '--build <id> ID do build.',
429
- '--cursor <n> Cursor inicial. Padrao: 0.',
430
- '--limit <n> Quantidade de logs por request. Padrao: 200.',
609
+ '--cursor <n> Cursor inicial inteiro, maior ou igual a 0. Padrao: 0.',
610
+ '--limit <n> Quantidade de logs por request, entre 1 e 500. Padrao: 200.',
431
611
  '--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.',
612
+ '--interval <seconds> Intervalo de polling com --follow, minimo 0.1. Padrao: 5.',
613
+ '--timeout <seconds> Timeout total com --follow, minimo 1. Padrao: 900.',
434
614
  '--json Imprime a resposta em JSON.',
435
615
  ],
436
616
  examples: ['zenifra builds logs --project 507f1f77bcf86cd799439012 --build build_123', 'zenifra builds logs --project 507f1f77bcf86cd799439012 --build build_123 --follow'],
@@ -466,7 +646,7 @@ const HELP_SPECS = [
466
646
  command: 'deploy watch',
467
647
  usage: 'zenifra deploy watch --project <id> --build <id> [--interval <seconds>] [--timeout <seconds>] [--json]',
468
648
  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.'],
649
+ 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
650
  examples: [
471
651
  'zenifra deploy watch --project 507f1f77bcf86cd799439012 --build build_123',
472
652
  'zenifra deploy watch --project 507f1f77bcf86cd799439012 --build build_123 --interval 2',
@@ -543,7 +723,7 @@ function removedProjectsCreateMessage() {
543
723
  }
544
724
 
545
725
  function isNamespaceCommand(command) {
546
- return ['auth', 'profile', 'project', 'org'].includes(command);
726
+ return ['auth', 'profile', 'project', 'org', 'valkey'].includes(command);
547
727
  }
548
728
 
549
729
  function parseArgs(argv) {
@@ -578,6 +758,17 @@ function parseArgs(argv) {
578
758
  return { positional, flags };
579
759
  }
580
760
 
761
+ function flagNameForDisplay(value) {
762
+ return `--${String(value).replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`)}`;
763
+ }
764
+
765
+ function validateKnownFlags(flags) {
766
+ const unknown = Object.keys(flags).filter((name) => !KNOWN_FLAG_NAMES.has(name));
767
+ if (unknown.length > 0) {
768
+ throw new CliError(`Flag desconhecida: ${unknown.map(flagNameForDisplay).join(', ')}.`);
769
+ }
770
+ }
771
+
581
772
  function emptyProfileStore() {
582
773
  return {
583
774
  version: PROFILE_STORE_VERSION,
@@ -667,7 +858,9 @@ async function migrateLegacySession() {
667
858
  });
668
859
  store.profiles[DEFAULT_PROFILE_NAME] = migratedProfile;
669
860
  store.activeProfile = DEFAULT_PROFILE_NAME;
670
- return writeProfileStore(store);
861
+ const normalized = await writeProfileStore(store);
862
+ await rm(SESSION_FILE, { force: true });
863
+ return normalized;
671
864
  }
672
865
 
673
866
  async function readProfileStore() {
@@ -861,9 +1054,16 @@ function estimateWizardTotal(state = {}) {
861
1054
  if (state.httpStoragePersistent) total += 1;
862
1055
  }
863
1056
 
1057
+ if (state.planAllowsAutoscaling) {
1058
+ total += 1;
1059
+ if (state.httpAutoscalingEnabled) total += 3;
1060
+ }
1061
+
864
1062
  total += 1;
865
1063
  if (state.envs?.enabled) total += state.envs.count * 3;
866
1064
 
1065
+ total += 1;
1066
+
867
1067
  if (state.httpSource === 'github') {
868
1068
  total += 9;
869
1069
  } else if (state.httpSource === 'oci') {
@@ -877,13 +1077,13 @@ function estimateWizardTotal(state = {}) {
877
1077
  total += 1;
878
1078
  }
879
1079
 
880
- if (state.planAllowsBlockIp) {
1080
+ if (state.httpExposure !== 'private' && state.planAllowsBlockIp) {
881
1081
  total += 2;
882
1082
  if (state.whitelist?.enabled) total += state.whitelist.count * 3;
883
1083
  if (state.blacklist?.enabled) total += state.blacklist.count * 3;
884
1084
  }
885
1085
 
886
- if (state.planAllowsSubdomain) total += 1;
1086
+ if (state.httpExposure !== 'private' && state.planAllowsSubdomain) total += 1;
887
1087
  return total;
888
1088
  }
889
1089
 
@@ -898,6 +1098,11 @@ function estimateWizardTotal(state = {}) {
898
1098
  return total;
899
1099
  }
900
1100
 
1101
+ if (typeProject === 'valkey') {
1102
+ total += 3;
1103
+ return total;
1104
+ }
1105
+
901
1106
  return 18;
902
1107
  }
903
1108
 
@@ -1199,8 +1404,25 @@ async function promptHidden(question) {
1199
1404
  });
1200
1405
  }
1201
1406
 
1202
- async function request(session, flags, method, path, { body, orgId, tokenRequired = true } = {}) {
1203
- const credential = resolveCredential(session);
1407
+ function httpTimeoutMs() {
1408
+ const configured = process.env.ZENIFRA_HTTP_TIMEOUT_MS;
1409
+ if (configured === undefined) return DEFAULT_HTTP_TIMEOUT_MS;
1410
+
1411
+ const value = Number(configured);
1412
+ if (!Number.isInteger(value) || value <= 0) {
1413
+ throw new CliError('ZENIFRA_HTTP_TIMEOUT_MS deve ser um inteiro positivo em milissegundos.');
1414
+ }
1415
+ return value;
1416
+ }
1417
+
1418
+ async function request(session, flags, method, path, {
1419
+ body,
1420
+ orgId,
1421
+ tokenRequired = true,
1422
+ credential: credentialOverride,
1423
+ headers: extraHeaders = {},
1424
+ } = {}) {
1425
+ const credential = credentialOverride || resolveCredential(session);
1204
1426
  if (tokenRequired && !credential) {
1205
1427
  throw new CliError('Voce precisa autenticar primeiro: zenifra auth login, zenifra auth api-key --key <znf_key> ou ZENIFRA_API_KEY.');
1206
1428
  }
@@ -1209,6 +1431,14 @@ async function request(session, flags, method, path, { body, orgId, tokenRequire
1209
1431
  if (body !== undefined) {
1210
1432
  headers['Content-Type'] = 'application/json';
1211
1433
  }
1434
+ for (const [name, value] of Object.entries(extraHeaders)) {
1435
+ if (value === undefined) continue;
1436
+ const normalizedName = name.toLowerCase();
1437
+ if (['authorization', 'x-organization-id', 'content-type'].includes(normalizedName)) {
1438
+ throw new CliError(`Header nao permitido: ${name}.`);
1439
+ }
1440
+ headers[name] = String(value);
1441
+ }
1212
1442
  if (credential?.token) {
1213
1443
  headers.Authorization = `Bearer ${credential.token}`;
1214
1444
  }
@@ -1216,11 +1446,21 @@ async function request(session, flags, method, path, { body, orgId, tokenRequire
1216
1446
  headers['x-organization-id'] = orgId;
1217
1447
  }
1218
1448
 
1219
- const response = await fetch(`${apiBaseUrl(session, flags)}${path}`, {
1220
- method,
1221
- headers,
1222
- body: body === undefined ? undefined : JSON.stringify(body),
1223
- });
1449
+ const timeoutMs = httpTimeoutMs();
1450
+ let response;
1451
+ try {
1452
+ response = await fetch(`${apiBaseUrl(session, flags)}${path}`, {
1453
+ method,
1454
+ headers,
1455
+ body: body === undefined ? undefined : JSON.stringify(body),
1456
+ signal: AbortSignal.timeout(timeoutMs),
1457
+ });
1458
+ } catch (error) {
1459
+ if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
1460
+ throw new CliError(`A API da Zenifra nao respondeu em ate ${timeoutMs} ms.`);
1461
+ }
1462
+ throw new CliError(`Nao foi possivel conectar a API da Zenifra: ${error?.message || 'erro de rede'}.`);
1463
+ }
1224
1464
 
1225
1465
  const text = await response.text();
1226
1466
  let payload = {};
@@ -1234,12 +1474,18 @@ async function request(session, flags, method, path, { body, orgId, tokenRequire
1234
1474
 
1235
1475
  if (!response.ok) {
1236
1476
  const message = payload?.message || payload?.error || `Zenifra API retornou HTTP ${response.status}`;
1237
- if (response.status === 401 || response.status === 403) {
1477
+ if (response.status === 401) {
1238
1478
  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.`);
1479
+ throw new CliError(`${message}. Verifique se a API key esta ativa e se o IP atual esta permitido.`);
1240
1480
  }
1241
1481
  throw new CliError(`${message}. Rode "zenifra auth login" para renovar sua sessao.`);
1242
1482
  }
1483
+ if (response.status === 403) {
1484
+ throw new CliError(message);
1485
+ }
1486
+ if (response.status === 429 && Number.isFinite(Number(payload?.retry_after_seconds))) {
1487
+ throw new CliError(`${message} Tente novamente em ${Number(payload.retry_after_seconds)} segundo(s).`);
1488
+ }
1243
1489
  if (String(message).includes('missing x-organization-id')) {
1244
1490
  throw new CliError('Organizacao nao selecionada. Rode "zenifra org set" ou use --org <id>.');
1245
1491
  }
@@ -1345,19 +1591,33 @@ function printEnvs(envs, flags) {
1345
1591
  }
1346
1592
 
1347
1593
  function printProject(project) {
1348
- const url = projectUrlOf(project);
1594
+ const managedService = project.managed_service;
1595
+ const isValkey = project.type_project === 'valkey' || managedService?.engine === 'valkey';
1349
1596
  const rows = [
1350
1597
  { label: 'ID', value: projectIdOf(project) || '-' },
1351
1598
  { label: 'Nome', value: project.name || '-' },
1352
1599
  { label: 'Status', value: project.status || '-' },
1353
1600
  { label: 'Tipo', value: project.type_project || '-' },
1354
1601
  { label: 'Plano', value: project.plan || '-' },
1355
- { label: 'URL', value: url || '-' },
1356
- { label: 'Imagem', value: project.image || '-' },
1357
- { label: 'Instancias', value: project.instances ?? '-' },
1358
- { label: 'Instancias atuais', value: project.additional_info?.current_instances ?? '-' },
1359
1602
  ];
1360
1603
 
1604
+ if (isValkey) {
1605
+ rows.push(
1606
+ { label: 'Perfil', value: managedService?.profile || '-' },
1607
+ { label: 'Versao', value: managedService?.version || '-' },
1608
+ { label: 'Persistente', value: managedService?.topology?.persistent === true ? 'sim' : 'nao' },
1609
+ { label: 'Instancias', value: project.instances ?? '-' },
1610
+ );
1611
+ } else {
1612
+ rows.push(
1613
+ { label: 'Exposicao', value: project.exposure || '-' },
1614
+ { label: 'URL', value: projectUrlOf(project) || '-' },
1615
+ { label: 'Imagem', value: project.image || '-' },
1616
+ { label: 'Instancias', value: project.instances ?? '-' },
1617
+ { label: 'Instancias atuais', value: project.additional_info?.current_instances ?? '-' },
1618
+ );
1619
+ }
1620
+
1361
1621
  for (const row of rows) {
1362
1622
  process.stdout.write(`${row.label}: ${row.value}\n`);
1363
1623
  }
@@ -1507,6 +1767,18 @@ async function handleLogout(session, flags) {
1507
1767
  throw new CliError('Nenhum perfil ativo configurado. Crie um perfil com "zenifra profile add" ou autentique com "zenifra auth login".');
1508
1768
  }
1509
1769
  const target = requireExistingProfile(session, targetName);
1770
+ if (flags.revoke) {
1771
+ if (!target.accessToken) {
1772
+ throw new CliError('A revogacao exige um perfil autenticado por login de usuario; API keys devem ser revogadas na organizacao.');
1773
+ }
1774
+ await request(target, flags, 'DELETE', '/authentication', {
1775
+ credential: {
1776
+ token: target.accessToken,
1777
+ type: 'user',
1778
+ source: 'profile',
1779
+ },
1780
+ });
1781
+ }
1510
1782
  target.accessToken = undefined;
1511
1783
  target.apiKey = undefined;
1512
1784
  target.selectedOrganizationId = undefined;
@@ -1516,7 +1788,7 @@ async function handleLogout(session, flags) {
1516
1788
  if (target.__profileName === getProfileName(session)) {
1517
1789
  Object.assign(session, buildSession(target.__store));
1518
1790
  }
1519
- process.stdout.write(`Autenticacao removida do perfil ${target.__profileName}.\n`);
1791
+ process.stdout.write(`Autenticacao removida do perfil ${target.__profileName}${flags.revoke ? ' e sessoes de usuario revogadas' : ''}.\n`);
1520
1792
  }
1521
1793
 
1522
1794
  function maskApiKey(value) {
@@ -1761,6 +2033,26 @@ function normalizePlan(value) {
1761
2033
  ['dbpremium', 'db-premium'],
1762
2034
  ['db_enterprise', 'db-enterprise'],
1763
2035
  ['dbenterprise', 'db-enterprise'],
2036
+ ['cache_free', 'cache-free'],
2037
+ ['cachefree', 'cache-free'],
2038
+ ['cache_starter', 'cache-starter'],
2039
+ ['cachestarter', 'cache-starter'],
2040
+ ['cache_basic', 'cache-basic'],
2041
+ ['cachebasic', 'cache-basic'],
2042
+ ['cache_premium', 'cache-premium'],
2043
+ ['cachepremium', 'cache-premium'],
2044
+ ['cache_enterprise', 'cache-enterprise'],
2045
+ ['cacheenterprise', 'cache-enterprise'],
2046
+ ['queue_free', 'queue-free'],
2047
+ ['queuefree', 'queue-free'],
2048
+ ['queue_starter', 'queue-starter'],
2049
+ ['queuestarter', 'queue-starter'],
2050
+ ['queue_basic', 'queue-basic'],
2051
+ ['queuebasic', 'queue-basic'],
2052
+ ['queue_premium', 'queue-premium'],
2053
+ ['queuepremium', 'queue-premium'],
2054
+ ['queue_enterprise', 'queue-enterprise'],
2055
+ ['queueenterprise', 'queue-enterprise'],
1764
2056
  ]);
1765
2057
 
1766
2058
  if (aliases.has(normalized)) return aliases.get(normalized);
@@ -1797,6 +2089,37 @@ function normalizeTypeProject(value) {
1797
2089
  ['postgresql', 'postgresql'],
1798
2090
  ['mariadb', 'mariadb'],
1799
2091
  ['maria db', 'mariadb'],
2092
+ ['valkey', 'valkey'],
2093
+ ['key value', 'valkey'],
2094
+ ['key-value', 'valkey'],
2095
+ ]);
2096
+ return aliases.get(raw) || raw;
2097
+ }
2098
+
2099
+ function normalizeValkeyProfile(value) {
2100
+ const raw = normalizeFreeText(value);
2101
+ const aliases = new Map([
2102
+ ['key_value', 'key_value'],
2103
+ ['key value', 'key_value'],
2104
+ ['key-value', 'key_value'],
2105
+ ['cache', 'cache'],
2106
+ ['queue', 'queue'],
2107
+ ]);
2108
+ return aliases.get(raw) || raw;
2109
+ }
2110
+
2111
+ function normalizeExposure(value) {
2112
+ const raw = normalizeFreeText(value);
2113
+ const aliases = new Map([
2114
+ ['public', 'public'],
2115
+ ['publico', 'public'],
2116
+ ['público', 'public'],
2117
+ ['exposto', 'public'],
2118
+ ['sim', 'public'],
2119
+ ['private', 'private'],
2120
+ ['privado', 'private'],
2121
+ ['nao', 'private'],
2122
+ ['não', 'private'],
1800
2123
  ]);
1801
2124
  return aliases.get(raw) || raw;
1802
2125
  }
@@ -1842,6 +2165,13 @@ function isIpAccessEnabled(planCatalog, planId) {
1842
2165
  return planHasFeature(planCatalog, planId, 'Bloqueio de IPs');
1843
2166
  }
1844
2167
 
2168
+ function isAutoscalingEnabledForPlan(planCatalog, planId) {
2169
+ const plan = Array.isArray(planCatalog)
2170
+ ? planCatalog.find((item) => item?.plan === planId)
2171
+ : undefined;
2172
+ return plan?.permissions?.allow_autoscaling === 'true';
2173
+ }
2174
+
1845
2175
  function getMaxReplicasFromFeatures(features) {
1846
2176
  const replicaFeature = features.find((feature) => /réplicas?/i.test(String(feature)));
1847
2177
  if (!replicaFeature) return 5;
@@ -1876,7 +2206,6 @@ async function fetchWizardCatalogs(session, flags) {
1876
2206
  unwrapData(await request(session, flags, 'GET', '/project/database/plans')),
1877
2207
  'os planos de banco',
1878
2208
  );
1879
-
1880
2209
  return { httpPlans, availableInstances, databasePlans };
1881
2210
  }
1882
2211
 
@@ -1885,6 +2214,10 @@ function normalizePlansCatalogType(value) {
1885
2214
  if (!normalized || normalized === 'all') return 'all';
1886
2215
  if (normalized === 'http') return 'http';
1887
2216
  if (['database', 'db', 'postgresql', 'mariadb'].includes(normalized)) return 'database';
2217
+ if (normalized === 'valkey') return 'valkey';
2218
+ if (normalized === 'key-value' || normalized === 'key value' || normalized === 'key_value') return 'valkey-key_value';
2219
+ if (normalized === 'cache') return 'valkey-cache';
2220
+ if (normalized === 'queue') return 'valkey-queue';
1888
2221
  if (normalized === 'storage') return 'storage';
1889
2222
  return null;
1890
2223
  }
@@ -1917,6 +2250,49 @@ function humanizeStorageName(value) {
1917
2250
  return { storage: raw, type: '-' };
1918
2251
  }
1919
2252
 
2253
+ const VALKEY_PROFILE_LABELS = {
2254
+ key_value: 'Key Value',
2255
+ cache: 'Cache',
2256
+ queue: 'Queue',
2257
+ };
2258
+
2259
+ function requireValkeyCatalog(value) {
2260
+ if (!value || typeof value !== 'object' || Array.isArray(value)
2261
+ || value.engine !== 'valkey'
2262
+ || typeof value.version !== 'string'
2263
+ || typeof value.currency !== 'string'
2264
+ || !Array.isArray(value.profiles)
2265
+ || !Array.isArray(value.plans)
2266
+ || value.profiles.length === 0
2267
+ || value.plans.length === 0) {
2268
+ throw new CliError('Falha ao carregar o catalogo Valkey. Tente novamente mais tarde.');
2269
+ }
2270
+
2271
+ const validProfiles = new Set(Object.keys(VALKEY_PROFILE_LABELS));
2272
+ if (value.profiles.some((profile) => !validProfiles.has(profile?.id) || typeof profile.persistence !== 'boolean')) {
2273
+ throw new CliError('Falha ao carregar o catalogo Valkey. Tente novamente mais tarde.');
2274
+ }
2275
+ if (value.plans.some((plan) => !validProfiles.has(plan?.profile) || typeof plan.id !== 'string' || !plan.prices || typeof plan.prices !== 'object')) {
2276
+ throw new CliError('Falha ao carregar o catalogo Valkey. Tente novamente mais tarde.');
2277
+ }
2278
+
2279
+ return value;
2280
+ }
2281
+
2282
+ function valkeyProfileFilter(type) {
2283
+ if (!type.startsWith('valkey-')) return null;
2284
+ return type.slice('valkey-'.length);
2285
+ }
2286
+
2287
+ function filterValkeyCatalog(catalog, profile) {
2288
+ if (!profile) return catalog;
2289
+ return {
2290
+ ...catalog,
2291
+ profiles: catalog.profiles.filter((entry) => entry.id === profile),
2292
+ plans: catalog.plans.filter((plan) => plan.profile === profile),
2293
+ };
2294
+ }
2295
+
1920
2296
  async function fetchPlansCatalogs(session, flags, type) {
1921
2297
  if (type === 'http') {
1922
2298
  return {
@@ -1951,19 +2327,51 @@ async function fetchPlansCatalogs(session, flags, type) {
1951
2327
  };
1952
2328
  }
1953
2329
 
1954
- const [httpPayload, databasePayload, storagePayload] = await Promise.all([
2330
+ if (type === 'valkey' || type.startsWith('valkey-')) {
2331
+ const catalog = requireValkeyCatalog(
2332
+ unwrapData(await request(session, flags, 'GET', '/managed-services/catalog', { tokenRequired: false })),
2333
+ );
2334
+ return {
2335
+ http: [],
2336
+ database: [],
2337
+ storage: [],
2338
+ valkey: filterValkeyCatalog(catalog, valkeyProfileFilter(type)),
2339
+ };
2340
+ }
2341
+
2342
+ const [httpPayload, databasePayload, storagePayload, valkeyPayload] = await Promise.all([
1955
2343
  request(session, flags, 'GET', '/project/plans', { tokenRequired: false }),
1956
2344
  request(session, flags, 'GET', '/project/database/plans', { tokenRequired: false }),
1957
2345
  request(session, flags, 'GET', '/project/storage/plans', { tokenRequired: false }),
2346
+ request(session, flags, 'GET', '/managed-services/catalog', { tokenRequired: false }),
1958
2347
  ]);
1959
2348
 
1960
2349
  return {
1961
2350
  http: requireWizardCatalogArray(unwrapData(httpPayload), 'os planos HTTP'),
1962
2351
  database: requireWizardCatalogArray(unwrapData(databasePayload), 'os planos de banco'),
1963
2352
  storage: requireWizardCatalogArray(unwrapData(storagePayload), 'os planos de storage'),
2353
+ valkey: requireValkeyCatalog(unwrapData(valkeyPayload)),
1964
2354
  };
1965
2355
  }
1966
2356
 
2357
+ function printValkeyCatalog(catalog, profileFilter) {
2358
+ const profiles = profileFilter ? [profileFilter] : Object.keys(VALKEY_PROFILE_LABELS);
2359
+ profiles.forEach((profile, index) => {
2360
+ if (index > 0) process.stdout.write('\n');
2361
+ process.stdout.write(`${VALKEY_PROFILE_LABELS[profile]}\n`);
2362
+ const plans = catalog.plans.filter((plan) => plan.profile === profile);
2363
+ printTable(plans, [
2364
+ { label: 'Plano', value: (plan) => plan.id || '-' },
2365
+ { label: 'Hora', value: (plan) => formatBrl(plan.prices?.hourly, catalog.currency) },
2366
+ { label: 'Mes', value: (plan) => formatBrl(plan.prices?.monthly, catalog.currency) },
2367
+ { label: 'Ano', value: (plan) => formatBrl(plan.prices?.yearly, catalog.currency) },
2368
+ { label: 'Capacidade', value: (plan) => `${plan.resources?.cpu || '-'} / ${plan.resources?.memory || '-'}` },
2369
+ { label: 'Storage incluído', value: (plan) => `${plan.included_storage_gb ?? 0} GB` },
2370
+ { label: 'Alta disponibilidade', value: (plan) => plan.high_availability?.[profile] ? 'sim' : 'nao' },
2371
+ ]);
2372
+ });
2373
+ }
2374
+
1967
2375
  function printPlansCatalogs(payload, type) {
1968
2376
  if (type === 'all' || type === 'http') {
1969
2377
  process.stdout.write('HTTP\n');
@@ -1999,12 +2407,17 @@ function printPlansCatalogs(payload, type) {
1999
2407
  { label: 'Ano', value: (storage) => formatBrl(storage.prices?.yearly) },
2000
2408
  ]);
2001
2409
  }
2410
+
2411
+ if (type === 'all' || type === 'valkey' || type.startsWith('valkey-')) {
2412
+ if (type === 'all') process.stdout.write('\n');
2413
+ printValkeyCatalog(payload.valkey, valkeyProfileFilter(type));
2414
+ }
2002
2415
  }
2003
2416
 
2004
2417
  async function handlePlans(session, flags) {
2005
2418
  const type = normalizePlansCatalogType(flags.type);
2006
2419
  if (!type) {
2007
- throw new CliError('Tipo de catalogo invalido. Valores aceitos: all, http, database, storage.');
2420
+ throw new CliError('Tipo de catalogo invalido. Valores aceitos: all, http, database, storage, valkey.');
2008
2421
  }
2009
2422
 
2010
2423
  const payload = await fetchPlansCatalogs(session, flags, type);
@@ -2016,9 +2429,99 @@ function formatAllowedValues(values) {
2016
2429
  return [...values].join(', ');
2017
2430
  }
2018
2431
 
2019
- function validateCreateInput({ plan, paymentMode, config }) {
2432
+ function validateCreateAutoscaling(config, plan, typeProject) {
2433
+ if (config?.autoscaling === undefined) return config;
2434
+ if (typeProject !== 'http') {
2435
+ throw new CliError('config.autoscaling deve ser informado apenas para projetos HTTP.');
2436
+ }
2437
+ if (plan === 'free') {
2438
+ throw new CliError('Auto-scaling nao esta disponivel para projetos HTTP no plano free.');
2439
+ }
2440
+
2441
+ const autoscaling = config.autoscaling;
2442
+ if (!autoscaling || typeof autoscaling !== 'object' || Array.isArray(autoscaling)) {
2443
+ throw new CliError('config.autoscaling deve ser um objeto.');
2444
+ }
2445
+ if (autoscaling.enabled !== true) {
2446
+ throw new CliError('config.autoscaling.enabled deve ser true na criacao do projeto.');
2447
+ }
2448
+ if (autoscaling.min_instances !== undefined) {
2449
+ throw new CliError('Use config.instances como minimo inicial; config.autoscaling.min_instances nao e aceito na criacao.');
2450
+ }
2451
+
2452
+ const instances = Number(config.instances);
2453
+ if (!Number.isInteger(instances) || instances <= 0) {
2454
+ throw new CliError('config.instances deve ser um inteiro positivo ao criar um projeto com auto-scaling.');
2455
+ }
2456
+
2457
+ const maxInstances = Number(autoscaling.max_instances);
2458
+ if (!Number.isInteger(maxInstances) || maxInstances < instances) {
2459
+ throw new CliError('config.autoscaling.max_instances deve ser um inteiro maior ou igual a config.instances.');
2460
+ }
2461
+
2462
+ const nextAutoscaling = {
2463
+ enabled: true,
2464
+ max_instances: maxInstances,
2465
+ };
2466
+ for (const key of ['target_cpu_utilization_percent', 'target_memory_utilization_percent']) {
2467
+ if (autoscaling[key] === undefined) continue;
2468
+ const value = Number(autoscaling[key]);
2469
+ if (!Number.isInteger(value) || value < 1 || value > 100) {
2470
+ throw new CliError(`config.autoscaling.${key} deve ser um inteiro entre 1 e 100.`);
2471
+ }
2472
+ nextAutoscaling[key] = value;
2473
+ }
2474
+
2475
+ return { ...config, autoscaling: nextAutoscaling };
2476
+ }
2477
+
2478
+ function validateValkeyConfig(config, plan, catalog) {
2479
+ const valkeyCatalog = requireValkeyCatalog(catalog);
2480
+ const profile = normalizeValkeyProfile(config?.profile);
2481
+ const profileDefinition = valkeyCatalog.profiles.find((entry) => entry.id === profile);
2482
+ if (!profileDefinition) {
2483
+ throw new CliError('config.profile invalido. Use key_value, cache ou queue.');
2484
+ }
2485
+
2486
+ const planDefinition = valkeyCatalog.plans.find((entry) => entry.id === plan && entry.profile === profile);
2487
+ if (!planDefinition) {
2488
+ throw new CliError(`Plano ${plan} nao pode ser usado com o perfil ${profile}. Consulte "zenifra plans --type valkey".`);
2489
+ }
2490
+
2491
+ if (config.version !== valkeyCatalog.version) {
2492
+ throw new CliError(`config.version invalida: use ${valkeyCatalog.version}, conforme o catalogo Valkey.`);
2493
+ }
2494
+
2495
+ const forbiddenFields = ['instances', 'envs', 'image', 'github', 'port', 'exposure', 'autoscaling'];
2496
+ const forbiddenField = forbiddenFields.find((field) => config[field] !== undefined);
2497
+ if (forbiddenField) {
2498
+ throw new CliError(`Valkey nao aceita config.${forbiddenField}. A capacidade e definida pelo plano.`);
2499
+ }
2500
+
2501
+ if (profileDefinition.persistence) {
2502
+ if (!isRecord(config.storage) || config.storage.persistent !== true) {
2503
+ throw new CliError('Perfis Valkey persistentes exigem storage.persistent=true.');
2504
+ }
2505
+ const capacity = Number(config.storage.capacity);
2506
+ if (!Number.isInteger(capacity) || capacity < 1 || capacity > 250) {
2507
+ throw new CliError('storage.capacity deve ser um inteiro entre 1 e 250 GiB.');
2508
+ }
2509
+ } else if (config.storage !== undefined) {
2510
+ throw new CliError('O perfil Cache nao aceita storage.');
2511
+ }
2512
+
2513
+ return {
2514
+ ...config,
2515
+ type_project: 'valkey',
2516
+ profile,
2517
+ version: valkeyCatalog.version,
2518
+ };
2519
+ }
2520
+
2521
+ function validateCreateInput({ plan, paymentMode, config, valkeyCatalog }) {
2522
+ const nextTypeProject = normalizeTypeProject(config?.type_project);
2020
2523
  const nextPlan = normalizePlan(plan);
2021
- if (!nextPlan || !ALLOWED_PLAN_VALUES.has(nextPlan)) {
2524
+ if (!nextPlan || (nextTypeProject !== 'valkey' && !ALLOWED_PLAN_VALUES.has(nextPlan))) {
2022
2525
  throw new CliError(`Plano invalido: "${plan}". Valores aceitos: ${formatAllowedValues(ALLOWED_PLAN_VALUES)}. Docs: ${DOCS_CREATE_HTTP_URL}`);
2023
2526
  }
2024
2527
 
@@ -2027,12 +2530,19 @@ function validateCreateInput({ plan, paymentMode, config }) {
2027
2530
  throw new CliError(`Modo de pagamento invalido: "${paymentMode}". Valores aceitos: ${formatAllowedValues(ALLOWED_PAYMENT_MODE_VALUES)}. Docs: ${DOCS_PAYMENTS_URL}`);
2028
2531
  }
2029
2532
 
2030
- const nextTypeProject = normalizeTypeProject(config?.type_project);
2031
2533
  if (!nextTypeProject || !ALLOWED_TYPE_PROJECT_VALUES.has(nextTypeProject)) {
2032
2534
  throw new CliError(`type_project invalido: "${config?.type_project}". Valores aceitos: ${formatAllowedValues(ALLOWED_TYPE_PROJECT_VALUES)}. Docs: ${DOCS_CREATE_HTTP_URL}`);
2033
2535
  }
2034
2536
 
2035
2537
  const nextConfig = { ...config, type_project: nextTypeProject };
2538
+ if (nextTypeProject === 'valkey') {
2539
+ return {
2540
+ plan: nextPlan,
2541
+ paymentMode: nextPaymentMode,
2542
+ config: validateValkeyConfig(nextConfig, nextPlan, valkeyCatalog),
2543
+ };
2544
+ }
2545
+
2036
2546
  if (nextTypeProject === 'http' && nextConfig.github) {
2037
2547
  const nextRuntime = normalizeRuntime(nextConfig.github.runtime);
2038
2548
  if (!nextRuntime || !ALLOWED_RUNTIME_VALUES.has(nextRuntime)) {
@@ -2041,10 +2551,22 @@ function validateCreateInput({ plan, paymentMode, config }) {
2041
2551
  nextConfig.github = { ...nextConfig.github, runtime: nextRuntime };
2042
2552
  }
2043
2553
 
2554
+ if (nextTypeProject === 'http') {
2555
+ const nextExposure = normalizeExposure(nextConfig.exposure);
2556
+ if (!nextExposure || !ALLOWED_EXPOSURE_VALUES.has(nextExposure)) {
2557
+ throw new CliError(`exposure invalido: "${nextConfig.exposure || ''}". Valores aceitos: ${formatAllowedValues(ALLOWED_EXPOSURE_VALUES)}. Docs: ${DOCS_CREATE_HTTP_URL}`);
2558
+ }
2559
+ nextConfig.exposure = nextExposure;
2560
+ } else if (nextConfig.exposure !== undefined) {
2561
+ throw new CliError('exposure deve ser informado apenas para projetos HTTP.');
2562
+ }
2563
+
2564
+ const validatedConfig = validateCreateAutoscaling(nextConfig, nextPlan, nextTypeProject);
2565
+
2044
2566
  return {
2045
2567
  plan: nextPlan,
2046
2568
  paymentMode: nextPaymentMode,
2047
- config: nextConfig,
2569
+ config: validatedConfig,
2048
2570
  };
2049
2571
  }
2050
2572
 
@@ -2072,7 +2594,10 @@ function printWizardSummary(payload) {
2072
2594
  if (masked.description) lines.push(` descricao: ${masked.description}`);
2073
2595
 
2074
2596
  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'})`);
2597
+ 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'})`);
2598
+ if (masked.config.autoscaling?.enabled) {
2599
+ 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}%`);
2600
+ }
2076
2601
  if (masked.config.github) {
2077
2602
  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
2603
  } else if (masked.config.image) {
@@ -2084,6 +2609,10 @@ function printWizardSummary(payload) {
2084
2609
  lines.push(` banco: versao ${masked.config.version} | instancias ${masked.config.instances} | storage ${masked.config.storage.capacity}Gi`);
2085
2610
  }
2086
2611
 
2612
+ if (masked.config.type_project === 'valkey') {
2613
+ lines.push(` Valkey: perfil ${masked.config.profile} | versao ${masked.config.version}${masked.config.storage ? ` | storage ${masked.config.storage.capacity}Gi persistente` : ' | sem storage persistente'}`);
2614
+ }
2615
+
2087
2616
  process.stdout.write(`${lines.join('\n')}\n`);
2088
2617
  }
2089
2618
 
@@ -2277,6 +2806,42 @@ async function buildHttpConfig({ plan, httpPlans, availableInstances }) {
2277
2806
  max: Math.max(1, Number(availableInstances?.[plan] || 1)),
2278
2807
  });
2279
2808
 
2809
+ let autoscaling;
2810
+ if (isAutoscalingEnabledForPlan(httpPlans, plan)) {
2811
+ const enabled = await promptWizardBoolean({
2812
+ label: 'Ativar auto-scaling',
2813
+ docs: DOCS_CONFIGURATION_URL,
2814
+ examples: ['sim', 'nao'],
2815
+ });
2816
+ promptWizardBoolean.ui.state.httpAutoscalingEnabled = enabled;
2817
+ if (enabled) {
2818
+ autoscaling = {
2819
+ enabled: true,
2820
+ max_instances: await promptWizardNumber({
2821
+ label: 'Maximo de instancias do auto-scaling',
2822
+ docs: DOCS_CONFIGURATION_URL,
2823
+ examples: [String(Math.max(instances, 2))],
2824
+ min: instances,
2825
+ max: Math.max(instances, Number(availableInstances?.[plan] || instances)),
2826
+ }),
2827
+ target_cpu_utilization_percent: await promptWizardNumber({
2828
+ label: 'CPU alvo do auto-scaling (%)',
2829
+ docs: DOCS_CONFIGURATION_URL,
2830
+ examples: ['70'],
2831
+ min: 1,
2832
+ max: 100,
2833
+ }),
2834
+ target_memory_utilization_percent: await promptWizardNumber({
2835
+ label: 'Memoria alvo do auto-scaling (%)',
2836
+ docs: DOCS_CONFIGURATION_URL,
2837
+ examples: ['80'],
2838
+ min: 1,
2839
+ max: 100,
2840
+ }),
2841
+ };
2842
+ }
2843
+ }
2844
+
2280
2845
  let storage;
2281
2846
  if (plan === 'free') {
2282
2847
  storage = { persistent: false, capacity: 1 };
@@ -2320,8 +2885,17 @@ async function buildHttpConfig({ plan, httpPlans, availableInstances }) {
2320
2885
  ],
2321
2886
  });
2322
2887
 
2888
+ const exposure = await promptWizardSelect({
2889
+ label: 'Exposicao HTTP',
2890
+ docs: DOCS_CREATE_HTTP_URL,
2891
+ examples: WIZARD_HTTP_EXPOSURE_OPTIONS,
2892
+ options: WIZARD_HTTP_EXPOSURE_OPTIONS.map((value) => ({ value })),
2893
+ });
2894
+ promptWizardSelect.ui.state.httpExposure = exposure;
2895
+
2896
+ const isPublicExposure = exposure === 'public';
2323
2897
  const allowBlockIp = isIpAccessEnabled(httpPlans, plan);
2324
- const customWhitelist = allowBlockIp
2898
+ const customWhitelist = isPublicExposure && allowBlockIp
2325
2899
  ? await promptWizardPairs({
2326
2900
  introLabel: 'Deseja configurar whitelist personalizada de entrada?',
2327
2901
  docs: DOCS_CONFIGURATION_URL,
@@ -2334,7 +2908,7 @@ async function buildHttpConfig({ plan, httpPlans, availableInstances }) {
2334
2908
  })
2335
2909
  : [];
2336
2910
 
2337
- const ingress_black_list = allowBlockIp
2911
+ const ingress_black_list = isPublicExposure && allowBlockIp
2338
2912
  ? await promptWizardPairs({
2339
2913
  introLabel: 'Deseja adicionar blacklist de entrada?',
2340
2914
  docs: DOCS_CONFIGURATION_URL,
@@ -2347,7 +2921,7 @@ async function buildHttpConfig({ plan, httpPlans, availableInstances }) {
2347
2921
  })
2348
2922
  : [];
2349
2923
 
2350
- const subdomain = isSubdomainCustomizable(httpPlans, plan)
2924
+ const subdomain = isPublicExposure && isSubdomainCustomizable(httpPlans, plan)
2351
2925
  ? await promptWizardText({
2352
2926
  label: 'Subdomain personalizado',
2353
2927
  examples: ['minha-api'],
@@ -2357,9 +2931,11 @@ async function buildHttpConfig({ plan, httpPlans, availableInstances }) {
2357
2931
 
2358
2932
  return {
2359
2933
  type_project: 'http',
2934
+ exposure,
2360
2935
  ...(source === 'github' ? { github: await buildHttpGithubConfig() } : { image: await buildHttpImageConfig() }),
2361
2936
  port,
2362
2937
  instances,
2938
+ ...(autoscaling ? { autoscaling } : {}),
2363
2939
  storage,
2364
2940
  envs,
2365
2941
  ...(subdomain ? { subdomain } : {}),
@@ -2428,6 +3004,65 @@ async function buildMariadbConfig() {
2428
3004
  };
2429
3005
  }
2430
3006
 
3007
+ async function buildValkeyConfig({ profile, plan, catalog }) {
3008
+ const planDefinition = catalog.plans.find((entry) => entry.id === plan && entry.profile === profile);
3009
+ const profileDefinition = catalog.profiles.find((entry) => entry.id === profile);
3010
+ if (!planDefinition || !profileDefinition) {
3011
+ throw new CliError('O catalogo Valkey nao possui o plano ou perfil selecionado. Tente novamente.');
3012
+ }
3013
+
3014
+ let storage;
3015
+ if (profileDefinition.persistence) {
3016
+ storage = {
3017
+ persistent: true,
3018
+ capacity: await promptWizardNumber({
3019
+ label: `Capacidade de armazenamento persistente (Gi, inclui ${planDefinition.included_storage_gb ?? 0} Gi)`,
3020
+ docs: DOCS_CREATE_VALKEY_URL,
3021
+ examples: [String(Math.max(1, planDefinition.included_storage_gb ?? 1)), '10', '20'],
3022
+ min: 1,
3023
+ max: 250,
3024
+ }),
3025
+ };
3026
+ }
3027
+
3028
+ const whitelist = await promptWizardPairs({
3029
+ introLabel: 'Deseja configurar acesso de rede para este projeto?',
3030
+ docs: DOCS_CREATE_VALKEY_URL,
3031
+ examples: ['sim', 'nao'],
3032
+ stateKey: 'valkeyNetworkAccess',
3033
+ itemLabels: [
3034
+ { key: 'cidr', label: 'CIDR permitido', required: true, examples: ['203.0.113.0/24'], docs: DOCS_CREATE_VALKEY_URL },
3035
+ { key: 'description', label: 'Descricao do acesso', required: true, examples: ['Aplicacao'], docs: DOCS_CREATE_VALKEY_URL },
3036
+ ],
3037
+ });
3038
+
3039
+ let blacklist = [];
3040
+ if (whitelist.length > 0) {
3041
+ blacklist = await promptWizardPairs({
3042
+ introLabel: 'Deseja adicionar bloqueios de rede?',
3043
+ docs: DOCS_CREATE_VALKEY_URL,
3044
+ examples: ['sim', 'nao'],
3045
+ itemLabels: [
3046
+ { key: 'cidr', label: 'CIDR bloqueado', required: true, examples: ['198.51.100.0/24'], docs: DOCS_CREATE_VALKEY_URL },
3047
+ { key: 'description', label: 'Descricao do bloqueio', required: true, examples: ['Bloqueio'], docs: DOCS_CREATE_VALKEY_URL },
3048
+ ],
3049
+ });
3050
+ }
3051
+
3052
+ return {
3053
+ type_project: 'valkey',
3054
+ profile,
3055
+ version: catalog.version,
3056
+ ...(storage ? { storage } : {}),
3057
+ ...(whitelist.length > 0 ? {
3058
+ network_access: {
3059
+ ingress_white_list: whitelist,
3060
+ ingress_black_list: blacklist,
3061
+ },
3062
+ } : {}),
3063
+ };
3064
+ }
3065
+
2431
3066
  async function runProjectCreateWizard(session, flags) {
2432
3067
  const catalogs = await fetchWizardCatalogs(session, flags);
2433
3068
  const ui = createWizardUi();
@@ -2458,16 +3093,34 @@ async function runProjectCreateWizard(session, flags) {
2458
3093
  options: WIZARD_TYPE_PROJECT_OPTIONS.map((value) => ({ value })),
2459
3094
  });
2460
3095
  ui.state.typeProject = typeProject;
3096
+ const valkeyCatalog = typeProject === 'valkey'
3097
+ ? requireValkeyCatalog(unwrapData(await request(session, flags, 'GET', '/managed-services/catalog')))
3098
+ : undefined;
3099
+ const profile = typeProject === 'valkey'
3100
+ ? await promptWizardSelect({
3101
+ label: 'Perfil Valkey',
3102
+ docs: DOCS_CREATE_VALKEY_URL,
3103
+ examples: WIZARD_VALKEY_PROFILE_OPTIONS,
3104
+ options: WIZARD_VALKEY_PROFILE_OPTIONS.map((value) => ({ value })),
3105
+ })
3106
+ : undefined;
3107
+ ui.state.valkeyProfile = profile;
2461
3108
  const planOptions = typeProject === 'http'
2462
3109
  ? sortWizardOptions(catalogs.httpPlans
2463
3110
  .map((plan) => plan?.plan)
2464
3111
  .filter((plan) => WIZARD_HTTP_PLAN_OPTIONS.includes(plan) && Number(catalogs.availableInstances?.[plan] || 0) > 0), WIZARD_HTTP_PLAN_OPTIONS)
3112
+ : typeProject === 'valkey'
3113
+ ? valkeyCatalog.plans
3114
+ .filter((planDefinition) => planDefinition.profile === profile)
3115
+ .map((planDefinition) => planDefinition.id)
2465
3116
  : sortWizardOptions(catalogs.databasePlans
2466
3117
  .map((plan) => plan?.plan)
2467
3118
  .filter((plan) => WIZARD_DATABASE_PLAN_OPTIONS.includes(plan) && (typeProject !== 'mariadb' || plan !== 'db-free')), WIZARD_DATABASE_PLAN_OPTIONS);
2468
3119
  const plan = await promptWizardSelect({
2469
3120
  label: 'Plano',
2470
- docs: typeProject === 'http' ? DOCS_CREATE_HTTP_URL : DOCS_DATABASE_CONFIGURATION_URL,
3121
+ docs: typeProject === 'http'
3122
+ ? DOCS_CREATE_HTTP_URL
3123
+ : typeProject === 'valkey' ? DOCS_CREATE_VALKEY_URL : DOCS_DATABASE_CONFIGURATION_URL,
2471
3124
  examples: [planOptions[0], planOptions.at(-1)],
2472
3125
  options: planOptions.map((value) => ({ value })),
2473
3126
  });
@@ -2475,6 +3128,7 @@ async function runProjectCreateWizard(session, flags) {
2475
3128
  if (typeProject === 'http') {
2476
3129
  ui.state.planAllowsBlockIp = isIpAccessEnabled(catalogs.httpPlans, plan);
2477
3130
  ui.state.planAllowsSubdomain = isSubdomainCustomizable(catalogs.httpPlans, plan);
3131
+ ui.state.planAllowsAutoscaling = isAutoscalingEnabledForPlan(catalogs.httpPlans, plan);
2478
3132
  }
2479
3133
  const paymentMode = await promptWizardSelect({
2480
3134
  label: 'Modo de pagamento',
@@ -2492,6 +3146,8 @@ async function runProjectCreateWizard(session, flags) {
2492
3146
  });
2493
3147
  } else if (typeProject === 'postgresql') {
2494
3148
  config = await buildPostgresqlConfig({ plan, databasePlans: catalogs.databasePlans });
3149
+ } else if (typeProject === 'valkey') {
3150
+ config = await buildValkeyConfig({ profile, plan, catalog: valkeyCatalog });
2495
3151
  } else {
2496
3152
  config = await buildMariadbConfig();
2497
3153
  }
@@ -2507,7 +3163,9 @@ async function runProjectCreateWizard(session, flags) {
2507
3163
  printWizardSummary(payload);
2508
3164
  const confirmed = await promptWizardBoolean({
2509
3165
  label: 'Confirmar criacao do projeto',
2510
- docs: typeProject === 'http' ? DOCS_CREATE_HTTP_URL : DOCS_DATABASE_CONFIGURATION_URL,
3166
+ docs: typeProject === 'http'
3167
+ ? DOCS_CREATE_HTTP_URL
3168
+ : typeProject === 'valkey' ? DOCS_CREATE_VALKEY_URL : DOCS_DATABASE_CONFIGURATION_URL,
2511
3169
  examples: ['sim', 'nao'],
2512
3170
  });
2513
3171
  if (!confirmed) return null;
@@ -2528,10 +3186,18 @@ async function handleProjectCreate(session, flags) {
2528
3186
  const plan = wizardPayload?.plan || flags.plan || await prompt('Plano');
2529
3187
  const paymentMode = wizardPayload?.payment_mode || flags.paymentMode || await prompt('Modo de pagamento');
2530
3188
  const config = wizardPayload?.config || await parseConfig(flags.config || await prompt('Config JSON ou @arquivo'));
2531
- const validated = validateCreateInput({ plan, paymentMode, config });
3189
+ const typeProject = normalizeTypeProject(config?.type_project);
3190
+ const valkeyCatalog = typeProject === 'valkey'
3191
+ ? requireValkeyCatalog(unwrapData(await request(session, flags, 'GET', '/managed-services/catalog')))
3192
+ : undefined;
3193
+ if (flags.idempotencyKey !== undefined && !/^[A-Za-z0-9._-]{16,200}$/.test(String(flags.idempotencyKey))) {
3194
+ throw new CliError('--idempotency-key deve ter entre 16 e 200 caracteres: letras, numeros, ponto, underscore ou hifen.');
3195
+ }
3196
+ const validated = validateCreateInput({ plan, paymentMode, config, valkeyCatalog });
2532
3197
 
2533
3198
  const payload = await request(session, flags, 'POST', '/project', {
2534
3199
  orgId,
3200
+ headers: flags.idempotencyKey === undefined ? {} : { 'Idempotency-Key': String(flags.idempotencyKey) },
2535
3201
  body: {
2536
3202
  name,
2537
3203
  ...(description ? { description: String(description) } : {}),
@@ -2551,6 +3217,21 @@ async function handleProjectCreate(session, flags) {
2551
3217
  ...(project.domain ? [{ field: 'Dominio', value: formatPublicUrl(project.domain) }] : []),
2552
3218
  ...(project.api_key ? [{ field: 'API key', value: project.api_key }] : []),
2553
3219
  ];
3220
+ if (validated.config.type_project === 'valkey') {
3221
+ const service = project.managed_service || {};
3222
+ rows.push(
3223
+ { field: 'Perfil', value: service.profile || validated.config.profile },
3224
+ { field: 'Host', value: service.host || '-' },
3225
+ { field: 'Porta', value: service.port ?? '-' },
3226
+ { field: 'TLS', value: service.tls === true ? 'sim' : 'nao' },
3227
+ );
3228
+ if (service.connection_string) {
3229
+ rows.push({ field: 'String de conexao', value: service.connection_string });
3230
+ process.stdout.write('Salve a string de conexao em um local seguro; ela pode nao ser exibida novamente.\n');
3231
+ } else {
3232
+ rows.push({ field: 'Credencial', value: 'nao retornada nesta resposta; use valkey credentials rotate' });
3233
+ }
3234
+ }
2554
3235
  process.stdout.write('\n');
2555
3236
  printTable(rows, [
2556
3237
  { label: 'Campo', value: (row) => row.field },
@@ -2654,6 +3335,33 @@ async function handleProjectImageSet(session, flags) {
2654
3335
  process.stdout.write('Imagem atualizada com sucesso.\n');
2655
3336
  }
2656
3337
 
3338
+ async function handleProjectExposureSet(session, flags) {
3339
+ const projectId = requireProjectId(flags, 'project exposure set');
3340
+ if (!projectId) return
3341
+ const exposure = normalizeExposure(flags.exposure);
3342
+
3343
+ if (!ALLOWED_EXPOSURE_VALUES.has(exposure)) {
3344
+ throw new CliError('exposure invalido. Use public ou private.');
3345
+ }
3346
+
3347
+ const orgId = await resolveOrgId(session, flags);
3348
+ const payload = await request(session, flags, 'PATCH', `/project/${projectId}/exposure`, {
3349
+ orgId,
3350
+ body: { exposure },
3351
+ });
3352
+
3353
+ if (flags.json) return printJson(payload);
3354
+
3355
+ printTable([
3356
+ { field: 'Exposicao', value: payload.exposure || exposure },
3357
+ { field: 'Dominio', value: payload.domain ? formatPublicUrl(payload.domain) : '-' },
3358
+ { field: 'Dominios customizados', value: asArray(payload.custom_domains).join(', ') || '-' },
3359
+ ], [
3360
+ { label: 'Campo', value: (row) => row.field },
3361
+ { label: 'Valor', value: (row) => row.value },
3362
+ ]);
3363
+ }
3364
+
2657
3365
  async function getProjectEnvs(session, flags, projectId, orgId) {
2658
3366
  return asArray(unwrapData(await request(session, flags, 'GET', `/project/${projectId}/envs`, { orgId })));
2659
3367
  }
@@ -2760,6 +3468,366 @@ async function handleProjectInstancesSet(session, flags) {
2760
3468
  process.stdout.write(`Quantidade de instancias atualizada para ${instances}.\n`);
2761
3469
  }
2762
3470
 
3471
+ function parseAutoscalingPositiveInteger(value, flagName) {
3472
+ const parsed = Number(value);
3473
+ if (!Number.isInteger(parsed) || parsed <= 0) {
3474
+ throw new CliError(`Informe ${flagName} <n> com um inteiro positivo.`);
3475
+ }
3476
+ return parsed;
3477
+ }
3478
+
3479
+ function parseAutoscalingTargetPercent(value, flagName) {
3480
+ const parsed = parseAutoscalingPositiveInteger(value, flagName);
3481
+ if (parsed > 100) {
3482
+ throw new CliError(`Informe ${flagName} entre 1 e 100.`);
3483
+ }
3484
+ return parsed;
3485
+ }
3486
+
3487
+ function autoscalingFromProject(project) {
3488
+ return project?.additional_info?.autoscaling || project?.autoscaling || { enabled: false };
3489
+ }
3490
+
3491
+ function printAutoscaling(autoscaling) {
3492
+ printTable([
3493
+ {
3494
+ status: autoscaling.enabled ? 'ativo' : 'inativo',
3495
+ min: autoscaling.min_instances || '-',
3496
+ max: autoscaling.max_instances || '-',
3497
+ cpu: autoscaling.target_cpu_utilization_percent ? `${autoscaling.target_cpu_utilization_percent}%` : '-',
3498
+ memory: autoscaling.target_memory_utilization_percent ? `${autoscaling.target_memory_utilization_percent}%` : '-',
3499
+ },
3500
+ ], [
3501
+ { label: 'Status', value: (item) => item.status },
3502
+ { label: 'Minimo', value: (item) => item.min },
3503
+ { label: 'Maximo', value: (item) => item.max },
3504
+ { label: 'CPU alvo', value: (item) => item.cpu },
3505
+ { label: 'Memoria alvo', value: (item) => item.memory },
3506
+ ]);
3507
+ }
3508
+
3509
+ async function handleProjectAutoscaling(session, flags) {
3510
+ const projectId = requireProjectId(flags, 'project autoscaling');
3511
+ if (!projectId) return
3512
+ const orgId = await resolveOrgId(session, flags);
3513
+ const project = await getProject(session, flags, projectId, orgId);
3514
+ const autoscaling = autoscalingFromProject(project);
3515
+
3516
+ if (flags.json) return printJson(autoscaling);
3517
+ printAutoscaling(autoscaling);
3518
+ }
3519
+
3520
+ async function handleProjectAutoscalingSet(session, flags) {
3521
+ const projectId = requireProjectId(flags, 'project autoscaling set');
3522
+ if (!projectId) return
3523
+ if (flags.min === undefined || flags.max === undefined) {
3524
+ printCommandHelpAndFail('project autoscaling set')
3525
+ return
3526
+ }
3527
+
3528
+ const minInstances = parseAutoscalingPositiveInteger(flags.min, '--min');
3529
+ const maxInstances = parseAutoscalingPositiveInteger(flags.max, '--max');
3530
+ if (maxInstances < minInstances) {
3531
+ throw new CliError('Informe --max maior ou igual a --min.');
3532
+ }
3533
+
3534
+ const body = {
3535
+ enabled: true,
3536
+ min_instances: minInstances,
3537
+ max_instances: maxInstances,
3538
+ };
3539
+
3540
+ if (flags.cpu !== undefined) {
3541
+ body.target_cpu_utilization_percent = parseAutoscalingTargetPercent(flags.cpu, '--cpu');
3542
+ }
3543
+ if (flags.memory !== undefined) {
3544
+ body.target_memory_utilization_percent = parseAutoscalingTargetPercent(flags.memory, '--memory');
3545
+ }
3546
+
3547
+ const orgId = await resolveOrgId(session, flags);
3548
+ const payload = await request(session, flags, 'PATCH', `/project/${projectId}/autoscaling`, {
3549
+ orgId,
3550
+ body,
3551
+ });
3552
+
3553
+ if (flags.json) return printJson(payload);
3554
+ process.stdout.write(`Auto-scaling atualizado: ${minInstances}-${maxInstances} instancias.\n`);
3555
+ }
3556
+
3557
+ async function handleProjectAutoscalingDisable(session, flags) {
3558
+ const projectId = requireProjectId(flags, 'project autoscaling disable');
3559
+ if (!projectId) return
3560
+
3561
+ const orgId = await resolveOrgId(session, flags);
3562
+ const payload = await request(session, flags, 'PATCH', `/project/${projectId}/autoscaling`, {
3563
+ orgId,
3564
+ body: { enabled: false },
3565
+ });
3566
+
3567
+ if (flags.json) return printJson(payload);
3568
+ process.stdout.write('Auto-scaling desativado.\n');
3569
+ }
3570
+
3571
+ function printAutoscalingEvents(data) {
3572
+ const directions = { scale_up: 'aumento', scale_down: 'reducao' };
3573
+ const reasons = { increased_capacity: 'capacidade aumentada', reduced_capacity: 'capacidade reduzida' };
3574
+ const events = asArray(data?.events);
3575
+ printTable(events, [
3576
+ { label: 'Quando', value: (event) => event.occurred_at || '-' },
3577
+ { label: 'Direcao', value: (event) => directions[event.direction] || event.direction || '-' },
3578
+ {
3579
+ label: 'Instancias',
3580
+ value: (event) => `${event.previous_instances ?? '-'} -> ${event.new_instances ?? '-'}`,
3581
+ },
3582
+ {
3583
+ label: 'Limites',
3584
+ value: (event) => {
3585
+ const cpu = typeof event.current_cpu_utilization_percent === 'number' || typeof event.target_cpu_utilization_percent === 'number'
3586
+ ? `CPU ${event.current_cpu_utilization_percent ?? '-'}%/${event.target_cpu_utilization_percent ?? '-'}%`
3587
+ : null;
3588
+ const memory = typeof event.current_memory_utilization_percent === 'number' || typeof event.target_memory_utilization_percent === 'number'
3589
+ ? `MEM ${event.current_memory_utilization_percent ?? '-'}%/${event.target_memory_utilization_percent ?? '-'}%`
3590
+ : null;
3591
+ return [cpu, memory].filter(Boolean).join(' ');
3592
+ },
3593
+ },
3594
+ { label: 'Motivo', value: (event) => reasons[event.reason] || event.reason || event.trigger_metric || '-' },
3595
+ ]);
3596
+
3597
+ printPagination(data?.pagination, 'evento');
3598
+ }
3599
+
3600
+ function isIsoDate(value) {
3601
+ const text = String(value);
3602
+ 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})?)?$/);
3603
+ if (!match || Number.isNaN(Date.parse(text))) return false;
3604
+
3605
+ const [, year, month, day, hour = '00', minute = '00', second = '00'] = match;
3606
+ const calendarDate = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
3607
+ return calendarDate.getUTCFullYear() === Number(year)
3608
+ && calendarDate.getUTCMonth() === Number(month) - 1
3609
+ && calendarDate.getUTCDate() === Number(day)
3610
+ && Number(hour) <= 23
3611
+ && Number(minute) <= 59
3612
+ && Number(second) <= 59;
3613
+ }
3614
+
3615
+ function validateProjectListFlags(flags, { maxLimit }) {
3616
+ for (const key of ['from', 'to']) {
3617
+ if (flags[key] !== undefined && !isIsoDate(flags[key])) {
3618
+ throw new CliError(`Informe --${key} <iso> com uma data ISO valida.`);
3619
+ }
3620
+ }
3621
+ if (flags.from !== undefined && flags.to !== undefined && Date.parse(String(flags.from)) > Date.parse(String(flags.to))) {
3622
+ throw new CliError('Informe --from anterior ou igual a --to.');
3623
+ }
3624
+ for (const key of ['page', 'limit']) {
3625
+ if (flags[key] === undefined) continue;
3626
+ const value = Number(flags[key]);
3627
+ if (!Number.isInteger(value) || value <= 0) {
3628
+ throw new CliError(`Informe --${key} <n> com um inteiro positivo.`);
3629
+ }
3630
+ if (key === 'limit' && value > maxLimit) {
3631
+ throw new CliError(`Informe --limit entre 1 e ${maxLimit}.`);
3632
+ }
3633
+ }
3634
+ }
3635
+
3636
+ function printPagination(pagination, singular = 'item') {
3637
+ if (!pagination) return;
3638
+ const { page, total_pages: totalPages, total } = pagination;
3639
+ process.stdout.write(`Pagina ${page} de ${totalPages || 1} - ${total} ${singular}(s)\n`);
3640
+ }
3641
+
3642
+ function formatMoney(value, currency = 'brl') {
3643
+ const amount = Number(value);
3644
+ if (!Number.isFinite(amount)) return '-';
3645
+ try {
3646
+ return new Intl.NumberFormat('pt-BR', {
3647
+ style: 'currency',
3648
+ currency: String(currency || 'brl').toUpperCase(),
3649
+ }).format(amount);
3650
+ } catch {
3651
+ return `${String(currency || 'brl').toUpperCase()} ${amount.toFixed(2)}`;
3652
+ }
3653
+ }
3654
+
3655
+ function printBillingUsage(data) {
3656
+ const hours = asArray(data?.hours);
3657
+ printTable(hours, [
3658
+ { label: 'Inicio', value: (item) => item.hour_start || '-' },
3659
+ { label: 'Fim', value: (item) => item.hour_end || '-' },
3660
+ { label: 'Instancia-horas', value: (item) => item.compute_instance_hours ?? '-' },
3661
+ { label: 'Armazenamento', value: (item) => item.storage_gb_hours === undefined ? '-' : `${item.storage_gb_hours} GB-h` },
3662
+ { label: 'Computacao', value: (item) => formatMoney(item.compute_amount, item.currency) },
3663
+ { label: 'Storage', value: (item) => formatMoney(item.storage_amount, item.currency) },
3664
+ { label: 'Total', value: (item) => formatMoney(item.total_amount, item.currency) },
3665
+ { label: 'Status', value: (item) => item.status || '-' },
3666
+ ]);
3667
+
3668
+ if (data?.summary) {
3669
+ const summary = data.summary;
3670
+ 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`);
3671
+ }
3672
+ printPagination(data?.pagination, 'hora');
3673
+ }
3674
+
3675
+
3676
+ async function handleProjectAutoscalingEvents(session, flags) {
3677
+ const projectId = requireProjectId(flags, 'project autoscaling events');
3678
+ if (!projectId) return
3679
+
3680
+ if (flags.direction !== undefined && !['scale_up', 'scale_down'].includes(String(flags.direction))) {
3681
+ throw new CliError('direction invalido. Use scale_up ou scale_down.');
3682
+ }
3683
+
3684
+ validateProjectListFlags(flags, { maxLimit: 100 });
3685
+
3686
+ const orgId = await resolveOrgId(session, flags);
3687
+ const query = buildQuery(flags, ['direction', 'from', 'to', 'page', 'limit']);
3688
+ const data = unwrapData(await request(session, flags, 'GET', `/project/${projectId}/autoscaling/events${query}`, { orgId }));
3689
+
3690
+ if (flags.json) return printJson(data);
3691
+ printAutoscalingEvents(data);
3692
+ }
3693
+
3694
+ async function handleProjectBillingUsage(session, flags) {
3695
+ const projectId = requireProjectId(flags, 'project billing usage');
3696
+ if (!projectId) return;
3697
+ validateProjectListFlags(flags, { maxLimit: 50 });
3698
+ const orgId = await resolveOrgId(session, flags);
3699
+ const query = buildQuery(flags, ['from', 'to', 'page', 'limit']);
3700
+ const data = unwrapData(await request(session, flags, 'GET', `/project/${projectId}/billing/hourly-usage${query}`, { orgId }));
3701
+
3702
+ if (flags.json) return printJson(data);
3703
+ printBillingUsage(data);
3704
+ }
3705
+
3706
+ function printValkeyStatus(data) {
3707
+ printTable([data], [
3708
+ { label: 'Status', value: (service) => service.status || '-' },
3709
+ { label: 'Perfil', value: (service) => service.profile || '-' },
3710
+ { label: 'Versao', value: (service) => service.version || '-' },
3711
+ { label: 'Persistente', value: (service) => service.persistence === true ? 'sim' : 'nao' },
3712
+ { label: 'Instancias', value: (service) => service.instances ?? '-' },
3713
+ ]);
3714
+ }
3715
+
3716
+ function printValkeyConnection(data) {
3717
+ printTable([data], [
3718
+ { label: 'Host', value: (service) => service.host || '-' },
3719
+ { label: 'Porta', value: (service) => service.port ?? '-' },
3720
+ { label: 'Usuario', value: (service) => service.username || '-' },
3721
+ { label: 'TLS', value: (service) => service.tls === true ? 'sim' : 'nao' },
3722
+ { label: 'Conexao', value: (service) => service.connection_string || '-' },
3723
+ ]);
3724
+ }
3725
+
3726
+ function printValkeyRotation(data, { accepted = false } = {}) {
3727
+ const rows = [
3728
+ { field: accepted ? 'Operacao aceita' : 'Operacao', value: data.operation_id || '-' },
3729
+ { field: 'Estado', value: data.state || '-' },
3730
+ ];
3731
+ if (data.connection_string) {
3732
+ rows.push({ field: 'String de conexao', value: data.connection_string });
3733
+ process.stdout.write('Salve a nova string de conexao em um local seguro; ela pode nao ser exibida novamente.\n');
3734
+ }
3735
+ printTable(rows, [
3736
+ { label: 'Campo', value: (row) => row.field },
3737
+ { label: 'Valor', value: (row) => row.value },
3738
+ ]);
3739
+ }
3740
+
3741
+ async function waitForValkeyRotation(session, flags, projectId, operationId, orgId) {
3742
+ const intervalMs = parseSecondsOption(flags.interval, '--interval', { defaultValue: 2, min: 0.1 }) * 1000;
3743
+ const timeoutMs = parseSecondsOption(flags.timeout, '--timeout', { defaultValue: 900, min: 1 }) * 1000;
3744
+ const startedAt = Date.now();
3745
+ let lastState = 'accepted';
3746
+
3747
+ while (true) {
3748
+ const data = unwrapData(await request(
3749
+ session,
3750
+ flags,
3751
+ 'GET',
3752
+ `/managed-services/${projectId}/credential-rotations/${operationId}`,
3753
+ { orgId },
3754
+ ));
3755
+ lastState = data?.state || lastState;
3756
+ if (lastState === 'completed') return data;
3757
+ if (['failed', 'cancelled', 'canceled'].includes(lastState)) {
3758
+ throw new CliError(`A renovacao de credencial terminou com estado ${lastState}. Operacao: ${operationId}.`);
3759
+ }
3760
+ if (Date.now() - startedAt >= timeoutMs) {
3761
+ throw new CliError(`Timeout aguardando a renovacao ${operationId}. Ultimo estado: ${lastState}.`);
3762
+ }
3763
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, intervalMs));
3764
+ }
3765
+ }
3766
+
3767
+ async function handleValkeyStatus(session, flags) {
3768
+ const projectId = requireProjectId(flags, 'valkey status');
3769
+ if (!projectId) return;
3770
+ const orgId = await resolveOrgId(session, flags);
3771
+ const data = unwrapData(await request(session, flags, 'GET', `/managed-services/${projectId}/status`, { orgId }));
3772
+ if (flags.json) return printJson(data);
3773
+ printValkeyStatus(data);
3774
+ }
3775
+
3776
+ async function handleValkeyConnection(session, flags) {
3777
+ const projectId = requireProjectId(flags, 'valkey connection');
3778
+ if (!projectId) return;
3779
+ const orgId = await resolveOrgId(session, flags);
3780
+ const data = unwrapData(await request(session, flags, 'GET', `/managed-services/${projectId}/connection`, { orgId }));
3781
+ if (flags.json) return printJson(data);
3782
+ printValkeyConnection(data);
3783
+ }
3784
+
3785
+ async function handleValkeyCredentialStatus(session, flags) {
3786
+ const projectId = requireProjectId(flags, 'valkey credentials status');
3787
+ if (!projectId) return;
3788
+ if (!flags.operation) {
3789
+ printCommandHelpAndFail('valkey credentials status');
3790
+ return;
3791
+ }
3792
+ const orgId = await resolveOrgId(session, flags);
3793
+ const data = unwrapData(await request(
3794
+ session,
3795
+ flags,
3796
+ 'GET',
3797
+ `/managed-services/${projectId}/credential-rotations/${flags.operation}`,
3798
+ { orgId },
3799
+ ));
3800
+ if (flags.json) return printJson(data);
3801
+ printValkeyRotation(data);
3802
+ }
3803
+
3804
+ async function handleValkeyCredentialRotate(session, flags) {
3805
+ const projectId = requireProjectId(flags, 'valkey credentials rotate');
3806
+ if (!projectId) return;
3807
+ const idempotencyKey = String(flags.idempotencyKey || randomUUID());
3808
+ if (!/^[A-Za-z0-9._-]{16,200}$/.test(idempotencyKey)) {
3809
+ throw new CliError('--idempotency-key deve ter entre 16 e 200 caracteres: letras, numeros, ponto, underscore ou hifen.');
3810
+ }
3811
+ const orgId = await resolveOrgId(session, flags);
3812
+ const acceptedPayload = await request(session, flags, 'PATCH', `/managed-services/${projectId}/credentials`, {
3813
+ orgId,
3814
+ body: {},
3815
+ headers: { 'Idempotency-Key': idempotencyKey },
3816
+ });
3817
+ const accepted = unwrapData(acceptedPayload);
3818
+ if (!accepted?.operation_id) throw new CliError('A API nao retornou o identificador da renovacao de credencial.');
3819
+
3820
+ if (flags.wait) {
3821
+ const completed = await waitForValkeyRotation(session, flags, projectId, accepted.operation_id, orgId);
3822
+ if (flags.json) return printJson(completed);
3823
+ printValkeyRotation(completed);
3824
+ return;
3825
+ }
3826
+
3827
+ if (flags.json) return printJson(acceptedPayload);
3828
+ printValkeyRotation(accepted, { accepted: true });
3829
+ }
3830
+
2763
3831
  function buildQuery(flags, allowedKeys) {
2764
3832
  const params = new URLSearchParams();
2765
3833
  for (const key of allowedKeys) {
@@ -2820,6 +3888,25 @@ async function getBuildLogs(session, flags, projectId, buildId, orgId, { cursor
2820
3888
  return unwrapData(await request(session, flags, 'GET', `/project/${projectId}/github/builds/${buildId}/logs${query}`, { orgId }))
2821
3889
  }
2822
3890
 
3891
+ function parseIntegerOption(value, flagName, { defaultValue, min, max }) {
3892
+ if (value === undefined) return defaultValue;
3893
+ const parsed = Number(value);
3894
+ if (!Number.isInteger(parsed) || parsed < min || (max !== undefined && parsed > max)) {
3895
+ const range = max === undefined ? `maior ou igual a ${min}` : `entre ${min} e ${max}`;
3896
+ throw new CliError(`Informe ${flagName} com um inteiro ${range}.`);
3897
+ }
3898
+ return parsed;
3899
+ }
3900
+
3901
+ function parseSecondsOption(value, flagName, { defaultValue, min }) {
3902
+ if (value === undefined) return defaultValue;
3903
+ const parsed = Number(value);
3904
+ if (!Number.isFinite(parsed) || parsed < min) {
3905
+ throw new CliError(`Informe ${flagName} com um numero maior ou igual a ${min}.`);
3906
+ }
3907
+ return parsed;
3908
+ }
3909
+
2823
3910
  function printBuildLogs(logs) {
2824
3911
  for (const log of asArray(logs)) {
2825
3912
  process.stdout.write(`${buildLogLineOf(log)}\n`)
@@ -2838,10 +3925,10 @@ async function followBuildLogs(session, flags, projectId, buildId, orgId, {
2838
3925
  intervalSeconds = 5,
2839
3926
  timeoutSeconds = 900,
2840
3927
  } = {}) {
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
3928
+ let cursor = parseIntegerOption(initialCursor, '--cursor', { defaultValue: 0, min: 0 })
3929
+ const pollLimit = parseIntegerOption(limit, '--limit', { defaultValue: 200, min: 1, max: 500 })
3930
+ const intervalMs = parseSecondsOption(intervalSeconds, '--interval', { defaultValue: 5, min: 0.1 }) * 1000
3931
+ const timeoutMs = parseSecondsOption(timeoutSeconds, '--timeout', { defaultValue: 900, min: 1 }) * 1000
2845
3932
  const startedAt = Date.now()
2846
3933
 
2847
3934
  while (true) {
@@ -2887,15 +3974,15 @@ async function handleBuildLogs(session, flags) {
2887
3974
  }
2888
3975
 
2889
3976
  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)
3977
+ const cursor = parseIntegerOption(flags.cursor, '--cursor', { defaultValue: 0, min: 0 })
3978
+ const limit = parseIntegerOption(flags.limit, '--limit', { defaultValue: 200, min: 1, max: 500 })
2892
3979
 
2893
3980
  if (flags.follow) {
2894
3981
  return followBuildLogs(session, flags, projectId, buildId, orgId, {
2895
3982
  initialCursor: cursor,
2896
3983
  limit,
2897
- intervalSeconds: Number(flags.interval || 5),
2898
- timeoutSeconds: Number(flags.timeout || 900),
3984
+ intervalSeconds: parseSecondsOption(flags.interval, '--interval', { defaultValue: 5, min: 0.1 }),
3985
+ timeoutSeconds: parseSecondsOption(flags.timeout, '--timeout', { defaultValue: 900, min: 1 }),
2899
3986
  })
2900
3987
  }
2901
3988
 
@@ -2916,13 +4003,14 @@ async function handleDeployWatch(session, flags) {
2916
4003
  return followBuildLogs(session, flags, projectId, buildId, orgId, {
2917
4004
  initialCursor: 0,
2918
4005
  limit: 200,
2919
- intervalSeconds: Number(flags.interval || 5),
2920
- timeoutSeconds: Number(flags.timeout || 900),
4006
+ intervalSeconds: parseSecondsOption(flags.interval, '--interval', { defaultValue: 5, min: 0.1 }),
4007
+ timeoutSeconds: parseSecondsOption(flags.timeout, '--timeout', { defaultValue: 900, min: 1 }),
2921
4008
  })
2922
4009
  }
2923
4010
 
2924
4011
  async function main() {
2925
4012
  const { positional, flags } = parseArgs(process.argv.slice(2));
4013
+ validateKnownFlags(flags);
2926
4014
  const [command, subcommand] = positional;
2927
4015
  const session = await readSession();
2928
4016
  try {
@@ -2966,16 +4054,30 @@ async function main() {
2966
4054
  if (command === 'orgs') return handleOrgs(session, flags);
2967
4055
  if (command === 'org' && subcommand === 'set') return handleOrgSet(session, flags);
2968
4056
  if (command === 'projects') return handleProjects(session, flags);
4057
+ if (command === 'valkey' && subcommand === 'credentials' && positional.length === 2) {
4058
+ process.stdout.write(commandHelp(['valkey', 'credentials']));
4059
+ return;
4060
+ }
4061
+ if (command === 'valkey' && subcommand === 'status') return handleValkeyStatus(session, flags);
4062
+ if (command === 'valkey' && subcommand === 'connection') return handleValkeyConnection(session, flags);
4063
+ if (command === 'valkey' && subcommand === 'credentials' && positional[2] === 'rotate') return handleValkeyCredentialRotate(session, flags);
4064
+ if (command === 'valkey' && subcommand === 'credentials' && positional[2] === 'status') return handleValkeyCredentialStatus(session, flags);
2969
4065
  if (command === 'project' && subcommand === 'info') return handleProjectInfo(session, flags);
2970
4066
  if (command === 'project' && subcommand === 'url') return handleProjectUrl(session, flags);
2971
4067
  if (command === 'project' && subcommand === 'logs') return handleProjectLogs(session, flags);
2972
4068
  if (command === 'project' && subcommand === 'metrics') return handleProjectMetrics(session, flags);
2973
4069
  if (command === 'project' && subcommand === 'network') return handleProjectNetwork(session, flags);
2974
4070
  if (command === 'project' && subcommand === 'image' && positional[2] === 'set') return handleProjectImageSet(session, flags);
4071
+ if (command === 'project' && subcommand === 'exposure' && positional[2] === 'set') return handleProjectExposureSet(session, flags);
2975
4072
  if (command === 'project' && subcommand === 'envs') return handleProjectEnvs(session, flags);
2976
4073
  if (command === 'project' && subcommand === 'env' && positional[2] === 'add') return handleProjectEnvMutation(session, flags, 'add');
2977
4074
  if (command === 'project' && subcommand === 'env' && positional[2] === 'update') return handleProjectEnvMutation(session, flags, 'update');
2978
4075
  if (command === 'project' && subcommand === 'env' && positional[2] === 'remove') return handleProjectEnvMutation(session, flags, 'remove');
4076
+ if (command === 'project' && subcommand === 'autoscaling' && positional[2] === 'set') return handleProjectAutoscalingSet(session, flags);
4077
+ if (command === 'project' && subcommand === 'autoscaling' && positional[2] === 'disable') return handleProjectAutoscalingDisable(session, flags);
4078
+ if (command === 'project' && subcommand === 'autoscaling' && positional[2] === 'events') return handleProjectAutoscalingEvents(session, flags);
4079
+ if (command === 'project' && subcommand === 'autoscaling') return handleProjectAutoscaling(session, flags);
4080
+ if (command === 'project' && subcommand === 'billing' && positional[2] === 'usage') return handleProjectBillingUsage(session, flags);
2979
4081
  if (command === 'project' && subcommand === 'instances' && positional[2] === 'set') return handleProjectInstancesSet(session, flags);
2980
4082
  if (command === 'project' && subcommand === 'instances') return handleProjectInstances(session, flags);
2981
4083
  if (command === 'builds' && subcommand === 'logs') return handleBuildLogs(session, flags);