@zenifra/cli 0.4.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/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',
@@ -37,6 +40,7 @@ const KNOWN_FLAG_NAMES = new Set([
37
40
  'challengeToken',
38
41
  'code',
39
42
  'connectionFile',
43
+ 'connection',
40
44
  'commitSha',
41
45
  'config',
42
46
  'count',
@@ -71,7 +75,10 @@ const KNOWN_FLAG_NAMES = new Set([
71
75
  'plan',
72
76
  'profile',
73
77
  'project',
78
+ 'repository',
74
79
  'revoke',
80
+ 'run',
81
+ 'runId',
75
82
  'showValues',
76
83
  'status',
77
84
  'tagPattern',
@@ -113,8 +120,8 @@ const ALLOWED_PLAN_VALUES = new Set([
113
120
  'queue-premium',
114
121
  'queue-enterprise',
115
122
  ]);
116
- const ALLOWED_PAYMENT_MODE_VALUES = new Set(['hourly', 'monthly', 'yearly']);
117
- 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']);
118
125
  const ALLOWED_RUNTIME_VALUES = new Set(['nodejs', 'python']);
119
126
  const ALLOWED_GITHUB_VERSION_DEPLOY_EVENTS = new Set(['tag', 'release']);
120
127
  const ALLOWED_GITHUB_VERSION_DEPLOY_FIELDS = new Set(['enabled', 'event', 'tag_pattern', 'include_prereleases']);
@@ -131,6 +138,15 @@ const GITHUB_PUBLIC_CONFIG_FIELDS = [
131
138
  'build_command',
132
139
  ];
133
140
  const GITHUB_PUBLIC_VERSION_DEPLOY_FIELDS = ['enabled', 'event', 'tag_pattern', 'include_prereleases'];
141
+ const GIT_BUILD_SETTINGS_FIELDS = [
142
+ 'runtime',
143
+ 'version',
144
+ 'start_command',
145
+ 'pre_build_command',
146
+ 'build_command',
147
+ 'dockerfile_path',
148
+ 'context_path',
149
+ ];
134
150
  const ALLOWED_EXPOSURE_VALUES = new Set(['public', 'private']);
135
151
  const GITHUB_RUNTIME_VERSIONS = {
136
152
  nodejs: ['24', '22', '20'],
@@ -160,6 +176,7 @@ const WIZARD_DATABASE_PLAN_OPTIONS = [
160
176
  'db-free',
161
177
  ];
162
178
  const WIZARD_PAYMENT_MODE_OPTIONS = ['hourly', 'monthly', 'yearly'];
179
+ const WIZARD_JOB_PLAN_OPTIONS = WIZARD_HTTP_PLAN_OPTIONS.map((plan) => `job-${plan}`);
163
180
  const WIZARD_TYPE_PROJECT_OPTIONS = ['http', 'postgresql', 'mariadb', 'valkey'];
164
181
  const WIZARD_VALKEY_PROFILE_OPTIONS = ['key_value', 'cache', 'queue'];
165
182
  const WIZARD_HTTP_EXPOSURE_OPTIONS = ['public', 'private'];
@@ -177,9 +194,10 @@ const ANSI = {
177
194
  };
178
195
 
179
196
  class CliError extends Error {
180
- constructor(message, exitCode = 1) {
197
+ constructor(message, exitCode = 1, httpStatus) {
181
198
  super(message);
182
199
  this.exitCode = exitCode;
200
+ if (httpStatus !== undefined) this.httpStatus = httpStatus;
183
201
  }
184
202
  }
185
203
 
@@ -200,10 +218,15 @@ Usage:
200
218
  zenifra whoami [--json]
201
219
  zenifra orgs [--json]
202
220
  zenifra org set [--org <id>]
203
- zenifra plans [--type <all|http|database|storage|valkey>] [--json]
221
+ zenifra plans [--type <all|http|database|storage|valkey|job>] [--json]
222
+ zenifra git providers [--json]
223
+ zenifra git runtimes [--json]
224
+ zenifra git connections [--json]
225
+ zenifra git repositories resolve --connection <id> --path <owner/repo> [--json]
226
+ zenifra git branches --connection <id> --repository <opaque-id> [--json]
204
227
  zenifra create project
205
- zenifra create project --name <name> --plan <plan> --payment-mode <mode> --config <json|@file> [--description <text>] [--org <id>] [--json]
206
- 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>]
207
230
  zenifra valkey status --project <id> [--json]
208
231
  zenifra valkey connection --project <id> [--json]
209
232
  zenifra valkey credentials rotate --project <id> [--idempotency-key <key>] [--wait] [--interval <seconds>] [--timeout <seconds>] [--json]
@@ -214,6 +237,9 @@ Usage:
214
237
  zenifra project delete --project <id> --yes [--json]
215
238
  zenifra project url --project <id> [--json]
216
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]
217
243
  zenifra project metrics --project <id> [--instance <id>] [--json]
218
244
  zenifra project metrics capabilities --project <id> [--json]
219
245
  zenifra project healthcheck get --project <id> [--json]
@@ -224,6 +250,9 @@ Usage:
224
250
  zenifra project image set --project <id> --image <image> [--json]
225
251
  zenifra project github --project <id> [--json]
226
252
  zenifra project github deploy-settings set --project <id> --mode <manual|branch|tag|release> [--tag-pattern <pattern>] [--include-prereleases <true|false>] [--json]
253
+ zenifra project source --project <id> [--json]
254
+ zenifra project source branches --project <id> [--json]
255
+ zenifra project source deploy-settings set --project <id> --mode <manual|branch|tag|release> [--tag-pattern <pattern>] [--include-prereleases <true|false>] [--json]
227
256
  zenifra project exposure set --project <id> --exposure <public|private> [--json]
228
257
  zenifra project envs --project <id> [--json] [--show-values]
229
258
  zenifra project env add --project <id> --name <name> --value <value> [--json]
@@ -393,13 +422,75 @@ const HELP_SPECS = [
393
422
  },
394
423
  {
395
424
  command: 'plans',
396
- usage: 'zenifra plans [--type <all|http|database|storage|valkey>] [--json]',
397
- description: 'Lista os catalogos publicos de preco de planos HTTP, banco, armazenamento e Valkey.',
398
- flags: ['--type <type> Filtra o catalogo: all, http, database, storage, valkey, key-value, cache ou queue.', '--json Imprime a resposta em JSON.'],
399
- 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'],
400
429
  output: 'HTTP\nPlano Hora Mes Ano Recursos\nfree R$ 0,00 R$ 0,00 R$ 0,00 1 GB Armazenamento Efemero',
401
430
  jsonOutput: '{"http":[{"plan":"free","prices":{"hourly":0,"monthly":0,"yearly":0},"features":["1 GB Armazenamento Efemero"]}],"database":[],"storage":[]}',
402
431
  },
432
+ {
433
+ command: 'git',
434
+ usage: 'zenifra git\n zenifra git providers [--json]\n zenifra git runtimes [--json]\n zenifra git connections [--json]\n zenifra git repositories resolve --connection <id> --path <owner/repo> [--json]\n zenifra git branches --connection <id> --repository <opaque-id> [--json]',
435
+ description: 'Consulta provedores, runtimes, conexoes e repositorios Git disponiveis na organizacao.',
436
+ examples: ['zenifra git providers', 'zenifra git connections', 'zenifra git repositories resolve --connection conn-1 --path equipe/aplicacao'],
437
+ output: 'Zenifra CLI - git',
438
+ notes: ['A conexao Forgejo e criada no Console por uma pessoa proprietaria da organizacao. A CLI lista e usa conexoes existentes.'],
439
+ },
440
+ {
441
+ command: 'git providers',
442
+ usage: 'zenifra git providers [--json]',
443
+ description: 'Lista provedores Git e as capacidades disponiveis para a organizacao selecionada.',
444
+ flags: ['--json Imprime a resposta publica da API em JSON.'],
445
+ examples: ['zenifra git providers', 'zenifra git providers --json'],
446
+ output: 'Provedor Disponivel Descoberta Deploy por branch Deploy por versao\nforgejo sim nao sim sim',
447
+ jsonOutput: '{"status":"success","data":{"api_version":1,"providers":[{"id":"forgejo","available":true,"capabilities":{"repositoryDiscovery":false,"pushDeploy":true,"nativePreviews":false,"versionDeploy":true}}]}}',
448
+ },
449
+ {
450
+ command: 'git runtimes',
451
+ usage: 'zenifra git runtimes [--json]',
452
+ description: 'Lista runtimes e versoes disponiveis para builds de repositorios Git.',
453
+ flags: ['--json Imprime a resposta publica da API em JSON.'],
454
+ examples: ['zenifra git runtimes', 'zenifra git runtimes --json'],
455
+ output: 'Runtime Versao padrao Versoes\nnodejs 24 24, 22, 20',
456
+ jsonOutput: '{"status":"success","data":[{"id":"nodejs","label":"Node.js","default_version":"24","default_port":3000,"default_start_command":"npm start","default_build_command":"npm run build","versions":[{"id":"24","label":"24"}]}]}',
457
+ },
458
+ {
459
+ command: 'git connections',
460
+ usage: 'zenifra git connections [--json]',
461
+ description: 'Lista as conexoes Git disponiveis para a organizacao selecionada.',
462
+ flags: ['--json Imprime as conexoes publicas em JSON.'],
463
+ examples: ['zenifra git connections', 'zenifra git connections --json'],
464
+ output: 'ID Provedor Nome Status\nconnection-1 forgejo Forgejo team active',
465
+ jsonOutput: '{"status":"success","data":[{"id":"connection-1","provider_id":"forgejo","display_name":"Forgejo team","status":"active","capabilities":{"repositoryDiscovery":false,"pushDeploy":true,"nativePreviews":false,"versionDeploy":true}}]}',
466
+ notes: ['A conexao precisa ter sido criada por uma pessoa proprietaria no Console. As credenciais nao aparecem na resposta.'],
467
+ },
468
+ {
469
+ command: 'git repositories',
470
+ usage: 'zenifra git repositories resolve --connection <id> --path <owner/repo> [--json]',
471
+ description: 'Agrupa comandos para resolver repositorios por caminho explicito.',
472
+ examples: ['zenifra git repositories resolve --connection conn-1 --path equipe/aplicacao'],
473
+ output: 'Zenifra CLI - git repositories',
474
+ },
475
+ {
476
+ command: 'git repositories resolve',
477
+ usage: 'zenifra git repositories resolve --connection <id> --path <owner/repo> [--json]',
478
+ description: 'Valida e resolve o caminho explicito de um repositorio na conexao selecionada.',
479
+ flags: ['--connection <id> ID da conexao Git da organizacao.', '--path <owner/repo> Caminho explicito do repositorio.', '--json Imprime o resultado em JSON.'],
480
+ examples: ['zenifra git repositories resolve --connection conn-1 --path equipe/aplicacao --json'],
481
+ output: 'ID Caminho Branch padrao Privado\nrepo_opaque_1 equipe/aplicacao main sim',
482
+ jsonOutput: '{"status":"success","data":{"id":"repository-id","path":"equipe/aplicacao","default_branch":"main","private":true}}',
483
+ notes: ['Use o ID opaco retornado somente com a mesma conexao.'],
484
+ },
485
+ {
486
+ command: 'git branches',
487
+ usage: 'zenifra git branches --connection <id> --repository <opaque-id> [--json]',
488
+ description: 'Lista branches de um repositorio resolvido pela conexao informada.',
489
+ flags: ['--connection <id> ID da conexao Git da organizacao.', '--repository <id> ID opaco retornado por git repositories resolve.', '--json Imprime a resposta em JSON.'],
490
+ examples: ['zenifra git branches --connection conn-1 --repository repository-id'],
491
+ output: 'Branch Commit\nmain abc123',
492
+ jsonOutput: '{"status":"success","data":[{"name":"main","commit_sha":"<commit-sha>"}]}',
493
+ },
403
494
  {
404
495
  command: 'valkey',
405
496
  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]',
@@ -451,7 +542,7 @@ const HELP_SPECS = [
451
542
  },
452
543
  {
453
544
  command: 'projects',
454
- 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>]',
455
546
  description: 'Lista projetos da organizacao ativa ou da API key.',
456
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.'],
457
548
  examples: ['zenifra projects --type http --page 1 --limit 15', 'zenifra projects --json'],
@@ -460,9 +551,9 @@ const HELP_SPECS = [
460
551
  },
461
552
  {
462
553
  command: 'create project',
463
- 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]',
464
555
  description: 'Cria um projeto via wizard interativo ou via payload de configuracao JSON.',
465
- 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.'],
466
557
  examples: ['zenifra create project', 'zenifra create project --name api-web --plan free --payment-mode hourly --config @examples/http-project.json'],
467
558
  output: 'Campo Valor\n------- --------------------------------------\nProjeto 507f1f77bcf86cd799439012\nDominio https://api-web.clients.zenifra.com',
468
559
  jsonOutput: '{"status":"success","data":{"id":"507f1f77bcf86cd799439012","name":"api-web"}}',
@@ -470,11 +561,61 @@ const HELP_SPECS = [
470
561
  },
471
562
  {
472
563
  command: 'project',
473
- usage: 'zenifra project\n zenifra project info --project <id> [--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]',
474
565
  description: 'Agrupa comandos operacionais e de introspecao sobre um projeto especifico.',
475
- examples: ['zenifra project', 'zenifra project info --project proj_1', 'zenifra project env add --project proj_1 --name NODE_ENV --value production'],
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'],
476
567
  output: 'Zenifra CLI - project',
477
- notes: ['Use "zenifra help project <subcomando>" para detalhes de info, stop, resume, delete, url, logs, metrics, capabilities, network, image, github, 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.'],
569
+ },
570
+ {
571
+ command: 'project source',
572
+ usage: '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]',
573
+ description: 'Consulta a origem Git generica de um projeto, lista branches ou altera seu modo de deploy.',
574
+ examples: ['zenifra project source --project proj_1', 'zenifra project source branches --project proj_1'],
575
+ output: 'Zenifra CLI - project source',
576
+ notes: ['Use project github para ler ou alterar configuracoes GitHub legadas.'],
577
+ },
578
+ {
579
+ command: 'project source branches',
580
+ usage: 'zenifra project source branches --project <id> [--json]',
581
+ description: 'Lista branches disponiveis para a origem Git generica atual do projeto.',
582
+ flags: ['--project <id> ID do projeto.', '--json Imprime a resposta em JSON.'],
583
+ examples: ['zenifra project source branches --project proj_1'],
584
+ output: 'Branch Commit\nmain abc123',
585
+ jsonOutput: '{"status":"success","data":[{"name":"main","commit_sha":"<commit-sha>"}]}',
586
+ },
587
+ {
588
+ command: 'project source deploy-settings',
589
+ usage: 'zenifra project source deploy-settings set --project <id> --mode <manual|branch|tag|release> [--tag-pattern <pattern>] [--include-prereleases <true|false>] [--json]',
590
+ description: 'Agrupa os modos de deploy da origem Git generica.',
591
+ examples: ['zenifra project source deploy-settings set --project proj_1 --mode branch'],
592
+ output: 'Zenifra CLI - project source deploy-settings',
593
+ },
594
+ {
595
+ command: 'project source deploy-settings set',
596
+ usage: 'zenifra project source deploy-settings set --project <id> --mode <manual|branch|tag|release> [--tag-pattern <pattern>] [--include-prereleases <true|false>] [--json]',
597
+ description: 'Define e confirma o modo de deploy da origem Git Forgejo do projeto.',
598
+ flags: [
599
+ '--project <id> ID do projeto.',
600
+ '--mode <manual|branch|tag|release> Modo de deploy exclusivo.',
601
+ '--tag-pattern <pattern> Obrigatorio para tag e release; aceita * e ?.',
602
+ '--include-prereleases <true|false> Inclui prereleases somente no modo release. Padrao: false.',
603
+ '--json Imprime a configuracao confirmada em JSON.',
604
+ ],
605
+ examples: [
606
+ 'zenifra project source deploy-settings set --project proj_1 --mode manual',
607
+ 'zenifra project source deploy-settings set --project proj_1 --mode branch',
608
+ 'zenifra project source deploy-settings set --project proj_1 --mode tag --tag-pattern "v*"',
609
+ 'zenifra project source deploy-settings set --project proj_1 --mode release --tag-pattern "v*" --include-prereleases true',
610
+ ],
611
+ output: 'Configuracao de deploy da origem Git confirmada: release (v*).',
612
+ jsonOutput: '{"status":"success","data":{"source":{"connection_id":"connection-id","repository_id":"repository-id","branch":"main","auto_deploy":false,"version_deploy":{"enabled":true,"event":"release","tag_pattern":"v*","include_prereleases":true},"provider_id":"forgejo","repository_path":"equipe/aplicacao"},"build":{"runtime":"nodejs","version":"24","start_command":"npm start"},"source_revision":2}}',
613
+ notes: [
614
+ 'Tag e release exigem --tag-pattern; prereleases so podem ser habilitadas para release.',
615
+ 'O comando preserva identidade, branch e configuracao de build da origem e confirma o resultado com uma nova leitura.',
616
+ 'A API substitui source e build juntos e nao oferece precondicao de revisao. Evite alterar a origem ao mesmo tempo por outro cliente.',
617
+ 'Para projetos com origem GitHub legada, use "zenifra project github deploy-settings set".',
618
+ ],
478
619
  },
479
620
  {
480
621
  command: 'project github',
@@ -566,6 +707,38 @@ const HELP_SPECS = [
566
707
  jsonOutput: '"server started\\nGET /health 200"',
567
708
  notes: ['Nao faz streaming continuo; retorna o snapshot disponibilizado pela API.'],
568
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
+ },
569
742
  {
570
743
  command: 'project metrics',
571
744
  usage: 'zenifra project metrics --project <id> [--instance <id>] [--json]',
@@ -770,7 +943,7 @@ const HELP_SPECS = [
770
943
  {
771
944
  command: 'builds',
772
945
  usage: 'zenifra builds --project <id> [--page <n>] [--limit <n>] [--branch <name>] [--status <status>] [--json]',
773
- description: 'Lista builds GitHub de um projeto.',
946
+ description: 'Lista builds de um projeto com origem Git.',
774
947
  flags: ['--project <id> ID do projeto.', '--page <n> Pagina.', '--limit <n> Itens por pagina.', '--branch <name> Filtra branch.', '--status <status> Filtra status.', '--json Imprime a resposta em JSON.'],
775
948
  examples: ['zenifra builds --project 507f1f77bcf86cd799439012 --status success'],
776
949
  output: 'Build Status Branch Commit\nbuild_123 success main abc123',
@@ -779,7 +952,7 @@ const HELP_SPECS = [
779
952
  {
780
953
  command: 'builds logs',
781
954
  usage: 'zenifra builds logs --project <id> --build <id> [--cursor <n>] [--limit <n>] [--follow] [--interval <seconds>] [--timeout <seconds>] [--json]',
782
- description: 'Lê os logs de um build GitHub existente e opcionalmente acompanha novos chunks em tempo real.',
955
+ description: 'Le os logs de um build Git existente e opcionalmente acompanha novos eventos em tempo real.',
783
956
  flags: [
784
957
  '--project <id> ID do projeto.',
785
958
  '--build <id> ID do build.',
@@ -797,7 +970,7 @@ const HELP_SPECS = [
797
970
  {
798
971
  command: 'deployments',
799
972
  usage: 'zenifra deployments --project <id> [--page <n>] [--limit <n>] [--branch <name>] [--status <status>] [--json]',
800
- description: 'Alias para listar builds/deployments GitHub de um projeto.',
973
+ description: 'Alias para listar builds e deployments Git de um projeto.',
801
974
  flags: ['--project <id> ID do projeto.', '--page <n> Pagina.', '--limit <n> Itens por pagina.', '--branch <name> Filtra branch.', '--status <status> Filtra status.', '--json Imprime a resposta em JSON.'],
802
975
  examples: ['zenifra deployments --project 507f1f77bcf86cd799439012'],
803
976
  output: 'Build Status Branch Commit\nbuild_123 success main abc123',
@@ -806,7 +979,7 @@ const HELP_SPECS = [
806
979
  {
807
980
  command: 'deploy',
808
981
  usage: 'zenifra deploy --project <id> [--branch <name>] [--commit-sha <sha>] [--json]',
809
- description: 'Dispara um build/deploy GitHub para o projeto e retorna o build_id para acompanhamento posterior.',
982
+ description: 'Dispara um build/deploy Git para o projeto e retorna o build_id para acompanhamento posterior.',
810
983
  flags: ['--project <id> ID do projeto.', '--branch <name> Branch a publicar.', '--commit-sha <sha> Commit especifico.', '--json Imprime a resposta em JSON.'],
811
984
  examples: [
812
985
  'zenifra deploy --project 507f1f77bcf86cd799439012 --branch main',
@@ -877,7 +1050,8 @@ function commandHelp(positionals) {
877
1050
  const key = helpKeyFromPositionals(positionals);
878
1051
  if (!key) return usage();
879
1052
 
880
- 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);
881
1055
  if (!spec) {
882
1056
  throw new CliError(`Ajuda nao encontrada para: ${key}. Use "zenifra help <command>".`);
883
1057
  }
@@ -900,7 +1074,7 @@ function removedProjectsCreateMessage() {
900
1074
  }
901
1075
 
902
1076
  function isNamespaceCommand(command) {
903
- return ['auth', 'profile', 'project', 'org', 'valkey'].includes(command);
1077
+ return ['auth', 'profile', 'project', 'org', 'valkey', 'git'].includes(command);
904
1078
  }
905
1079
 
906
1080
  function parseArgs(argv) {
@@ -1470,9 +1644,9 @@ async function promptWizardBoolean({ label, docs, examples = ['sim', 'nao'] }) {
1470
1644
  }
1471
1645
  }
1472
1646
 
1473
- async function promptWizardSelect({ label, options, docs, examples = [] }) {
1647
+ async function promptWizardSelect({ label, options, docs, examples = [], extraValues = [] }) {
1474
1648
  while (true) {
1475
- const allowedValues = options.map((option) => option.value);
1649
+ const allowedValues = [...options.map((option) => option.value), ...extraValues];
1476
1650
  const answer = await prompt(buildWizardPromptLine({
1477
1651
  ui: promptWizardSelect.ui,
1478
1652
  label,
@@ -1493,6 +1667,8 @@ async function promptWizardSelect({ label, options, docs, examples = [] }) {
1493
1667
  const normalized = normalizeFreeText(answer);
1494
1668
  const found = options.find((option) => normalizeFreeText(option.value) === normalized);
1495
1669
  if (found) return found.value;
1670
+ const extraValue = extraValues.find((value) => normalizeFreeText(value) === normalized);
1671
+ if (extraValue) return extraValue;
1496
1672
  process.stdout.write(`${tone(`valor invalido: escolha um item entre ${options.map((_, index) => index + 1).join(', ')} ou um dos valores aceitos.`, 'red')}\n`);
1497
1673
  promptWizardSelect.ui.step -= 1;
1498
1674
  }
@@ -1625,6 +1801,14 @@ function httpTimeoutMs() {
1625
1801
  return value;
1626
1802
  }
1627
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
+
1628
1812
  async function request(session, flags, method, path, {
1629
1813
  body,
1630
1814
  orgId,
@@ -1691,31 +1875,394 @@ async function request(session, flags, method, path, {
1691
1875
  }
1692
1876
 
1693
1877
  if (!response.ok) {
1694
- 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}`);
1879
+ const httpError = (errorMessage) => new CliError(errorMessage, 1, response.status);
1695
1880
  if (response.status === 401) {
1696
1881
  if (credential?.type === 'api_key') {
1697
- throw new CliError(`${message}. Verifique se a API key esta ativa e se o IP atual esta permitido.`);
1882
+ throw httpError(`${message}. Verifique se a API key esta ativa e se o IP atual esta permitido.`);
1698
1883
  }
1699
- throw new CliError(`${message}. Rode "zenifra auth login" para renovar sua sessao.`);
1884
+ throw httpError(`${message}. Rode "zenifra auth login" para renovar sua sessao.`);
1700
1885
  }
1701
1886
  if (response.status === 403) {
1702
- throw new CliError(message);
1887
+ throw httpError(message);
1703
1888
  }
1704
1889
  if (response.status === 429 && Number.isFinite(Number(payload?.retry_after_seconds))) {
1705
- throw new CliError(`${message} Tente novamente em ${Number(payload.retry_after_seconds)} segundo(s).`);
1890
+ throw httpError(`${message} Tente novamente em ${Number(payload.retry_after_seconds)} segundo(s).`);
1706
1891
  }
1707
1892
  if (response.status === 402 && path.includes('/metrics') && /support metrics/i.test(String(message))) {
1708
- throw new CliError('Este projeto nao possui acesso a metricas.');
1893
+ throw httpError('Este projeto nao possui acesso a metricas.');
1709
1894
  }
1710
1895
  if (String(message).includes('missing x-organization-id')) {
1711
- throw new CliError('Organizacao nao selecionada. Rode "zenifra org set" ou use --org <id>.');
1896
+ throw httpError('Organizacao nao selecionada. Rode "zenifra org set" ou use --org <id>.');
1712
1897
  }
1713
- throw new CliError(message);
1898
+ throw httpError(message);
1714
1899
  }
1715
1900
 
1716
1901
  return payload;
1717
1902
  }
1718
1903
 
1904
+ function hasValidGitProvidersApiV1(payload) {
1905
+ if (payload?.status !== 'success' || !isRecord(payload.data) || payload.data.api_version !== 1 || !Array.isArray(payload.data.providers)) {
1906
+ return false;
1907
+ }
1908
+
1909
+ return payload.data.providers.every((provider) => (
1910
+ isRecord(provider)
1911
+ && typeof provider.id === 'string'
1912
+ && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(provider.id)
1913
+ && typeof provider.available === 'boolean'
1914
+ && isRecord(provider.capabilities)
1915
+ && typeof provider.capabilities.repositoryDiscovery === 'boolean'
1916
+ && typeof provider.capabilities.pushDeploy === 'boolean'
1917
+ && typeof provider.capabilities.nativePreviews === 'boolean'
1918
+ ));
1919
+ }
1920
+
1921
+ async function supportsNeutralGitRoutes(session, flags, orgId) {
1922
+ let payload;
1923
+ try {
1924
+ payload = await request(session, flags, 'GET', '/git/providers', { orgId });
1925
+ } catch (error) {
1926
+ if ([404, 405, 501].includes(error?.httpStatus)) return false;
1927
+ throw error;
1928
+ }
1929
+
1930
+ if (!hasValidGitProvidersApiV1(payload)) {
1931
+ throw new CliError('A API retornou uma resposta invalida para as capacidades de origem Git.');
1932
+ }
1933
+ return true;
1934
+ }
1935
+
1936
+ function hasLegacyGithubSource(project) {
1937
+ if (!isRecord(project)) return false;
1938
+ // Public Git source projections must never be rewritten as legacy GitHub input.
1939
+ // preview.source is separate preview metadata and does not identify the primary project source.
1940
+ const genericSourceFields = ['source', 'build', 'git_source', 'git_build'];
1941
+ if (genericSourceFields.some((field) => project[field] !== undefined || project.config?.[field] !== undefined)) {
1942
+ return false;
1943
+ }
1944
+
1945
+ const github = project.github || project.config?.github;
1946
+ return isRecord(github)
1947
+ && typeof github.repository_owner === 'string'
1948
+ && github.repository_owner.trim().length > 0
1949
+ && typeof github.repository_name === 'string'
1950
+ && github.repository_name.trim().length > 0;
1951
+ }
1952
+
1953
+ async function resolveGitProjectApiPath(session, flags, projectId, orgId) {
1954
+ if (await supportsNeutralGitRoutes(session, flags, orgId)) {
1955
+ return `/project/${projectId}`;
1956
+ }
1957
+
1958
+ const project = await getProject(session, flags, projectId, orgId);
1959
+ if (!hasLegacyGithubSource(project)) {
1960
+ throw new CliError('Esta API antiga so permite builds e deploys de projetos com uma origem GitHub legada compativel.');
1961
+ }
1962
+ return `/project/${projectId}/github`;
1963
+ }
1964
+
1965
+ function requiredGitOpaqueId(flags, name, commandKey) {
1966
+ const value = flags[name];
1967
+ if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(value)) {
1968
+ printCommandHelpAndFail(commandKey);
1969
+ return null;
1970
+ }
1971
+ return value;
1972
+ }
1973
+
1974
+ function requiredGitRepositoryPath(flags, commandKey) {
1975
+ const value = flags.path;
1976
+ const segments = typeof value === 'string' ? value.split('/') : [];
1977
+ if (typeof value !== 'string'
1978
+ || value.length < 3
1979
+ || value.length > 1024
1980
+ || segments.length < 2
1981
+ || segments.some((segment) => segment.length === 0)
1982
+ || /[\u0000-\u001f\u007f-\u009f\\?#%]/u.test(value)) {
1983
+ printCommandHelpAndFail(commandKey);
1984
+ return null;
1985
+ }
1986
+ return value;
1987
+ }
1988
+
1989
+ function yesNo(value) {
1990
+ if (value === true) return 'sim';
1991
+ if (value === false) return 'nao';
1992
+ return 'indisponivel';
1993
+ }
1994
+
1995
+ async function handleGitProviders(session, flags) {
1996
+ const orgId = await resolveOrgId(session, flags);
1997
+ const payload = await request(session, flags, 'GET', '/git/providers', { orgId });
1998
+ if (!hasValidGitProvidersApiV1(payload)) {
1999
+ throw new CliError('A API retornou uma resposta invalida para o catalogo de provedores Git.');
2000
+ }
2001
+ if (flags.json) return printJson(payload);
2002
+
2003
+ printTable(payload.data.providers, [
2004
+ { label: 'Provedor', value: (provider) => provider.id },
2005
+ { label: 'Disponivel', value: (provider) => yesNo(provider.available) },
2006
+ { label: 'Descoberta', value: (provider) => yesNo(provider.capabilities.repositoryDiscovery) },
2007
+ { label: 'Deploy por branch', value: (provider) => yesNo(provider.capabilities.pushDeploy) },
2008
+ { label: 'Deploy por versao', value: (provider) => yesNo(provider.capabilities.versionDeploy) },
2009
+ ]);
2010
+ }
2011
+
2012
+ async function handleGitRuntimes(session, flags) {
2013
+ const orgId = await resolveOrgId(session, flags);
2014
+ const payload = await request(session, flags, 'GET', '/git/runtime-catalog', { orgId });
2015
+ const runtimes = unwrapData(payload);
2016
+ if (!Array.isArray(runtimes)) {
2017
+ throw new CliError('A API retornou uma resposta invalida para o catalogo de runtimes Git.');
2018
+ }
2019
+ if (flags.json) return printJson(payload);
2020
+
2021
+ printTable(runtimes, [
2022
+ { label: 'Runtime', value: (runtime) => runtime.label || runtime.id || '-' },
2023
+ { label: 'Versao padrao', value: (runtime) => runtime.default_version || '-' },
2024
+ { label: 'Versoes', value: (runtime) => Array.isArray(runtime.versions) ? runtime.versions.map((version) => version.id).join(', ') : '-' },
2025
+ ]);
2026
+ }
2027
+
2028
+ async function handleGitConnections(session, flags) {
2029
+ const orgId = await resolveOrgId(session, flags);
2030
+ const payload = await request(session, flags, 'GET', '/git/connections', { orgId });
2031
+ const connections = unwrapData(payload);
2032
+ if (!Array.isArray(connections)) {
2033
+ throw new CliError('A API retornou uma resposta invalida para as conexoes Git.');
2034
+ }
2035
+ if (flags.json) return printJson(payload);
2036
+
2037
+ printTable(connections, [
2038
+ { label: 'ID', value: (connection) => connection.id || '-' },
2039
+ { label: 'Provedor', value: (connection) => connection.provider_id || '-' },
2040
+ { label: 'Nome', value: (connection) => connection.display_name || '-' },
2041
+ { label: 'Status', value: (connection) => connection.status || '-' },
2042
+ ]);
2043
+ }
2044
+
2045
+ async function handleGitRepositoryResolve(session, flags) {
2046
+ const connectionId = requiredGitOpaqueId(flags, 'connection', 'git repositories resolve');
2047
+ const repositoryPath = requiredGitRepositoryPath(flags, 'git repositories resolve');
2048
+ if (!connectionId || !repositoryPath) return;
2049
+
2050
+ const orgId = await resolveOrgId(session, flags);
2051
+ const endpoint = '/git/connections/' + encodeURIComponent(connectionId) + '/repositories/resolve';
2052
+ const payload = await request(session, flags, 'POST', endpoint, {
2053
+ orgId,
2054
+ body: { path: repositoryPath },
2055
+ });
2056
+ if (flags.json) return printJson(payload);
2057
+
2058
+ const repository = unwrapData(payload);
2059
+ printTable([repository], [
2060
+ { label: 'ID', value: (entry) => entry.id || '-' },
2061
+ { label: 'Caminho', value: (entry) => entry.path || '-' },
2062
+ { label: 'Branch padrao', value: (entry) => entry.default_branch || '-' },
2063
+ { label: 'Privado', value: (entry) => yesNo(entry.private) },
2064
+ ]);
2065
+ }
2066
+
2067
+ async function handleGitBranches(session, flags) {
2068
+ const connectionId = requiredGitOpaqueId(flags, 'connection', 'git branches');
2069
+ const repositoryId = requiredGitOpaqueId(flags, 'repository', 'git branches');
2070
+ if (!connectionId || !repositoryId) return;
2071
+
2072
+ const orgId = await resolveOrgId(session, flags);
2073
+ const endpoint = '/git/connections/' + encodeURIComponent(connectionId)
2074
+ + '/repositories/' + encodeURIComponent(repositoryId) + '/branches';
2075
+ const payload = await request(session, flags, 'GET', endpoint, { orgId });
2076
+ const branches = unwrapData(payload);
2077
+ if (!Array.isArray(branches)) {
2078
+ throw new CliError('A API retornou uma resposta invalida para as branches Git.');
2079
+ }
2080
+ if (flags.json) return printJson(payload);
2081
+
2082
+ printTable(branches, [
2083
+ { label: 'Branch', value: (branch) => branch.name || '-' },
2084
+ { label: 'Commit', value: (branch) => branch.commit_sha || '-' },
2085
+ ]);
2086
+ }
2087
+
2088
+ function printGitProjectSource(data) {
2089
+ if (!isRecord(data?.source)) {
2090
+ process.stdout.write('Este projeto nao possui uma origem Git generica configurada.\n');
2091
+ return;
2092
+ }
2093
+
2094
+ const source = data.source;
2095
+ const build = isRecord(data.build) ? data.build : {};
2096
+ const mode = source.provider_id === 'github' ? null : gitSourceDeploymentMode(source);
2097
+ const rows = [
2098
+ { field: 'Provedor', value: source.provider_id || '-' },
2099
+ { field: 'Repositorio', value: source.repository_path || source.repository_id || '-' },
2100
+ { field: 'Branch', value: source.branch || '-' },
2101
+ { field: 'Runtime', value: build.runtime ? build.runtime + '@' + (build.version || '-') : '-' },
2102
+ { field: 'Modo de deploy', value: mode || (source.provider_id === 'github' ? 'consulte project github' : 'indisponivel') },
2103
+ ];
2104
+ if (mode === 'tag' || mode === 'release') {
2105
+ rows.push(
2106
+ { field: 'Padrao de tags', value: source.version_deploy?.tag_pattern || '-' },
2107
+ { field: 'Incluir prereleases', value: source.version_deploy?.include_prereleases === true ? 'sim' : 'nao' },
2108
+ );
2109
+ }
2110
+ printTable(rows, [
2111
+ { label: 'Campo', value: (row) => row.field },
2112
+ { label: 'Valor', value: (row) => row.value },
2113
+ ]);
2114
+ }
2115
+
2116
+ async function handleProjectSource(session, flags) {
2117
+ const projectId = requireProjectId(flags, 'project source');
2118
+ if (!projectId) return;
2119
+ const orgId = await resolveOrgId(session, flags);
2120
+ const payload = await request(session, flags, 'GET', '/project/' + encodeURIComponent(projectId) + '/source', { orgId });
2121
+ const source = unwrapData(payload);
2122
+ if (flags.json) return printJson(payload);
2123
+ printGitProjectSource(source);
2124
+ }
2125
+
2126
+ async function handleProjectSourceBranches(session, flags) {
2127
+ const projectId = requireProjectId(flags, 'project source branches');
2128
+ if (!projectId) return;
2129
+ const orgId = await resolveOrgId(session, flags);
2130
+ const endpoint = '/project/' + encodeURIComponent(projectId) + '/source/branches';
2131
+ const payload = await request(session, flags, 'GET', endpoint, { orgId });
2132
+ const branches = unwrapData(payload);
2133
+ if (!Array.isArray(branches)) {
2134
+ throw new CliError('A API retornou uma resposta invalida para as branches da origem Git.');
2135
+ }
2136
+ if (flags.json) return printJson(payload);
2137
+ printTable(branches, [
2138
+ { label: 'Branch', value: (branch) => branch.name || '-' },
2139
+ { label: 'Commit', value: (branch) => branch.commit_sha || '-' },
2140
+ ]);
2141
+ }
2142
+
2143
+ function projectGitSourceInput(source) {
2144
+ if (!isRecord(source)
2145
+ || typeof source.connection_id !== 'string'
2146
+ || typeof source.repository_id !== 'string'
2147
+ || typeof source.branch !== 'string'
2148
+ || typeof source.auto_deploy !== 'boolean') {
2149
+ throw new CliError('A API retornou uma origem Git incompleta; nenhuma alteracao foi enviada.');
2150
+ }
2151
+
2152
+ const input = {
2153
+ connection_id: source.connection_id,
2154
+ repository_id: source.repository_id,
2155
+ branch: source.branch,
2156
+ auto_deploy: source.auto_deploy,
2157
+ };
2158
+ if (source.version_deploy === undefined || source.version_deploy === null) return input;
2159
+ if (!isRecord(source.version_deploy)) {
2160
+ throw new CliError('A API retornou configuracoes de deploy Git invalidas; nenhuma alteracao foi enviada.');
2161
+ }
2162
+ const versionDeploy = {};
2163
+ for (const field of GITHUB_PUBLIC_VERSION_DEPLOY_FIELDS) {
2164
+ if (Object.hasOwn(source.version_deploy, field)) versionDeploy[field] = source.version_deploy[field];
2165
+ }
2166
+ input.version_deploy = versionDeploy;
2167
+ return input;
2168
+ }
2169
+
2170
+ function projectGitBuildSettingsInput(build) {
2171
+ if (!isRecord(build)) {
2172
+ throw new CliError('A API retornou configuracoes de build incompletas; nenhuma alteracao foi enviada.');
2173
+ }
2174
+ const input = {};
2175
+ for (const field of GIT_BUILD_SETTINGS_FIELDS) {
2176
+ if (Object.hasOwn(build, field)) input[field] = build[field];
2177
+ }
2178
+ return input;
2179
+ }
2180
+
2181
+ function gitSourceDeploymentMode(source) {
2182
+ const versionDeployEnabled = source?.version_deploy?.enabled === true;
2183
+ if (source?.auto_deploy === true && versionDeployEnabled) return null;
2184
+ if (source?.auto_deploy === true) return 'branch';
2185
+ if (!versionDeployEnabled) return 'manual';
2186
+ if (!ALLOWED_GITHUB_VERSION_DEPLOY_EVENTS.has(source.version_deploy.event)) return null;
2187
+ return source.version_deploy.event;
2188
+ }
2189
+
2190
+ function projectGitSourceMatches(data, expectedSource, expectedBuild, desired) {
2191
+ if (!isRecord(data) || !isRecord(data.source) || !isRecord(data.build)) return false;
2192
+ const source = data.source;
2193
+ if (source.provider_id !== 'forgejo'
2194
+ || source.connection_id !== expectedSource.connection_id
2195
+ || source.repository_id !== expectedSource.repository_id
2196
+ || source.branch !== expectedSource.branch
2197
+ || gitSourceDeploymentMode(source) !== desired.mode) return false;
2198
+
2199
+ if (desired.mode === 'tag' || desired.mode === 'release') {
2200
+ if (source.version_deploy?.tag_pattern !== desired.body.version_deploy.tag_pattern
2201
+ || (source.version_deploy?.include_prereleases === true) !== desired.includePrereleases) return false;
2202
+ }
2203
+
2204
+ return Object.entries(expectedBuild).every(([field, value]) => data.build[field] === value);
2205
+ }
2206
+
2207
+ async function handleProjectSourceDeploySettingsSet(session, flags) {
2208
+ const desired = parseGithubDeploySettings(flags);
2209
+ const projectId = requireProjectId(flags, 'project source deploy-settings set');
2210
+ if (!projectId) return;
2211
+ const orgId = await resolveOrgId(session, flags);
2212
+ const endpoint = '/project/' + encodeURIComponent(projectId) + '/source';
2213
+ let current;
2214
+ try {
2215
+ current = unwrapData(await request(session, flags, 'GET', endpoint, { orgId }));
2216
+ } catch (error) {
2217
+ if (error?.httpStatus === 404) {
2218
+ throw new CliError('A origem Git generica nao esta disponivel para este projeto ou API. Para projetos GitHub legados, use "zenifra project github deploy-settings set".', 1, 404);
2219
+ }
2220
+ throw error;
2221
+ }
2222
+
2223
+ if (!isRecord(current?.source) || !isRecord(current?.build)) {
2224
+ throw new CliError('Este projeto nao tem uma origem Git generica configurada. Configure-a antes de alterar o modo de deploy.');
2225
+ }
2226
+ if (current.source.provider_id === 'github') {
2227
+ throw new CliError('Esta origem GitHub nao pode ser alterada por este comando. Consulte e altere com "zenifra project github deploy-settings set".');
2228
+ }
2229
+ if (current.source.provider_id !== 'forgejo') {
2230
+ throw new CliError('O provedor da origem nao e suportado por este comando; nenhuma alteracao foi enviada.');
2231
+ }
2232
+ if (desired.mode === 'branch' && current.capabilities?.pushDeploy !== true) {
2233
+ throw new CliError('A origem Git nao oferece deploy automatico por branch.');
2234
+ }
2235
+ if ((desired.mode === 'tag' || desired.mode === 'release') && current.capabilities?.versionDeploy !== true) {
2236
+ throw new CliError('A origem Git nao oferece deploy automatico por tag ou release.');
2237
+ }
2238
+
2239
+ const source = projectGitSourceInput(current.source);
2240
+ const build = projectGitBuildSettingsInput(current.build);
2241
+ source.auto_deploy = desired.body.auto_deploy;
2242
+ delete source.version_deploy;
2243
+ if (desired.mode === 'tag' || desired.mode === 'release') {
2244
+ source.version_deploy = desired.body.version_deploy;
2245
+ }
2246
+ const expectedSource = {
2247
+ connection_id: source.connection_id,
2248
+ repository_id: source.repository_id,
2249
+ branch: source.branch,
2250
+ };
2251
+
2252
+ await request(session, flags, 'PUT', endpoint, {
2253
+ orgId,
2254
+ body: { source, build },
2255
+ });
2256
+ const confirmed = unwrapData(await request(session, flags, 'GET', endpoint, { orgId }));
2257
+ if (!projectGitSourceMatches(confirmed, expectedSource, build, desired)) {
2258
+ throw new CliError('A configuracao solicitada nao foi confirmada pela API. Consulte "zenifra project source".');
2259
+ }
2260
+
2261
+ if (flags.json) return printJson({ status: 'success', data: confirmed });
2262
+ process.stdout.write('Configuracao de deploy da origem Git confirmada: ' + desired.mode
2263
+ + ((desired.mode === 'tag' || desired.mode === 'release') ? ' (' + desired.body.version_deploy.tag_pattern + ')' : '') + '.\n');
2264
+ }
2265
+
1719
2266
  function unwrapData(payload) {
1720
2267
  return payload?.data ?? payload;
1721
2268
  }
@@ -1945,9 +2492,66 @@ function printEnvs(envs, flags) {
1945
2492
  ]);
1946
2493
  }
1947
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
+
1948
2551
  function printProject(project) {
1949
2552
  const managedService = project.managed_service;
1950
2553
  const isValkey = project.type_project === 'valkey' || managedService?.engine === 'valkey';
2554
+ const isJob = isJobProject(project);
1951
2555
  const rows = [
1952
2556
  { label: 'ID', value: projectIdOf(project) || '-' },
1953
2557
  { label: 'Nome', value: project.name || '-' },
@@ -1956,7 +2560,13 @@ function printProject(project) {
1956
2560
  { label: 'Plano', value: project.plan || '-' },
1957
2561
  ];
1958
2562
 
1959
- 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) {
1960
2570
  rows.push(
1961
2571
  { label: 'Perfil', value: managedService?.profile || '-' },
1962
2572
  { label: 'Versao', value: managedService?.version || '-' },
@@ -2423,9 +3033,10 @@ async function handleProjects(session, flags) {
2423
3033
  };
2424
3034
  const query = buildQuery(projectFlags, ['type', 'page', 'limit']);
2425
3035
  const data = unwrapData(await request(session, flags, 'GET', `/project${query}`, { orgId }));
2426
- const projects = asArray(data);
3036
+ const publicData = publicProjectsData(data);
3037
+ const projects = asArray(publicData);
2427
3038
 
2428
- if (flags.json) return printJson(data);
3039
+ if (flags.json) return printJson(publicData);
2429
3040
  printTable(projects, [
2430
3041
  { label: 'ID', value: projectIdOf },
2431
3042
  { label: 'Nome', value: (project) => project.name || '-' },
@@ -2514,6 +3125,8 @@ function normalizePlan(value) {
2514
3125
  ]);
2515
3126
 
2516
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;
2517
3130
  if (ALLOWED_PLAN_VALUES.has(normalized)) return normalized;
2518
3131
  return normalized;
2519
3132
  }
@@ -2533,6 +3146,10 @@ function normalizePaymentMode(value) {
2533
3146
  ['annual', 'yearly'],
2534
3147
  ['anual', 'yearly'],
2535
3148
  ['por ano', 'yearly'],
3149
+ ['per_minute', 'per_minute'],
3150
+ ['per-minute', 'per_minute'],
3151
+ ['por minuto', 'per_minute'],
3152
+ ['minuto', 'per_minute'],
2536
3153
  ]);
2537
3154
  return aliases.get(raw) || raw;
2538
3155
  }
@@ -2669,12 +3286,21 @@ async function fetchWizardCatalogs(session, flags) {
2669
3286
  return { httpPlans, availableInstances, databasePlans };
2670
3287
  }
2671
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
+
2672
3297
  function normalizePlansCatalogType(value) {
2673
3298
  const normalized = normalizeFreeText(value || 'all');
2674
3299
  if (!normalized || normalized === 'all') return 'all';
2675
3300
  if (normalized === 'http') return 'http';
2676
3301
  if (['database', 'db', 'postgresql', 'mariadb'].includes(normalized)) return 'database';
2677
3302
  if (normalized === 'valkey') return 'valkey';
3303
+ if (normalized === 'job' || normalized === 'jobs' || normalized === 'scheduled job' || normalized === 'scheduled jobs') return 'job';
2678
3304
  if (normalized === 'key-value' || normalized === 'key value' || normalized === 'key_value') return 'valkey-key_value';
2679
3305
  if (normalized === 'cache') return 'valkey-cache';
2680
3306
  if (normalized === 'queue') return 'valkey-queue';
@@ -2695,6 +3321,18 @@ function formatBrlFromCents(value) {
2695
3321
  return formatBrl(Number.isFinite(number) ? number / 100 : 0);
2696
3322
  }
2697
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
+
2698
3336
  function humanizeStorageName(value) {
2699
3337
  const raw = String(value || '').trim();
2700
3338
  if (!raw) return { storage: '-', type: '-' };
@@ -2792,6 +3430,17 @@ async function fetchPlansCatalogs(session, flags, type) {
2792
3430
  };
2793
3431
  }
2794
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
+
2795
3444
  if (type === 'valkey' || type.startsWith('valkey-')) {
2796
3445
  const catalog = requireValkeyCatalog(
2797
3446
  unwrapData(await request(session, flags, 'GET', '/managed-services/catalog', { tokenRequired: false })),
@@ -2804,10 +3453,11 @@ async function fetchPlansCatalogs(session, flags, type) {
2804
3453
  };
2805
3454
  }
2806
3455
 
2807
- const [httpPayload, databasePayload, storagePayload, valkeyPayload] = await Promise.all([
3456
+ const [httpPayload, databasePayload, storagePayload, jobPayload, valkeyPayload] = await Promise.all([
2808
3457
  request(session, flags, 'GET', '/project/plans', { tokenRequired: false }),
2809
3458
  request(session, flags, 'GET', '/project/database/plans', { tokenRequired: false }),
2810
3459
  request(session, flags, 'GET', '/project/storage/plans', { tokenRequired: false }),
3460
+ request(session, flags, 'GET', '/project/job/plans', { tokenRequired: false }),
2811
3461
  request(session, flags, 'GET', '/managed-services/catalog', { tokenRequired: false }),
2812
3462
  ]);
2813
3463
 
@@ -2815,6 +3465,7 @@ async function fetchPlansCatalogs(session, flags, type) {
2815
3465
  http: requireWizardCatalogArray(unwrapData(httpPayload), 'os planos HTTP'),
2816
3466
  database: requireWizardCatalogArray(unwrapData(databasePayload), 'os planos de banco'),
2817
3467
  storage: requireWizardCatalogArray(unwrapData(storagePayload), 'os planos de storage'),
3468
+ job: requireJobPlans(unwrapData(jobPayload)),
2818
3469
  valkey: requireValkeyCatalog(unwrapData(valkeyPayload)),
2819
3470
  };
2820
3471
  }
@@ -2853,6 +3504,15 @@ function formatPlanCapabilities(capabilities) {
2853
3504
  return available.join(', ') || '-';
2854
3505
  }
2855
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
+
2856
3516
  function printPlansCatalogs(payload, type) {
2857
3517
  if (type === 'all' || type === 'http') {
2858
3518
  process.stdout.write('HTTP\n');
@@ -2890,6 +3550,16 @@ function printPlansCatalogs(payload, type) {
2890
3550
  ]);
2891
3551
  }
2892
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
+
2893
3563
  if (type === 'all' || type === 'valkey' || type.startsWith('valkey-')) {
2894
3564
  if (type === 'all') process.stdout.write('\n');
2895
3565
  printValkeyCatalog(payload.valkey, valkeyProfileFilter(type));
@@ -2899,7 +3569,7 @@ function printPlansCatalogs(payload, type) {
2899
3569
  async function handlePlans(session, flags) {
2900
3570
  const type = normalizePlansCatalogType(flags.type);
2901
3571
  if (!type) {
2902
- 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.');
2903
3573
  }
2904
3574
 
2905
3575
  const payload = await fetchPlansCatalogs(session, flags, type);
@@ -2911,6 +3581,75 @@ function formatAllowedValues(values) {
2911
3581
  return [...values].join(', ');
2912
3582
  }
2913
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
+
2914
3653
  function validateCreateAutoscaling(config, plan, typeProject) {
2915
3654
  if (config?.autoscaling === undefined) return config;
2916
3655
  if (typeProject !== 'http') {
@@ -3054,19 +3793,39 @@ function validateGithubVersionDeployConfig(github) {
3054
3793
  function validateCreateInput({ plan, paymentMode, config, valkeyCatalog }) {
3055
3794
  const nextTypeProject = normalizeTypeProject(config?.type_project);
3056
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
+
3057
3807
  if (!nextPlan || (nextTypeProject !== 'valkey' && !ALLOWED_PLAN_VALUES.has(nextPlan))) {
3058
3808
  throw new CliError(`Plano invalido: "${plan}". Valores aceitos: ${formatAllowedValues(ALLOWED_PLAN_VALUES)}. Docs: ${DOCS_CREATE_HTTP_URL}`);
3059
3809
  }
3060
-
3061
- const nextPaymentMode = normalizePaymentMode(paymentMode);
3062
- if (!nextPaymentMode || !ALLOWED_PAYMENT_MODE_VALUES.has(nextPaymentMode)) {
3063
- 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}`);
3064
3812
  }
3065
3813
 
3066
3814
  if (!nextTypeProject || !ALLOWED_TYPE_PROJECT_VALUES.has(nextTypeProject)) {
3067
3815
  throw new CliError(`type_project invalido: "${config?.type_project}". Valores aceitos: ${formatAllowedValues(ALLOWED_TYPE_PROJECT_VALUES)}. Docs: ${DOCS_CREATE_HTTP_URL}`);
3068
3816
  }
3069
3817
 
3818
+ const hasGenericGitConfig = config?.source !== undefined || config?.build !== undefined;
3819
+ if (hasGenericGitConfig && (!isRecord(config.source) || !isRecord(config.build))) {
3820
+ throw new CliError('config.source e config.build devem ser informados juntos para projetos com origem Git.');
3821
+ }
3822
+ if (hasGenericGitConfig && nextTypeProject !== 'http') {
3823
+ throw new CliError('config.source e config.build so podem ser usados em projetos HTTP.');
3824
+ }
3825
+ if (hasGenericGitConfig && config.github !== undefined) {
3826
+ throw new CliError('Use config.github ou config.source/config.build, sem misturar os formatos de origem Git.');
3827
+ }
3828
+
3070
3829
  const nextConfig = { ...config, type_project: nextTypeProject };
3071
3830
  if (nextTypeProject === 'valkey') {
3072
3831
  return {
@@ -3118,10 +3877,13 @@ function maskWizardSummary(value, key = '') {
3118
3877
 
3119
3878
  function printWizardSummary(payload) {
3120
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}`;
3121
3883
  const lines = [
3122
3884
  '',
3123
3885
  tone('Resumo da criacao', 'green'),
3124
- ` projeto: ${masked.name} | plano: ${masked.plan} | pagamento: ${masked.payment_mode}`,
3886
+ projectSummary,
3125
3887
  ` tipo: ${masked.config.type_project}`,
3126
3888
  ];
3127
3889
 
@@ -3143,6 +3905,12 @@ function printWizardSummary(payload) {
3143
3905
  lines.push(` banco: versao ${masked.config.version} | instancias ${masked.config.instances} | storage ${masked.config.storage.capacity}Gi`);
3144
3906
  }
3145
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
+
3146
3914
  if (masked.config.type_project === 'valkey') {
3147
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'}`);
3148
3916
  }
@@ -3632,6 +4400,67 @@ async function buildValkeyConfig({ profile, plan, catalog }) {
3632
4400
  };
3633
4401
  }
3634
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
+
3635
4464
  async function runProjectCreateWizard(session, flags) {
3636
4465
  const catalogs = await fetchWizardCatalogs(session, flags);
3637
4466
  const ui = createWizardUi();
@@ -3660,11 +4489,15 @@ async function runProjectCreateWizard(session, flags) {
3660
4489
  docs: DOCS_CONFIGURATION_URL,
3661
4490
  examples: WIZARD_TYPE_PROJECT_OPTIONS,
3662
4491
  options: WIZARD_TYPE_PROJECT_OPTIONS.map((value) => ({ value })),
4492
+ extraValues: ['job'],
3663
4493
  });
3664
4494
  ui.state.typeProject = typeProject;
3665
4495
  const valkeyCatalog = typeProject === 'valkey'
3666
4496
  ? requireValkeyCatalog(unwrapData(await request(session, flags, 'GET', '/managed-services/catalog')))
3667
4497
  : undefined;
4498
+ const jobPlans = typeProject === 'job'
4499
+ ? requireJobPlans(unwrapData(await request(session, flags, 'GET', '/project/job/plans')))
4500
+ : undefined;
3668
4501
  const profile = typeProject === 'valkey'
3669
4502
  ? await promptWizardSelect({
3670
4503
  label: 'Perfil Valkey',
@@ -3682,6 +4515,8 @@ async function runProjectCreateWizard(session, flags) {
3682
4515
  ? valkeyCatalog.plans
3683
4516
  .filter((planDefinition) => planDefinition.profile === profile)
3684
4517
  .map((planDefinition) => planDefinition.id)
4518
+ : typeProject === 'job'
4519
+ ? sortWizardOptions(jobPlans.map((plan) => plan?.plan || plan?.id).filter(Boolean), WIZARD_JOB_PLAN_OPTIONS)
3685
4520
  : sortWizardOptions(catalogs.databasePlans
3686
4521
  .map((plan) => plan?.plan)
3687
4522
  .filter((plan) => WIZARD_DATABASE_PLAN_OPTIONS.includes(plan) && (typeProject !== 'mariadb' || plan !== 'db-free')), WIZARD_DATABASE_PLAN_OPTIONS);
@@ -3689,7 +4524,8 @@ async function runProjectCreateWizard(session, flags) {
3689
4524
  label: 'Plano',
3690
4525
  docs: typeProject === 'http'
3691
4526
  ? DOCS_CREATE_HTTP_URL
3692
- : 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,
3693
4529
  examples: [planOptions[0], planOptions.at(-1)],
3694
4530
  options: planOptions.map((value) => ({ value })),
3695
4531
  });
@@ -3699,7 +4535,7 @@ async function runProjectCreateWizard(session, flags) {
3699
4535
  ui.state.planAllowsSubdomain = isSubdomainCustomizable(catalogs.httpPlans, plan);
3700
4536
  ui.state.planAllowsAutoscaling = isAutoscalingEnabledForPlan(catalogs.httpPlans, plan);
3701
4537
  }
3702
- const paymentMode = await promptWizardSelect({
4538
+ const paymentMode = typeProject === 'job' ? 'per_minute' : await promptWizardSelect({
3703
4539
  label: 'Modo de pagamento',
3704
4540
  docs: DOCS_PAYMENTS_URL,
3705
4541
  examples: WIZARD_PAYMENT_MODE_OPTIONS,
@@ -3717,6 +4553,8 @@ async function runProjectCreateWizard(session, flags) {
3717
4553
  config = await buildPostgresqlConfig({ plan, databasePlans: catalogs.databasePlans });
3718
4554
  } else if (typeProject === 'valkey') {
3719
4555
  config = await buildValkeyConfig({ profile, plan, catalog: valkeyCatalog });
4556
+ } else if (typeProject === 'job') {
4557
+ config = await buildJobConfig();
3720
4558
  } else {
3721
4559
  config = await buildMariadbConfig();
3722
4560
  }
@@ -3753,18 +4591,26 @@ async function handleProjectCreate(session, flags) {
3753
4591
  const name = wizardPayload?.name || flags.name || await prompt('Nome do projeto');
3754
4592
  const description = wizardPayload?.description || flags.description;
3755
4593
  const plan = wizardPayload?.plan || flags.plan || await prompt('Plano');
3756
- const paymentMode = wizardPayload?.payment_mode || flags.paymentMode || await prompt('Modo de pagamento');
3757
4594
  const config = wizardPayload?.config || await parseConfig(flags.config || await prompt('Config JSON ou @arquivo'));
3758
4595
  const idempotencyKey = flags.idempotencyKey === undefined ? undefined : validateIdempotencyKey(flags.idempotencyKey);
3759
4596
  if (Array.isArray(config?.custom_domains)) {
3760
4597
  config.custom_domains = normalizeCustomDomains(config.custom_domains, { primaryDomain: config.domain });
3761
4598
  }
3762
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');
3763
4603
  const valkeyCatalog = typeProject === 'valkey'
3764
4604
  ? requireValkeyCatalog(unwrapData(await request(session, flags, 'GET', '/managed-services/catalog')))
3765
4605
  : undefined;
3766
4606
  const validated = validateCreateInput({ plan, paymentMode, config, valkeyCatalog });
3767
4607
 
4608
+ if (validated.config.source !== undefined || validated.config.build !== undefined) {
4609
+ if (!await supportsNeutralGitRoutes(session, flags, orgId)) {
4610
+ throw new CliError('Esta API nao oferece suporte a criacao de projetos com origem Git generica. Atualize a API ou use a configuracao GitHub legada compativel.');
4611
+ }
4612
+ }
4613
+
3768
4614
  const payload = await request(session, flags, 'POST', '/project', {
3769
4615
  orgId,
3770
4616
  headers: idempotencyKey === undefined ? {} : { 'Idempotency-Key': idempotencyKey },
@@ -3777,14 +4623,16 @@ async function handleProjectCreate(session, flags) {
3777
4623
  },
3778
4624
  });
3779
4625
 
3780
- if (flags.json) return printJson(payload);
4626
+ if (flags.json) {
4627
+ return printJson(validated.config.type_project === 'job' ? sanitizeJobProjectResponse(payload) : payload);
4628
+ }
3781
4629
  if (wizardPayload) {
3782
4630
  closePromptResources();
3783
4631
  }
3784
4632
  const project = unwrapData(payload);
3785
4633
  const rows = [
3786
4634
  { field: 'Projeto', value: projectIdOf(project) || 'id indisponivel' },
3787
- ...(project.domain ? [{ field: 'Dominio', value: formatPublicUrl(project.domain) }] : []),
4635
+ ...(validated.config.type_project === 'job' ? [] : project.domain ? [{ field: 'Dominio', value: formatPublicUrl(project.domain) }] : []),
3788
4636
  ...(project.api_key ? [{ field: 'API key', value: project.api_key }] : []),
3789
4637
  ];
3790
4638
  if (validated.config.type_project === 'valkey') {
@@ -3819,7 +4667,9 @@ async function handleProjectInfo(session, flags) {
3819
4667
  const orgId = await resolveOrgId(session, flags);
3820
4668
  const project = await getProject(session, flags, projectId, orgId);
3821
4669
 
3822
- if (flags.json) return printJson(projectInfoForOutput(project));
4670
+ if (flags.json) return printJson(projectInfoForOutput(
4671
+ isJobProject(project) ? sanitizePublicProject(project) : project,
4672
+ ));
3823
4673
  printProject({ ...project, id: projectId });
3824
4674
  }
3825
4675
 
@@ -4032,6 +4882,231 @@ async function handleProjectLogs(session, flags) {
4032
4882
  printLogs(logs, flags);
4033
4883
  }
4034
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
+
4035
5110
  async function handleProjectMetricsCapabilities(session, flags) {
4036
5111
  const projectId = requireProjectId(flags, 'project metrics capabilities');
4037
5112
  if (!projectId) return;
@@ -4742,8 +5817,9 @@ async function handleDeployments(session, flags) {
4742
5817
  return
4743
5818
  }
4744
5819
  const orgId = await resolveOrgId(session, flags);
5820
+ const projectApiPath = await resolveGitProjectApiPath(session, flags, projectId, orgId);
4745
5821
  const query = buildQuery(flags, ['page', 'limit', 'branch', 'status']);
4746
- const data = unwrapData(await request(session, flags, 'GET', `/project/${projectId}/github/builds${query}`, { orgId }));
5822
+ const data = unwrapData(await request(session, flags, 'GET', `${projectApiPath}/builds${query}`, { orgId }));
4747
5823
  const builds = asArray(data);
4748
5824
 
4749
5825
  if (flags.json) return printJson(data);
@@ -4764,11 +5840,12 @@ async function handleDeploy(session, flags) {
4764
5840
  return;
4765
5841
  }
4766
5842
  const orgId = await resolveOrgId(session, flags);
5843
+ const projectApiPath = await resolveGitProjectApiPath(session, flags, projectId, orgId);
4767
5844
  const body = {};
4768
5845
  if (flags.branch) body.branch = String(flags.branch);
4769
5846
  if (flags.commitSha) body.commit_sha = String(flags.commitSha);
4770
5847
 
4771
- const payload = await request(session, flags, 'POST', `/project/${projectId}/github/deploy`, {
5848
+ const payload = await request(session, flags, 'POST', `${projectApiPath}/deploy`, {
4772
5849
  orgId,
4773
5850
  body,
4774
5851
  });
@@ -4778,9 +5855,9 @@ async function handleDeploy(session, flags) {
4778
5855
  process.stdout.write(`Deploy iniciado${buildId ? `: ${buildId}` : '.'}\n`);
4779
5856
  }
4780
5857
 
4781
- async function getBuildLogs(session, flags, projectId, buildId, orgId, { cursor = 0, limit = 200 } = {}) {
5858
+ async function getBuildLogs(session, flags, projectApiPath, buildId, orgId, { cursor = 0, limit = 200 } = {}) {
4782
5859
  const query = buildQuery({ cursor, limit }, ['cursor', 'limit'])
4783
- return unwrapData(await request(session, flags, 'GET', `/project/${projectId}/github/builds/${buildId}/logs${query}`, { orgId }))
5860
+ return unwrapData(await request(session, flags, 'GET', `${projectApiPath}/builds/${buildId}/logs${query}`, { orgId }))
4784
5861
  }
4785
5862
 
4786
5863
  function parseIntegerOption(value, flagName, { defaultValue, min, max }) {
@@ -4831,7 +5908,7 @@ function printBuildLogEventsJson(logs) {
4831
5908
  }
4832
5909
  }
4833
5910
 
4834
- async function followBuildLogs(session, flags, projectId, buildId, orgId, {
5911
+ async function followBuildLogs(session, flags, projectApiPath, buildId, orgId, {
4835
5912
  initialCursor = 0,
4836
5913
  limit = 200,
4837
5914
  intervalSeconds = 5,
@@ -4844,7 +5921,7 @@ async function followBuildLogs(session, flags, projectId, buildId, orgId, {
4844
5921
  const startedAt = Date.now()
4845
5922
 
4846
5923
  while (true) {
4847
- const result = await getBuildLogs(session, flags, projectId, buildId, orgId, {
5924
+ const result = await getBuildLogs(session, flags, projectApiPath, buildId, orgId, {
4848
5925
  cursor,
4849
5926
  limit: pollLimit,
4850
5927
  })
@@ -4885,20 +5962,27 @@ async function handleBuildLogs(session, flags) {
4885
5962
  return
4886
5963
  }
4887
5964
 
4888
- const orgId = await resolveOrgId(session, flags);
4889
5965
  const cursor = parseIntegerOption(flags.cursor, '--cursor', { defaultValue: 0, min: 0 })
4890
5966
  const limit = parseIntegerOption(flags.limit, '--limit', { defaultValue: 200, min: 1, max: 500 })
5967
+ const intervalSeconds = flags.follow
5968
+ ? parseSecondsOption(flags.interval, '--interval', { defaultValue: 5, min: 0.1 })
5969
+ : undefined;
5970
+ const timeoutSeconds = flags.follow
5971
+ ? parseSecondsOption(flags.timeout, '--timeout', { defaultValue: 900, min: 1 })
5972
+ : undefined;
5973
+ const orgId = await resolveOrgId(session, flags);
5974
+ const projectApiPath = await resolveGitProjectApiPath(session, flags, projectId, orgId);
4891
5975
 
4892
5976
  if (flags.follow) {
4893
- return followBuildLogs(session, flags, projectId, buildId, orgId, {
5977
+ return followBuildLogs(session, flags, projectApiPath, buildId, orgId, {
4894
5978
  initialCursor: cursor,
4895
5979
  limit,
4896
- intervalSeconds: parseSecondsOption(flags.interval, '--interval', { defaultValue: 5, min: 0.1 }),
4897
- timeoutSeconds: parseSecondsOption(flags.timeout, '--timeout', { defaultValue: 900, min: 1 }),
5980
+ intervalSeconds,
5981
+ timeoutSeconds,
4898
5982
  })
4899
5983
  }
4900
5984
 
4901
- const result = await getBuildLogs(session, flags, projectId, buildId, orgId, { cursor, limit })
5985
+ const result = await getBuildLogs(session, flags, projectApiPath, buildId, orgId, { cursor, limit })
4902
5986
  if (flags.json) return printJson(result)
4903
5987
  printBuildLogs(result?.logs)
4904
5988
  }
@@ -4911,12 +5995,15 @@ async function handleDeployWatch(session, flags) {
4911
5995
  return;
4912
5996
  }
4913
5997
 
5998
+ const intervalSeconds = parseSecondsOption(flags.interval, '--interval', { defaultValue: 5, min: 0.1 });
5999
+ const timeoutSeconds = parseSecondsOption(flags.timeout, '--timeout', { defaultValue: 900, min: 1 });
4914
6000
  const orgId = await resolveOrgId(session, flags);
4915
- return followBuildLogs(session, flags, projectId, buildId, orgId, {
6001
+ const projectApiPath = await resolveGitProjectApiPath(session, flags, projectId, orgId);
6002
+ return followBuildLogs(session, flags, projectApiPath, buildId, orgId, {
4916
6003
  initialCursor: 0,
4917
6004
  limit: 200,
4918
- intervalSeconds: parseSecondsOption(flags.interval, '--interval', { defaultValue: 5, min: 0.1 }),
4919
- timeoutSeconds: parseSecondsOption(flags.timeout, '--timeout', { defaultValue: 900, min: 1 }),
6005
+ intervalSeconds,
6006
+ timeoutSeconds,
4920
6007
  })
4921
6008
  }
4922
6009
 
@@ -4962,6 +6049,15 @@ async function main() {
4962
6049
  if (command === 'login') return await handleLogin(session, flags);
4963
6050
  if (command === 'logout') return handleLogout(session, flags);
4964
6051
  if (command === 'plans') return handlePlans(session, flags);
6052
+ if (command === 'git' && subcommand === 'providers') return handleGitProviders(session, flags);
6053
+ if (command === 'git' && subcommand === 'runtimes') return handleGitRuntimes(session, flags);
6054
+ if (command === 'git' && subcommand === 'connections') return handleGitConnections(session, flags);
6055
+ if (command === 'git' && subcommand === 'repositories' && positional.length === 2) {
6056
+ process.stdout.write(commandHelp(['git', 'repositories']));
6057
+ return;
6058
+ }
6059
+ if (command === 'git' && subcommand === 'repositories' && positional[2] === 'resolve') return handleGitRepositoryResolve(session, flags);
6060
+ if (command === 'git' && subcommand === 'branches') return handleGitBranches(session, flags);
4965
6061
  if (command === 'create' && subcommand === 'project') return handleProjectCreate(session, flags);
4966
6062
  if (command === 'orgs') return handleOrgs(session, flags);
4967
6063
  if (command === 'whoami') return handleWhoami(session, flags);
@@ -4989,11 +6085,24 @@ async function main() {
4989
6085
  return;
4990
6086
  }
4991
6087
  if (command === 'project' && subcommand === 'github' && positional.length === 2) return handleProjectGithub(session, flags);
6088
+ if (command === 'project' && subcommand === 'source' && positional.length === 2) return handleProjectSource(session, flags);
6089
+ if (command === 'project' && subcommand === 'source' && positional[2] === 'branches') return handleProjectSourceBranches(session, flags);
6090
+ if (command === 'project' && subcommand === 'source' && positional[2] === 'deploy-settings' && positional[3] === 'set') {
6091
+ return handleProjectSourceDeploySettingsSet(session, flags);
6092
+ }
6093
+ if (command === 'project' && subcommand === 'source' && positional[2] === 'deploy-settings') {
6094
+ process.stdout.write(commandHelp(['project', 'source', 'deploy-settings']));
6095
+ return;
6096
+ }
4992
6097
  if (command === 'project' && subcommand === 'info') return handleProjectInfo(session, flags);
4993
6098
  if (command === 'project' && subcommand === 'stop') return handleProjectLifecycleMutation(session, flags, 'stop');
4994
6099
  if (command === 'project' && subcommand === 'resume') return handleProjectLifecycleMutation(session, flags, 'resume');
4995
6100
  if (command === 'project' && subcommand === 'delete') return handleProjectDelete(session, flags);
4996
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);
4997
6106
  if (command === 'project' && subcommand === 'logs') return handleProjectLogs(session, flags);
4998
6107
  if (command === 'project' && subcommand === 'metrics' && positional[2] === 'capabilities') return handleProjectMetricsCapabilities(session, flags);
4999
6108
  if (command === 'project' && subcommand === 'metrics') return handleProjectMetrics(session, flags);