@zenifra/cli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -59,6 +59,7 @@ zenifra git runtimes
59
59
  zenifra git connections
60
60
  zenifra git repositories resolve --connection <connection-id> --path equipe/aplicacao
61
61
  zenifra git branches --connection <connection-id> --repository <repository-id>
62
+ zenifra plans --type job
62
63
  zenifra create project
63
64
  zenifra create project --name <name> --plan free --payment-mode hourly --config @examples/http-project.json
64
65
  zenifra create project --name <name> --plan basic --payment-mode hourly --config @examples/http-github-project.json
@@ -70,6 +71,7 @@ zenifra create project --name <name> --plan analytics-starter --payment-mode hou
70
71
  zenifra create project --name <name> --plan db-free --payment-mode hourly --config @examples/valkey-key-value-project.json
71
72
  zenifra create project --name <name> --plan cache-free --payment-mode hourly --config @examples/valkey-cache-project.json
72
73
  zenifra create project --name <name> --plan queue-free --payment-mode hourly --config @examples/valkey-queue-project.json
74
+ zenifra create project --name nightly-report --plan job-basic --payment-mode per_minute --config @examples/job-project.json
73
75
  zenifra projects --type http --page 1 --limit 15
74
76
  zenifra projects --type valkey --page 1 --limit 15
75
77
  zenifra project info --project <project-id>
@@ -82,6 +84,9 @@ zenifra valkey credentials rotate --project <project-id> --wait
82
84
  zenifra valkey credentials status --project <project-id> --operation <operation-id>
83
85
  zenifra project url --project <project-id>
84
86
  zenifra project logs --project <project-id> --instance <instance-id>
87
+ zenifra project runs --project <project-id> --page 1 --limit 20
88
+ zenifra project runs cancel --project <project-id> --run <run-id>
89
+ zenifra project runs logs --project <project-id> --run <run-id>
85
90
  zenifra project metrics --project <project-id> --instance <instance-id>
86
91
  zenifra project metrics capabilities --project <project-id>
87
92
  zenifra project network --project <project-id> --view summary
@@ -275,7 +280,7 @@ zenifra plans --type database
275
280
  zenifra plans --type storage --json
