@zenifra/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # Zenifra CLI
2
+
3
+ CLI oficial da Zenifra para autenticar, selecionar organizacao, listar projetos e disparar deployments.
4
+
5
+ ## Uso local
6
+
7
+ Instale pelo npm:
8
+
9
+ ```bash
10
+ npm install -g @zenifra/cli
11
+ zenifra --help
12
+ ```
13
+
14
+ Para desenvolvimento local:
15
+
16
+ ```bash
17
+ npm link
18
+ zenifra --help
19
+ zenifra help project logs
20
+ ```
21
+
22
+ Ou sem link:
23
+
24
+ ```bash
25
+ node bin/zenifra.mjs --help
26
+ ```
27
+
28
+ Cada comando tem ajuda especifica com exemplos de uso e retorno:
29
+
30
+ ```bash
31
+ zenifra project logs --help
32
+ zenifra help project env add
33
+ ```
34
+
35
+ ## Comandos
36
+
37
+ ```bash
38
+ zenifra auth login
39
+ zenifra auth login --code 123456
40
+ zenifra auth api-key --key znf_sua_chave
41
+ zenifra auth logout
42
+ zenifra orgs
43
+ zenifra org set
44
+ zenifra projects --type http
45
+ zenifra projects create --name <name> --plan free --payment-mode hourly --config @examples/http-project.json
46
+ zenifra project info --project <project-id>
47
+ zenifra project url --project <project-id>
48
+ zenifra project logs --project <project-id> --instance <instance-id>
49
+ zenifra project metrics --project <project-id> --instance <instance-id>
50
+ zenifra project network --project <project-id> --view summary
51
+ zenifra project image set --project <project-id> --image ghcr.io/zenifra/app:tag
52
+ zenifra project envs --project <project-id>
53
+ zenifra project env add --project <project-id> --name NODE_ENV --value production
54
+ zenifra project env update --project <project-id> --name NODE_ENV --value staging
55
+ zenifra project env remove --project <project-id> --name NODE_ENV
56
+ zenifra project instances --project <project-id>
57
+ zenifra project instances set --project <project-id> --count 3
58
+ zenifra builds --project <project-id>
59
+ zenifra deployments --project <project-id>
60
+ zenifra deploy --project <project-id> --branch main
61
+ zenifra deploy watch --project <project-id> --build <build-id>
62
+ ```
63
+
64
+ ## Configuracao
65
+
66
+ - API padrao: `https://api.zenifra.com/v1`
67
+ - Override de API: `ZENIFRA_API_URL=https://api-stg.zenifra.com/v1`
68
+ - API key global: `ZENIFRA_API_KEY=znf_sua_chave`
69
+ - Sessao local: `~/.config/zenifra-cli/session.json`
70
+ - Override de sessao: `ZENIFRA_CONFIG_DIR=/path/custom`
71
+
72
+ Todos os comandos de listagem aceitam `--json`.
73
+
74
+ Valores de variaveis de ambiente sao mascarados por padrao, inclusive em `--json`.
75
+ Use `--show-values` apenas quando precisar inspecionar os valores completos.
76
+
77
+ ## Automacao com API key
78
+
79
+ Crie uma API key global no painel da organizacao e use-a em jobs, pipelines e scripts:
80
+
81
+ ```bash
82
+ export ZENIFRA_API_KEY=znf_sua_chave
83
+ zenifra projects --type http
84
+ zenifra deploy --project <project-id> --branch main
85
+ ```
86
+
87
+ Tambem e possivel salvar a chave localmente:
88
+
89
+ ```bash
90
+ zenifra auth api-key --key znf_sua_chave
91
+ ```
92
+
93
+ API keys globais ja sao vinculadas a uma organizacao, entao comandos como `projects`, `deploy`, `builds` e `deployments` nao precisam de `org set`. Comandos pessoais como `orgs` e `org set` continuam exigindo `zenifra auth login`.
94
+
95
+ Para reduzir impacto de vazamento, configure IPs permitidos na criacao da API key sempre que a automacao tiver origem fixa.
96
+
97
+ ## Exemplo de projeto HTTP
98
+
99
+ Use `examples/http-project.json` como base para `zenifra projects create`.
100
+
101
+ ## Publicacao no npm
102
+
103
+ Este pacote e publicado pelo GitHub Actions usando npm Trusted Publishing. O pacote precisa existir no npm antes de configurar Trusted Publishing.
104
+
105
+ ### Primeiro publish
106
+
107
+ Como `@zenifra/cli` e um pacote scoped publico, faca o primeiro publish autenticado no npm:
108
+
109
+ ```bash
110
+ npm login
111
+ npm publish --access public
112
+ ```
113
+
114
+ ### Releases seguintes
115
+
116
+ 1. Em npmjs.com, configure o pacote `@zenifra/cli` com Trusted Publisher:
117
+ - Organization/user: `zenifra`
118
+ - Repository: `zenifra-cli`
119
+ - Workflow filename: `publish.yml`
120
+ - Environment: `npm`
121
+ - Allowed action: `npm publish`
122
+ 2. Garanta que o repositorio GitHub esteja publico para gerar provenance automatica.
123
+ 3. Crie uma release tag semver:
124
+
125
+ ```bash
126
+ npm version patch
127
+ git push origin main --follow-tags
128
+ ```
129
+
130
+ Tags `v*` disparam `.github/workflows/publish.yml`, que roda `npm ci`, `npm run check`, `npm test`, `npm pack --dry-run` e `npm publish --access public`.
@@ -0,0 +1,1139 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdir, readFile, rm, writeFile, chmod } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ import process from 'node:process';
8
+ import readline from 'node:readline/promises';
9
+
10
+ const DEFAULT_API_BASE_URL = 'https://api.zenifra.com/v1';
11
+ const SESSION_DIR = process.env.ZENIFRA_CONFIG_DIR || join(homedir(), '.config', 'zenifra-cli');
12
+ const SESSION_FILE = join(SESSION_DIR, 'session.json');
13
+ const FINAL_BUILD_STATUSES = new Set(['success', 'failed']);
14
+
15
+ class CliError extends Error {
16
+ constructor(message, exitCode = 1) {
17
+ super(message);
18
+ this.exitCode = exitCode;
19
+ }
20
+ }
21
+
22
+ function usage() {
23
+ return `Zenifra CLI
24
+
25
+ Usage:
26
+ zenifra help <command>
27
+ zenifra auth login [--api-base <url>] [--code <code>]
28
+ zenifra auth api-key --key <znf_key> [--api-base <url>]
29
+ zenifra auth logout
30
+ zenifra orgs [--json]
31
+ zenifra org set [--org <id>]
32
+ zenifra projects [--json] [--org <id>] [--type <http|postgresql|mariadb>]
33
+ zenifra projects create --name <name> --plan <plan> --payment-mode <mode> --config <json|@file> [--description <text>] [--org <id>] [--json]
34
+ zenifra project info --project <id> [--json]
35
+ zenifra project url --project <id> [--json]
36
+ zenifra project logs --project <id> [--instance <id>] [--json]
37
+ zenifra project metrics --project <id> [--instance <id>] [--json]
38
+ zenifra project network --project <id> [--view <summary|status-codes|routes|user-agents|request-events|source-ips>] [--json]
39
+ zenifra project image set --project <id> --image <image> [--json]
40
+ zenifra project envs --project <id> [--json] [--show-values]
41
+ zenifra project env add --project <id> --name <name> --value <value> [--json]
42
+ zenifra project env update --project <id> --name <name> --value <value> [--json]
43
+ zenifra project env remove --project <id> --name <name> [--json]
44
+ zenifra project instances --project <id> [--json]
45
+ zenifra project instances set --project <id> --count <n> [--json]
46
+ zenifra builds --project <id> [--page <n>] [--limit <n>] [--branch <name>] [--status <status>] [--json]
47
+ zenifra deployments --project <id> [--page <n>] [--limit <n>] [--branch <name>] [--status <status>] [--json]
48
+ zenifra deploy --project <id> [--branch <name>] [--commit-sha <sha>] [--json]
49
+ zenifra deploy watch --project <id> --build <id> [--interval <seconds>] [--timeout <seconds>] [--json]
50
+
51
+ Environment:
52
+ ZENIFRA_API_URL Override API base URL.
53
+ ZENIFRA_CONFIG_DIR Override local session directory.
54
+ ZENIFRA_API_KEY Use a global organization API key for automation.
55
+
56
+ Run "zenifra help <command>" or "zenifra <command> --help" for command-specific usage, examples, and output.
57
+ `;
58
+ }
59
+
60
+ const HELP_SPECS = [
61
+ {
62
+ command: 'auth login',
63
+ usage: 'zenifra auth login [--api-base <url>] [--code <code>]',
64
+ description: 'Autentica um usuario Zenifra e salva a sessao local.',
65
+ flags: [
66
+ '--api-base <url> Usa uma API diferente da producao.',
67
+ '--code <code> Informa o codigo de desafio sem prompt interativo.',
68
+ ],
69
+ examples: ['zenifra auth login', 'zenifra auth login --code 123456'],
70
+ output: 'Login realizado com sucesso.',
71
+ notes: ['Exige email, senha e, quando habilitado, codigo de desafio.'],
72
+ },
73
+ {
74
+ command: 'auth api-key',
75
+ usage: 'zenifra auth api-key --key <znf_key> [--api-base <url>]',
76
+ description: 'Salva uma API key organizacional para comandos de automacao.',
77
+ flags: ['--key <znf_key> API key organizacional.', '--api-base <url> Usa uma API diferente da producao.'],
78
+ examples: ['zenifra auth api-key --key znf_0123456789abcdef01234567_abcd...'],
79
+ output: 'API key salva com sucesso. Ela sera usada em comandos de automacao.',
80
+ notes: ['API keys ja carregam a organizacao e nao exigem "zenifra org set".'],
81
+ },
82
+ {
83
+ command: 'auth logout',
84
+ usage: 'zenifra auth logout',
85
+ description: 'Remove a sessao local do CLI.',
86
+ examples: ['zenifra auth logout'],
87
+ output: 'Sessao removida.',
88
+ },
89
+ {
90
+ command: 'orgs',
91
+ usage: 'zenifra orgs [--json]',
92
+ description: 'Lista organizacoes disponiveis para o usuario autenticado.',
93
+ flags: ['--json Imprime a resposta em JSON.'],
94
+ examples: ['zenifra orgs', 'zenifra orgs --json'],
95
+ output: 'ID Nome Role Status\n507f1f77bcf86cd799439011 Minha org owner active',
96
+ jsonOutput: '[{"_id":"507f1f77bcf86cd799439011","name":"Minha org","role":"owner","status":"active"}]',
97
+ },
98
+ {
99
+ command: 'org set',
100
+ usage: 'zenifra org set [--org <id>]',
101
+ description: 'Seleciona a organizacao ativa para comandos com sessao de usuario.',
102
+ flags: ['--org <id> Define a organizacao sem prompt interativo.'],
103
+ examples: ['zenifra org set', 'zenifra org set --org 507f1f77bcf86cd799439011'],
104
+ output: 'Organizacao ativa: 507f1f77bcf86cd799439011',
105
+ },
106
+ {
107
+ command: 'projects',
108
+ usage: 'zenifra projects [--json] [--org <id>] [--type <http|postgresql|mariadb>]',
109
+ description: 'Lista projetos da organizacao ativa ou da API key.',
110
+ flags: ['--json Imprime a resposta em JSON.', '--org <id> Usa uma organizacao especifica com sessao de usuario.', '--type <type> Filtra por tipo de projeto.'],
111
+ examples: ['zenifra projects --type http', 'zenifra projects --json'],
112
+ output: 'ID Nome Status Plano Tipo\n507f1f77bcf86cd799439012 api-web running free http',
113
+ jsonOutput: '[{"id":"507f1f77bcf86cd799439012","name":"api-web","status":"running","type_project":"http"}]',
114
+ },
115
+ {
116
+ command: 'projects create',
117
+ usage: 'zenifra projects create --name <name> --plan <plan> --payment-mode <mode> --config <json|@file> [--description <text>] [--org <id>] [--json]',
118
+ description: 'Cria um projeto usando um payload de configuracao JSON.',
119
+ flags: ['--name <name> Nome do projeto.', '--plan <plan> Plano do projeto.', '--payment-mode <mode> Modo de pagamento.', '--config <json|@file> JSON inline ou arquivo.', '--description <text> Descricao opcional.', '--json Imprime a resposta em JSON.'],
120
+ examples: ['zenifra projects create --name api-web --plan free --payment-mode hourly --config @examples/http-project.json'],
121
+ output: 'Projeto criado: 507f1f77bcf86cd799439012\nDominio: api-web.client.zenifra.com',
122
+ jsonOutput: '{"status":"success","data":{"id":"507f1f77bcf86cd799439012","name":"api-web"}}',
123
+ },
124
+ {
125
+ command: 'project info',
126
+ usage: 'zenifra project info --project <id> [--json]',
127
+ description: 'Mostra dados principais de um projeto.',
128
+ flags: ['--project <id> ID do projeto.', '--json Imprime a resposta em JSON.'],
129
+ examples: ['zenifra project info --project 507f1f77bcf86cd799439012'],
130
+ output: 'Nome: api-web\nStatus: running\nURL: https://api-web.client.zenifra.com\nInstancias: 2',
131
+ jsonOutput: '{"name":"api-web","status":"running","domain":"api-web.client.zenifra.com","instances":2}',
132
+ },
133
+ {
134
+ command: 'project url',
135
+ usage: 'zenifra project url --project <id> [--json]',
136
+ description: 'Imprime a URL publica principal do projeto.',
137
+ flags: ['--project <id> ID do projeto.', '--json Inclui dominio e custom domains em JSON.'],
138
+ examples: ['zenifra project url --project 507f1f77bcf86cd799439012'],
139
+ output: 'https://api-web.client.zenifra.com',
140
+ jsonOutput: '{"project_id":"507f1f77bcf86cd799439012","url":"https://api-web.client.zenifra.com","domain":"api-web.client.zenifra.com","custom_domains":[]}',
141
+ },
142
+ {
143
+ command: 'project logs',
144
+ usage: 'zenifra project logs --project <id> [--instance <id>] [--json]',
145
+ description: 'Mostra snapshot dos logs de runtime do projeto.',
146
+ flags: ['--project <id> ID do projeto.', '--instance <id> Filtra uma instancia.', '--json Imprime a resposta em JSON.'],
147
+ examples: ['zenifra project logs --project 507f1f77bcf86cd799439012', 'zenifra project logs --project 507f1f77bcf86cd799439012 --instance web-1'],
148
+ output: 'server started\nGET /health 200',
149
+ jsonOutput: '"server started\\nGET /health 200"',
150
+ notes: ['Nao faz streaming continuo; retorna o snapshot disponibilizado pela API.'],
151
+ },
152
+ {
153
+ command: 'project metrics',
154
+ usage: 'zenifra project metrics --project <id> [--instance <id>] [--json]',
155
+ description: 'Mostra CPU, memoria e dados basicos de rede do projeto.',
156
+ flags: ['--project <id> ID do projeto.', '--instance <id> Filtra uma instancia.', '--json Imprime a resposta em JSON.'],
157
+ examples: ['zenifra project metrics --project 507f1f77bcf86cd799439012 --instance web-1'],
158
+ output: 'Instancia Tipo CPU Memoria\nweb-1 application 0.2 64',
159
+ jsonOutput: '{"type":"application","instance":"web-1","cpu":0.2,"memory":64,"network":{"requests":120}}',
160
+ },
161
+ {
162
+ command: 'project network',
163
+ usage: 'zenifra project network --project <id> [--view <summary|status-codes|routes|user-agents|request-events|source-ips>] [--json]',
164
+ description: 'Mostra analytics de rede para projetos HTTP.',
165
+ flags: ['--project <id> ID do projeto.', '--view <view> Visao de rede. Padrao: summary.', '--json Imprime a resposta em JSON.'],
166
+ examples: ['zenifra project network --project 507f1f77bcf86cd799439012 --view summary'],
167
+ output: '{"requests":120,"bytes_received":4096,"bytes_sent":8192}',
168
+ jsonOutput: '{"requests":120,"bytes_received":4096,"bytes_sent":8192}',
169
+ },
170
+ {
171
+ command: 'project image set',
172
+ usage: 'zenifra project image set --project <id> --image <image> [--json]',
173
+ description: 'Atualiza a imagem de deploy do projeto.',
174
+ flags: ['--project <id> ID do projeto.', '--image <image> Imagem completa.', '--json Imprime a resposta em JSON.'],
175
+ examples: ['zenifra project image set --project 507f1f77bcf86cd799439012 --image ghcr.io/zenifra/app:1.2.3'],
176
+ output: 'Imagem atualizada com sucesso.',
177
+ jsonOutput: '{"status":"success","message":"updated with success"}',
178
+ },
179
+ {
180
+ command: 'project envs',
181
+ usage: 'zenifra project envs --project <id> [--json] [--show-values]',
182
+ description: 'Lista variaveis de ambiente do projeto.',
183
+ flags: ['--project <id> ID do projeto.', '--json Imprime a resposta em JSON.', '--show-values Mostra valores completos. Use com cuidado.'],
184
+ examples: ['zenifra project envs --project 507f1f77bcf86cd799439012'],
185
+ output: 'Nome Valor\nNODE_ENV ********',
186
+ jsonOutput: '[{"name":"NODE_ENV","value":"********"}]',
187
+ notes: ['Valores sao mascarados por padrao, inclusive em JSON.'],
188
+ },
189
+ {
190
+ command: 'project env add',
191
+ usage: 'zenifra project env add --project <id> --name <name> --value <value> [--json]',
192
+ description: 'Adiciona uma variavel de ambiente ao projeto.',
193
+ flags: ['--project <id> ID do projeto.', '--name <name> Nome da variavel.', '--value <value> Valor da variavel.', '--json Imprime a resposta em JSON.'],
194
+ examples: ['zenifra project env add --project 507f1f77bcf86cd799439012 --name NODE_ENV --value production'],
195
+ output: 'Env NODE_ENV atualizada com sucesso.',
196
+ jsonOutput: '{"status":"success","message":"updated with success","envs":[{"name":"NODE_ENV","value":"********"}]}',
197
+ notes: ['Falha se a variavel ja existir. Use update para alterar.'],
198
+ },
199
+ {
200
+ command: 'project env update',
201
+ usage: 'zenifra project env update --project <id> --name <name> --value <value> [--json]',
202
+ description: 'Atualiza uma variavel de ambiente existente.',
203
+ flags: ['--project <id> ID do projeto.', '--name <name> Nome da variavel.', '--value <value> Novo valor.', '--json Imprime a resposta em JSON.'],
204
+ examples: ['zenifra project env update --project 507f1f77bcf86cd799439012 --name NODE_ENV --value staging'],
205
+ output: 'Env NODE_ENV atualizada com sucesso.',
206
+ jsonOutput: '{"status":"success","message":"updated with success","envs":[{"name":"NODE_ENV","value":"********"}]}',
207
+ notes: ['Falha se a variavel nao existir.'],
208
+ },
209
+ {
210
+ command: 'project env remove',
211
+ usage: 'zenifra project env remove --project <id> --name <name> [--json]',
212
+ description: 'Remove uma variavel de ambiente existente.',
213
+ flags: ['--project <id> ID do projeto.', '--name <name> Nome da variavel.', '--json Imprime a resposta em JSON.'],
214
+ examples: ['zenifra project env remove --project 507f1f77bcf86cd799439012 --name NODE_ENV'],
215
+ output: 'Env NODE_ENV removida com sucesso.',
216
+ jsonOutput: '{"status":"success","message":"updated with success","envs":[]}',
217
+ notes: ['Falha se a variavel nao existir.'],
218
+ },
219
+ {
220
+ command: 'project instances',
221
+ usage: 'zenifra project instances --project <id> [--json]',
222
+ description: 'Lista instancias/pods do projeto.',
223
+ flags: ['--project <id> ID do projeto.', '--json Imprime a resposta em JSON.'],
224
+ examples: ['zenifra project instances --project 507f1f77bcf86cd799439012'],
225
+ output: 'Instancia\nweb-1',
226
+ jsonOutput: '[{"instance":"web-1"}]',
227
+ },
228
+ {
229
+ command: 'project instances set',
230
+ usage: 'zenifra project instances set --project <id> --count <n> [--json]',
231
+ description: 'Atualiza a quantidade de instancias do projeto.',
232
+ flags: ['--project <id> ID do projeto.', '--count <n> Quantidade desejada.', '--json Imprime a resposta em JSON.'],
233
+ examples: ['zenifra project instances set --project 507f1f77bcf86cd799439012 --count 3'],
234
+ output: 'Quantidade de instancias atualizada para 3.',
235
+ jsonOutput: '{"status":"success","message":"project instances changed with success"}',
236
+ },
237
+ {
238
+ command: 'builds',
239
+ usage: 'zenifra builds --project <id> [--page <n>] [--limit <n>] [--branch <name>] [--status <status>] [--json]',
240
+ description: 'Lista builds GitHub de um projeto.',
241
+ 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.'],
242
+ examples: ['zenifra builds --project 507f1f77bcf86cd799439012 --status success'],
243
+ output: 'Build Status Branch Commit\nbuild_123 success main abc123',
244
+ jsonOutput: '[{"id":"build_123","status":"success","branch":"main","commit_sha":"abc123"}]',
245
+ },
246
+ {
247
+ command: 'deployments',
248
+ usage: 'zenifra deployments --project <id> [--page <n>] [--limit <n>] [--branch <name>] [--status <status>] [--json]',
249
+ description: 'Alias para listar builds/deployments GitHub de um projeto.',
250
+ 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.'],
251
+ examples: ['zenifra deployments --project 507f1f77bcf86cd799439012'],
252
+ output: 'Build Status Branch Commit\nbuild_123 success main abc123',
253
+ jsonOutput: '[{"id":"build_123","status":"success","branch":"main","commit_sha":"abc123"}]',
254
+ },
255
+ {
256
+ command: 'deploy',
257
+ usage: 'zenifra deploy --project <id> [--branch <name>] [--commit-sha <sha>] [--json]',
258
+ description: 'Dispara um deploy GitHub para o projeto.',
259
+ flags: ['--project <id> ID do projeto.', '--branch <name> Branch a publicar.', '--commit-sha <sha> Commit especifico.', '--json Imprime a resposta em JSON.'],
260
+ examples: ['zenifra deploy --project 507f1f77bcf86cd799439012 --branch main'],
261
+ output: 'Deploy iniciado: build_123',
262
+ jsonOutput: '{"status":"success","data":{"build_id":"build_123"}}',
263
+ },
264
+ {
265
+ command: 'deploy watch',
266
+ usage: 'zenifra deploy watch --project <id> --build <id> [--interval <seconds>] [--timeout <seconds>] [--json]',
267
+ description: 'Acompanha um build ate status terminal.',
268
+ flags: ['--project <id> ID do projeto.', '--build <id> ID do build.', '--interval <seconds> Intervalo de polling. Padrao: 5.', '--timeout <seconds> Timeout total. Padrao: 900.', '--json Imprime cada resposta em JSON.'],
269
+ examples: ['zenifra deploy watch --project 507f1f77bcf86cd799439012 --build build_123'],
270
+ output: '[2026-05-27T12:00:00.000Z] build build_123: building\n[2026-05-27T12:00:05.000Z] build build_123: success',
271
+ jsonOutput: '{"id":"build_123","status":"success"}',
272
+ },
273
+ ];
274
+
275
+ const HELP_BY_COMMAND = new Map(HELP_SPECS.map((spec) => [spec.command, spec]));
276
+
277
+ function formatHelp(spec) {
278
+ const sections = [
279
+ `Zenifra CLI - ${spec.command}`,
280
+ '',
281
+ 'Usage:',
282
+ ` ${spec.usage}`,
283
+ '',
284
+ 'Description:',
285
+ ` ${spec.description}`,
286
+ ];
287
+
288
+ if (spec.flags?.length) {
289
+ sections.push('', 'Flags:', ...spec.flags.map((flag) => ` ${flag}`));
290
+ }
291
+
292
+ sections.push('', 'Examples:', ...spec.examples.map((example) => ` ${example}`));
293
+ sections.push('', 'Example output:', spec.output.split('\n').map((line) => ` ${line}`).join('\n'));
294
+
295
+ if (spec.jsonOutput) {
296
+ sections.push('', 'Example JSON output:', ` ${spec.jsonOutput}`);
297
+ }
298
+
299
+ if (spec.notes?.length) {
300
+ sections.push('', 'Notes:', ...spec.notes.map((note) => ` ${note}`));
301
+ }
302
+
303
+ return `${sections.join('\n')}\n`;
304
+ }
305
+
306
+ function helpKeyFromPositionals(positionals) {
307
+ return positionals.filter((value) => value !== 'help').join(' ').trim();
308
+ }
309
+
310
+ function commandHelp(positionals) {
311
+ const key = helpKeyFromPositionals(positionals);
312
+ if (!key) return usage();
313
+
314
+ const spec = HELP_BY_COMMAND.get(key);
315
+ if (!spec) {
316
+ throw new CliError(`Ajuda nao encontrada para: ${key}. Use "zenifra help <command>".`);
317
+ }
318
+
319
+ return formatHelp(spec);
320
+ }
321
+
322
+ function parseArgs(argv) {
323
+ const positional = [];
324
+ const flags = {};
325
+
326
+ for (let index = 0; index < argv.length; index += 1) {
327
+ const arg = argv[index];
328
+ if (!arg.startsWith('--')) {
329
+ positional.push(arg);
330
+ continue;
331
+ }
332
+
333
+ const [rawKey, inlineValue] = arg.slice(2).split('=', 2);
334
+ const key = rawKey.replace(/-([a-z])/g, (_, value) => value.toUpperCase());
335
+
336
+ if (inlineValue !== undefined) {
337
+ flags[key] = inlineValue;
338
+ continue;
339
+ }
340
+
341
+ const next = argv[index + 1];
342
+ if (!next || next.startsWith('--')) {
343
+ flags[key] = true;
344
+ continue;
345
+ }
346
+
347
+ flags[key] = next;
348
+ index += 1;
349
+ }
350
+
351
+ return { positional, flags };
352
+ }
353
+
354
+ async function readSession() {
355
+ if (!existsSync(SESSION_FILE)) {
356
+ return {};
357
+ }
358
+
359
+ try {
360
+ return JSON.parse(await readFile(SESSION_FILE, 'utf8'));
361
+ } catch {
362
+ throw new CliError(`Sessao local invalida em ${SESSION_FILE}. Rode "zenifra auth logout" e faca login novamente.`);
363
+ }
364
+ }
365
+
366
+ async function writeSession(session) {
367
+ await mkdir(dirname(SESSION_FILE), { recursive: true });
368
+ await writeFile(SESSION_FILE, `${JSON.stringify(session, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
369
+ await chmod(SESSION_FILE, 0o600).catch(() => undefined);
370
+ }
371
+
372
+ function apiBaseUrl(session, flags) {
373
+ return String(flags.apiBase || process.env.ZENIFRA_API_URL || session.apiBaseUrl || DEFAULT_API_BASE_URL).replace(/\/$/, '');
374
+ }
375
+
376
+ function resolveCredential(session) {
377
+ if (process.env.ZENIFRA_API_KEY) {
378
+ return { token: process.env.ZENIFRA_API_KEY, type: 'api_key', source: 'env' };
379
+ }
380
+
381
+ if (session.apiKey) {
382
+ return { token: session.apiKey, type: 'api_key', source: 'session' };
383
+ }
384
+
385
+ if (session.accessToken) {
386
+ return { token: session.accessToken, type: 'user', source: 'session' };
387
+ }
388
+
389
+ return null;
390
+ }
391
+
392
+ function hasApiKeyCredential(session) {
393
+ return Boolean(process.env.ZENIFRA_API_KEY || session.apiKey);
394
+ }
395
+
396
+ function requireUserSession(session, action) {
397
+ if (session.accessToken) {
398
+ return;
399
+ }
400
+
401
+ if (hasApiKeyCredential(session)) {
402
+ throw new CliError(`${action} exige login de usuario. A API key global ja define a organizacao para comandos de automacao.`);
403
+ }
404
+
405
+ throw new CliError('Voce precisa fazer login primeiro: zenifra auth login');
406
+ }
407
+
408
+ async function prompt(question, defaultValue) {
409
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
410
+ const suffix = defaultValue ? ` (${defaultValue})` : '';
411
+ const answer = await rl.question(`${question}${suffix}: `);
412
+ rl.close();
413
+ return answer.trim() || defaultValue || '';
414
+ }
415
+
416
+ async function promptHidden(question) {
417
+ if (!process.stdin.isTTY) {
418
+ return prompt(question);
419
+ }
420
+
421
+ return new Promise((resolve, reject) => {
422
+ let value = '';
423
+ const stdin = process.stdin;
424
+
425
+ const cleanup = () => {
426
+ stdin.setRawMode(false);
427
+ stdin.pause();
428
+ stdin.off('data', onData);
429
+ process.stdout.write('\n');
430
+ };
431
+
432
+ const onData = (buffer) => {
433
+ const char = buffer.toString('utf8');
434
+ if (char === '\u0003') {
435
+ cleanup();
436
+ reject(new CliError('Operacao cancelada.', 130));
437
+ return;
438
+ }
439
+ if (char === '\r' || char === '\n') {
440
+ cleanup();
441
+ resolve(value);
442
+ return;
443
+ }
444
+ if (char === '\u007f') {
445
+ value = value.slice(0, -1);
446
+ return;
447
+ }
448
+ value += char;
449
+ };
450
+
451
+ process.stdout.write(`${question}: `);
452
+ stdin.setRawMode(true);
453
+ stdin.resume();
454
+ stdin.on('data', onData);
455
+ });
456
+ }
457
+
458
+ async function request(session, flags, method, path, { body, orgId, tokenRequired = true } = {}) {
459
+ const credential = resolveCredential(session);
460
+ if (tokenRequired && !credential) {
461
+ throw new CliError('Voce precisa autenticar primeiro: zenifra auth login, zenifra auth api-key --key <znf_key> ou ZENIFRA_API_KEY.');
462
+ }
463
+
464
+ const headers = { Accept: 'application/json' };
465
+ if (body !== undefined) {
466
+ headers['Content-Type'] = 'application/json';
467
+ }
468
+ if (credential?.token) {
469
+ headers.Authorization = `Bearer ${credential.token}`;
470
+ }
471
+ if (orgId && credential?.type !== 'api_key') {
472
+ headers['x-organization-id'] = orgId;
473
+ }
474
+
475
+ const response = await fetch(`${apiBaseUrl(session, flags)}${path}`, {
476
+ method,
477
+ headers,
478
+ body: body === undefined ? undefined : JSON.stringify(body),
479
+ });
480
+
481
+ const text = await response.text();
482
+ let payload = {};
483
+ if (text) {
484
+ try {
485
+ payload = JSON.parse(text);
486
+ } catch {
487
+ payload = { message: text };
488
+ }
489
+ }
490
+
491
+ if (!response.ok) {
492
+ const message = payload?.message || payload?.error || `Zenifra API retornou HTTP ${response.status}`;
493
+ if (response.status === 401 || response.status === 403) {
494
+ if (credential?.type === 'api_key') {
495
+ throw new CliError(`${message}. Verifique se a API key esta ativa, possui permissao RBAC e se o IP atual esta permitido.`);
496
+ }
497
+ throw new CliError(`${message}. Rode "zenifra auth login" para renovar sua sessao.`);
498
+ }
499
+ if (String(message).includes('missing x-organization-id')) {
500
+ throw new CliError('Organizacao nao selecionada. Rode "zenifra org set" ou use --org <id>.');
501
+ }
502
+ throw new CliError(message);
503
+ }
504
+
505
+ return payload;
506
+ }
507
+
508
+ function unwrapData(payload) {
509
+ return payload?.data ?? payload;
510
+ }
511
+
512
+ function pickToken(payload) {
513
+ return payload?.data?.token || payload?.data?.access_token || payload?.token || payload?.access_token;
514
+ }
515
+
516
+ function printJson(value) {
517
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
518
+ }
519
+
520
+ function printTable(rows, columns) {
521
+ if (!rows.length) {
522
+ process.stdout.write('Nenhum registro encontrado.\n');
523
+ return;
524
+ }
525
+
526
+ const widths = columns.map((column) => Math.max(
527
+ column.label.length,
528
+ ...rows.map((row) => String(column.value(row) ?? '').length),
529
+ ));
530
+
531
+ process.stdout.write(`${columns.map((column, index) => column.label.padEnd(widths[index])).join(' ')}\n`);
532
+ process.stdout.write(`${widths.map((width) => '-'.repeat(width)).join(' ')}\n`);
533
+
534
+ for (const row of rows) {
535
+ process.stdout.write(`${columns.map((column, index) => String(column.value(row) ?? '').padEnd(widths[index])).join(' ')}\n`);
536
+ }
537
+ }
538
+
539
+ function asArray(value) {
540
+ if (Array.isArray(value)) return value;
541
+ if (Array.isArray(value?.projects)) return value.projects;
542
+ if (Array.isArray(value?.organizations)) return value.organizations;
543
+ if (Array.isArray(value?.builds)) return value.builds;
544
+ if (Array.isArray(value?.deployments)) return value.deployments;
545
+ return [];
546
+ }
547
+
548
+ function idOf(item) {
549
+ return item?._id || item?.id || item?.organization_id || item?.project_id || '';
550
+ }
551
+
552
+ function projectIdOf(project) {
553
+ return project?.project_id || idOf(project);
554
+ }
555
+
556
+ function buildIdOf(build) {
557
+ return build?.id || build?._id || build?.build_id || '';
558
+ }
559
+
560
+ function requireProjectId(flags) {
561
+ if (!flags.project) throw new CliError('Informe --project <id>.');
562
+ return String(flags.project);
563
+ }
564
+
565
+ function maskEnvValue(value) {
566
+ if (value === undefined || value === null) return '';
567
+ return '********';
568
+ }
569
+
570
+ function envsForOutput(envs, { showValues = false } = {}) {
571
+ return asArray(envs).map((env) => ({
572
+ ...env,
573
+ value: showValues ? env.value : maskEnvValue(env.value),
574
+ }));
575
+ }
576
+
577
+ function printEnvs(envs, flags) {
578
+ const output = envsForOutput(envs, { showValues: Boolean(flags.showValues) });
579
+ if (flags.json) return printJson(output);
580
+ printTable(output, [
581
+ { label: 'Nome', value: (env) => env.name || '-' },
582
+ { label: 'Valor', value: (env) => env.value || '-' },
583
+ ]);
584
+ }
585
+
586
+ function printProject(project) {
587
+ const url = projectUrlOf(project);
588
+ const rows = [
589
+ { label: 'ID', value: projectIdOf(project) || '-' },
590
+ { label: 'Nome', value: project.name || '-' },
591
+ { label: 'Status', value: project.status || '-' },
592
+ { label: 'Tipo', value: project.type_project || '-' },
593
+ { label: 'Plano', value: project.plan || '-' },
594
+ { label: 'URL', value: url || '-' },
595
+ { label: 'Imagem', value: project.image || '-' },
596
+ { label: 'Instancias', value: project.instances ?? '-' },
597
+ { label: 'Instancias atuais', value: project.additional_info?.current_instances ?? '-' },
598
+ ];
599
+
600
+ for (const row of rows) {
601
+ process.stdout.write(`${row.label}: ${row.value}\n`);
602
+ }
603
+ }
604
+
605
+ function projectUrlOf(project) {
606
+ const domain = project?.domain || project?.url || '';
607
+ if (!domain) return '';
608
+ return /^https?:\/\//i.test(domain) ? domain : `https://${domain}`;
609
+ }
610
+
611
+ function printLogs(logs, flags) {
612
+ if (flags.json) return printJson(logs);
613
+ if (Array.isArray(logs)) {
614
+ process.stdout.write(`${logs.filter(Boolean).join('\n')}\n`);
615
+ return;
616
+ }
617
+ process.stdout.write(`${logs || ''}${String(logs || '').endsWith('\n') ? '' : '\n'}`);
618
+ }
619
+
620
+ async function getOrganizations(session, flags) {
621
+ requireUserSession(session, 'Listar organizacoes');
622
+ return asArray(unwrapData(await request(session, flags, 'GET', '/organizations')));
623
+ }
624
+
625
+ async function resolveOrgId(session, flags, { interactive = true } = {}) {
626
+ if (hasApiKeyCredential(session)) return undefined;
627
+ if (flags.org) return String(flags.org);
628
+ if (session.selectedOrganizationId) return session.selectedOrganizationId;
629
+ if (!interactive) throw new CliError('Organizacao nao selecionada. Rode "zenifra org set" ou use --org <id>.');
630
+
631
+ const organizations = await getOrganizations(session, flags);
632
+ if (organizations.length === 0) {
633
+ throw new CliError('Nenhuma organizacao disponivel para este usuario.');
634
+ }
635
+ if (organizations.length === 1) {
636
+ session.selectedOrganizationId = idOf(organizations[0]);
637
+ await writeSession(session);
638
+ return session.selectedOrganizationId;
639
+ }
640
+
641
+ process.stdout.write('Selecione a organizacao ativa:\n');
642
+ organizations.forEach((org, index) => {
643
+ process.stdout.write(` ${index + 1}. ${org.name || org.organization?.name || idOf(org)} (${idOf(org)})\n`);
644
+ });
645
+
646
+ const answer = await prompt('Numero da organizacao');
647
+ const selected = organizations[Number(answer) - 1];
648
+ if (!selected) {
649
+ throw new CliError('Organizacao invalida.');
650
+ }
651
+
652
+ session.selectedOrganizationId = idOf(selected);
653
+ await writeSession(session);
654
+ return session.selectedOrganizationId;
655
+ }
656
+
657
+ async function handleLogin(session, flags) {
658
+ const email = String(flags.email || await prompt('Email'));
659
+ const password = String(flags.password || await promptHidden('Senha'));
660
+
661
+ const loginPayload = await request(session, flags, 'PATCH', '/authentication', {
662
+ tokenRequired: false,
663
+ body: { email, password },
664
+ });
665
+
666
+ let payload = loginPayload;
667
+ const directToken = pickToken(loginPayload);
668
+
669
+ if (!directToken) {
670
+ const loginData = unwrapData(loginPayload);
671
+
672
+ if (!loginData?.requires_challenge) {
673
+ throw new CliError('Login nao retornou token nem desafio de autenticacao.');
674
+ }
675
+
676
+ const challengeToken = String(flags.challengeToken || loginData.challenge_token || '');
677
+ if (!challengeToken) {
678
+ throw new CliError('Login exigiu desafio, mas a API nao retornou challenge_token.');
679
+ }
680
+
681
+ const challengeType = loginData.challenge_type || 'email_code';
682
+ const destination = loginData.masked_destination ? ` (${loginData.masked_destination})` : '';
683
+ const label = challengeType === 'totp_app'
684
+ ? 'Codigo do app autenticador'
685
+ : `Codigo de verificacao${destination}`;
686
+ const code = String(flags.code || flags.totp || await prompt(label));
687
+
688
+ payload = await request(session, flags, 'POST', '/authentication/challenge/verify', {
689
+ tokenRequired: false,
690
+ body: {
691
+ challenge_token: challengeToken,
692
+ code,
693
+ },
694
+ });
695
+ }
696
+
697
+ const accessToken = pickToken(payload);
698
+ if (!accessToken) {
699
+ throw new CliError('Login concluido sem token na resposta. Verifique o contrato de /authentication/challenge/verify.');
700
+ }
701
+
702
+ const nextSession = {
703
+ ...session,
704
+ accessToken,
705
+ apiKey: undefined,
706
+ apiBaseUrl: apiBaseUrl(session, flags),
707
+ selectedOrganizationId: session.selectedOrganizationId,
708
+ updatedAt: new Date().toISOString(),
709
+ };
710
+
711
+ await writeSession(nextSession);
712
+ process.stdout.write('Login realizado com sucesso.\n');
713
+ }
714
+
715
+ async function handleApiKeyLogin(session, flags) {
716
+ const apiKey = String(flags.key || await promptHidden('API key Zenifra'));
717
+
718
+ if (!apiKey.startsWith('znf_')) {
719
+ throw new CliError('API key invalida. As API keys globais da Zenifra comecam com "znf_".');
720
+ }
721
+
722
+ const nextSession = {
723
+ ...session,
724
+ accessToken: undefined,
725
+ selectedOrganizationId: undefined,
726
+ apiKey,
727
+ apiBaseUrl: apiBaseUrl(session, flags),
728
+ updatedAt: new Date().toISOString(),
729
+ };
730
+
731
+ await writeSession(nextSession);
732
+ process.stdout.write('API key salva com sucesso. Ela sera usada em comandos de automacao.\n');
733
+ }
734
+
735
+ async function handleLogout() {
736
+ await rm(SESSION_FILE, { force: true });
737
+ process.stdout.write('Sessao removida.\n');
738
+ }
739
+
740
+ async function handleOrgs(session, flags) {
741
+ const organizations = await getOrganizations(session, flags);
742
+ if (flags.json) return printJson(organizations);
743
+ printTable(organizations, [
744
+ { label: 'ID', value: idOf },
745
+ { label: 'Nome', value: (org) => org.name || org.organization?.name || '-' },
746
+ { label: 'Role', value: (org) => org.role || '-' },
747
+ { label: 'Status', value: (org) => org.status || '-' },
748
+ ]);
749
+ }
750
+
751
+ async function handleOrgSet(session, flags) {
752
+ requireUserSession(session, 'Selecionar organizacao');
753
+
754
+ if (flags.org) {
755
+ session.selectedOrganizationId = String(flags.org);
756
+ await writeSession(session);
757
+ process.stdout.write(`Organizacao ativa: ${session.selectedOrganizationId}\n`);
758
+ return;
759
+ }
760
+
761
+ const orgId = await resolveOrgId({ ...session, selectedOrganizationId: undefined }, flags, { interactive: true });
762
+ process.stdout.write(`Organizacao ativa: ${orgId}\n`);
763
+ }
764
+
765
+ async function handleProjects(session, flags) {
766
+ const orgId = await resolveOrgId(session, flags);
767
+ const query = buildQuery(flags, ['type']);
768
+ const data = unwrapData(await request(session, flags, 'GET', `/project${query}`, { orgId }));
769
+ const projects = asArray(data);
770
+
771
+ if (flags.json) return printJson(data);
772
+ printTable(projects, [
773
+ { label: 'ID', value: projectIdOf },
774
+ { label: 'Nome', value: (project) => project.name || '-' },
775
+ { label: 'Status', value: (project) => project.status || '-' },
776
+ { label: 'Plano', value: (project) => project.plan || '-' },
777
+ { label: 'Tipo', value: (project) => project.type_project || project.config?.type_project || '-' },
778
+ ]);
779
+ }
780
+
781
+ async function parseConfig(input) {
782
+ if (!input) {
783
+ throw new CliError('Informe --config com JSON ou @arquivo.json.');
784
+ }
785
+ const raw = String(input).startsWith('@')
786
+ ? await readFile(String(input).slice(1), 'utf8')
787
+ : String(input);
788
+
789
+ try {
790
+ return JSON.parse(raw);
791
+ } catch {
792
+ throw new CliError('Config invalido. Use JSON valido ou @arquivo.json.');
793
+ }
794
+ }
795
+
796
+ async function handleProjectCreate(session, flags) {
797
+ const orgId = await resolveOrgId(session, flags);
798
+ const name = flags.name || await prompt('Nome do projeto');
799
+ const plan = flags.plan || await prompt('Plano', 'free');
800
+ const paymentMode = flags.paymentMode || await prompt('Modo de pagamento', 'hourly');
801
+ const config = await parseConfig(flags.config || await prompt('Config JSON ou @arquivo'));
802
+
803
+ const payload = await request(session, flags, 'POST', '/project', {
804
+ orgId,
805
+ body: {
806
+ name,
807
+ ...(flags.description ? { description: String(flags.description) } : {}),
808
+ plan,
809
+ payment_mode: paymentMode,
810
+ config,
811
+ },
812
+ });
813
+
814
+ if (flags.json) return printJson(payload);
815
+ const project = unwrapData(payload);
816
+ process.stdout.write(`Projeto criado: ${projectIdOf(project) || 'id indisponivel'}\n`);
817
+ if (project.domain) process.stdout.write(`Dominio: ${project.domain}\n`);
818
+ if (project.api_key) process.stdout.write(`API key: ${project.api_key}\n`);
819
+ }
820
+
821
+ async function getProject(session, flags, projectId, orgId) {
822
+ return unwrapData(await request(session, flags, 'GET', `/project/${projectId}`, { orgId }));
823
+ }
824
+
825
+ async function handleProjectInfo(session, flags) {
826
+ const projectId = requireProjectId(flags);
827
+ const orgId = await resolveOrgId(session, flags);
828
+ const project = await getProject(session, flags, projectId, orgId);
829
+
830
+ if (flags.json) return printJson(project);
831
+ printProject({ ...project, id: projectId });
832
+ }
833
+
834
+ async function handleProjectUrl(session, flags) {
835
+ const projectId = requireProjectId(flags);
836
+ const orgId = await resolveOrgId(session, flags);
837
+ const project = await getProject(session, flags, projectId, orgId);
838
+ const url = projectUrlOf(project);
839
+ const payload = {
840
+ project_id: projectId,
841
+ url,
842
+ domain: project.domain || null,
843
+ custom_domains: project.custom_domains || [],
844
+ };
845
+
846
+ if (flags.json) return printJson(payload);
847
+ if (!url) throw new CliError('Projeto nao possui URL disponivel.');
848
+ process.stdout.write(`${url}\n`);
849
+ }
850
+
851
+ async function handleProjectLogs(session, flags) {
852
+ const projectId = requireProjectId(flags);
853
+ const orgId = await resolveOrgId(session, flags);
854
+ const query = buildQuery(flags, ['instance']);
855
+ const logs = unwrapData(await request(session, flags, 'GET', `/project/${projectId}/logs${query}`, { orgId }));
856
+
857
+ printLogs(logs, flags);
858
+ }
859
+
860
+ async function handleProjectMetrics(session, flags) {
861
+ const projectId = requireProjectId(flags);
862
+ const orgId = await resolveOrgId(session, flags);
863
+ const query = buildQuery(flags, ['instance']);
864
+ const metrics = unwrapData(await request(session, flags, 'GET', `/project/${projectId}/metrics${query}`, { orgId }));
865
+
866
+ if (flags.json) return printJson(metrics);
867
+ if (Array.isArray(metrics)) {
868
+ return printTable(metrics, [
869
+ { label: 'Instancia', value: (metric) => metric.instance || '-' },
870
+ { label: 'Tipo', value: (metric) => metric.type || '-' },
871
+ { label: 'CPU', value: (metric) => metric.cpu ?? '-' },
872
+ { label: 'Memoria', value: (metric) => metric.memory ?? '-' },
873
+ ]);
874
+ }
875
+ printJson(metrics);
876
+ }
877
+
878
+ async function handleProjectNetwork(session, flags) {
879
+ const projectId = requireProjectId(flags);
880
+ const orgId = await resolveOrgId(session, flags);
881
+ const view = String(flags.view || 'summary');
882
+ const allowedViews = new Set(['summary', 'status-codes', 'routes', 'user-agents', 'request-events', 'source-ips']);
883
+
884
+ if (!allowedViews.has(view)) {
885
+ throw new CliError('View de rede invalida. Use summary, status-codes, routes, user-agents, request-events ou source-ips.');
886
+ }
887
+
888
+ const data = unwrapData(await request(session, flags, 'GET', `/project/${projectId}/metrics/network/${view}`, { orgId }));
889
+ if (flags.json) return printJson(data);
890
+ printJson(data);
891
+ }
892
+
893
+ async function handleProjectImageSet(session, flags) {
894
+ const projectId = requireProjectId(flags);
895
+ const image = flags.image || await prompt('Imagem');
896
+ if (!image) throw new CliError('Informe --image <image>.');
897
+
898
+ const orgId = await resolveOrgId(session, flags);
899
+ const payload = await request(session, flags, 'PATCH', `/project/${projectId}/image`, {
900
+ orgId,
901
+ body: { image: String(image) },
902
+ });
903
+
904
+ if (flags.json) return printJson(payload);
905
+ process.stdout.write('Imagem atualizada com sucesso.\n');
906
+ }
907
+
908
+ async function getProjectEnvs(session, flags, projectId, orgId) {
909
+ return asArray(unwrapData(await request(session, flags, 'GET', `/project/${projectId}/envs`, { orgId })));
910
+ }
911
+
912
+ async function patchProjectEnvs(session, flags, projectId, orgId, envs) {
913
+ return request(session, flags, 'PATCH', `/project/${projectId}/envs`, {
914
+ orgId,
915
+ body: { envs },
916
+ });
917
+ }
918
+
919
+ async function handleProjectEnvs(session, flags) {
920
+ const projectId = requireProjectId(flags);
921
+ const orgId = await resolveOrgId(session, flags);
922
+ const envs = await getProjectEnvs(session, flags, projectId, orgId);
923
+
924
+ printEnvs(envs, flags);
925
+ }
926
+
927
+ async function handleProjectEnvMutation(session, flags, action) {
928
+ const projectId = requireProjectId(flags);
929
+ const name = String(flags.name || '').trim();
930
+ if (!name) throw new CliError('Informe --name <name>.');
931
+
932
+ const needsValue = action === 'add' || action === 'update';
933
+ const value = needsValue ? String(flags.value ?? await promptHidden('Valor')) : undefined;
934
+ if (needsValue && !value) throw new CliError('Informe --value <value>.');
935
+
936
+ const orgId = await resolveOrgId(session, flags);
937
+ const envs = await getProjectEnvs(session, flags, projectId, orgId);
938
+ const existingIndex = envs.findIndex((env) => env.name === name);
939
+
940
+ if (action === 'add' && existingIndex !== -1) {
941
+ throw new CliError(`Env ${name} ja existe. Use "zenifra project env update".`);
942
+ }
943
+
944
+ if ((action === 'update' || action === 'remove') && existingIndex === -1) {
945
+ throw new CliError(`Env ${name} nao existe.`);
946
+ }
947
+
948
+ const nextEnvs = [...envs];
949
+ if (action === 'add') nextEnvs.push({ name, value });
950
+ if (action === 'update') nextEnvs[existingIndex] = { ...nextEnvs[existingIndex], name, value };
951
+ if (action === 'remove') nextEnvs.splice(existingIndex, 1);
952
+
953
+ const payload = await patchProjectEnvs(session, flags, projectId, orgId, nextEnvs);
954
+
955
+ if (flags.json) {
956
+ return printJson({
957
+ status: payload.status || 'success',
958
+ message: payload.message || 'updated with success',
959
+ envs: envsForOutput(nextEnvs),
960
+ });
961
+ }
962
+
963
+ process.stdout.write(`Env ${name} ${action === 'remove' ? 'removida' : 'atualizada'} com sucesso.\n`);
964
+ }
965
+
966
+ async function handleProjectInstances(session, flags) {
967
+ const projectId = requireProjectId(flags);
968
+ const orgId = await resolveOrgId(session, flags);
969
+ const instances = unwrapData(await request(session, flags, 'GET', `/project/${projectId}/instances`, { orgId }));
970
+
971
+ if (flags.json) return printJson(instances);
972
+ printTable(asArray(instances), [
973
+ { label: 'Instancia', value: (instance) => instance.instance || instance.name || '-' },
974
+ ]);
975
+ }
976
+
977
+ async function handleProjectInstancesSet(session, flags) {
978
+ const projectId = requireProjectId(flags);
979
+ const instances = Number(flags.count);
980
+ if (!Number.isInteger(instances) || instances <= 0) {
981
+ throw new CliError('Informe --count <n> com um inteiro positivo.');
982
+ }
983
+
984
+ const orgId = await resolveOrgId(session, flags);
985
+ const payload = await request(session, flags, 'PATCH', `/project/${projectId}/instances`, {
986
+ orgId,
987
+ body: { instances },
988
+ });
989
+
990
+ if (flags.json) return printJson(payload);
991
+ process.stdout.write(`Quantidade de instancias atualizada para ${instances}.\n`);
992
+ }
993
+
994
+ function buildQuery(flags, allowedKeys) {
995
+ const params = new URLSearchParams();
996
+ for (const key of allowedKeys) {
997
+ if (flags[key] !== undefined && flags[key] !== true) {
998
+ const apiKey = key.replace(/[A-Z]/g, (value) => `_${value.toLowerCase()}`);
999
+ params.set(apiKey, String(flags[key]));
1000
+ }
1001
+ }
1002
+ const query = params.toString();
1003
+ return query ? `?${query}` : '';
1004
+ }
1005
+
1006
+ async function handleDeployments(session, flags) {
1007
+ const projectId = flags.project;
1008
+ if (!projectId) throw new CliError('Informe --project <id>.');
1009
+ const orgId = await resolveOrgId(session, flags);
1010
+ const query = buildQuery(flags, ['page', 'limit', 'branch', 'status']);
1011
+ const data = unwrapData(await request(session, flags, 'GET', `/project/${projectId}/github/builds${query}`, { orgId }));
1012
+ const builds = asArray(data);
1013
+
1014
+ if (flags.json) return printJson(data);
1015
+ printTable(builds, [
1016
+ { label: 'Build', value: (build) => buildIdOf(build) || '-' },
1017
+ { label: 'Status', value: (build) => build.status || '-' },
1018
+ { label: 'Branch', value: (build) => build.branch || '-' },
1019
+ { label: 'Commit', value: (build) => build.commit_sha || '-' },
1020
+ { label: 'Inicio', value: (build) => build.started_at || build.created_at || '-' },
1021
+ ]);
1022
+ }
1023
+
1024
+ async function handleDeploy(session, flags) {
1025
+ const projectId = flags.project;
1026
+ if (!projectId) throw new CliError('Informe --project <id>.');
1027
+ const orgId = await resolveOrgId(session, flags);
1028
+ const body = {};
1029
+ if (flags.branch) body.branch = String(flags.branch);
1030
+ if (flags.commitSha) body.commit_sha = String(flags.commitSha);
1031
+
1032
+ const payload = await request(session, flags, 'POST', `/project/${projectId}/github/deploy`, {
1033
+ orgId,
1034
+ body,
1035
+ });
1036
+
1037
+ if (flags.json) return printJson(payload);
1038
+ const buildId = unwrapData(payload)?.build_id || payload?.build_id || unwrapData(payload)?.id;
1039
+ process.stdout.write(`Deploy iniciado${buildId ? `: ${buildId}` : '.'}\n`);
1040
+ }
1041
+
1042
+ async function getBuild(session, flags, projectId, buildId, orgId) {
1043
+ return unwrapData(await request(session, flags, 'GET', `/project/${projectId}/github/builds/${buildId}`, { orgId }));
1044
+ }
1045
+
1046
+ async function handleDeployWatch(session, flags) {
1047
+ const projectId = flags.project;
1048
+ const buildId = flags.build;
1049
+ if (!projectId) throw new CliError('Informe --project <id>.');
1050
+ if (!buildId) throw new CliError('Informe --build <id>.');
1051
+
1052
+ const orgId = await resolveOrgId(session, flags);
1053
+ const intervalMs = Math.max(Number(flags.interval || 5), 1) * 1000;
1054
+ const timeoutMs = Math.max(Number(flags.timeout || 900), 1) * 1000;
1055
+ const startedAt = Date.now();
1056
+
1057
+ while (true) {
1058
+ const build = await getBuild(session, flags, projectId, buildId, orgId);
1059
+ const status = build?.status || 'unknown';
1060
+
1061
+ if (flags.json) {
1062
+ printJson(build);
1063
+ } else {
1064
+ process.stdout.write(`[${new Date().toISOString()}] build ${buildId}: ${status}\n`);
1065
+ }
1066
+
1067
+ if (FINAL_BUILD_STATUSES.has(status)) {
1068
+ if (status === 'failed') {
1069
+ process.exitCode = 1;
1070
+ }
1071
+ return;
1072
+ }
1073
+
1074
+ if (Date.now() - startedAt > timeoutMs) {
1075
+ throw new CliError(`Timeout aguardando build ${buildId}. Ultimo status: ${status}`);
1076
+ }
1077
+
1078
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
1079
+ }
1080
+ }
1081
+
1082
+ async function main() {
1083
+ const { positional, flags } = parseArgs(process.argv.slice(2));
1084
+ const [command, subcommand] = positional;
1085
+ const session = await readSession();
1086
+
1087
+ if (command === 'help') {
1088
+ process.stdout.write(commandHelp(positional.slice(1)));
1089
+ return;
1090
+ }
1091
+
1092
+ if (!command) {
1093
+ process.stdout.write(usage());
1094
+ return;
1095
+ }
1096
+
1097
+ if (flags.help) {
1098
+ process.stdout.write(commandHelp(positional));
1099
+ return;
1100
+ }
1101
+
1102
+ if (command === 'auth' && subcommand === 'login') return handleLogin(session, flags);
1103
+ if (command === 'auth' && subcommand === 'api-key') return handleApiKeyLogin(session, flags);
1104
+ if (command === 'auth' && subcommand === 'logout') return handleLogout();
1105
+ if (command === 'login') return handleLogin(session, flags);
1106
+ if (command === 'logout') return handleLogout();
1107
+ if (command === 'orgs') return handleOrgs(session, flags);
1108
+ if (command === 'org' && subcommand === 'set') return handleOrgSet(session, flags);
1109
+ if (command === 'projects' && subcommand === 'create') return handleProjectCreate(session, flags);
1110
+ if (command === 'projects') return handleProjects(session, flags);
1111
+ if (command === 'project' && subcommand === 'info') return handleProjectInfo(session, flags);
1112
+ if (command === 'project' && subcommand === 'url') return handleProjectUrl(session, flags);
1113
+ if (command === 'project' && subcommand === 'logs') return handleProjectLogs(session, flags);
1114
+ if (command === 'project' && subcommand === 'metrics') return handleProjectMetrics(session, flags);
1115
+ if (command === 'project' && subcommand === 'network') return handleProjectNetwork(session, flags);
1116
+ if (command === 'project' && subcommand === 'image' && positional[2] === 'set') return handleProjectImageSet(session, flags);
1117
+ if (command === 'project' && subcommand === 'envs') return handleProjectEnvs(session, flags);
1118
+ if (command === 'project' && subcommand === 'env' && positional[2] === 'add') return handleProjectEnvMutation(session, flags, 'add');
1119
+ if (command === 'project' && subcommand === 'env' && positional[2] === 'update') return handleProjectEnvMutation(session, flags, 'update');
1120
+ if (command === 'project' && subcommand === 'env' && positional[2] === 'remove') return handleProjectEnvMutation(session, flags, 'remove');
1121
+ if (command === 'project' && subcommand === 'instances' && positional[2] === 'set') return handleProjectInstancesSet(session, flags);
1122
+ if (command === 'project' && subcommand === 'instances') return handleProjectInstances(session, flags);
1123
+ if (command === 'builds') return handleDeployments(session, flags);
1124
+ if (command === 'deployments') return handleDeployments(session, flags);
1125
+ if (command === 'deploy' && subcommand === 'watch') return handleDeployWatch(session, flags);
1126
+ if (command === 'deploy') return handleDeploy(session, flags);
1127
+
1128
+ throw new CliError(`Comando desconhecido: ${positional.join(' ')}`);
1129
+ }
1130
+
1131
+ main().catch((error) => {
1132
+ if (error instanceof CliError) {
1133
+ process.stderr.write(`${error.message}\n`);
1134
+ process.exit(error.exitCode);
1135
+ }
1136
+
1137
+ process.stderr.write(`${error.stack || error.message}\n`);
1138
+ process.exit(1);
1139
+ });
@@ -0,0 +1,24 @@
1
+ {
2
+ "type_project": "http",
3
+ "image": {
4
+ "url": "nginxinc/nginx-unprivileged:latest",
5
+ "is_public": true
6
+ },
7
+ "instances": 1,
8
+ "storage": {
9
+ "persistent": false,
10
+ "capacity": 1
11
+ },
12
+ "envs": [],
13
+ "port": 8080,
14
+ "network_access": {
15
+ "ingress_white_list": [
16
+ {
17
+ "cidr": "0.0.0.0/0",
18
+ "description": "Public access"
19
+ }
20
+ ],
21
+ "ingress_black_list": []
22
+ },
23
+ "custom_domains": []
24
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@zenifra/cli",
3
+ "version": "0.1.0",
4
+ "description": "Official Zenifra command line interface.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://zenifra.com/",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/zenifra/zenifra-cli.git"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "keywords": [
16
+ "zenifra",
17
+ "cli",
18
+ "deploy",
19
+ "projects",
20
+ "cloud"
21
+ ],
22
+ "bin": {
23
+ "zenifra": "./bin/zenifra.mjs"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "files": [
29
+ "bin",
30
+ "examples",
31
+ "README.md"
32
+ ],
33
+ "scripts": {
34
+ "check": "node --check bin/zenifra.mjs",
35
+ "test": "node --test"
36
+ }
37
+ }