@neetru/cli 2.32.0 → 2.33.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.
@@ -21,7 +21,7 @@ import * as fs from 'node:fs';
21
21
  import { promises as fsp } from 'node:fs';
22
22
  import { spawnSync } from 'node:child_process';
23
23
  import * as path from 'node:path';
24
- import inquirer from 'inquirer';
24
+ import { carregarInquirer } from '../lib/inquirer-sob-demanda.js';
25
25
  import chalk from 'chalk';
26
26
  import ora from 'ora';
27
27
  import { log } from '../utils/logger.js';
@@ -576,7 +576,7 @@ async function pickProduct(opts, nonInteractive, cwd) {
576
576
  if (catalog.products.length === 0) {
577
577
  deployFail(opts.json, 'no_products', 'Nenhum produto público encontrado. Crie um via `neetru publish` antes.');
578
578
  }
579
- const { slug } = await inquirer.prompt([
579
+ const { slug } = await (await carregarInquirer()).prompt([
580
580
  {
581
581
  type: 'list',
582
582
  name: 'slug',
@@ -601,7 +601,7 @@ export async function pickTarget(opts, nonInteractive, runtime) {
601
601
  // handler .ts, não um app Next.js. Recomendar "Workspace" aqui levava quem
602
602
  // aceita o padrão direto ao aviso do contrato (confirmação final do PR #74).
603
603
  const ehNext = runtime === 'nextjs';
604
- const { target } = await inquirer.prompt([
604
+ const { target } = await (await carregarInquirer()).prompt([
605
605
  {
606
606
  type: 'list',
607
607
  name: 'target',
@@ -674,7 +674,7 @@ async function pickServer(opts, nonInteractive) {
674
674
  if (nonInteractive) {
675
675
  deployFail(opts.json, 'server_required', '--server <id> é obrigatório em modo não interativo.');
676
676
  }
677
- const { serverId } = await inquirer.prompt([
677
+ const { serverId } = await (await carregarInquirer()).prompt([
678
678
  {
679
679
  type: 'list',
680
680
  name: 'serverId',
@@ -704,7 +704,7 @@ async function pickDomain(opts, nonInteractive, productSlug) {
704
704
  if (nonInteractive)
705
705
  return { domain };
706
706
  if (!domain) {
707
- const { d } = await inquirer.prompt([
707
+ const { d } = await (await carregarInquirer()).prompt([
708
708
  {
709
709
  type: 'input',
710
710
  name: 'd',
@@ -1264,7 +1264,7 @@ async function runWorkspaceDeploy(opts, productSlug, envName, cwd, nonInteractiv
1264
1264
  if (sourceSizeBytes > WORKSPACE_SOURCE_GCS_THRESHOLD_BYTES) {
1265
1265
  log.dim(` upload: via GCS (source grande — acima de ${Math.round(WORKSPACE_SOURCE_GCS_THRESHOLD_BYTES / 1024)} KB)`);
1266
1266
  }
1267
- const { ok } = await inquirer.prompt([
1267
+ const { ok } = await (await carregarInquirer()).prompt([
1268
1268
  { type: 'confirm', name: 'ok', message: 'Confirmar deploy?', default: true },
1269
1269
  ]);
1270
1270
  if (!ok) {
@@ -1730,7 +1730,7 @@ envFileTexto) {
1730
1730
  log.dim(` ambiente: ${envName}`);
1731
1731
  log.dim(' destino: cloud-run (serviço dedicado)');
1732
1732
  log.dim(` imagem: ${imageUrl}`);
1733
- const { ok } = await inquirer.prompt([
1733
+ const { ok } = await (await carregarInquirer()).prompt([
1734
1734
  { type: 'confirm', name: 'ok', message: 'Confirmar deploy?', default: true },
1735
1735
  ]);
1736
1736
  if (!ok) {
@@ -1886,7 +1886,7 @@ async function checkSchemaGate(cwd, opts) {
1886
1886
  log.error(msg);
1887
1887
  return false;
1888
1888
  }
1889
- const { continuar } = await inquirer.prompt([
1889
+ const { continuar } = await (await carregarInquirer()).prompt([
1890
1890
  {
1891
1891
  type: 'confirm',
1892
1892
  name: 'continuar',
@@ -2095,7 +2095,7 @@ export async function runDeploy(opts) {
2095
2095
  log.dim(` sha256: ${artifact.artifactSha256.slice(0, 16)}…`);
2096
2096
  // Em prod o default do confirm é FALSE — Enter não deploya produção sem
2097
2097
  // uma decisão explícita (auditoria UX 2026-09).
2098
- const { ok } = await inquirer.prompt([
2098
+ const { ok } = await (await carregarInquirer()).prompt([
2099
2099
  { type: 'confirm', name: 'ok', message: 'Confirmar deploy?', default: envName !== 'prod' },
2100
2100
  ]);
2101
2101
  if (!ok) {
@@ -7,6 +7,7 @@ import ora from 'ora';
7
7
  // todo `neetru init --yes` (CI e scripts) e pelo 1º teste do init.test.ts,
8
8
  // que estourava o timeout de 10 s com a máquina carregada.
9
9
  import { log } from '../utils/logger.js';
10
+ import { carregarInquirer } from '../lib/inquirer-sob-demanda.js';
10
11
  import { safeExit } from '../utils/safe-exit.js';
11
12
  /**
12
13
  * Inspeciona um diretório pra decidir se é projeto existente, vazio, ou
@@ -86,7 +87,7 @@ async function resolveTarget(name, cwd, opts) {
86
87
  }
87
88
  // ── Interactive ──────────────────────────────────────────────────────
88
89
  // Só aqui há pergunta: o inquirer é carregado agora (ver o topo do arquivo).
89
- const { default: inquirer } = await import('inquirer');
90
+ const inquirer = await carregarInquirer();
90
91
  console.log();
91
92
  log.info(`Onde você quer inicializar o projeto "${name}"?`);
92
93
  console.log();
@@ -9,7 +9,7 @@
9
9
  * Saída: tabela (default) ou `--json`.
10
10
  */
11
11
  import chalk from 'chalk';
12
- import inquirer from 'inquirer';
12
+ import { carregarInquirer } from '../lib/inquirer-sob-demanda.js';
13
13
  import ora from 'ora';
14
14
  import { apiRequest, CliApiError } from '../lib/api-client.js';
15
15
  import { config } from '../lib/config.js';
@@ -190,7 +190,7 @@ export async function runProductsCreate(opts) {
190
190
  const environmentsRequested = parseEnvCsv(opts.env, [...AMBIENTES_PEDIDOS_POR_PADRAO]);
191
191
  const interactive = !opts.yes && !opts.json && process.stdin.isTTY;
192
192
  if (interactive) {
193
- const answers = await inquirer.prompt([
193
+ const answers = await (await carregarInquirer()).prompt([
194
194
  {
195
195
  type: 'input',
196
196
  name: 'name',
@@ -53,7 +53,36 @@ export declare function runServersDeactivate(id: string | undefined, opts: StepU
53
53
  */
54
54
  export interface ServersForceCleanupOptions extends StepUpOptions {
55
55
  reason: string;
56
+ /**
57
+ * core#732 (bug_90ec21cd): id numérico da instância no Google Cloud que o
58
+ * operador CONFERIU. Só faz falta quando o servidor não tem o id da VM
59
+ * registrado (`externalId`) e existe uma VM com o mesmo nome — o Core recusa
60
+ * com `vm_sem_externalid` e mostra o id visto + o comando de conferência.
61
+ * A CLI NUNCA preenche este id a partir da resposta: o operador digita.
62
+ */
63
+ confirmInstanceId?: string;
64
+ }
65
+ /**
66
+ * core#732: recusas da guarda de identidade da VM no force-cleanup. A mensagem
67
+ * do Core (com o id visto e o comando de conferência) vem antes; isto é o que
68
+ * fazer. Nenhum texto monta o comando com o id preenchido — de propósito.
69
+ */
70
+ export declare const FORCE_CLEANUP_IDENTITY_GUIDANCE: Record<string, string>;
71
+ /**
72
+ * Detalhes que o core#732 manda junto da recusa (`{ error: { …, instanceId?,
73
+ * instanceCreatedAt?, docCreatedAt?, outrosServidores?, comandoConferencia? } }`),
74
+ * só os campos conhecidos e com o tipo certo. Nunca vira comando com o id.
75
+ */
76
+ export interface ForceCleanupIdentityDetails {
77
+ instanceId?: string;
78
+ instanceCreatedAt?: string;
79
+ docCreatedAt?: string;
80
+ outrosServidores?: string[];
81
+ comandoConferencia?: string;
56
82
  }
83
+ export declare function forceCleanupIdentityDetails(raw: unknown): ForceCleanupIdentityDetails | undefined;
84
+ /** Linhas legíveis dos detalhes (texto). Mostra o id VISTO, mas não monta comando com ele. */
85
+ export declare function forceCleanupIdentityDetailLines(d: ForceCleanupIdentityDetails | undefined): string[];
57
86
  export declare function runServersForceCleanup(id: string, opts: ServersForceCleanupOptions): Promise<void>;
58
87
  export interface ServersDispatchOptions extends StepUpOptions {
59
88
  params?: string;
@@ -12,6 +12,7 @@ import { apiRequest, CliApiError } from '../lib/api-client.js';
12
12
  import { requireToken, handleApiError } from '../lib/cli-read.js';
13
13
  import { confirmDestructive, printWriteResult, resolveStepUp, } from '../lib/cli-write.js';
14
14
  import { renderTable } from '../lib/render.js';
15
+ import { carregarInquirer } from '../lib/inquirer-sob-demanda.js';
15
16
  import { pickServer, promptText, promptChoice } from '../lib/pickers.js';
16
17
  import { AGENT_COMMAND_ALLOWLIST, AGENT_COMMAND_CHOICES, commandRequiresMfa, } from '../lib/agent-commands.js';
17
18
  import { log } from '../utils/logger.js';
@@ -341,6 +342,73 @@ export async function runServersDeactivate(id, opts) {
341
342
  }
342
343
  printWriteResult(res.dryRun ? res : { serverId: res.serverId, status: res.status }, `Servidor ${res.serverId} → ${res.status}`, opts.json);
343
344
  }
345
+ /** Formato que a rota do Core aceita (id numérico da instância no GCP). */
346
+ const INSTANCE_ID_RE = /^[1-9][0-9]{0,19}$/;
347
+ /**
348
+ * core#732: recusas da guarda de identidade da VM no force-cleanup. A mensagem
349
+ * do Core (com o id visto e o comando de conferência) vem antes; isto é o que
350
+ * fazer. Nenhum texto monta o comando com o id preenchido — de propósito.
351
+ */
352
+ export const FORCE_CLEANUP_IDENTITY_GUIDANCE = {
353
+ vm_sem_externalid: 'Este servidor não tem o id da VM registrado, e existe no Google Cloud uma VM com o mesmo nome — ' +
354
+ 'pode ser OUTRA VM (nome reaproveitado). Nada foi apagado. Rode o comando de conferência acima; se a VM ' +
355
+ 'for mesmo a deste servidor, repita o force-cleanup com --confirm-instance-id <id que você conferiu>. ' +
356
+ 'A CLI não preenche esse id por você.',
357
+ vm_confirmacao_nao_confere: 'O id passado em --confirm-instance-id não é o da VM que o Core leu (a mensagem acima mostra os dois). ' +
358
+ 'Nada foi apagado. Confira de novo antes de repetir.',
359
+ vm_id_divergente: 'O Google Cloud devolveu outra instância ao ler o id registrado deste servidor. Nada foi apagado — ' +
360
+ 'fale com o Dev Core antes de tentar de novo.',
361
+ vm_leitura_falhou: 'Não deu para ler a VM no Google Cloud agora. Nada foi apagado. Tente de novo em alguns minutos; ' +
362
+ 'se continuar, fale com o Dev Core.',
363
+ // core#732 rodada 2: a confirmação do id NÃO destrava este caso — nada de
364
+ // orientar "repita com o id".
365
+ vm_nome_em_uso_por_outro_servidor: 'Outro servidor registrado e ativo usa uma VM com o mesmo nome e zona — a VM pode ser dele. Nada foi apagado, ' +
366
+ 'e NÃO adianta confirmar o id: o Core recusa este caso mesmo com --confirm-instance-id. Confira de qual servidor ' +
367
+ 'é a VM e registre o id dela no servidor certo antes de excluir (fale com o Dev Core).',
368
+ };
369
+ export function forceCleanupIdentityDetails(raw) {
370
+ if (!raw || typeof raw !== 'object')
371
+ return undefined;
372
+ const r = raw;
373
+ const texto = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
374
+ const out = {};
375
+ const instanceId = texto(r.instanceId);
376
+ const instanceCreatedAt = texto(r.instanceCreatedAt);
377
+ const docCreatedAt = texto(r.docCreatedAt);
378
+ const comandoConferencia = texto(r.comandoConferencia);
379
+ const outros = Array.isArray(r.outrosServidores)
380
+ ? r.outrosServidores.map(texto).filter((x) => !!x)
381
+ : [];
382
+ if (instanceId)
383
+ out.instanceId = instanceId;
384
+ if (instanceCreatedAt)
385
+ out.instanceCreatedAt = instanceCreatedAt;
386
+ if (docCreatedAt)
387
+ out.docCreatedAt = docCreatedAt;
388
+ if (outros.length > 0)
389
+ out.outrosServidores = outros;
390
+ if (comandoConferencia)
391
+ out.comandoConferencia = comandoConferencia;
392
+ return Object.keys(out).length > 0 ? out : undefined;
393
+ }
394
+ /** Linhas legíveis dos detalhes (texto). Mostra o id VISTO, mas não monta comando com ele. */
395
+ export function forceCleanupIdentityDetailLines(d) {
396
+ if (!d)
397
+ return [];
398
+ const linhas = [];
399
+ if (d.outrosServidores) {
400
+ linhas.push(`Outro(s) servidor(es) ativo(s) com o mesmo nome e zona: ${d.outrosServidores.join(', ')}`);
401
+ }
402
+ if (d.instanceId)
403
+ linhas.push(`VM vista no Google Cloud: id ${d.instanceId}`);
404
+ if (d.instanceCreatedAt || d.docCreatedAt) {
405
+ linhas.push(`Criação da VM: ${d.instanceCreatedAt ?? '?'} · criação deste servidor: ${d.docCreatedAt ?? '?'} ` +
406
+ '(a VM do próprio servidor nasce segundos depois dele; diferença grande indica VM recriada)');
407
+ }
408
+ if (d.comandoConferencia)
409
+ linhas.push(`Para conferir (só leitura): ${d.comandoConferencia}`);
410
+ return linhas;
411
+ }
344
412
  export async function runServersForceCleanup(id, opts) {
345
413
  if (!id || !/^[a-zA-Z0-9_-]{1,64}$/.test(id)) {
346
414
  log.error('serverId inválido. Uso: neetru servers force-cleanup <serverId> --reason="<motivo min 10 chars>"');
@@ -350,6 +418,16 @@ export async function runServersForceCleanup(id, opts) {
350
418
  log.error('--reason obrigatório com pelo menos 10 caracteres (paper-trail audit).');
351
419
  safeExit(1);
352
420
  }
421
+ // `--confirm-instance-id ""` (ou só espaços) é RECUSADO — não vira "flag não passada".
422
+ const confirmInstanceId = opts.confirmInstanceId === undefined ? undefined : opts.confirmInstanceId.trim();
423
+ if (confirmInstanceId !== undefined && !INSTANCE_ID_RE.test(confirmInstanceId)) {
424
+ const msg = '--confirm-instance-id deve ser o id numérico da VM no Google Cloud (o que você conferiu no comando de conferência).';
425
+ if (opts.json)
426
+ console.log(JSON.stringify({ ok: false, error: 'invalid_param', message: msg }));
427
+ else
428
+ log.error(msg);
429
+ safeExit(1);
430
+ }
353
431
  const token = await requireToken(opts.json);
354
432
  // Double-confirm (digitar o ID) — dry-run não aplica nada, então pula: nada
355
433
  // destrutivo acontece, não faz sentido travar numa confirmação interativa.
@@ -368,8 +446,12 @@ export async function runServersForceCleanup(id, opts) {
368
446
  }
369
447
  console.log(chalk.yellow.bold('\n⚠ FORCE-CLEANUP é IRREVERSÍVEL.'));
370
448
  console.log(chalk.dim(' Server stuck em provisioning/timeout que está bloqueando o pipeline.'));
371
- console.log(chalk.dim(' Audit pesado + notification all staff serão gerados.\n'));
372
- const inquirer = (await import('inquirer')).default;
449
+ console.log(chalk.dim(' Audit pesado + notification all staff serão gerados.'));
450
+ if (confirmInstanceId) {
451
+ console.log(chalk.dim(` VM a apagar: a instância de id ${confirmInstanceId}, conferida por você.`));
452
+ }
453
+ console.log('');
454
+ const inquirer = await carregarInquirer();
373
455
  const ans = await inquirer.prompt([
374
456
  {
375
457
  type: 'input',
@@ -395,12 +477,28 @@ export async function runServersForceCleanup(id, opts) {
395
477
  body: {
396
478
  reason: opts.reason.trim(),
397
479
  confirm: 'I-UNDERSTAND-FORCE-CLEANUP',
480
+ ...(confirmInstanceId ? { confirmInstanceId } : {}),
398
481
  },
399
482
  });
400
483
  spinner?.stop();
401
484
  }
402
485
  catch (error) {
403
486
  spinner?.fail('Falha no force-cleanup.');
487
+ // core#732: a guarda de identidade da VM recusou — nada foi apagado. Mostra
488
+ // a mensagem do Core (id visto + comando de conferência) e o que fazer. Sem
489
+ // nova tentativa automática e sem montar o comando com o id: o operador
490
+ // confere e digita.
491
+ if (error instanceof CliApiError && FORCE_CLEANUP_IDENTITY_GUIDANCE[error.code]) {
492
+ const guidance = FORCE_CLEANUP_IDENTITY_GUIDANCE[error.code];
493
+ const details = forceCleanupIdentityDetails(error.details);
494
+ if (opts.json) {
495
+ console.log(JSON.stringify({ ok: false, error: error.code, message: error.message, guidance, ...(details ? { details } : {}) }));
496
+ }
497
+ else {
498
+ log.error([error.message, ...forceCleanupIdentityDetailLines(details), guidance].join('\n '));
499
+ }
500
+ safeExit(1);
501
+ }
404
502
  handleApiError(error, opts.json);
405
503
  }
406
504
  if (opts.json) {
@@ -413,6 +511,16 @@ export async function runServersForceCleanup(id, opts) {
413
511
  }
414
512
  log.success(`Server ${res.serverId} force-cleanup concluído.`);
415
513
  log.dim(` Status anterior: ${res.previousStatus} (há ${res.ageMinutes}min)`);
514
+ if (res.vmIdentidade) {
515
+ const identidade = {
516
+ 'confere:externalId': 'conferida pelo id registrado no servidor',
517
+ 'confere:confirmacao': 'conferida pelo id que você confirmou',
518
+ 'ausente:id_ausente': 'a VM registrada já não existia',
519
+ 'ausente:nome_ausente': 'não havia VM com o nome do servidor',
520
+ 'ausente:nome_de_outro_servidor': 'a VM com esse nome é de OUTRO servidor — não foi tocada',
521
+ };
522
+ log.dim(` Identidade da VM: ${identidade[res.vmIdentidade] ?? res.vmIdentidade}`);
523
+ }
416
524
  if (res.vmDeleted) {
417
525
  log.dim(' VM GCP: deletada com sucesso.');
418
526
  }
package/dist/index.js CHANGED
@@ -854,6 +854,7 @@ serversCmd
854
854
  .option('--yes', 'pula a confirmação interativa (modo script)')
855
855
  .option('--mfa-token <code>', 'código TOTP para step-up MFA (ou use `neetru auth mfa-session`)')
856
856
  .option('--dry-run', 'valida e retorna o efeito sem aplicar (sem MFA)')
857
+ .option('--confirm-instance-id <id>', 'id numérico da VM no Google Cloud que você conferiu — só quando o Core pedir (servidor sem o id da VM registrado)')
857
858
  .option('--json', 'saída em JSON')
858
859
  .action(async (id, opts) => {
859
860
  const { runServersForceCleanup } = await import('./commands/servers.js');
@@ -862,6 +863,7 @@ serversCmd
862
863
  yes: !!opts.yes,
863
864
  mfaToken: opts.mfaToken,
864
865
  dryRun: !!opts.dryRun,
866
+ confirmInstanceId: opts.confirmInstanceId,
865
867
  json: !!opts.json,
866
868
  });
867
869
  });
@@ -2281,6 +2283,60 @@ dbMoveCmd
2281
2283
  json: !!opts.json,
2282
2284
  });
2283
2285
  });
2286
+ // ── neetru db frontdoor ───────────────────────────────────────────────
2287
+ // Porta de entrada PRIVADA de um banco Postgres em VM para um app em OUTRA VM,
2288
+ // sem mover o banco (Core #727, bug_5c0e7738). O Core prepara o PgBouncer na
2289
+ // VM do banco, testa a partir da VM do app e na própria VM do banco, e só então
2290
+ // grava o novo endereço. `enable` repetido RETOMA a mesma operação.
2291
+ const dbFrontdoorCmd = dbCmd
2292
+ .command('frontdoor')
2293
+ .description('Porta de entrada privada do banco para um app em outra VM da rede interna (sem mover o banco)');
2294
+ dbFrontdoorCmd
2295
+ .command('enable <dbId>')
2296
+ .description('Ligar a porta de entrada para a VM do app (POST /api/cli/v1/db/[id]/frontdoor — exige MFA; repetir retoma)')
2297
+ .requiredOption('--app-server <serverId>', 'VM onde o app roda ou vai rodar (recebe a liberação na rede interna)')
2298
+ .requiredOption('--reason <text>', 'motivo (vai para o registro de auditoria)')
2299
+ .option('--dry-run', 'só confere os requisitos (não altera nada, sem MFA)')
2300
+ .option('-y, --yes', 'pula a confirmação interativa (modo script)')
2301
+ .option('--mfa-token <code>', 'código TOTP step-up MFA (ou use `neetru auth mfa-session`)')
2302
+ .option('--json', 'saída em JSON estruturado')
2303
+ .action(async (dbId, opts) => {
2304
+ const { runDbFrontdoorEnable } = await import('./commands/db-frontdoor.js');
2305
+ await runDbFrontdoorEnable(dbId, {
2306
+ appServer: opts.appServer,
2307
+ reason: opts.reason,
2308
+ dryRun: !!opts.dryRun,
2309
+ yes: !!opts.yes,
2310
+ mfaToken: opts.mfaToken,
2311
+ json: !!opts.json,
2312
+ });
2313
+ });
2314
+ dbFrontdoorCmd
2315
+ .command('status <dbId>')
2316
+ .description('Estado da porta de entrada e da última operação (GET /api/cli/v1/db/[id]/frontdoor)')
2317
+ .option('--json', 'saída em JSON')
2318
+ .action(async (dbId, opts) => {
2319
+ const { runDbFrontdoorStatus } = await import('./commands/db-frontdoor.js');
2320
+ await runDbFrontdoorStatus(dbId, { json: !!opts.json });
2321
+ });
2322
+ dbFrontdoorCmd
2323
+ .command('disable <dbId>')
2324
+ .description('Desligar a porta de entrada — o app volta ao endereço local da VM do banco (POST /api/cli/v1/db/[id]/frontdoor/disable — exige MFA)')
2325
+ .requiredOption('--reason <text>', 'motivo (vai para o registro de auditoria)')
2326
+ .option('--force', 'obrigatório para desligar uma porta ligada (o desfazer normal) ou quando não se sabe onde o app roda: o app volta ao endereço local da VM do banco e, se ele roda em outra VM, fica sem banco')
2327
+ .option('-y, --yes', 'pula a confirmação interativa (modo script)')
2328
+ .option('--mfa-token <code>', 'código TOTP step-up MFA (ou use `neetru auth mfa-session`)')
2329
+ .option('--json', 'saída em JSON')
2330
+ .action(async (dbId, opts) => {
2331
+ const { runDbFrontdoorDisable } = await import('./commands/db-frontdoor.js');
2332
+ await runDbFrontdoorDisable(dbId, {
2333
+ reason: opts.reason,
2334
+ force: !!opts.force,
2335
+ yes: !!opts.yes,
2336
+ mfaToken: opts.mfaToken,
2337
+ json: !!opts.json,
2338
+ });
2339
+ });
2284
2340
  // ── neetru db where ───────────────────────────────────────────────────
2285
2341
  // READ-ONLY: onde o banco de um produto VIVE hoje (VM/serverId + endpoint).
2286
2342
  // Complementa `neetru db status <dbId>` (que exige o dbId) aceitando --product.
@@ -16,7 +16,14 @@ export declare class CliApiError extends Error {
16
16
  * por quem faz vários envios em sequência (ex.: `neetru docs sync`).
17
17
  */
18
18
  readonly retryAfterSeconds?: number;
19
- constructor(status: number, code: string, message: string, correlationId?: string, retryAfterSeconds?: number);
19
+ /**
20
+ * Campos EXTRAS do erro canônico `{ error: { code, message, ...detalhes } }`
21
+ * (ex.: core#732 — `instanceId`, `instanceCreatedAt`, `docCreatedAt`,
22
+ * `outrosServidores`, `comandoConferencia` no force-cleanup). Ausente quando
23
+ * o corpo só traz `code`/`message`. Quem usa valida o tipo de cada campo.
24
+ */
25
+ readonly details?: Readonly<Record<string, unknown>>;
26
+ constructor(status: number, code: string, message: string, correlationId?: string, retryAfterSeconds?: number, details?: Readonly<Record<string, unknown>>);
20
27
  }
21
28
  /** `Retry-After` em segundos (aceita número ou data HTTP). undefined se ausente/ilegível. */
22
29
  export declare function parseRetryAfter(value: string | null | undefined, now?: number): number | undefined;
@@ -17,13 +17,22 @@ export class CliApiError extends Error {
17
17
  * por quem faz vários envios em sequência (ex.: `neetru docs sync`).
18
18
  */
19
19
  retryAfterSeconds;
20
- constructor(status, code, message, correlationId, retryAfterSeconds) {
20
+ /**
21
+ * Campos EXTRAS do erro canônico `{ error: { code, message, ...detalhes } }`
22
+ * (ex.: core#732 — `instanceId`, `instanceCreatedAt`, `docCreatedAt`,
23
+ * `outrosServidores`, `comandoConferencia` no force-cleanup). Ausente quando
24
+ * o corpo só traz `code`/`message`. Quem usa valida o tipo de cada campo.
25
+ */
26
+ details;
27
+ constructor(status, code, message, correlationId, retryAfterSeconds, details) {
21
28
  super(message);
22
29
  this.name = 'CliApiError';
23
30
  this.status = status;
24
31
  this.code = code;
25
32
  this.correlationId = correlationId;
26
33
  this.retryAfterSeconds = retryAfterSeconds;
34
+ if (details && Object.keys(details).length > 0)
35
+ this.details = details;
27
36
  }
28
37
  }
29
38
  /** `Retry-After` em segundos (aceita número ou data HTTP). undefined se ausente/ilegível. */
@@ -182,6 +191,7 @@ export async function apiRequest(path, opts = {}) {
182
191
  const errBody = parsed;
183
192
  let code = `http_${response.status}`;
184
193
  let message = `Erro HTTP ${response.status}`;
194
+ let details;
185
195
  if (errBody?.error) {
186
196
  if (typeof errBody.error === 'string') {
187
197
  // Formato RFC. Auditoria cli/v1 2026-09-29: o 401 do `requireCliAuth`
@@ -196,6 +206,12 @@ export async function apiRequest(path, opts = {}) {
196
206
  // Formato interno Neetru
197
207
  code = errBody.error.code ?? code;
198
208
  message = errBody.error.message ?? message;
209
+ // Campos além de `code`/`message` (core#732): vão em `details`, crus.
210
+ const { code: _c, message: _m, ...extras } = errBody.error;
211
+ void _c;
212
+ void _m;
213
+ if (Object.keys(extras).length > 0)
214
+ details = extras;
199
215
  }
200
216
  }
201
217
  else if (typeof parsed === 'string' && parsed.trim() !== '') {
@@ -242,7 +258,7 @@ export async function apiRequest(path, opts = {}) {
242
258
  }
243
259
  // LABS #6 (2026-09-28): captura o x-correlation-id do Core p/ o rodapé do erro.
244
260
  const correlationId = response.headers.get('x-correlation-id') ?? undefined;
245
- throw new CliApiError(response.status, code, message, correlationId, parseRetryAfter(response.headers.get('retry-after')));
261
+ throw new CliApiError(response.status, code, message, correlationId, parseRetryAfter(response.headers.get('retry-after')), details);
246
262
  }
247
263
  return parsed;
248
264
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * `inquirer` sob demanda (P115, 2026-10-18; o `db.ts` já fazia o mesmo desde
3
+ * o bug_b1e9af59, e o `init.ts` desde o P112).
4
+ *
5
+ * O `inquirer` é o pacote mais caro de carregar a frio no grafo da CLI: ~0,31 s
6
+ * sozinho e ~1 s com a máquina carregada (medido no P115). Quem o importa no
7
+ * topo faz TODO caminho do comando pagar a carga — inclusive `--json`,
8
+ * `--yes` e os não interativos, que nunca perguntam nada. Os comandos carregam
9
+ * o inquirer por aqui, só no ponto em que vão perguntar.
10
+ *
11
+ * A guarda `guarda-inquirer-sob-demanda.test.ts` impede import estático novo
12
+ * do inquirer em `src/`.
13
+ */
14
+ export declare function carregarInquirer(): Promise<typeof import('inquirer').default>;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `inquirer` sob demanda (P115, 2026-10-18; o `db.ts` já fazia o mesmo desde
3
+ * o bug_b1e9af59, e o `init.ts` desde o P112).
4
+ *
5
+ * O `inquirer` é o pacote mais caro de carregar a frio no grafo da CLI: ~0,31 s
6
+ * sozinho e ~1 s com a máquina carregada (medido no P115). Quem o importa no
7
+ * topo faz TODO caminho do comando pagar a carga — inclusive `--json`,
8
+ * `--yes` e os não interativos, que nunca perguntam nada. Os comandos carregam
9
+ * o inquirer por aqui, só no ponto em que vão perguntar.
10
+ *
11
+ * A guarda `guarda-inquirer-sob-demanda.test.ts` impede import estático novo
12
+ * do inquirer em `src/`.
13
+ */
14
+ export async function carregarInquirer() {
15
+ const { default: inquirer } = await import('inquirer');
16
+ return inquirer;
17
+ }
18
+ //# sourceMappingURL=inquirer-sob-demanda.js.map
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import inquirer from 'inquirer';
2
+ import { carregarInquirer } from './inquirer-sob-demanda.js';
3
3
  import { apiRequest } from './api-client.js';
4
4
  import { handleApiError } from './api-error.js';
5
5
  import { adviseCommand, declaredInputs } from '../cli-kit/command-context.js';
@@ -76,6 +76,8 @@ export async function resolveOrPick(opts) {
76
76
  : `Múltiplos candidatos encontrados, e este comando não recebe ${sujeito} por argumento: ` +
77
77
  `rode num terminal interativo para escolher.\nDisponiveis:\n${available}`, opts.json);
78
78
  }
79
+ // Só aqui o picker pergunta: o inquirer é carregado agora (P115).
80
+ const inquirer = await carregarInquirer();
79
81
  const choices = items.map((item) => ({
80
82
  name: opts.format(item),
81
83
  value: opts.getId(item),
@@ -107,7 +109,7 @@ export async function promptText(message, opts = {}) {
107
109
  return '';
108
110
  failInput(`Informe ${message}`, opts.json);
109
111
  }
110
- const answer = await inquirer.prompt([
112
+ const answer = await (await carregarInquirer()).prompt([
111
113
  {
112
114
  type: 'input',
113
115
  name: 'value',
@@ -131,7 +133,7 @@ export async function promptChoice(message, choices, opts = {}) {
131
133
  return opts.default;
132
134
  failInput(`Escolha ${message}`, opts.json);
133
135
  }
134
- const answer = await inquirer.prompt([
136
+ const answer = await (await carregarInquirer()).prompt([
135
137
  {
136
138
  type: 'list',
137
139
  name: 'picked',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neetru/cli",
3
- "version": "2.32.0",
3
+ "version": "2.33.0",
4
4
  "type": "module",
5
5
  "description": "Neetru Developer Kit — scaffold, AI assistant, publish, build e deploy de produtos SaaS Neetru via Core (VM-based agents)",
6
6
  "keywords": [