@zenifra/cli 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -148,6 +148,10 @@ Fluxos:
148
148
  - `zenifra deploy`: dispara o build/deploy GitHub e retorna o `build_id`
149
149
  - `zenifra deploy watch`: usa esse `build_id` para acompanhar o build em tempo real e imprimir os logs incrementais ate o fim
150
150
 
151
+ Antes de uma mutacao, confirme o perfil, a API e a organizacao ativa. Para criacoes, confirme tambem o catalogo, plano, pagamento, exposicao, dominio, origem do deploy, porta, instancias e ambientes. Depois, leia o projeto de volta e acompanhe o build/deployment ate um estado terminal.
152
+
153
+ O dominio principal e um dominio personalizado sao campos diferentes. Nao repita o dominio principal em `config.custom_domains`; depois de adicionar um dominio personalizado, valide DNS/TLS e leia a URL final antes de considerar a operacao concluida.
154
+
151
155
  Se voce rodar apenas `zenifra deploy`, a CLI mostra a ajuda especifica do comando com uso, flags, exemplos e exemplo de retorno.
152
156
 
153
157
  ## Login pelo navegador (OAuth)
@@ -317,9 +321,17 @@ Observacoes do wizard:
317
321
  - em projetos de banco, o wizard nao pergunta `username`, `password` nem `database name`
318
322
  - em projetos de banco, a CLI preenche apenas campos tecnicos minimos exigidos pela validacao atual da API
319
323
  - em projetos Valkey, a capacidade é definida pelo plano e a CLI não pergunta instâncias, imagem, variáveis de ambiente ou exposição HTTP
320
- - a conexão mascarada pode ser consultada a qualquer momento; a credencial completa aparece apenas na criação ou em uma rotação concluída
324
+ - a conexão mascarada pode ser consultada a qualquer momento; uma rotação concluída pode salvar a conexão utilizável em arquivo privado com `--connection-file <path>`
321
325
  - `valkey credentials rotate` retorna uma operação assíncrona; use `--wait` ou `valkey credentials status` para acompanhar
322
326
 
327
+ Exemplo de entrega segura para automação local:
328
+
329
+ ```bash
330
+ zenifra valkey credentials rotate --project <project-id> --wait --connection-file /caminho/privado/redis-url.txt --json
331
+ ```
332
+
333
+ O arquivo é criado com permissão privada; a conexão não aparece na saída do comando quando essa opção é usada.
334
+
323
335
  ## Regressao manual de auto-scaling em staging
324
336
 
325
337
  O teste de staging cria projetos, gera trafego, consulta consumo e remove somente os projetos criados pela propria execucao. Ele exige uma API de teste explicita, rejeita a API de producao e exige habilitacao explicita das mutacoes. O exemplo abaixo usa uma API local de teste.
@@ -0,0 +1,40 @@
1
+ import { atomicPrivateWrite } from './profile-store.mjs';
2
+
3
+ const MASKED_SECRET_PATTERN = /\*{4,}|\[redacted\]|<redacted>/i;
4
+
5
+ function normalizeDomain(value) {
6
+ return String(value || '').trim().replace(/\.+$/, '').toLowerCase();
7
+ }
8
+
9
+ export function normalizeCustomDomains(domains, { primaryDomain } = {}) {
10
+ if (!Array.isArray(domains)) return [];
11
+ const normalized = domains.map(normalizeDomain).filter(Boolean);
12
+ const seen = new Set();
13
+ for (const domain of normalized) {
14
+ if (seen.has(domain)) throw new Error(`Duplicate custom domain: ${domain}`);
15
+ seen.add(domain);
16
+ }
17
+ const primary = normalizeDomain(primaryDomain);
18
+ if (primary && seen.has(primary)) {
19
+ throw new Error(`Custom domain conflicts with the primary domain: ${primary}`);
20
+ }
21
+ return normalized;
22
+ }
23
+
24
+ export function validateValkeyConnection(value) {
25
+ const connection = String(value ?? '');
26
+ if (!connection || connection.trim() === '' || MASKED_SECRET_PATTERN.test(connection)) {
27
+ throw new Error('The Valkey connection is masked. Rotate credentials and save the one-time connection securely.');
28
+ }
29
+ return connection;
30
+ }
31
+
32
+ export async function writeValkeyConnectionFile(path, value) {
33
+ const connection = validateValkeyConnection(value);
34
+ await atomicPrivateWrite(path, connection);
35
+ return connection;
36
+ }
37
+
38
+ export function isMaskedSecret(value) {
39
+ return MASKED_SECRET_PATTERN.test(String(value || ''));
40
+ }
package/bin/zenifra.mjs CHANGED
@@ -9,6 +9,7 @@ import process from 'node:process';
9
9
  import readline from 'node:readline/promises';