276
281
  ```
277
282
 
278
- `zenifra plans` funciona sem autenticacao e mostra os catalogos publicos de HTTP, banco, armazenamento e Valkey. Use `--type valkey` para consultar Key Value, Cache e Queue.
283
+ `zenifra plans` funciona sem autenticacao e mostra os catalogos publicos de HTTP, banco, armazenamento, Jobs agendados e Valkey. Use `--type job` para consultar Jobs agendados ou `--type valkey` para consultar Key Value, Cache e Queue.
279
284
 
280
285
  Para planos HTTP, a saida legivel tambem mostra as capacidades disponiveis, como logs, metricas, verificacao de saude, auto-scaling, subdominio personalizado e acesso de rede. Use `--json` quando precisar consumir o catalogo sem formatacao.
281
286
 
@@ -353,6 +358,7 @@ Use os arquivos em `examples/` como base para `zenifra create project`:
353
358
  - `examples/valkey-key-value-project.json`: projeto Valkey Key Value com armazenamento persistente
354
359
  - `examples/valkey-cache-project.json`: projeto Valkey Cache sem armazenamento persistente
355
360
  - `examples/valkey-queue-project.json`: projeto Valkey Queue com armazenamento persistente
361
+ - `examples/job-project.json`: Job agendado com cron UTC, imagem e armazenamento efêmero
356
362
 
357
363
  Se voce rodar apenas `zenifra create project`, a CLI abre um wizard interativo estilo `npm init` e pergunta todos os campos guiados. Cada pergunta mostra:
358
364
 
@@ -366,17 +372,18 @@ O wizard atual cobre:
366
372
  - projetos `postgresql`
367
373
  - projetos `mariadb`
368
374
  - projetos `valkey` nos perfis `key_value`, `cache` e `queue`
375
+ - Jobs agendados com uma imagem OCI pronta
369
376
 
370
377
  Projetos `clickhouse` usam atualmente o fluxo nao interativo com `--config`, como em `examples/clickhouse-project.json`.
371
378
 
372
- `zenifra create project` nao assume valores default para `--plan` e `--payment-mode`.
379
+ `zenifra create project` nao assume valores default para `--plan` e `--payment-mode`, exceto para Jobs, que usam `per_minute` automaticamente.
373
380
  Configs HTTP nao interativas tambem devem informar `config.exposure`; use `public` para criar rota/dominio publico ou `private` para manter a aplicacao sem exposicao na internet.
374
381
  Antes de escolher um plano com o usuario, compare os catalogos com `zenifra plans` para evitar suposicoes sobre custo.
375
382
 
376
383
  Valores aceitos:
377
384
 
378
- - `payment_mode`: `hourly`, `monthly`, `yearly`
379
- - `type_project` no `config`: `http`, `postgresql`, `mariadb`, `valkey`, `clickhouse`
385
+ - `payment_mode`: `hourly`, `monthly`, `yearly`, `per_minute` (somente Jobs)
386
+ - `type_project` no `config`: `http`, `postgresql`, `mariadb`, `valkey`, `clickhouse`, `job`
380
387
  - `exposure` no `config` HTTP: `public`, `private`
381
388
  - `plan`: consulte `zenifra plans` para os planos atuais; ClickHouse usa `analytics-*`; Valkey usa `db-*` para Key Value, `cache-*` para Cache e `queue-*` para Queue
382
389
  - `config.profile` em projetos Valkey: `key_value`, `cache` ou `queue`
@@ -387,6 +394,7 @@ Valores aceitos:
387
394
  - `config.github.version_deploy`: use `enabled: true`, `event: "tag"` ou `"release"` e um `tag_pattern` explicito; `include_prereleases` e opcional e padrao `false`
388
395
  - `config.source` e `config.build` (quando houver origem Git por conexao): use os IDs retornados pela configuracao segura no Console/API; nao informe credenciais Git nesses campos
389
396
  - `config.autoscaling` (somente HTTP pago): `enabled: true`, `max_instances` maior ou igual a `config.instances` e alvos opcionais de CPU/memoria entre 1 e 100
397
+ - `config.job` (somente Jobs): cron com cinco campos em UTC; o wizard usa a imagem OCI pronta e nao pergunta comando, argumentos, URL, exposicao, porta ou instancias
390
398
 
391
399
  Observacoes do wizard:
392
400
 
@@ -397,7 +405,8 @@ Observacoes do wizard:
397
405
  - em projetos de banco, o wizard nao pergunta `username`, `password` nem `database name`
398
406
  - em projetos de banco, a CLI preenche apenas campos tecnicos minimos exigidos pela validacao atual da API
399
407
  - em projetos Valkey, a capacidade é definida pelo plano e a CLI não pergunta instâncias, imagem, variáveis de ambiente ou exposição HTTP
400
- - a conexão mascarada pode ser consultada a qualquer momento; uma rotação concluída pode salvar a conexão utilizável em arquivo privado com `--connection-file <path>`
408
+ - em Jobs, a imagem OCI pronta é obrigatória, o cron usa cinco campos em UTC, a cobrança é por minuto inteiro e a CLI não pergunta origem GitHub, tipo de pagamento, comando, argumentos, exposição HTTP, porta ou instâncias
409
+ - a conexão mascarada pode ser consultada a qualquer momento; a credencial completa aparece apenas na criação ou em uma rotação concluída, podendo ser salva em arquivo privado com `--connection-file <path>`
401
410
  - `valkey credentials rotate` retorna uma operação assíncrona; use `--wait` ou `valkey credentials status` para acompanhar
402
411
 
403
412
  Exemplo de entrega segura para automação local:
@@ -408,6 +417,20 @@ zenifra valkey credentials rotate --project <project-id> --wait --connection-fil
408
417
 
409
418
  O arquivo é criado com permissão privada; a conexão não aparece na saída do comando quando essa opção é usada.
410
419
 
420
+ ## Execuções de Jobs agendados
421
+
422
+ Consulte o histórico de execuções e os logs de uma execução específica:
423
+
424
+ ```bash
425
+ zenifra project runs --project <project-id>
426
+ zenifra project runs cancel --project <project-id> --run <run-id>
427
+ zenifra project runs logs --project <project-id> --run <run-id>
428
+ ```
429
+
430
+ `project runs cancel` cancela somente a execução selecionada. O cron do projeto continua agendando novas execuções; use `zenifra project stop --project <project-id>` para pausar o projeto e interromper execuções futuras.
431
+
432
+ O cancelamento aguarda até 30 segundos pela finalização segura e pode forçar o encerramento depois desse período. Se uma execução antiga não puder ser cancelada com segurança, o comando informa o problema e você deve aguardar que ela termine ou alcance o limite de tempo.
433
+
411
434
  ## Regressao manual de auto-scaling em staging
412
435
 
413
436
  O teste de staging cria projetos, gera trafego, consulta consumo e remove somente os projetos criados pela propria execucao. Ele exige uma API de teste explicita, rejeita a API de producao e exige habilitacao explicita das mutacoes. O exemplo abaixo usa uma API local de teste.
package/bin/zenifra.mjs CHANGED
@@ -27,6 +27,9 @@ const PROFILES_FILE = join(SESSION_DIR, 'profiles.json');
27
27
  const PROFILE_STORE_VERSION = 1;
28
28
  const DEFAULT_PROFILE_NAME = 'default';
29
29
  const DEFAULT_HTTP_TIMEOUT_MS = 300_000;
30
+ const OCI_IMAGE_REFERENCE_PATTERN = /^[a-z0-9._-]+\/(?:[a-z0-9._-]+\/)*[a-z0-9._-]+(?::[a-z0-9._-]+|@sha256:[a-f0-9]{64})$/;
31
+ const OCI_IMAGE_REFERENCE_MIN_LENGTH = 8;
32
+ const OCI_IMAGE_REFERENCE_MAX_LENGTH = 256;
30
33
  const KNOWN_FLAG_NAMES = new Set([
31
34
  'apiBase',
32
35
  'oauth',
@@ -74,6 +77,8 @@ const KNOWN_FLAG_NAMES = new Set([
74
77
  'project',
75
78
  'repository',
76
79
  'revoke',
80
+ 'run',
81
+ 'runId',
77
82
  'showValues',
78
83
  'status',
79
84
  'tagPattern',
@@ -115,8 +120,8 @@ const ALLOWED_PLAN_VALUES = new Set([
115
120
  'queue-premium',
116
121
  'queue-enterprise',
117
122
  ]);
118
- const ALLOWED_PAYMENT_MODE_VALUES = new Set(['hourly', 'monthly', 'yearly']);
119
- const ALLOWED_TYPE_PROJECT_VALUES = new Set(['http', 'postgresql', 'mariadb', 'valkey', 'clickhouse']);
123
+ const ALLOWED_PAYMENT_MODE_VALUES = new Set(['hourly', 'monthly', 'yearly', 'per_minute']);
124
+ const ALLOWED_TYPE_PROJECT_VALUES = new Set(['http', 'postgresql', 'mariadb', 'valkey', 'clickhouse', 'job']);
120
125
  const ALLOWED_RUNTIME_VALUES = new Set(['nodejs', 'python']);
121
126
  const ALLOWED_GITHUB_VERSION_DEPLOY_EVENTS = new Set(['tag', 'release']);
122
127
  const ALLOWED_GITHUB_VERSION_DEPLOY_FIELDS = new Set(['enabled', 'event', 'tag_pattern', 'include_prereleases']);
@@ -171,6 +176,7 @@ const WIZARD_DATABASE_PLAN_OPTIONS = [
171
176
  'db-free',
172
177
  ];
173
178
  const WIZARD_PAYMENT_MODE_OPTIONS = ['hourly', 'monthly', 'yearly'];
179
+ const WIZARD_JOB_PLAN_OPTIONS = WIZARD_HTTP_PLAN_OPTIONS.map((plan) => `job-${plan}`);
174
180
  const WIZARD_TYPE_PROJECT_OPTIONS = ['http', 'postgresql', 'mariadb', 'valkey'];
175
181
  const WIZARD_VALKEY_PROFILE_OPTIONS = ['key_value', 'cache', 'queue'];
176
182
  const WIZARD_HTTP_EXPOSURE_OPTIONS = ['public', 'private'];
@@ -212,15 +218,15 @@ Usage:
212
218
  zenifra whoami [--json]
213
219
  zenifra orgs [--json]
214
220
  zenifra org set [--org <id>]
215
- zenifra plans [--type <all|http|database|storage|valkey>] [--json]
221
+ zenifra plans [--type <all|http|database|storage|valkey|job>] [--json]
216
222
  zenifra git providers [--json]
217
223
  zenifra git runtimes [--json]
218
224
  zenifra git connections [--json]
219
225
  zenifra git repositories resolve --connection <id> --path <owner/repo> [--json]
220
226
  zenifra git branches --connection <id> --repository <opaque-id> [--json]
221
227
  zenifra create project
222
- zenifra create project --name <name> --plan <plan> --payment-mode <mode> --config <json|@file> [--description <text>] [--org <id>] [--json]
223
- zenifra projects [--json] [--org <id>] [--type <http|postgresql|mariadb|valkey>] [--page <n>] [--limit <n>]
228
+ zenifra create project --name <name> --plan <plan> [--payment-mode <mode>] --config <json|@file> [--description <text>] [--org <id>] [--json]
229
+ zenifra projects [--json] [--org <id>] [--type <http|postgresql|mariadb|valkey|job>] [--page <n>] [--limit <n>]
224
230
  zenifra valkey status --project <id> [--json]
225
231
  zenifra valkey connection --project <id> [--json]
226
232
  zenifra valkey credentials rotate --project <id> [--idempotency-key <key>] [--wait] [--interval <seconds>] [--timeout <seconds>] [--json]
@@ -231,6 +237,9 @@ Usage:
231
237
  zenifra project delete --project <id> --yes [--json]
232
238
  zenifra project url --project <id> [--json]
233
239
  zenifra project logs --project <id> [--instance <id>] [--json]
240
+ zenifra project runs --project <id> [--page <n>] [--limit <n>] [--json]
241
+ zenifra project runs cancel --project <id> --run <id> [--json]
242
+ zenifra project runs logs --project <id> --run <id> [--json]
234
243
  zenifra project metrics --project <id> [--instance <id>] [--json]
235
244
  zenifra project metrics capabilities --project <id> [--json]
236
245
  zenifra project healthcheck get --project <id> [--json]
@@ -413,10 +422,10 @@ const HELP_SPECS = [
413
422
  },
414
423
  {
415
424
  command: 'plans',
416
- usage: 'zenifra plans [--type <all|http|database|storage|valkey>] [--json]',
417
- description: 'Lista os catalogos publicos de preco de planos HTTP, banco, armazenamento e Valkey.',
418
- flags: ['--type <type> Filtra o catalogo: all, http, database, storage, valkey, key-value, cache ou queue.', '--json Imprime a resposta em JSON.'],
419
- examples: ['zenifra plans', 'zenifra plans --type http', 'zenifra plans --type valkey --json'],
425
+ usage: 'zenifra plans [--type <all|http|database|storage|valkey|job>] [--json]',
426
+ description: 'Lista os catalogos publicos de preco de planos HTTP, banco, armazenamento, Valkey e Jobs agendados.',
427
+ flags: ['--type <type> Filtra o catalogo: all, http, database, storage, valkey, job, key-value, cache ou queue.', '--json Imprime a resposta em JSON.'],
428
+ examples: ['zenifra plans', 'zenifra plans --type http', 'zenifra plans --type valkey --json', 'zenifra plans --type job --json'],
420
429
  output: 'HTTP\nPlano Hora Mes Ano Recursos\nfree R$ 0,00 R$ 0,00 R$ 0,00 1 GB Armazenamento Efemero',
421
430
  jsonOutput: '{"http":[{"plan":"free","prices":{"hourly":0,"monthly":0,"yearly":0},"features":["1 GB Armazenamento Efemero"]}],"database":[],"storage":[]}',
422
431
  },
@@ -533,7 +542,7 @@ const HELP_SPECS = [
533
542
  },
534
543
  {
535
544
  command: 'projects',
536
- usage: 'zenifra projects [--json] [--org <id>] [--type <http|postgresql|mariadb|valkey>] [--page <n>] [--limit <n>]',
545
+ usage: 'zenifra projects [--json] [--org <id>] [--type <http|postgresql|mariadb|valkey|job>] [--page <n>] [--limit <n>]',
537
546
  description: 'Lista projetos da organizacao ativa ou da API key.',
538
547
  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.'],
539
548
  examples: ['zenifra projects --type http --page 1 --limit 15', 'zenifra projects --json'],
@@ -542,9 +551,9 @@ const HELP_SPECS = [
542
551
  },
543
552
  {
544
553
  command: 'create project',
545
- 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]',
554
+ 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]',
546
555
  description: 'Cria um projeto via wizard interativo ou via payload de configuracao JSON.',
547
- 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.'],
556
+ flags: ['--name <name> Nome do projeto.', '--plan <plan> Plano do projeto.', '--payment-mode <mode> Opcional para Jobs; demais tipos seguem a validacao do catalogo.', '--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.'],
548
557
  examples: ['zenifra create project', 'zenifra create project --name api-web --plan free --payment-mode hourly --config @examples/http-project.json'],
549
558
  output: 'Campo Valor\n------- --------------------------------------\nProjeto 507f1f77bcf86cd799439012\nDominio https://api-web.clients.zenifra.com',
550
559
  jsonOutput: '{"status":"success","data":{"id":"507f1f77bcf86cd799439012","name":"api-web"}}',
@@ -552,11 +561,11 @@ const HELP_SPECS = [
552
561
  },
553
562
  {
554
563
  command: 'project',
555
- usage: 'zenifra project\n zenifra project info --project <id> [--json]\n zenifra project source --project <id> [--json]\n zenifra project source branches --project <id> [--json]\n zenifra project source deploy-settings set --project <id> --mode <manual|branch|tag|release> [--tag-pattern <pattern>] [--include-prereleases <true|false>] [--json]\n zenifra project stop --project <id> [--json]\n zenifra project resume --project <id> [--json]\n zenifra project delete --project <id> --yes [--json]\n zenifra project url --project <id> [--json]\n zenifra project logs --project <id> [--instance <id>] [--json]\n zenifra project metrics --project <id> [--instance <id>] [--json]\n zenifra project metrics capabilities --project <id> [--json]\n zenifra project healthcheck get --project <id> [--json]\n zenifra project healthcheck set --project <id> --path /health [--json]\n zenifra project healthcheck disable --project <id> [--json]\n zenifra project healthcheck failures --project <id> [--page <n>] [--limit <n>] [--json]\n zenifra project network --project <id> [--view <summary|status-codes|routes|user-agents|request-events|source-ips>] [--json]\n zenifra project image set --project <id> --image <image> [--json]\n zenifra project exposure set --project <id> --exposure <public|private> [--json]\n zenifra project envs --project <id> [--json] [--show-values]\n zenifra project env add --project <id> --name <name> --value <value> [--json]\n zenifra project env update --project <id> --name <name> --value <value> [--json]\n zenifra project env remove --project <id> --name <name> [--json]\n zenifra project autoscaling --project <id> [--json]\n zenifra project autoscaling set --project <id> --min <n> --max <n> [--cpu <percent>] [--memory <percent>] [--json]\n zenifra project autoscaling disable --project <id> [--json]\n zenifra project autoscaling events --project <id> [--direction <scale_up|scale_down>] [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]\n zenifra project billing usage --project <id> [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]\n zenifra project instances --project <id> [--json]\n zenifra project instances set --project <id> --count <n> [--json]',
564
+ usage: 'zenifra project\n zenifra project info --project <id> [--json]\n zenifra project source --project <id> [--json]\n zenifra project source branches --project <id> [--json]\n zenifra project source deploy-settings set --project <id> --mode <manual|branch|tag|release> [--tag-pattern <pattern>] [--include-prereleases <true|false>] [--json]\n zenifra project stop --project <id> [--json]\n zenifra project resume --project <id> [--json]\n zenifra project delete --project <id> --yes [--json]\n zenifra project url --project <id> [--json]\n zenifra project logs --project <id> [--instance <id>] [--json]\n zenifra project runs --project <id> [--page <n>] [--limit <n>] [--json]\n zenifra project runs cancel --project <id> --run <id> [--json]\n zenifra project runs logs --project <id> --run <id> [--json]\n zenifra project metrics --project <id> [--instance <id>] [--json]\n zenifra project metrics capabilities --project <id> [--json]\n zenifra project healthcheck get --project <id> [--json]\n zenifra project healthcheck set --project <id> --path /health [--json]\n zenifra project healthcheck disable --project <id> [--json]\n zenifra project healthcheck failures --project <id> [--page <n>] [--limit <n>] [--json]\n zenifra project network --project <id> [--view <summary|status-codes|routes|user-agents|request-events|source-ips>] [--json]\n zenifra project image set --project <id> --image <image> [--json]\n zenifra project exposure set --project <id> --exposure <public|private> [--json]\n zenifra project envs --project <id> [--json] [--show-values]\n zenifra project env add --project <id> --name <name> --value <value> [--json]\n zenifra project env update --project <id> --name <name> --value <value> [--json]\n zenifra project env remove --project <id> --name <name> [--json]\n zenifra project autoscaling --project <id> [--json]\n zenifra project autoscaling set --project <id> --min <n> --max <n> [--cpu <percent>] [--memory <percent>] [--json]\n zenifra project autoscaling disable --project <id> [--json]\n zenifra project autoscaling events --project <id> [--direction <scale_up|scale_down>] [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]\n zenifra project billing usage --project <id> [--from <iso>] [--to <iso>] [--page <n>] [--limit <n>] [--json]\n zenifra project instances --project <id> [--json]\n zenifra project instances set --project <id> --count <n> [--json]',
556
565
  description: 'Agrupa comandos operacionais e de introspecao sobre um projeto especifico.',
557
566
  examples: ['zenifra project', 'zenifra project info --project proj_1', 'zenifra project source --project proj_1', 'zenifra project env add --project proj_1 --name NODE_ENV --value production'],
558
567
  output: 'Zenifra CLI - project',
559
- notes: ['Use "zenifra help project <subcomando>" para detalhes de info, stop, resume, delete, url, logs, metrics, capabilities, network, image, github, source, exposure, autoscaling, billing, envs e instances.'],
568
+ notes: ['Use "zenifra help project <subcomando>" para detalhes de info, stop, resume, delete, url, logs, runs, cancelamento, metrics, capabilities, network, image, github, source, exposure, autoscaling, billing, envs e instances.'],
560
569
  },
561
570
  {
562
571
  command: 'project source',
@@ -698,6 +707,38 @@ const HELP_SPECS = [
698
707
  jsonOutput: '"server started\\nGET /health 200"',
699
708
  notes: ['Nao faz streaming continuo; retorna o snapshot disponibilizado pela API.'],
700
709
  },
710
+ {
711
+ command: 'project runs',
712
+ usage: 'zenifra project runs --project <id> [--page <n>] [--limit <n>] [--json]',
713
+ description: 'Lista as execucoes de um Job agendado com o historico de cobranca.',
714
+ flags: ['--project <id> ID do projeto.', '--page <n> Pagina. Padrao: 1.', '--limit <n> Itens por pagina. Maximo: 50.', '--json Imprime a resposta em JSON.'],
715
+ examples: ['zenifra project runs --project 507f1f77bcf86cd799439012', 'zenifra project runs --project 507f1f77bcf86cd799439012 --page 2 --limit 20 --json'],
716
+ output: 'ID Status Agendada Duracao Minutos cobrados Valor\nrun_1 succeeded 2026-09-01T12:00:00.000Z 70 s 2 R$ 0,04',
717
+ jsonOutput: '{"runs":[{"id":"run_1","status":"succeeded","duration_seconds":70,"billed_minutes":2,"plan":"job-basic","currency":"brl","amount":4}],"pagination":{"page":1,"limit":20,"total":1,"total_pages":1}}',
718
+ },
719
+ {
720
+ command: 'project runs cancel',
721
+ usage: 'zenifra project runs cancel --project <id> --run <id> [--json]',
722
+ description: 'Cancela somente a execucao selecionada de um Job e cobra somente o tempo consumido.',
723
+ flags: ['--project <id> ID do projeto.', '--run <id> ID publico da execucao.', '--json Imprime a resposta em JSON.'],
724
+ examples: ['zenifra project runs cancel --project 507f1f77bcf86cd799439012 --run run_1'],
725
+ output: 'Execucao: cancelled',
726
+ jsonOutput: '{"run":{"id":"run_1","status":"cancelled","billed_minutes":2}}',
727
+ notes: [
728
+ 'Cancela somente a execucao informada; o cron do projeto continua agendando novas execucoes. Use "zenifra project stop --project <id>" para pausar o projeto e interromper execucoes futuras.',
729
+ 'O cancelamento aguarda ate 30 segundos pela finalizacao segura e pode forcar o encerramento depois desse periodo. Se a execucao nao puder ser cancelada com seguranca, aguarde que ela termine ou alcance o limite de tempo.',
730
+ ],
731
+ },
732
+ {
733
+ command: 'project runs logs',
734
+ usage: 'zenifra project runs logs --project <id> --run <id> [--json]',
735
+ description: 'Mostra os logs de uma execucao especifica de um Job agendado.',
736
+ flags: ['--project <id> ID do projeto.', '--run <id> ID publico da execucao.', '--json Imprime a resposta em JSON.'],
737
+ examples: ['zenifra project runs logs --project 507f1f77bcf86cd799439012 --run run_1', 'zenifra project runs logs --project 507f1f77bcf86cd799439012 --run run_1 --json'],
738
+ output: 'started\ncompleted',
739
+ jsonOutput: '{"logs":"started\\ncompleted","truncated":false}',
740
+ notes: ['Os logs pertencem a uma execucao identificada pelo ID publico retornado em project runs.'],
741
+ },
701
742
  {
702
743
  command: 'project metrics',
703
744
  usage: 'zenifra project metrics --project <id> [--instance <id>] [--json]',
@@ -1009,7 +1050,8 @@ function commandHelp(positionals) {
1009
1050
  const key = helpKeyFromPositionals(positionals);
1010
1051
  if (!key) return usage();
1011
1052
 
1012
- const spec = HELP_BY_COMMAND.get(key);
1053
+ const spec = HELP_BY_COMMAND.get(key)
1054
+ || (key === 'project run logs' ? HELP_BY_COMMAND.get('project runs logs') : undefined);
1013
1055
  if (!spec) {
1014
1056
  throw new CliError(`Ajuda nao encontrada para: ${key}. Use "zenifra help <command>".`);
1015
1057
  }
@@ -1602,9 +1644,9 @@ async function promptWizardBoolean({ label, docs, examples = ['sim', 'nao'] }) {
1602
1644
  }
1603
1645
  }
1604
1646
 
1605
- async function promptWizardSelect({ label, options, docs, examples = [] }) {
1647
+ async function promptWizardSelect({ label, options, docs, examples = [], extraValues = [] }) {
1606
1648
  while (true) {
1607
- const allowedValues = options.map((option) => option.value);
1649
+ const allowedValues = [...options.map((option) => option.value), ...extraValues];
1608
1650
  const answer = await prompt(buildWizardPromptLine({
1609
1651
  ui: promptWizardSelect.ui,
1610
1652
  label,
@@ -1625,6 +1667,8 @@ async function promptWizardSelect({ label, options, docs, examples = [] }) {
1625
1667
  const normalized = normalizeFreeText(answer);
1626
1668
  const found = options.find((option) => normalizeFreeText(option.value) === normalized);
1627
1669
  if (found) return found.value;
1670
+ const extraValue = extraValues.find((value) => normalizeFreeText(value) === normalized);
1671
+ if (extraValue) return extraValue;
1628
1672
  process.stdout.write(`${tone(`valor invalido: escolha um item entre ${options.map((_, index) => index + 1).join(', ')} ou um dos valores aceitos.`, 'red')}\n`);
1629
1673
  promptWizardSelect.ui.step -= 1;
1630
1674
  }
@@ -1757,6 +1801,14 @@ function httpTimeoutMs() {
1757
1801
  return value;
1758
1802
  }
1759
1803
 
1804
+ function publicApiErrorMessage(value) {
1805
+ const message = String(value || 'Nao foi possivel concluir a operacao.');
1806
+ if (/kubernetes|k8s|namespace|cluster|pod|serviceaccount|deployment|internal[_ -]/i.test(message)) {
1807
+ return 'Nao foi possivel concluir a operacao. Tente novamente mais tarde.';
1808
+ }
1809
+ return message;
1810
+ }
1811
+
1760
1812
  async function request(session, flags, method, path, {
1761
1813
  body,
1762
1814
  orgId,
@@ -1823,7 +1875,7 @@ async function request(session, flags, method, path, {
1823
1875
  }
1824
1876
 
1825
1877
  if (!response.ok) {
1826
- const message = payload?.message || payload?.error || `Zenifra API retornou HTTP ${response.status}`;
1878
+ const message = publicApiErrorMessage(payload?.message || payload?.error || `Zenifra API retornou HTTP ${response.status}`);
1827
1879
  const httpError = (errorMessage) => new CliError(errorMessage, 1, response.status);
1828
1880
  if (response.status === 401) {
1829
1881
  if (credential?.type === 'api_key') {
@@ -2440,9 +2492,66 @@ function printEnvs(envs, flags) {
2440
2492
  ]);
2441
2493
  }
2442
2494
 
2495
+ function isJobProject(project) {
2496
+ return project?.type_project === 'job' || project?.config?.type_project === 'job';
2497
+ }
2498
+
2499
+ const JOB_INFO_HIDDEN_KEYS = new Set(['domain', 'url', 'exposure', 'port', 'instances']);
2500
+ const JOB_CONFIG_HIDDEN_KEYS = new Set(['exposure', 'port', 'instances', 'network_access', 'domain', 'custom_domains', 'autoscaling', 'healthcheck']);
2501
+ const INTERNAL_PROJECT_KEYS = new Set([
2502
+ 'current_instances',
2503
+ 'runtime_name',
2504
+ 'namespace',
2505
+ 'k8s_job_name',
2506
+ ]);
2507
+
2508
+ function sanitizePublicProjectValue(value) {
2509
+ if (Array.isArray(value)) return value.map(sanitizePublicProjectValue);
2510
+ if (!value || typeof value !== 'object') return value;
2511
+ return Object.fromEntries(Object.entries(value)
2512
+ .filter(([key]) => !INTERNAL_PROJECT_KEYS.has(key))
2513
+ .map(([key, entryValue]) => [key, sanitizePublicProjectValue(entryValue)]));
2514
+ }
2515
+
2516
+ function sanitizeJobConfigInfo(value) {
2517
+ if (Array.isArray(value)) return value.map(sanitizeJobConfigInfo);
2518
+ if (!value || typeof value !== 'object') return value;
2519
+ return Object.fromEntries(Object.entries(value)
2520
+ .filter(([key]) => !JOB_CONFIG_HIDDEN_KEYS.has(key))
2521
+ .map(([key, entryValue]) => [key, sanitizeJobConfigInfo(entryValue)]));
2522
+ }
2523
+
2524
+ function sanitizePublicProject(project) {
2525
+ const sanitized = sanitizePublicProjectValue(project);
2526
+ if (!sanitized || typeof sanitized !== 'object' || Array.isArray(sanitized)) return sanitized;
2527
+ return Object.fromEntries(Object.entries(sanitized)
2528
+ .filter(([key]) => !JOB_INFO_HIDDEN_KEYS.has(key))
2529
+ .map(([key, value]) => [key, key === 'config' ? sanitizeJobConfigInfo(value) : value]));
2530
+ }
2531
+
2532
+ function sanitizeJobProjectResponse(payload) {
2533
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload;
2534
+ if (payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) {
2535
+ return { ...payload, data: sanitizePublicProject(payload.data) };
2536
+ }
2537
+ return sanitizePublicProjectValue(payload);
2538
+ }
2539
+
2540
+ function publicProjectsData(data) {
2541
+ if (Array.isArray(data)) return data.map((project) => isJobProject(project) ? sanitizePublicProject(project) : project);
2542
+ if (!data || typeof data !== 'object') return data;
2543
+ return {
2544
+ ...data,
2545
+ projects: Array.isArray(data.projects)
2546
+ ? data.projects.map((project) => isJobProject(project) ? sanitizePublicProject(project) : project)
2547
+ : data.projects,
2548
+ };
2549
+ }
2550
+
2443
2551
  function printProject(project) {
2444
2552
  const managedService = project.managed_service;
2445
2553
  const isValkey = project.type_project === 'valkey' || managedService?.engine === 'valkey';
2554
+ const isJob = isJobProject(project);
2446
2555
  const rows = [
2447
2556
  { label: 'ID', value: projectIdOf(project) || '-' },
2448
2557
  { label: 'Nome', value: project.name || '-' },
@@ -2451,7 +2560,13 @@ function printProject(project) {
2451
2560
  { label: 'Plano', value: project.plan || '-' },
2452
2561
  ];
2453
2562
 
2454
- if (isValkey) {
2563
+ if (isJob) {
2564
+ const schedule = project.job || project.config?.job || {};
2565
+ rows.push(
2566
+ { label: 'Agendamento', value: schedule.cron || '-' },
2567
+ { label: 'Armazenamento', value: project.storage?.persistent === true || project.config?.storage?.persistent === true ? 'persistente' : 'efemero' },
2568
+ );
2569
+ } else if (isValkey) {
2455
2570
  rows.push(
2456
2571
  { label: 'Perfil', value: managedService?.profile || '-' },
2457
2572
  { label: 'Versao', value: managedService?.version || '-' },
@@ -2918,9 +3033,10 @@ async function handleProjects(session, flags) {
2918
3033
  };
2919
3034
  const query = buildQuery(projectFlags, ['type', 'page', 'limit']);
2920
3035
  const data = unwrapData(await request(session, flags, 'GET', `/project${query}`, { orgId }));
2921
- const projects = asArray(data);
3036
+ const publicData = publicProjectsData(data);
3037
+ const projects = asArray(publicData);
2922
3038
 
2923
- if (flags.json) return printJson(data);
3039
+ if (flags.json) return printJson(publicData);
2924
3040
  printTable(projects, [
2925
3041
  { label: 'ID', value: projectIdOf },
2926
3042
  { label: 'Nome', value: (project) => project.name || '-' },
@@ -3009,6 +3125,8 @@ function normalizePlan(value) {
3009
3125
  ]);
3010
3126
 
3011
3127
  if (aliases.has(normalized)) return aliases.get(normalized);
3128
+ const normalizedJobPlan = normalized.startsWith('job_') ? `job-${normalized.slice(4)}` : '';
3129
+ if (WIZARD_JOB_PLAN_OPTIONS.includes(normalizedJobPlan)) return normalizedJobPlan;
3012
3130
  if (ALLOWED_PLAN_VALUES.has(normalized)) return normalized;
3013
3131
  return normalized;
3014
3132
  }
@@ -3028,6 +3146,10 @@ function normalizePaymentMode(value) {
3028
3146
  ['annual', 'yearly'],
3029
3147
  ['anual', 'yearly'],
3030
3148
  ['por ano', 'yearly'],
3149
+ ['per_minute', 'per_minute'],
3150
+ ['per-minute', 'per_minute'],
3151
+ ['por minuto', 'per_minute'],
3152
+ ['minuto', 'per_minute'],
3031
3153
  ]);
3032
3154
  return aliases.get(raw) || raw;
3033
3155
  }
@@ -3164,12 +3286,21 @@ async function fetchWizardCatalogs(session, flags) {
3164
3286
  return { httpPlans, availableInstances, databasePlans };
3165
3287
  }
3166
3288
 
3289
+ function requireJobPlans(value) {
3290
+ const plans = Array.isArray(value) ? value : value?.plans;
3291
+ if (!Array.isArray(plans)) {
3292
+ throw new CliError('Falha ao carregar o catalogo de Jobs agendados. Tente novamente mais tarde.');
3293
+ }
3294
+ return plans;
3295
+ }
3296
+
3167
3297
  function normalizePlansCatalogType(value) {
3168
3298
  const normalized = normalizeFreeText(value || 'all');
3169
3299
  if (!normalized || normalized === 'all') return 'all';
3170
3300
  if (normalized === 'http') return 'http';
3171
3301
  if (['database', 'db', 'postgresql', 'mariadb'].includes(normalized)) return 'database';
3172
3302
  if (normalized === 'valkey') return 'valkey';
3303
+ if (normalized === 'job' || normalized === 'jobs' || normalized === 'scheduled job' || normalized === 'scheduled jobs') return 'job';
3173
3304
  if (normalized === 'key-value' || normalized === 'key value' || normalized === 'key_value') return 'valkey-key_value';
3174
3305
  if (normalized === 'cache') return 'valkey-cache';
3175
3306
  if (normalized === 'queue') return 'valkey-queue';
@@ -3190,6 +3321,18 @@ function formatBrlFromCents(value) {
3190
3321
  return formatBrl(Number.isFinite(number) ? number / 100 : 0);
3191
3322
  }
3192
3323
 
3324
+ function formatJobBrlFromCents(value) {
3325
+ if (value === undefined || value === null || value === '') return '-';
3326
+ const number = Number(value);
3327
+ if (!Number.isFinite(number)) return '-';
3328
+ return new Intl.NumberFormat('pt-BR', {
3329
+ style: 'currency',
3330
+ currency: 'BRL',
3331
+ minimumFractionDigits: 2,
3332
+ maximumFractionDigits: 4,
3333
+ }).format(number / 100);
3334
+ }
3335
+
3193
3336
  function humanizeStorageName(value) {
3194
3337
  const raw = String(value || '').trim();
3195
3338
  if (!raw) return { storage: '-', type: '-' };
@@ -3287,6 +3430,17 @@ async function fetchPlansCatalogs(session, flags, type) {
3287
3430
  };
3288
3431
  }
3289
3432
 
3433
+ if (type === 'job') {
3434
+ return {
3435
+ http: [],
3436
+ database: [],
3437
+ storage: [],
3438
+ job: requireJobPlans(
3439
+ unwrapData(await request(session, flags, 'GET', '/project/job/plans', { tokenRequired: false })),
3440
+ ),
3441
+ };
3442
+ }
3443
+
3290
3444
  if (type === 'valkey' || type.startsWith('valkey-')) {
3291
3445
  const catalog = requireValkeyCatalog(
3292
3446
  unwrapData(await request(session, flags, 'GET', '/managed-services/catalog', { tokenRequired: false })),
@@ -3299,10 +3453,11 @@ async function fetchPlansCatalogs(session, flags, type) {
3299
3453
  };
3300
3454
  }
3301
3455
 
3302
- const [httpPayload, databasePayload, storagePayload, valkeyPayload] = await Promise.all([
3456
+ const [httpPayload, databasePayload, storagePayload, jobPayload, valkeyPayload] = await Promise.all([
3303
3457
  request(session, flags, 'GET', '/project/plans', { tokenRequired: false }),
3304
3458
  request(session, flags, 'GET', '/project/database/plans', { tokenRequired: false }),
3305
3459
  request(session, flags, 'GET', '/project/storage/plans', { tokenRequired: false }),
3460
+ request(session, flags, 'GET', '/project/job/plans', { tokenRequired: false }),
3306
3461
  request(session, flags, 'GET', '/managed-services/catalog', { tokenRequired: false }),
3307
3462
  ]);
3308
3463
 
@@ -3310,6 +3465,7 @@ async function fetchPlansCatalogs(session, flags, type) {
3310
3465
  http: requireWizardCatalogArray(unwrapData(httpPayload), 'os planos HTTP'),
3311
3466
  database: requireWizardCatalogArray(unwrapData(databasePayload), 'os planos de banco'),
3312
3467
  storage: requireWizardCatalogArray(unwrapData(storagePayload), 'os planos de storage'),
3468
+ job: requireJobPlans(unwrapData(jobPayload)),
3313
3469
  valkey: requireValkeyCatalog(unwrapData(valkeyPayload)),
3314
3470
  };
3315
3471
  }
@@ -3348,6 +3504,15 @@ function formatPlanCapabilities(capabilities) {
3348
3504
  return available.join(', ') || '-';
3349
3505
  }
3350
3506
 
3507
+ function printJobCatalog(plans) {
3508
+ process.stdout.write('Jobs agendados' + String.fromCharCode(10));
3509
+ printTable(asArray(plans), [
3510
+ { label: 'Plano', value: (plan) => plan.plan || plan.id || '-' },
3511
+ { label: 'Por minuto', value: (plan) => formatJobBrlFromCents(plan.price_per_minute) },
3512
+ { label: 'Features', value: (plan) => asArray(plan.features).join(', ') || '-' },
3513
+ ]);
3514
+ }
3515
+
3351
3516
  function printPlansCatalogs(payload, type) {
3352
3517
  if (type === 'all' || type === 'http') {
3353
3518
  process.stdout.write('HTTP\n');
@@ -3385,6 +3550,16 @@ function printPlansCatalogs(payload, type) {
3385
3550
  ]);
3386
3551
  }
3387
3552
 
3553
+ if (type === 'all') {
3554
+ process.stdout.write('\n');
3555
+ printJobCatalog(payload.job);
3556
+ }
3557
+
3558
+ if (type === 'job') {
3559
+ printJobCatalog(payload.job);
3560
+ return;
3561
+ }
3562
+
3388
3563
  if (type === 'all' || type === 'valkey' || type.startsWith('valkey-')) {
3389
3564
  if (type === 'all') process.stdout.write('\n');
3390
3565
  printValkeyCatalog(payload.valkey, valkeyProfileFilter(type));
@@ -3394,7 +3569,7 @@ function printPlansCatalogs(payload, type) {
3394
3569
  async function handlePlans(session, flags) {
3395
3570
  const type = normalizePlansCatalogType(flags.type);
3396
3571
  if (!type) {
3397
- throw new CliError('Tipo de catalogo invalido. Valores aceitos: all, http, database, storage, valkey.');
3572
+ throw new CliError('Tipo de catalogo invalido. Valores aceitos: all, http, database, storage, valkey, job, key-value, cache ou queue.');
3398
3573
  }
3399
3574
 
3400
3575
  const payload = await fetchPlansCatalogs(session, flags, type);
@@ -3406,6 +3581,75 @@ function formatAllowedValues(values) {
3406
3581
  return [...values].join(', ');
3407
3582
  }
3408
3583
 
3584
+ function validateJobConfig(config) {
3585
+ if (!isRecord(config)) throw new CliError('config deve ser um objeto.');
3586
+
3587
+ if (!isRecord(config.job)) throw new CliError('Jobs exigem config.job.');
3588
+ const cron = String(config.job.cron || '').trim();
3589
+ if (cron.split(/\s+/).filter(Boolean).length !== 5) {
3590
+ throw new CliError('config.job.cron deve conter exatamente cinco campos em UTC.');
3591
+ }
3592
+ const forbiddenFields = ['instances', 'port', 'exposure', 'network_access', 'domain', 'custom_domains', 'autoscaling', 'healthcheck'];
3593
+ const forbiddenField = forbiddenFields.find((field) => config[field] !== undefined);
3594
+ if (forbiddenField) {
3595
+ throw new CliError(`Jobs nao aceitam config.${forbiddenField}.`);
3596
+ }
3597
+ const forbiddenJobField = ['command', 'args'].find((field) => config.job[field] !== undefined);
3598
+ if (forbiddenJobField) {
3599
+ throw new CliError(`Jobs nao aceitam config.job.${forbiddenJobField}. A imagem ja define o comando e os argumentos.`);
3600
+ }
3601
+
3602
+ if (!isRecord(config.image) || typeof config.image.url !== 'string' || !config.image.url.trim()) {
3603
+ throw new CliError('Jobs exigem uma imagem OCI em config.image. O fluxo GitHub ainda nao esta disponivel para Jobs.');
3604
+ }
3605
+ const imageReference = config.image.url.trim();
3606
+ if (imageReference.length < OCI_IMAGE_REFERENCE_MIN_LENGTH
3607
+ || imageReference.length > OCI_IMAGE_REFERENCE_MAX_LENGTH
3608
+ || !OCI_IMAGE_REFERENCE_PATTERN.test(imageReference)) {
3609
+ throw new CliError('config.image.url deve ser uma referencia OCI completa com registro e tag ou digest.');
3610
+ }
3611
+ if (config.github !== undefined) {
3612
+ throw new CliError('Jobs aceitam somente config.image. O fluxo GitHub ainda nao esta disponivel para Jobs.');
3613
+ }
3614
+
3615
+ if (!Array.isArray(config.envs)) {
3616
+ throw new CliError('config.envs deve ser um array.');
3617
+ }
3618
+ if (config.envs.length > 100) throw new CliError('config.envs nao pode conter mais de 100 variaveis.');
3619
+ for (const env of config.envs) {
3620
+ if (!isRecord(env) || typeof env.name !== 'string' || typeof env.value !== 'string') {
3621
+ throw new CliError('Cada item de config.envs deve conter name e value como texto.');
3622
+ }
3623
+ }
3624
+
3625
+ if (!isRecord(config.storage)) throw new CliError('Jobs exigem config.storage.');
3626
+ if (typeof config.storage.persistent !== 'boolean') {
3627
+ throw new CliError('config.storage.persistent deve ser booleano.');
3628
+ }
3629
+ const capacity = Number(config.storage.capacity);
3630
+ if (!Number.isInteger(capacity) || capacity < 1 || capacity > 250) {
3631
+ throw new CliError('config.storage.capacity deve ser um inteiro entre 1 e 250 GiB.');
3632
+ }
3633
+ if (config.storage.persistent) {
3634
+ if (typeof config.storage.dir_path_to_persist !== 'string' || !config.storage.dir_path_to_persist.trim()) {
3635
+ throw new CliError('config.storage.dir_path_to_persist e obrigatorio para storage persistente.');
3636
+ }
3637
+ } else if (config.storage.dir_path_to_persist !== undefined) {
3638
+ throw new CliError('config.storage.dir_path_to_persist nao e aceito para storage efemero.');
3639
+ }
3640
+
3641
+ return { ...config, type_project: 'job', job: { ...config.job, cron } };
3642
+ }
3643
+
3644
+ function validateJobPlanAndPayment(plan, paymentMode) {
3645
+ if (!WIZARD_JOB_PLAN_OPTIONS.includes(plan)) {
3646
+ throw new CliError(`Plano invalido para Jobs: "${plan}". Valores aceitos: ${formatAllowedValues(WIZARD_JOB_PLAN_OPTIONS)}.`);
3647
+ }
3648
+ if (paymentMode !== 'per_minute') {
3649
+ throw new CliError('Jobs agendados aceitam somente o modo de pagamento per_minute.');
3650
+ }
3651
+ }
3652
+
3409
3653
  function validateCreateAutoscaling(config, plan, typeProject) {
3410
3654
  if (config?.autoscaling === undefined) return config;
3411
3655
  if (typeProject !== 'http') {
@@ -3549,13 +3793,22 @@ function validateGithubVersionDeployConfig(github) {
3549
3793
  function validateCreateInput({ plan, paymentMode, config, valkeyCatalog }) {
3550
3794
  const nextTypeProject = normalizeTypeProject(config?.type_project);
3551
3795
  const nextPlan = normalizePlan(plan);
3796
+ const nextPaymentMode = normalizePaymentMode(paymentMode);
3797
+
3798
+ if (nextTypeProject === 'job') {
3799
+ validateJobPlanAndPayment(nextPlan, nextPaymentMode);
3800
+ return {
3801
+ plan: nextPlan,
3802
+ paymentMode: nextPaymentMode,
3803
+ config: validateJobConfig({ ...config, type_project: nextTypeProject }),
3804
+ };
3805
+ }
3806
+
3552
3807
  if (!nextPlan || (nextTypeProject !== 'valkey' && !ALLOWED_PLAN_VALUES.has(nextPlan))) {
3553
3808
  throw new CliError(`Plano invalido: "${plan}". Valores aceitos: ${formatAllowedValues(ALLOWED_PLAN_VALUES)}. Docs: ${DOCS_CREATE_HTTP_URL}`);
3554
3809
  }
3555
-
3556
- const nextPaymentMode = normalizePaymentMode(paymentMode);
3557
- if (!nextPaymentMode || !ALLOWED_PAYMENT_MODE_VALUES.has(nextPaymentMode)) {
3558
- throw new CliError(`Modo de pagamento invalido: "${paymentMode}". Valores aceitos: ${formatAllowedValues(ALLOWED_PAYMENT_MODE_VALUES)}. Docs: ${DOCS_PAYMENTS_URL}`);
3810
+ if (!nextPaymentMode || !ALLOWED_PAYMENT_MODE_VALUES.has(nextPaymentMode) || nextPaymentMode === 'per_minute') {
3811
+ throw new CliError(`Modo de pagamento invalido: "${paymentMode}". Valores aceitos: ${formatAllowedValues(new Set(['hourly', 'monthly', 'yearly']))}. Docs: ${DOCS_PAYMENTS_URL}`);
3559
3812
  }
3560
3813
 
3561
3814
  if (!nextTypeProject || !ALLOWED_TYPE_PROJECT_VALUES.has(nextTypeProject)) {
@@ -3624,10 +3877,13 @@ function maskWizardSummary(value, key = '') {
3624
3877
 
3625
3878
  function printWizardSummary(payload) {
3626
3879
  const masked = maskWizardSummary(payload);
3880
+ const projectSummary = masked.config.type_project === 'job'
3881
+ ? ` projeto: ${masked.name} | plano: ${masked.plan}`
3882
+ : ` projeto: ${masked.name} | plano: ${masked.plan} | pagamento: ${masked.payment_mode}`;
3627
3883
  const lines = [
3628
3884
  '',
3629
3885
  tone('Resumo da criacao', 'green'),
3630
- ` projeto: ${masked.name} | plano: ${masked.plan} | pagamento: ${masked.payment_mode}`,
3886
+ projectSummary,
3631
3887
  ` tipo: ${masked.config.type_project}`,
3632
3888
  ];
3633
3889
 
@@ -3649,6 +3905,12 @@ function printWizardSummary(payload) {
3649
3905
  lines.push(` banco: versao ${masked.config.version} | instancias ${masked.config.instances} | storage ${masked.config.storage.capacity}Gi`);
3650
3906
  }
3651
3907
 
3908
+ if (masked.config.type_project === 'job') {
3909
+ lines.push(` Job: cron ${masked.config.job.cron} UTC | storage ${masked.config.storage.capacity}Gi (${masked.config.storage.persistent ? 'persistente' : 'efemero'})`);
3910
+ if (masked.config.image) lines.push(` imagem: ${masked.config.image.url} | publica: ${masked.config.image.is_public ? 'sim' : 'nao'}`);
3911
+ if (masked.config.github) lines.push(` github: ${masked.config.github.repository_owner}/${masked.config.github.repository_name}@${masked.config.github.branch}`);
3912
+ }
3913
+
3652
3914
  if (masked.config.type_project === 'valkey') {
3653
3915
  lines.push(` Valkey: perfil ${masked.config.profile} | versao ${masked.config.version}${masked.config.storage ? ` | storage ${masked.config.storage.capacity}Gi persistente` : ' | sem storage persistente'}`);
3654
3916
  }
@@ -4138,6 +4400,67 @@ async function buildValkeyConfig({ profile, plan, catalog }) {
4138
4400
  };
4139
4401
  }
4140
4402
 
4403
+ async function buildJobConfig() {
4404
+ const image = {
4405
+ url: await promptWizardText({
4406
+ label: 'Imagem OCI da tarefa',
4407
+ required: true,
4408
+ examples: ['ghcr.io/example/report:1.0.0', 'registry.example.com/team/job@sha256:digest'],
4409
+ docs: DOCS_CONFIGURATION_URL,
4410
+ validate: (value) => {
4411
+ const reference = value.trim();
4412
+ return reference.length >= OCI_IMAGE_REFERENCE_MIN_LENGTH
4413
+ && reference.length <= OCI_IMAGE_REFERENCE_MAX_LENGTH
4414
+ && OCI_IMAGE_REFERENCE_PATTERN.test(reference)
4415
+ ? null
4416
+ : 'use uma referencia OCI com registro e tag ou digest';
4417
+ },
4418
+ }),
4419
+ is_public: true,
4420
+ };
4421
+ const persistent = await promptWizardBoolean({
4422
+ label: 'Armazenamento persistente',
4423
+ docs: DOCS_CONFIGURATION_URL,
4424
+ examples: ['sim', 'nao'],
4425
+ });
4426
+ const storage = {
4427
+ persistent,
4428
+ capacity: await promptWizardNumber({
4429
+ label: 'Capacidade de armazenamento (Gi)',
4430
+ docs: DOCS_CONFIGURATION_URL,
4431
+ examples: ['1', '5', '10'],
4432
+ min: 1,
4433
+ max: 250,
4434
+ }),
4435
+ ...(persistent ? {
4436
+ dir_path_to_persist: await promptWizardText({
4437
+ label: 'Diretorio persistente',
4438
+ required: true,
4439
+ examples: ['/data'],
4440
+ docs: DOCS_CONFIGURATION_URL,
4441
+ }),
4442
+ } : {}),
4443
+ };
4444
+ const envs = await promptWizardPairs({
4445
+ introLabel: 'Deseja adicionar variaveis de ambiente?',
4446
+ docs: DOCS_CONFIGURATION_URL,
4447
+ examples: ['sim', 'nao'],
4448
+ stateKey: 'jobEnvs',
4449
+ itemLabels: [
4450
+ { key: 'name', label: 'Nome da env', required: true, examples: ['MODE'], docs: DOCS_CONFIGURATION_URL },
4451
+ { key: 'value', label: 'Valor da env', required: true, examples: ['daily'], docs: DOCS_CONFIGURATION_URL },
4452
+ ],
4453
+ });
4454
+ const cron = await promptWizardText({
4455
+ label: 'Cron em UTC (cinco campos)',
4456
+ required: true,
4457
+ examples: ['0 * * * *'],
4458
+ docs: DOCS_CONFIGURATION_URL,
4459
+ validate: (value) => value.split(/\s+/).filter(Boolean).length === 5 ? null : 'use exatamente cinco campos em UTC',
4460
+ });
4461
+ return { type_project: 'job', image, envs, storage, job: { cron } };
4462
+ }
4463
+
4141
4464
  async function runProjectCreateWizard(session, flags) {
4142
4465
  const catalogs = await fetchWizardCatalogs(session, flags);
4143
4466
  const ui = createWizardUi();
@@ -4166,11 +4489,15 @@ async function runProjectCreateWizard(session, flags) {
4166
4489
  docs: DOCS_CONFIGURATION_URL,
4167
4490
  examples: WIZARD_TYPE_PROJECT_OPTIONS,
4168
4491
  options: WIZARD_TYPE_PROJECT_OPTIONS.map((value) => ({ value })),
4492
+ extraValues: ['job'],
4169
4493
  });
4170
4494
  ui.state.typeProject = typeProject;
4171
4495
  const valkeyCatalog = typeProject === 'valkey'
4172
4496
  ? requireValkeyCatalog(unwrapData(await request(session, flags, 'GET', '/managed-services/catalog')))
4173
4497
  : undefined;
4498
+ const jobPlans = typeProject === 'job'
4499
+ ? requireJobPlans(unwrapData(await request(session, flags, 'GET', '/project/job/plans')))
4500
+ : undefined;
4174
4501
  const profile = typeProject === 'valkey'
4175
4502
  ? await promptWizardSelect({
4176
4503
  label: 'Perfil Valkey',
@@ -4188,6 +4515,8 @@ async function runProjectCreateWizard(session, flags) {
4188
4515
  ? valkeyCatalog.plans
4189
4516
  .filter((planDefinition) => planDefinition.profile === profile)
4190
4517
  .map((planDefinition) => planDefinition.id)
4518
+ : typeProject === 'job'
4519
+ ? sortWizardOptions(jobPlans.map((plan) => plan?.plan || plan?.id).filter(Boolean), WIZARD_JOB_PLAN_OPTIONS)
4191
4520
  : sortWizardOptions(catalogs.databasePlans
4192
4521
  .map((plan) => plan?.plan)
4193
4522
  .filter((plan) => WIZARD_DATABASE_PLAN_OPTIONS.includes(plan) && (typeProject !== 'mariadb' || plan !== 'db-free')), WIZARD_DATABASE_PLAN_OPTIONS);
@@ -4195,7 +4524,8 @@ async function runProjectCreateWizard(session, flags) {
4195
4524
  label: 'Plano',
4196
4525
  docs: typeProject === 'http'
4197
4526
  ? DOCS_CREATE_HTTP_URL
4198
- : typeProject === 'valkey' ? DOCS_CREATE_VALKEY_URL : DOCS_DATABASE_CONFIGURATION_URL,
4527
+ : typeProject === 'valkey' ? DOCS_CREATE_VALKEY_URL
4528
+ : typeProject === 'job' ? DOCS_CONFIGURATION_URL : DOCS_DATABASE_CONFIGURATION_URL,
4199
4529
  examples: [planOptions[0], planOptions.at(-1)],
4200
4530
  options: planOptions.map((value) => ({ value })),
4201
4531
  });
@@ -4205,7 +4535,7 @@ async function runProjectCreateWizard(session, flags) {
4205
4535
  ui.state.planAllowsSubdomain = isSubdomainCustomizable(catalogs.httpPlans, plan);
4206
4536
  ui.state.planAllowsAutoscaling = isAutoscalingEnabledForPlan(catalogs.httpPlans, plan);
4207
4537
  }
4208
- const paymentMode = await promptWizardSelect({
4538
+ const paymentMode = typeProject === 'job' ? 'per_minute' : await promptWizardSelect({
4209
4539
  label: 'Modo de pagamento',
4210
4540
  docs: DOCS_PAYMENTS_URL,
4211
4541
  examples: WIZARD_PAYMENT_MODE_OPTIONS,
@@ -4223,6 +4553,8 @@ async function runProjectCreateWizard(session, flags) {
4223
4553
  config = await buildPostgresqlConfig({ plan, databasePlans: catalogs.databasePlans });
4224
4554
  } else if (typeProject === 'valkey') {
4225
4555
  config = await buildValkeyConfig({ profile, plan, catalog: valkeyCatalog });
4556
+ } else if (typeProject === 'job') {
4557
+ config = await buildJobConfig();
4226
4558
  } else {
4227
4559
  config = await buildMariadbConfig();
4228
4560
  }
@@ -4259,13 +4591,15 @@ async function handleProjectCreate(session, flags) {
4259
4591
  const name = wizardPayload?.name || flags.name || await prompt('Nome do projeto');
4260
4592
  const description = wizardPayload?.description || flags.description;
4261
4593
  const plan = wizardPayload?.plan || flags.plan || await prompt('Plano');
4262
- const paymentMode = wizardPayload?.payment_mode || flags.paymentMode || await prompt('Modo de pagamento');
4263
4594
  const config = wizardPayload?.config || await parseConfig(flags.config || await prompt('Config JSON ou @arquivo'));
4264
4595
  const idempotencyKey = flags.idempotencyKey === undefined ? undefined : validateIdempotencyKey(flags.idempotencyKey);
4265
4596
  if (Array.isArray(config?.custom_domains)) {
4266
4597
  config.custom_domains = normalizeCustomDomains(config.custom_domains, { primaryDomain: config.domain });
4267
4598
  }
4268
4599
  const typeProject = normalizeTypeProject(config?.type_project);
4600
+ const paymentMode = typeProject === 'job'
4601
+ ? flags.paymentMode || 'per_minute'
4602
+ : wizardPayload?.payment_mode || flags.paymentMode || await prompt('Modo de pagamento');
4269
4603
  const valkeyCatalog = typeProject === 'valkey'
4270
4604
  ? requireValkeyCatalog(unwrapData(await request(session, flags, 'GET', '/managed-services/catalog')))
4271
4605
  : undefined;
@@ -4289,14 +4623,16 @@ async function handleProjectCreate(session, flags) {
4289
4623
  },
4290
4624
  });
4291
4625
 
4292
- if (flags.json) return printJson(payload);
4626
+ if (flags.json) {
4627
+ return printJson(validated.config.type_project === 'job' ? sanitizeJobProjectResponse(payload) : payload);
4628
+ }
4293
4629
  if (wizardPayload) {
4294
4630
  closePromptResources();
4295
4631
  }
4296
4632
  const project = unwrapData(payload);
4297
4633
  const rows = [
4298
4634
  { field: 'Projeto', value: projectIdOf(project) || 'id indisponivel' },
4299
- ...(project.domain ? [{ field: 'Dominio', value: formatPublicUrl(project.domain) }] : []),
4635
+ ...(validated.config.type_project === 'job' ? [] : project.domain ? [{ field: 'Dominio', value: formatPublicUrl(project.domain) }] : []),
4300
4636
  ...(project.api_key ? [{ field: 'API key', value: project.api_key }] : []),
4301
4637
  ];
4302
4638
  if (validated.config.type_project === 'valkey') {
@@ -4331,7 +4667,9 @@ async function handleProjectInfo(session, flags) {
4331
4667
  const orgId = await resolveOrgId(session, flags);
4332
4668
  const project = await getProject(session, flags, projectId, orgId);
4333
4669
 
4334
- if (flags.json) return printJson(projectInfoForOutput(project));
4670
+ if (flags.json) return printJson(projectInfoForOutput(
4671
+ isJobProject(project) ? sanitizePublicProject(project) : project,
4672
+ ));
4335
4673
  printProject({ ...project, id: projectId });
4336
4674
  }
4337
4675
 
@@ -4544,6 +4882,231 @@ async function handleProjectLogs(session, flags) {
4544
4882
  printLogs(logs, flags);
4545
4883
  }
4546
4884
 
4885
+ const PUBLIC_JOB_RUN_KEYS = [
4886
+ 'status',
4887
+ 'scheduled_at',
4888
+ 'started_at',
4889
+ 'finished_at',
4890
+ 'duration_seconds',
4891
+ 'billed_minutes',
4892
+ 'plan',
4893
+ 'currency',
4894
+ 'amount',
4895
+ ];
4896
+
4897
+ function sanitizeJobRun(run) {
4898
+ if (!run || typeof run !== 'object' || Array.isArray(run)) return {};
4899
+ const output = {};
4900
+ const id = run.id || run._id || run.run_id;
4901
+ if (id !== undefined) output.id = id;
4902
+ for (const key of PUBLIC_JOB_RUN_KEYS) {
4903
+ if (run[key] !== undefined) output[key] = run[key];
4904
+ }
4905
+ return output;
4906
+ }
4907
+
4908
+ function jobRunsDataOf(data) {
4909
+ if (Array.isArray(data)) return { runs: data.map(sanitizeJobRun), pagination: null };
4910
+ if (!data || typeof data !== 'object') return { runs: [], pagination: null };
4911
+ return {
4912
+ runs: Array.isArray(data.runs) ? data.runs.map(sanitizeJobRun) : [],
4913
+ pagination: data.pagination ?? null,
4914
+ };
4915
+ }
4916
+
4917
+ function sanitizeJobRunResponse(data) {
4918
+ if (data && typeof data === 'object' && !Array.isArray(data) && data.run !== undefined) {
4919
+ return { run: sanitizeJobRun(data.run) };
4920
+ }
4921
+ return sanitizeJobRun(data);
4922
+ }
4923
+
4924
+ function formatJobRunDuration(durationSeconds) {
4925
+ if (durationSeconds === undefined || durationSeconds === null || !Number.isFinite(Number(durationSeconds))) return '-';
4926
+ return `${Number(Number(durationSeconds).toFixed(1))} s`;
4927
+ }
4928
+
4929
+ function formatJobRunAmount(run) {
4930
+ const amount = run.amount;
4931
+ if (amount === undefined || amount === null) return '-';
4932
+ return formatJobBrlFromCents(amount);
4933
+ }
4934
+
4935
+ function printJobRuns(data) {
4936
+ const runs = asArray(data?.runs);
4937
+ printTable(runs, [
4938
+ { label: 'ID', value: (run) => run.id || '-' },
4939
+ { label: 'Status', value: (run) => run.status || '-' },
4940
+ { label: 'Agendada', value: (run) => run.scheduled_at || '-' },
4941
+ { label: 'Iniciada', value: (run) => run.started_at || '-' },
4942
+ { label: 'Finalizada', value: (run) => run.finished_at || '-' },
4943
+ { label: 'Duracao', value: (run) => formatJobRunDuration(run.duration_seconds) },
4944
+ { label: 'Minutos cobrados', value: (run) => run.billed_minutes ?? '-' },
4945
+ { label: 'Valor', value: formatJobRunAmount },
4946
+ ]);
4947
+ printPagination(data?.pagination, 'execucao');
4948
+ }
4949
+
4950
+ function sanitizeJobRunLogEntries(logs) {
4951
+ if (!Array.isArray(logs)) return logs;
4952
+ return logs.map((entry) => {
4953
+ if (typeof entry === 'string') return entry;
4954
+ if (!entry || typeof entry !== 'object') return String(entry ?? '');
4955
+ return Object.fromEntries(['sequence', 'timestamp', 'level', 'message']
4956
+ .filter((key) => entry[key] !== undefined)
4957
+ .map((key) => [key, entry[key]]));
4958
+ });
4959
+ }
4960
+
4961
+ function sanitizeJobRunLogs(data) {
4962
+ if (typeof data === 'string') return data;
4963
+ if (Array.isArray(data)) return sanitizeJobRunLogEntries(data);
4964
+ if (!data || typeof data !== 'object') return data;
4965
+ const output = {};
4966
+ for (const key of ['logs', 'next_cursor', 'truncated', 'finished', 'status']) {
4967
+ if (data[key] !== undefined) output[key] = key === 'logs' ? sanitizeJobRunLogEntries(data[key]) : data[key];
4968
+ }
4969
+ return output;
4970
+ }
4971
+
4972
+ function skipTerminalString(text, index) {
4973
+ while (index < text.length) {
4974
+ const code = text.charCodeAt(index);
4975
+ if (code === 0x07 || code === 0x9c) return index + 1;
4976
+ if (code === 0x1b && text.charCodeAt(index + 1) === 0x5c) return index + 2;
4977
+ index += 1;
4978
+ }
4979
+ return index;
4980
+ }
4981
+
4982
+ function skipTerminalCsi(text, index) {
4983
+ while (index < text.length) {
4984
+ const code = text.charCodeAt(index);
4985
+ index += 1;
4986
+ if (code >= 0x40 && code <= 0x7e) break;
4987
+ }
4988
+ return index;
4989
+ }
4990
+
4991
+ function skipTerminalEscape(text, index) {
4992
+ const next = text.charCodeAt(index);
4993
+ if (next >= 0x30 && next <= 0x7e) return index + 1;
4994
+ while (index < text.length && text.charCodeAt(index) >= 0x20 && text.charCodeAt(index) <= 0x2f) {
4995
+ index += 1;
4996
+ }
4997
+ if (index < text.length && text.charCodeAt(index) >= 0x30 && text.charCodeAt(index) <= 0x7e) {
4998
+ return index + 1;
4999
+ }
5000
+ return index;
5001
+ }
5002
+
5003
+ function stripTerminalControls(value) {
5004
+ const text = String(value ?? '');
5005
+ let output = '';
5006
+ let index = 0;
5007
+
5008
+ while (index < text.length) {
5009
+ const code = text.charCodeAt(index);
5010
+ if (code === 0x1b) {
5011
+ const next = text.charCodeAt(index + 1);
5012
+ if (next === 0x5d) {
5013
+ index = skipTerminalString(text, index + 2);
5014
+ } else if (next === 0x50 || next === 0x58 || next === 0x5e || next === 0x5f) {
5015
+ index = skipTerminalString(text, index + 2);
5016
+ } else if (next === 0x5b) {
5017
+ index = skipTerminalCsi(text, index + 2);
5018
+ } else {
5019
+ index = skipTerminalEscape(text, index + 1);
5020
+ }
5021
+ continue;
5022
+ }
5023
+ if (code === 0x9b) {
5024
+ index = skipTerminalCsi(text, index + 1);
5025
+ continue;
5026
+ }
5027
+ if (code === 0x9d || code === 0x90 || code === 0x98 || code === 0x9e || code === 0x9f) {
5028
+ index = skipTerminalString(text, index + 1);
5029
+ continue;
5030
+ }
5031
+ if ((code <= 0x1f && code !== 0x09 && code !== 0x0a) || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
5032
+ index += 1;
5033
+ continue;
5034
+ }
5035
+ output += text[index];
5036
+ index += 1;
5037
+ }
5038
+
5039
+ return output;
5040
+ }
5041
+
5042
+ function printHumanJobRunLogs(logs) {
5043
+ if (Array.isArray(logs)) {
5044
+ for (const log of logs) {
5045
+ const line = typeof log === 'string' ? log : buildLogLineOf(log);
5046
+ process.stdout.write(`${stripTerminalControls(line)}\n`);
5047
+ }
5048
+ return;
5049
+ }
5050
+ const value = logs || '';
5051
+ process.stdout.write(`${stripTerminalControls(value)}${String(value).endsWith('\n') ? '' : '\n'}`);
5052
+ }
5053
+
5054
+ async function handleProjectRuns(session, flags) {
5055
+ const projectId = requireProjectId(flags, 'project runs');
5056
+ if (!projectId) return;
5057
+ validateProjectListFlags(flags, { maxLimit: 50 });
5058
+ const orgId = await resolveOrgId(session, flags);
5059
+ const query = buildQuery(flags, ['page', 'limit']);
5060
+ const data = jobRunsDataOf(unwrapData(await request(session, flags, 'GET', `/project/${projectId}/job-runs${query}`, { orgId })));
5061
+
5062
+ if (flags.json) return printJson(data);
5063
+ printJobRuns(data);
5064
+ }
5065
+
5066
+ async function handleProjectRunCancel(session, flags) {
5067
+ const projectId = requireProjectId(flags, 'project runs cancel');
5068
+ if (!projectId) return;
5069
+ if (!flags.run && !flags.runId || flags.run === true || flags.runId === true) {
5070
+ printCommandHelpAndFail('project runs cancel');
5071
+ return;
5072
+ }
5073
+ const runId = String(flags.run || flags.runId);
5074
+ const orgId = await resolveOrgId(session, flags);
5075
+ const data = unwrapData(await request(
5076
+ session,
5077
+ flags,
5078
+ 'POST',
5079
+ `/project/${projectId}/job-runs/${encodeURIComponent(runId)}/cancel`,
5080
+ { orgId, body: {} },
5081
+ ));
5082
+
5083
+ if (flags.json) return printJson(sanitizeJobRunResponse(data));
5084
+ const run = data?.run || data;
5085
+ process.stdout.write(`Execucao: ${run?.status || 'cancelled'}\n`);
5086
+ }
5087
+
5088
+ async function handleProjectRunLogs(session, flags) {
5089
+ const projectId = requireProjectId(flags, 'project runs logs');
5090
+ if (!projectId) return;
5091
+ if (!flags.run && !flags.runId || flags.run === true || flags.runId === true) {
5092
+ printCommandHelpAndFail('project runs logs');
5093
+ return;
5094
+ }
5095
+ const runId = String(flags.run || flags.runId);
5096
+ const orgId = await resolveOrgId(session, flags);
5097
+ const data = sanitizeJobRunLogs(unwrapData(await request(
5098
+ session,
5099
+ flags,
5100
+ 'GET',
5101
+ `/project/${projectId}/job-runs/${encodeURIComponent(runId)}/logs`,
5102
+ { orgId },
5103
+ )));
5104
+
5105
+ if (flags.json) return printJson(data);
5106
+ const logs = typeof data === 'object' && data !== null && !Array.isArray(data) ? data.logs : data;
5107
+ printHumanJobRunLogs(logs);
5108
+ }
5109
+
4547
5110
  async function handleProjectMetricsCapabilities(session, flags) {
4548
5111
  const projectId = requireProjectId(flags, 'project metrics capabilities');
4549
5112
  if (!projectId) return;
@@ -5536,6 +6099,10 @@ async function main() {
5536
6099
  if (command === 'project' && subcommand === 'resume') return handleProjectLifecycleMutation(session, flags, 'resume');
5537
6100
  if (command === 'project' && subcommand === 'delete') return handleProjectDelete(session, flags);
5538
6101
  if (command === 'project' && subcommand === 'url') return handleProjectUrl(session, flags);
6102
+ if (command === 'project' && subcommand === 'runs' && positional[2] === 'cancel') return handleProjectRunCancel(session, flags);
6103
+ if (command === 'project' && subcommand === 'runs' && positional[2] === 'logs') return handleProjectRunLogs(session, flags);
6104
+ if (command === 'project' && subcommand === 'runs') return handleProjectRuns(session, flags);
6105
+ if (command === 'project' && subcommand === 'run' && positional[2] === 'logs') return handleProjectRunLogs(session, flags);
5539
6106
  if (command === 'project' && subcommand === 'logs') return handleProjectLogs(session, flags);
5540
6107
  if (command === 'project' && subcommand === 'metrics' && positional[2] === 'capabilities') return handleProjectMetricsCapabilities(session, flags);
5541
6108
  if (command === 'project' && subcommand === 'metrics') return handleProjectMetrics(session, flags);
@@ -0,0 +1,20 @@
1
+ {
2
+ "type_project": "job",
3
+ "image": {
4
+ "url": "ghcr.io/example/report:1.0.0",
5
+ "is_public": true
6
+ },
7
+ "envs": [
8
+ {
9
+ "name": "MODE",
10
+ "value": "daily"
11
+ }
12
+ ],
13
+ "storage": {
14
+ "persistent": false,
15
+ "capacity": 1
16
+ },
17
+ "job": {
18
+ "cron": "0 3 * * *"
19
+ }
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenifra/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Official Zenifra command line interface.",
5
5
  "type": "module",
6
6
  "license": "MIT",