10
10
  import { oauthLogin, openBrowser, validateOAuthProfile, refreshOAuth, revokeOAuth } from './lib/oauth.mjs';
11
11
  import { withProfileLock, atomicPrivateWrite } from './lib/profile-store.mjs';
12
+ import { normalizeCustomDomains, writeValkeyConnectionFile } from './lib/public-ux.mjs';
12
13
 
13
14
  const DEFAULT_API_BASE_URL = 'https://api.zenifra.com/v1';
14
15
  const DOCS_BASE_URL = 'https://docs.zenifra.com/pt';
@@ -35,6 +36,7 @@ const KNOWN_FLAG_NAMES = new Set([
35
36
  'build',
36
37
  'challengeToken',
37
38
  'code',
39
+ 'connectionFile',
38
40
  'commitSha',
39
41
  'config',
40
42
  'count',
@@ -394,18 +396,18 @@ const HELP_SPECS = [
394
396
  },
395
397
  {
396
398
  command: 'valkey credentials rotate',
397
- usage: 'zenifra valkey credentials rotate --project <id> [--idempotency-key <key>] [--wait] [--interval <seconds>] [--timeout <seconds>] [--json]',
399
+ usage: 'zenifra valkey credentials rotate --project <id> [--idempotency-key <key>] [--wait] [--interval <seconds>] [--timeout <seconds>] [--connection-file <path>] [--json]',
398
400
  description: 'Solicita a renovacao da credencial e retorna uma operacao acompanhavel.',
399
- flags: ['--project <id> ID do projeto.', '--idempotency-key <key> Chave para repetir a mesma solicitacao.', '--wait Aguarda a conclusao.', '--interval <seconds> Intervalo do polling. Padrao: 2.', '--timeout <seconds> Timeout total. Padrao: 900.', '--json Imprime a resposta em JSON.'],
401
+ flags: ['--project <id> ID do projeto.', '--idempotency-key <key> Chave para repetir a mesma solicitacao.', '--wait Aguarda a conclusao.', '--interval <seconds> Intervalo do polling. Padrao: 2.', '--timeout <seconds> Timeout total. Padrao: 900.', '--connection-file <path> Salva a conexao concluida em arquivo privado.', '--json Imprime a resposta em JSON.'],
400
402
  examples: ['zenifra valkey credentials rotate --project proj_1', 'zenifra valkey credentials rotate --project proj_1 --wait --timeout 120'],
401
403
  output: 'Operacao aceita: rotation_123\nEstado: accepted',
402
404
  notes: ['Salve a nova string de conexao quando a operacao atingir completed.'],
403
405
  },
404
406
  {
405
407
  command: 'valkey credentials status',
406
- usage: 'zenifra valkey credentials status --project <id> --operation <id> [--json]',
408
+ usage: 'zenifra valkey credentials status --project <id> --operation <id> [--connection-file <path>] [--json]',
407
409
  description: 'Consulta o estado de uma renovacao de credencial Valkey.',
408
- flags: ['--project <id> ID do projeto.', '--operation <id> ID da operacao.', '--json Imprime a resposta em JSON.'],
410
+ flags: ['--project <id> ID do projeto.', '--operation <id> ID da operacao.', '--connection-file <path> Salva a conexao concluida em arquivo privado.', '--json Imprime a resposta em JSON.'],
409
411
  examples: ['zenifra valkey credentials status --project proj_1 --operation rotation_123'],
410
412
  output: 'Operacao: rotation_123\nEstado: completed',
411
413
  },
@@ -1811,6 +1813,18 @@ function envsForOutput(envs, { showValues = false } = {}) {
1811
1813
  }));
1812
1814
  }
1813
1815
 
1816
+ const PROJECT_INFO_SECRET_FIELD = /(?:^|_)(?:api_?key|access_?token|refresh_?token|secret|password|credential|connection_string|private_?jwk)(?:$|_)/i;
1817
+
1818
+ function projectInfoForOutput(value, key = '') {
1819
+ if (key === 'envs' && Array.isArray(value)) return envsForOutput(value);
1820
+ if (Array.isArray(value)) return value.map((item) => projectInfoForOutput(item, key));
1821
+ if (!value || typeof value !== 'object') return PROJECT_INFO_SECRET_FIELD.test(key) ? maskEnvValue(value) : value;
1822
+ return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [
1823
+ entryKey,
1824
+ PROJECT_INFO_SECRET_FIELD.test(entryKey) ? maskEnvValue(entryValue) : projectInfoForOutput(entryValue, entryKey),
1825
+ ]));
1826
+ }
1827
+
1814
1828
  function formatPublicUrl(value) {
1815
1829
  const raw = String(value || '').trim();
1816
1830
  if (!raw) return '';
@@ -3491,6 +3505,9 @@ async function handleProjectCreate(session, flags) {
3491
3505
  const plan = wizardPayload?.plan || flags.plan || await prompt('Plano');
3492
3506
  const paymentMode = wizardPayload?.payment_mode || flags.paymentMode || await prompt('Modo de pagamento');
3493
3507
  const config = wizardPayload?.config || await parseConfig(flags.config || await prompt('Config JSON ou @arquivo'));
3508
+ if (Array.isArray(config?.custom_domains)) {
3509
+ config.custom_domains = normalizeCustomDomains(config.custom_domains, { primaryDomain: config.domain });
3510
+ }
3494
3511
  const typeProject = normalizeTypeProject(config?.type_project);
3495
3512
  const valkeyCatalog = typeProject === 'valkey'
3496
3513
  ? requireValkeyCatalog(unwrapData(await request(session, flags, 'GET', '/managed-services/catalog')))
@@ -3554,7 +3571,7 @@ async function handleProjectInfo(session, flags) {
3554
3571
  const orgId = await resolveOrgId(session, flags);
3555
3572
  const project = await getProject(session, flags, projectId, orgId);
3556
3573
 
3557
- if (flags.json) return printJson(project);
3574
+ if (flags.json) return printJson(projectInfoForOutput(project));
3558
3575
  printProject({ ...project, id: projectId });
3559
3576
  }
3560
3577
 
@@ -4227,6 +4244,17 @@ async function handleValkeyCredentialStatus(session, flags) {
4227
4244
  `/managed-services/${projectId}/credential-rotations/${flags.operation}`,
4228
4245
  { orgId },
4229
4246
  ));
4247
+ if (flags.connectionFile) {
4248
+ await writeValkeyConnectionFile(String(flags.connectionFile), data.connection_string);
4249
+ if (flags.json) return printJson({
4250
+ operation_id: data.operation_id,
4251
+ state: data.state,
4252
+ connection_saved: true,
4253
+ connection_file: String(flags.connectionFile),
4254
+ });
4255
+ process.stdout.write(`Credencial concluida e salva em ${flags.connectionFile}.\n`);
4256
+ return;
4257
+ }
4230
4258
  if (flags.json) return printJson(data);
4231
4259
  printValkeyRotation(data);
4232
4260
  }
@@ -4249,6 +4277,17 @@ async function handleValkeyCredentialRotate(session, flags) {
4249
4277
 
4250
4278
  if (flags.wait) {
4251
4279
  const completed = await waitForValkeyRotation(session, flags, projectId, accepted.operation_id, orgId);
4280
+ if (flags.connectionFile) {
4281
+ await writeValkeyConnectionFile(String(flags.connectionFile), completed.connection_string);
4282
+ if (flags.json) return printJson({
4283
+ operation_id: completed.operation_id,
4284
+ state: completed.state,
4285
+ connection_saved: true,
4286
+ connection_file: String(flags.connectionFile),
4287
+ });
4288
+ process.stdout.write(`Credencial concluida e salva em ${flags.connectionFile}.\n`);
4289
+ return;
4290
+ }
4252
4291
  if (flags.json) return printJson(completed);
4253
4292
  printValkeyRotation(completed);
4254
4293
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenifra/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Official Zenifra command line interface.",
5
5
  "type": "module",
6
6
  "license": "MIT",