@neetru/cli 2.32.1 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ Convenção: [Keep a Changelog](https://keepachangelog.com/) + [SemVer](https://
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.33.0] — 2026-10-19 (`neetru db frontdoor` e confirmação da VM na limpeza forçada)
8
+
9
+ > **Por que MINOR.** Entram um comando novo (`neetru db frontdoor enable|status|disable`) e uma flag nova (`neetru servers force-cleanup --confirm-instance-id`), sem quebrar comportamento existente. Os dois só funcionam contra um Core com o core#727 e o core#732 no ar; antes disso a rota da porta de entrada responde 404, e a limpeza forçada ignora a confirmação.
10
+
11
+ ### Adicionado
12
+
13
+ - **`neetru db frontdoor enable|status|disable` — porta de entrada privada de um banco Postgres em VM para um app em OUTRA VM da rede interna, sem mover o banco** (Core #727, bug_5c0e7738). Até aqui o único caminho era o `neetru db move`, que recusa destino igual à origem — um banco que já estava na VM certa não tinha como atender um app noutra VM (caso do Zelo: banco na snee-003-db, app indo para a snee-002-multi).
14
+ - `neetru db frontdoor enable <dbId> --app-server <serverId> --reason "<motivo>"` pede ao Core que prepare o PgBouncer na VM do banco, teste a conexão a partir da VM do app, teste o login pelo IP interno na própria VM do banco e só então grave o novo endereço. Falha em qualquer passo não muda nada para o app. Enquanto o Core trabalha, a CLI mostra o passo em que ele está (1 de 3, 2 de 3, 3 de 3). A operação só anda enquanto o comando roda: se o Core devolver a operação ainda em curso, ou a conexão cair, a CLI chama de novo e o Core retoma a mesma operação; repetir o comando depois também retoma. `--dry-run` só confere os requisitos, sem alterar nada e sem MFA (inclusive o novo "VM do app é da frota da Neetru"). A confirmação avisa que o PgBouncer da VM do banco reinicia uma vez. Só o endereço gravado no cadastro do banco faz o comando dizer "ligada": o vencimento do certificado pode aparecer antes, ou depois de uma falha, e não conta.
15
+ - `neetru db frontdoor status <dbId>` mostra se a porta está ligada (e em que IP), quando o certificado vence e a última operação, com o erro e o que fazer; uma operação parada no meio vem com o comando para retomar ou cancelar.
16
+ - `neetru db frontdoor disable <dbId> --reason "<motivo>"` volta o app ao endereço local da VM do banco e cancela uma ligação em andamento. **Desligar uma porta ligada (o desfazer normal) exige `--force`**, e também quando não se sabe em que VM o app roda: o app volta ao endereço local da VM do banco e, se ainda roda em outra VM, fica sem banco. A CLI explica isso antes de pedir MFA e, com `--force`, pede confirmação forte (digitar o id do banco; em script, `--yes` junto). Cancelar uma ligação em andamento que ainda não gravou nada não precisa de `--force`. Um endereço gravado por outra origem (por exemplo uma mudança de VM do banco) nunca é tirado por aqui: a CLI avisa que ele ficou (`host_kept`) ou que nem `--force` o desfaz (`FRONT_DOOR_HOST_NOT_OWNED`).
17
+ - MFA como no `db move`: `--mfa-token` ou a sessão de 1h de `neetru auth mfa-session`; sessão recusada pelo Core leva a um pedido de TOTP fresco, uma vez. O TOTP é de uso único, então nas retomadas o código já usado não é reenviado (vale a sessão, ou um TOTP novo).
18
+ - Cada código `FRONT_DOOR_*` da rota tem mensagem própria em português (a do Core primeiro, depois o que fazer); no `--json`, `{ ok:false, error, message, guidance }`.
19
+ - `neetru db move` passa a explicar a recusa `RELOCATE_FRONT_DOOR_OP_IN_PROGRESS` (409): há uma ligação da porta de entrada do banco em andamento — esperar ou cancelá-la com `neetru db frontdoor disable` e rodar a mudança de novo.
20
+ - Conferência opt-in contra o Core de verdade (`NEETRU_CORE_DIR`, a mesma do manifesto de MFA): todo código que a CLI trata existe no código das rotas do Core, todo `FRONT_DOOR_*` do Core tem mensagem na CLI e todo requisito do preflight tem nome legível.
21
+ - **Depende do Core #727** (rotas `POST|GET /api/cli/v1/db/{id}/frontdoor` e `POST /api/cli/v1/db/{id}/frontdoor/disable`); antes do deploy dele o comando responde 404.
22
+
23
+ - **`neetru servers force-cleanup --confirm-instance-id <id>` — confirmar a VM que a limpeza forçada vai apagar** (core#732, bug_90ec21cd). O Core passou a só apagar a VM de um servidor quando ela é provadamente a dele: nome de VM é reaproveitado (uma VM recriada com o mesmo nome já foi apagada no lugar da velha). Quando o servidor não tem o id da VM registrado e existe uma VM com o mesmo nome, o Core recusa (`vm_sem_externalid`) e diz o id que viu e o comando `gcloud` para conferir; o operador confere e repete o comando com `--confirm-instance-id <id que conferiu>`. A CLI mostra, em português, a mensagem de cada recusa da guarda (`vm_sem_externalid`, `vm_confirmacao_nao_confere`, `vm_id_divergente`, `vm_leitura_falhou` — esta quando o Google Cloud não pôde ser lido — e `vm_nome_em_uso_por_outro_servidor`, quando outro servidor registrado e ativo usa uma VM com o mesmo nome e zona: nesse caso **não adianta confirmar o id**, o Core recusa mesmo com `--confirm-instance-id`, e a CLI lista os outros servidores) com o que fazer. Quando o Core manda os detalhes da recusa, a CLI mostra o id visto, a criação da VM ao lado da criação do servidor (a VM do próprio servidor nasce segundos depois dele; diferença grande indica VM recriada) e o comando de conferência. No `--json` devolve `{ ok:false, error, message, guidance, details? }`. `--confirm-instance-id` vazio ou só com espaços é recusado (`invalid_param`), sem ir ao Core. O `CliApiError` passa a carregar em `details` os campos extras do erro canônico `{ error: { code, message, … } }`. **A CLI nunca preenche o id a partir da resposta nem tenta de novo sozinha**: quem confere digita. Id fora do formato numérico é recusado antes de ir ao Core. No sucesso, a CLI diz como a VM foi identificada (pelo id registrado, pelo id confirmado, ou que a VM com aquele nome era de outro servidor e não foi tocada). **Depende do core#732**: antes do deploy dele o Core ignora a flag e ainda apaga a VM pelo nome, sem conferir a identidade (o comportamento antigo).
24
+
7
25
  ## [2.32.1] — 2026-10-18 (comandos sem pergunta não carregam o `inquirer`)
8
26
 
9
27
  > **Por que PATCH.** Só conserto de desempenho e testes, sem mudança de comportamento nem de saída documentada.
@@ -0,0 +1,116 @@
1
+ export type FrontDoorState = 'planning' | 'preparing' | 'verifying_app' | 'verifying_local' | 'enabled' | 'failed' | 'cancelled' | 'disabled';
2
+ export interface FrontDoorLedger {
3
+ serverId: string | null;
4
+ frontDoorHost: string | null;
5
+ frontDoorCertNotAfter: string | null;
6
+ }
7
+ export interface FrontDoorOp {
8
+ opId: string;
9
+ state: FrontDoorState;
10
+ dbServerId: string;
11
+ appServerId: string;
12
+ host: string;
13
+ port: number;
14
+ allowedClientCidr: string | null;
15
+ frontDoorCertNotAfter: string | null;
16
+ preparedAt: string | null;
17
+ appVerifiedAt: string | null;
18
+ localVerifiedAt: string | null;
19
+ enabledAt: string | null;
20
+ failedAt: string | null;
21
+ cancelledAt: string | null;
22
+ disabledAt: string | null;
23
+ lastError: {
24
+ code: string;
25
+ message: string;
26
+ at: string;
27
+ } | null;
28
+ reason: string;
29
+ updatedAt: string;
30
+ }
31
+ export interface FrontDoorCheck {
32
+ name: string;
33
+ ok: boolean;
34
+ detail?: string;
35
+ }
36
+ export interface FrontDoorEnableResponse {
37
+ ok: true;
38
+ dbId: string;
39
+ dryRun: boolean;
40
+ done: boolean;
41
+ alreadyEnabled: boolean;
42
+ state: FrontDoorState;
43
+ preflight: {
44
+ ok: boolean;
45
+ checks: FrontDoorCheck[];
46
+ };
47
+ op: FrontDoorOp | null;
48
+ ledger: FrontDoorLedger | null;
49
+ }
50
+ export interface FrontDoorStatusResponse {
51
+ ok: true;
52
+ dbId: string;
53
+ ledger: FrontDoorLedger;
54
+ op: FrontDoorOp | null;
55
+ }
56
+ export interface FrontDoorDisableResponse {
57
+ ok: true;
58
+ dbId: string;
59
+ /**
60
+ * `host_kept` (Core #727, rodada 2): a ligação em curso foi cancelada, mas o
61
+ * endereço gravado no cadastro não é desta porta de entrada e FICOU.
62
+ */
63
+ outcome: 'disabled' | 'already_disabled' | 'host_kept';
64
+ previousFrontDoorHost: string | null;
65
+ opTransition: 'cancelled' | 'disabled' | null;
66
+ opId: string | null;
67
+ ledger: FrontDoorLedger | null;
68
+ }
69
+ /** O que cada estado quer dizer, em português simples. */
70
+ export declare const STATE_LABELS: Record<FrontDoorState, string>;
71
+ export declare function isInFlight(state: FrontDoorState | undefined): boolean;
72
+ /** Nome legível de cada requisito do `--dry-run` (o Core manda o nome técnico). */
73
+ export declare const CHECK_LABELS: Record<string, string>;
74
+ /**
75
+ * O que fazer para cada código `FRONT_DOOR_*` que a rota devolve. A mensagem
76
+ * do Core (com VMs, IPs e o motivo do agente) vem antes; isto é o "e agora?".
77
+ */
78
+ export declare const FRONT_DOOR_GUIDANCE: Record<string, string>;
79
+ /** Mensagem do Core primeiro (dados reais), depois o que fazer. Pura. */
80
+ export declare function describeFrontDoorError(code: string, serverMessage: string): string;
81
+ export interface DbFrontdoorEnableOptions {
82
+ appServer?: string;
83
+ reason?: string;
84
+ dryRun?: boolean;
85
+ yes?: boolean;
86
+ mfaToken?: string;
87
+ json?: boolean;
88
+ /** Somente testes: intervalo da consulta de progresso (ms). Default 3000. */
89
+ pollIntervalMs?: number;
90
+ /** Somente testes: prazo total somando as voltas (ms). Default 20 min. */
91
+ timeoutMs?: number;
92
+ /** Somente testes: espera de cada POST no Core (s). Default 240 (o máximo da rota). */
93
+ waitSeconds?: number;
94
+ }
95
+ export declare function runDbFrontdoorEnable(dbId: string, opts?: DbFrontdoorEnableOptions): Promise<void>;
96
+ export declare function runDbFrontdoorStatus(dbId: string, opts?: {
97
+ json?: boolean;
98
+ }): Promise<void>;
99
+ export interface DbFrontdoorDisableOptions {
100
+ reason?: string;
101
+ force?: boolean;
102
+ yes?: boolean;
103
+ mfaToken?: string;
104
+ json?: boolean;
105
+ }
106
+ /**
107
+ * Desligar (desfazer) — regras do Core #727 (rodada 2), decididas na transação:
108
+ * - porta LIGADA por este comando pra um app noutra VM (o desfazer normal) ⇒
109
+ * exige `--force`; idem quando não se sabe em que VM o app roda;
110
+ * - ligação em curso sem endereço gravado ⇒ só cancela, sem `--force`;
111
+ * - endereço gravado por OUTRA origem (ex.: mudança de VM do banco) nunca é
112
+ * tirado por aqui: `host_kept` (cancelou a ligação em curso) ou
113
+ * `FRONT_DOOR_HOST_NOT_OWNED` (409, nem com `--force`).
114
+ * Com `--force` a confirmação é forte: digitar o id do banco (ou `--yes`).
115
+ */
116
+ export declare function runDbFrontdoorDisable(dbId: string, opts?: DbFrontdoorDisableOptions): Promise<void>;
@@ -0,0 +1,567 @@
1
+ /**
2
+ * `neetru db frontdoor` — PORTA DE ENTRADA privada de um banco Postgres em VM,
3
+ * SEM mover o banco (Core #727, bug_5c0e7738).
4
+ *
5
+ * Caso de uso: o banco fica onde está e o app passa a rodar (ou já roda) em
6
+ * OUTRA VM da rede interna — ex.: banco do Zelo na snee-003-db, app indo pra
7
+ * snee-002-multi. O `neetru db move` não serve (recusa destino == origem).
8
+ *
9
+ * enable <dbId> --app-server <serverId> --reason "<motivo>"
10
+ * O Core prepara o PgBouncer na VM do banco, testa a conexão a partir da VM
11
+ * do app, testa o login pelo IP interno NA PRÓPRIA VM do banco e só então
12
+ * grava o novo endereço no cadastro do banco. Falha em qualquer passo = nada
13
+ * muda para o app. `--dry-run` só confere os requisitos (sem MFA).
14
+ * status <dbId>
15
+ * disable <dbId> --reason "<motivo>" [--force]
16
+ * Volta o endereço do app para o local da VM do banco (desfazer).
17
+ *
18
+ * COMO ANDA (contrato do Core): a operação só avança DENTRO da chamada de
19
+ * `enable` (não há nada rodando no Core em segundo plano). Por isso o CLI faz o
20
+ * POST com espera longa e, enquanto ele roda, consulta o GET pra mostrar o
21
+ * passo em que está. Se o POST voltar com a operação ainda em curso, o CLI
22
+ * chama de novo (o Core RETOMA a mesma operação) até um fim ou o prazo total.
23
+ * Repetir o comando depois também retoma.
24
+ *
25
+ * MFA (igual ao `db move`/`products db set-status`): `--mfa-token` ou a sessão
26
+ * de 1h de `neetru auth mfa-session`; sessão recusada pelo Core ⇒ pede um TOTP
27
+ * fresco UMA vez. O TOTP é de uso único: nas retomadas o código já gasto não é
28
+ * reenviado (vale a sessão, ou um TOTP novo).
29
+ *
30
+ * Endpoints (Core):
31
+ * POST /api/cli/v1/db/{id}/frontdoor — liga/retoma/dry-run (MFA fora do dry-run)
32
+ * GET /api/cli/v1/db/{id}/frontdoor — estado
33
+ * POST /api/cli/v1/db/{id}/frontdoor/disable — desliga (MFA)
34
+ *
35
+ * Plain PT-BR em toda string visível ao usuário.
36
+ */
37
+ import { createInterface } from 'node:readline';
38
+ import chalk from 'chalk';
39
+ import ora from 'ora';
40
+ import { apiRequest, CliApiError, CliNetworkError } from '../lib/api-client.js';
41
+ import { handleApiError } from '../lib/api-error.js';
42
+ import { requireToken } from '../lib/cli-read.js';
43
+ import { confirmDestructive, resolveStepUp, withStepUpRetry } from '../lib/cli-write.js';
44
+ import { log } from '../utils/logger.js';
45
+ import { isSafeExitHalt, safeExit, setSafeExitCode } from '../utils/safe-exit.js';
46
+ // ---------------------------------------------------------------------------
47
+ // Textos (puros — testáveis sem rede)
48
+ // ---------------------------------------------------------------------------
49
+ /** O que cada estado quer dizer, em português simples. */
50
+ export const STATE_LABELS = {
51
+ planning: 'conferindo os requisitos',
52
+ preparing: 'passo 1 de 3 — preparando o PgBouncer na VM do banco',
53
+ verifying_app: 'passo 2 de 3 — testando a conexão a partir da VM do app',
54
+ verifying_local: 'passo 3 de 3 — testando o login pelo IP interno na própria VM do banco',
55
+ enabled: 'ligada',
56
+ failed: 'falhou (nada mudou para o app)',
57
+ cancelled: 'cancelada (nada mudou para o app)',
58
+ disabled: 'desligada',
59
+ };
60
+ const IN_FLIGHT = new Set(['preparing', 'verifying_app', 'verifying_local']);
61
+ export function isInFlight(state) {
62
+ return !!state && IN_FLIGHT.has(state);
63
+ }
64
+ /** Nome legível de cada requisito do `--dry-run` (o Core manda o nome técnico). */
65
+ export const CHECK_LABELS = {
66
+ db_active: 'banco ativo',
67
+ engine_supported: 'tipo de banco suportado',
68
+ db_server_known: 'VM do banco conhecida',
69
+ credentials: 'credencial do banco registrada',
70
+ endpoint: 'endereço do banco legível',
71
+ distinct_server: 'app e banco em VMs diferentes',
72
+ db_server_ready: 'VM do banco no ar',
73
+ app_server_ready: 'VM do app no ar',
74
+ front_door_plan: 'IPs internos das duas VMs',
75
+ front_door_host_consistent: 'sem outro endereço já gravado',
76
+ no_active_relocate: 'nenhuma mudança de VM do banco em andamento',
77
+ app_environment: 'onde o produto diz que o app roda',
78
+ app_server_fleet: 'VM do app é da frota da Neetru',
79
+ };
80
+ /**
81
+ * O que fazer para cada código `FRONT_DOOR_*` que a rota devolve. A mensagem
82
+ * do Core (com VMs, IPs e o motivo do agente) vem antes; isto é o "e agora?".
83
+ */
84
+ export const FRONT_DOOR_GUIDANCE = {
85
+ FRONT_DOOR_INPUT_INVALID: 'Algum dado do pedido está fora do formato: o banco é um id que começa com pdb_ e a VM do app é o id do servidor.',
86
+ FRONT_DOOR_DB_NOT_FOUND: 'Banco não encontrado. Confira o id do banco com `neetru db where --product <slug>`.',
87
+ FRONT_DOOR_DB_NOT_ACTIVE: 'O banco não está ativo (ou o cadastro dele está incompleto). Só dá para ligar a porta de entrada de um banco ativo.',
88
+ FRONT_DOOR_ENGINE_UNSUPPORTED: 'A porta de entrada só existe para Postgres em VM (vm-postgres-single).',
89
+ FRONT_DOOR_NO_CREDENTIALS: 'O banco não tem credencial registrada, então não há como testar o login pela porta nova. Fale com o Dev Core.',
90
+ FRONT_DOOR_SAME_SERVER: 'A VM do app informada é a própria VM do banco. Nesse caso o app já fala com o banco pelo endereço local — não há nada a ligar.',
91
+ FRONT_DOOR_DB_SERVER_NOT_READY: 'A VM do banco não está no ar ou o agente dela está sem dar sinal há mais de 5 minutos. Verifique a VM e tente de novo.',
92
+ FRONT_DOOR_APP_SERVER_NOT_READY: 'A VM do app não existe, não está no ar ou o agente dela está sem dar sinal há mais de 5 minutos. Confira o id passado em --app-server.',
93
+ FRONT_DOOR_APP_SERVER_NOT_FLEET: 'A VM do app não é uma VM da Neetru (é de cliente ou não tem identidade no Google Cloud do projeto). A liberação abre o banco para a VM inteira, então só vale para VM da frota. Confira o id passado em --app-server.',
94
+ FRONT_DOOR_NOT_PLANNABLE: 'Não foi possível descobrir o IP interno de uma das VMs (ou ele não é da rede interna). Nada foi alterado.',
95
+ FRONT_DOOR_RELOCATE_IN_PROGRESS: 'Este banco está mudando de VM. Até a mudança terminar a porta de entrada é dela — acompanhe com `neetru db move status <id>` e tente depois.',
96
+ FRONT_DOOR_HOST_CONFLICT: 'O cadastro do banco já aponta outro IP interno, diferente do IP atual da VM do banco. Desligue primeiro com `neetru db frontdoor disable <dbId>` e ligue de novo.',
97
+ FRONT_DOOR_IN_PROGRESS: 'Já há uma ligação em andamento para outra VM do app. Repita o comando com aquela VM para concluir, ou cancele com `neetru db frontdoor disable <dbId>`.',
98
+ FRONT_DOOR_PREPARE_FAILED: 'O preparo na VM do banco falhou (o motivo acima vem da própria VM). Nada mudou para o app: ele segue usando o endereço local.',
99
+ FRONT_DOOR_VERIFY_APP_FAILED: 'A VM do app não conseguiu chegar ao banco pela rede interna (rede, firewall ou certificado). Nada mudou para o app.',
100
+ FRONT_DOOR_VERIFY_LOCAL_FAILED: 'O teste de login pelo IP interno, feito na própria VM do banco, falhou — o app que roda lá poderia perder o banco, então nada foi gravado.',
101
+ FRONT_DOOR_UNVERIFIED: 'Os testes ficaram antigos (mais de 10 minutos) antes da gravação. Rode o mesmo comando de novo: ele recomeça e testa outra vez.',
102
+ FRONT_DOOR_STATE_CHANGED: 'O banco mudou (de VM, de credencial ou de estado) enquanto a porta era testada, ou a operação foi trocada por outra. Nada foi gravado — confira com `neetru db frontdoor status <dbId>` e tente de novo.',
103
+ // NÃO está na lista de códigos do Core (`FRONT_DOOR_OP_ERROR_CODES`): é o
104
+ // `lastError.code` que o desligar grava na operação que ele cancela
105
+ // (`frontdoor-op-deps.ts`). A rota o devolve quando uma ligação em curso
106
+ // percebe o cancelamento; e o ramo "terminou sem erro" do `enable` também o
107
+ // sintetiza a partir do estado (`FRONT_DOOR_<ESTADO>`) — defesa em
108
+ // profundidade, porque o Core sempre responde erro numa operação cancelada.
109
+ FRONT_DOOR_CANCELLED: 'A ligação foi cancelada porque a porta de entrada foi desligada enquanto ela andava. Nada foi gravado.',
110
+ FRONT_DOOR_APP_ON_OTHER_VM: 'Desligar faz o app voltar a receber o endereço local da VM do banco: um app que roda em OUTRA VM fica sem banco. ' +
111
+ 'Desligar uma porta ligada (o desfazer normal) sempre pede --force, e também quando não se sabe em que VM o app roda. ' +
112
+ 'Leve o app de volta para a VM do banco antes ou, se é isso mesmo, repita com --force.',
113
+ FRONT_DOOR_HOST_NOT_OWNED: 'O endereço gravado no cadastro do banco não foi ligado por este comando (veio de outra origem, por exemplo uma mudança de VM do banco). ' +
114
+ 'Ele não é desfeito por aqui, nem com --force. Fale com o Dev Core.',
115
+ };
116
+ /** Mensagem do Core primeiro (dados reais), depois o que fazer. Pura. */
117
+ export function describeFrontDoorError(code, serverMessage) {
118
+ const guidance = FRONT_DOOR_GUIDANCE[code];
119
+ return guidance ? `${serverMessage}\n ${guidance}` : serverMessage;
120
+ }
121
+ /** Erro da rota: `FRONT_DOOR_*` vira mensagem + o que fazer; o resto vai pro handler comum. */
122
+ function failFrontDoor(err, json) {
123
+ if (isSafeExitHalt(err))
124
+ throw err;
125
+ if (err instanceof CliApiError && typeof err.code === 'string' && err.code.startsWith('FRONT_DOOR_')) {
126
+ if (json) {
127
+ console.log(JSON.stringify({
128
+ ok: false,
129
+ error: err.code,
130
+ message: err.message,
131
+ guidance: FRONT_DOOR_GUIDANCE[err.code] ?? null,
132
+ }));
133
+ }
134
+ else {
135
+ log.error(describeFrontDoorError(err.code, err.message));
136
+ }
137
+ return safeExit(1);
138
+ }
139
+ return handleApiError(err, json);
140
+ }
141
+ function inputError(message, json) {
142
+ if (json)
143
+ console.log(JSON.stringify({ ok: false, error: 'input', message }));
144
+ else
145
+ log.error(message);
146
+ return safeExit(1);
147
+ }
148
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
149
+ /** Sinal interno: o POST longo caiu por rede/tempo — retoma na próxima volta. */
150
+ class RetomarVolta extends Error {
151
+ }
152
+ /**
153
+ * Consulta o estado em paralelo ao POST longo, só pra mostrar o passo. Erro de
154
+ * consulta não importa (é exibição). Devolve a função que para.
155
+ */
156
+ function acompanharPassos(dbId, intervalMs, onOp) {
157
+ let parado = false;
158
+ void (async () => {
159
+ while (!parado) {
160
+ await sleep(intervalMs);
161
+ if (parado)
162
+ break;
163
+ try {
164
+ const s = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/frontdoor`);
165
+ if (!parado && s.op)
166
+ onOp(s.op);
167
+ }
168
+ catch {
169
+ /* só exibição */
170
+ }
171
+ }
172
+ })();
173
+ return () => {
174
+ parado = true;
175
+ };
176
+ }
177
+ export async function runDbFrontdoorEnable(dbId, opts = {}) {
178
+ if (!opts.json)
179
+ log.banner();
180
+ if (!dbId || !dbId.trim())
181
+ inputError('Informe o id do banco (pdb_…).', opts.json);
182
+ if (!opts.appServer || !opts.appServer.trim()) {
183
+ inputError('--app-server <serverId> é obrigatório (a VM onde o app roda ou vai rodar).', opts.json);
184
+ }
185
+ if (!opts.reason || !opts.reason.trim()) {
186
+ inputError('--reason "<texto>" é obrigatório (vai para o registro de auditoria).', opts.json);
187
+ }
188
+ const appServer = opts.appServer.trim();
189
+ await requireToken(opts.json);
190
+ // ── Estado ANTES: mostra o plano e falha cedo no caso óbvio. Rede instável
191
+ // não bloqueia (o Core confere tudo de novo). ─────────────────────────────
192
+ let before = null;
193
+ try {
194
+ before = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/frontdoor`);
195
+ }
196
+ catch (err) {
197
+ if (!(err instanceof CliNetworkError))
198
+ failFrontDoor(err, opts.json);
199
+ }
200
+ const dbServer = before?.ledger.serverId ?? null;
201
+ if (dbServer && dbServer === appServer) {
202
+ const msg = describeFrontDoorError('FRONT_DOOR_SAME_SERVER', `A VM do app (${appServer}) é a mesma VM do banco.`);
203
+ if (opts.json) {
204
+ console.log(JSON.stringify({ ok: false, error: 'FRONT_DOOR_SAME_SERVER', message: msg }));
205
+ }
206
+ else {
207
+ log.error(msg);
208
+ }
209
+ safeExit(1);
210
+ }
211
+ if (!opts.json) {
212
+ log.heading(opts.dryRun ? 'Porta de entrada do banco (só conferência)' : 'Porta de entrada do banco');
213
+ log.info(`Banco: ${chalk.cyan(dbId)}`);
214
+ log.info(`VM do banco: ${dbServer ? chalk.bold(dbServer) : chalk.dim('(o Core confere)')}`);
215
+ log.info(`VM do app: ${chalk.bold(appServer)}`);
216
+ log.info(`Hoje: ${before?.ledger.frontDoorHost
217
+ ? `ligada em ${before.ledger.frontDoorHost}:5433`
218
+ : 'desligada — o app recebe o endereço local da VM do banco'}`);
219
+ }
220
+ const body = { appServerId: appServer, reason: opts.reason.trim() };
221
+ // ── Só conferência: nada é criado, sem MFA. ───────────────────────────────
222
+ if (opts.dryRun) {
223
+ const { headers } = await resolveStepUp({ dryRun: true, json: opts.json, mfaToken: opts.mfaToken });
224
+ let r;
225
+ try {
226
+ r = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/frontdoor`, {
227
+ method: 'POST',
228
+ body: { ...body, dryRun: true },
229
+ headers,
230
+ });
231
+ }
232
+ catch (err) {
233
+ failFrontDoor(err, opts.json);
234
+ }
235
+ if (opts.json) {
236
+ console.log(JSON.stringify({ ...r, ok: r.preflight.ok }));
237
+ if (!r.preflight.ok)
238
+ setSafeExitCode(1);
239
+ return;
240
+ }
241
+ log.heading('Requisitos');
242
+ for (const c of r.preflight.checks) {
243
+ const mark = c.ok ? chalk.green('✓') : chalk.red('✗');
244
+ log.dim(` ${mark} ${CHECK_LABELS[c.name] ?? c.name}${c.detail ? ` — ${c.detail}` : ''}`);
245
+ }
246
+ if (r.preflight.ok) {
247
+ log.success('Tudo certo para ligar. Nada foi alterado. Rode de novo sem --dry-run para ligar.');
248
+ }
249
+ else {
250
+ log.error('Algum requisito falhou — a porta de entrada não seria ligada. Nada foi alterado.');
251
+ setSafeExitCode(1);
252
+ }
253
+ return;
254
+ }
255
+ await confirmDestructive(`Ligar a porta de entrada do banco ${dbId} para a VM ${appServer}? O PgBouncer da VM do banco ` +
256
+ 'reinicia uma vez (as conexões do app caem e voltam em segundos). Depois de ligada, TODO app ' +
257
+ 'deste banco passa a falar com ele pelo IP interno da VM do banco — isso é testado antes de gravar.', { yes: opts.yes, json: opts.json });
258
+ // ── Voltas de POST longo (o Core anda só durante a chamada) ───────────────
259
+ const intervalMs = opts.pollIntervalMs ?? 3000;
260
+ const totalMs = opts.timeoutMs ?? 20 * 60 * 1000;
261
+ const waitSeconds = opts.waitSeconds ?? 240;
262
+ const inicio = Date.now();
263
+ const spinner = opts.json ? null : ora({ text: 'Ligando a porta de entrada…', color: 'blue' }).start();
264
+ let ultimoEstado = '';
265
+ const mostrar = (state) => {
266
+ if (state === ultimoEstado)
267
+ return;
268
+ ultimoEstado = state;
269
+ if (spinner)
270
+ spinner.text = STATE_LABELS[state] ?? state;
271
+ };
272
+ let volta = 0;
273
+ // eslint-disable-next-line no-constant-condition
274
+ while (true) {
275
+ if (Date.now() - inicio >= totalMs) {
276
+ spinner?.fail('Prazo esgotado esperando a porta de entrada.');
277
+ const msg = 'A ligação continua registrada no Core e não mudou nada para o app. ' +
278
+ `Rode de novo \`neetru db frontdoor enable ${dbId} --app-server ${appServer} --reason "<motivo>"\` para retomar.`;
279
+ if (opts.json)
280
+ console.log(JSON.stringify({ ok: false, error: 'watch_timeout', dbId, message: msg }));
281
+ else
282
+ log.warn(msg);
283
+ setSafeExitCode(1);
284
+ return;
285
+ }
286
+ volta += 1;
287
+ const parar = spinner ? acompanharPassos(dbId, intervalMs, (op) => mostrar(op.state)) : () => undefined;
288
+ let r = null;
289
+ try {
290
+ r = await withStepUpRetry(
291
+ // O TOTP é de uso único: só a 1ª volta manda o `--mfa-token`; as
292
+ // retomadas usam a sessão de 1h (ou pedem um TOTP fresco).
293
+ { mfaToken: volta === 1 ? opts.mfaToken : undefined, json: opts.json, yes: opts.yes }, (headers) => apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/frontdoor`, {
294
+ method: 'POST',
295
+ body: { ...body, waitSeconds },
296
+ headers,
297
+ timeoutMs: (waitSeconds + 60) * 1000,
298
+ }), (err) => {
299
+ // POST longo que caiu por rede/tempo: o Core segue com a operação
300
+ // registrada — a próxima volta retoma.
301
+ if (err instanceof CliNetworkError)
302
+ throw new RetomarVolta();
303
+ parar();
304
+ spinner?.fail('A porta de entrada NÃO foi ligada.');
305
+ return failFrontDoor(err, opts.json);
306
+ }, () => {
307
+ parar();
308
+ spinner?.stop();
309
+ });
310
+ }
311
+ catch (err) {
312
+ if (!(err instanceof RetomarVolta))
313
+ throw err;
314
+ }
315
+ finally {
316
+ parar();
317
+ }
318
+ if (!r) {
319
+ if (spinner)
320
+ spinner.text = 'Conexão com o Core caiu — retomando…';
321
+ await sleep(Math.min(intervalMs, 3000));
322
+ continue;
323
+ }
324
+ mostrar(r.state);
325
+ if (r.done) {
326
+ // Só o `frontDoorHost` do cadastro diz que a porta está ligada. O Core #727
327
+ // grava `frontDoorCertNotAfter` antes (no despacho do preparo), inclusive
328
+ // em operação que depois falha — esse campo NUNCA é prova de "ligada".
329
+ const hostLigado = r.ledger?.frontDoorHost ?? null;
330
+ if (!hostLigado) {
331
+ spinner?.fail('A porta de entrada NÃO aparece ligada no cadastro do banco.');
332
+ const msg = `O Core respondeu que terminou, mas o cadastro do banco ${dbId} não tem o endereço da porta de entrada — ` +
333
+ `nada é dado como ligado. Confira com \`neetru db frontdoor status ${dbId}\`.`;
334
+ if (opts.json)
335
+ console.log(JSON.stringify({ ok: false, error: 'not_recorded', dbId, message: msg }));
336
+ else
337
+ log.error(msg);
338
+ setSafeExitCode(1);
339
+ return;
340
+ }
341
+ spinner?.succeed(r.alreadyEnabled ? 'A porta de entrada já estava ligada.' : 'Porta de entrada LIGADA.');
342
+ if (opts.json) {
343
+ console.log(JSON.stringify(r));
344
+ return;
345
+ }
346
+ const op = r.op;
347
+ if (r.alreadyEnabled) {
348
+ log.info(`Já estava ligada para a VM ${appServer} — nada mudou.`);
349
+ }
350
+ else {
351
+ log.success(`O banco ${dbId} agora atende a VM ${appServer} pelo IP interno ${hostLigado}:${op?.port ?? 5433}.`);
352
+ log.dim(' Vale para todo app deste banco, inclusive o que ainda roda na VM do banco ' +
353
+ '(o login por esse caminho foi testado lá antes de gravar).');
354
+ }
355
+ const vence = r.ledger?.frontDoorCertNotAfter;
356
+ if (vence)
357
+ log.dim(` Certificado do PgBouncer vence em: ${vence}`);
358
+ log.info(`Próximo passo: faça o deploy do app na VM ${appServer} e confira login e uma operação real.`);
359
+ return;
360
+ }
361
+ if (!isInFlight(r.state)) {
362
+ // Não deveria acontecer (o Core responde erro na falha), mas sem mentir:
363
+ const code = r.op?.lastError?.code ?? `FRONT_DOOR_${String(r.state).toUpperCase()}`;
364
+ const message = r.op?.lastError?.message ?? `Operação terminou em ${r.state}.`;
365
+ spinner?.fail('A porta de entrada NÃO foi ligada.');
366
+ if (opts.json) {
367
+ console.log(JSON.stringify({ ok: false, error: code, message, guidance: FRONT_DOOR_GUIDANCE[code] ?? null }));
368
+ }
369
+ else {
370
+ log.error(describeFrontDoorError(code, message));
371
+ }
372
+ setSafeExitCode(1);
373
+ return;
374
+ }
375
+ // Ainda andando depois da espera do Core: chama de novo (retoma a mesma operação).
376
+ await sleep(Math.min(intervalMs, 3000));
377
+ }
378
+ }
379
+ // ---------------------------------------------------------------------------
380
+ // neetru db frontdoor status <dbId>
381
+ // ---------------------------------------------------------------------------
382
+ export async function runDbFrontdoorStatus(dbId, opts = {}) {
383
+ await requireToken(opts.json);
384
+ let s;
385
+ try {
386
+ s = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/frontdoor`);
387
+ }
388
+ catch (err) {
389
+ failFrontDoor(err, opts.json);
390
+ }
391
+ if (opts.json) {
392
+ console.log(JSON.stringify(s));
393
+ return;
394
+ }
395
+ log.heading(`Porta de entrada do banco ${s.dbId}`);
396
+ log.info(`VM do banco: ${s.ledger.serverId ?? '—'}`);
397
+ log.info(`Porta: ${s.ledger.frontDoorHost
398
+ ? chalk.green(`ligada em ${s.ledger.frontDoorHost}:5433`)
399
+ : 'desligada — o app recebe o endereço local da VM do banco'}`);
400
+ if (s.ledger.frontDoorCertNotAfter) {
401
+ log.dim(` certificado do PgBouncer vence em: ${s.ledger.frontDoorCertNotAfter}` +
402
+ (s.ledger.frontDoorHost ? '' : ' (ele segue na VM do banco mesmo com a porta desligada)'));
403
+ }
404
+ const op = s.op;
405
+ if (!op) {
406
+ log.dim(' Nenhuma ligação feita por este comando até agora.');
407
+ return;
408
+ }
409
+ log.heading('Última operação');
410
+ log.info(`Estado: ${STATE_LABELS[op.state] ?? op.state}`);
411
+ log.info(`VM do app: ${op.appServerId}${op.allowedClientCidr ? ` (liberada: ${op.allowedClientCidr})` : ''}`);
412
+ log.dim(` pedido: ${op.reason}`);
413
+ if (op.preparedAt)
414
+ log.dim(` preparado na VM do banco em: ${op.preparedAt}`);
415
+ if (op.appVerifiedAt)
416
+ log.dim(` testado a partir da VM do app em: ${op.appVerifiedAt}`);
417
+ if (op.localVerifiedAt)
418
+ log.dim(` login testado na VM do banco em: ${op.localVerifiedAt}`);
419
+ if (op.enabledAt)
420
+ log.dim(` ligada em: ${op.enabledAt}`);
421
+ if (op.failedAt)
422
+ log.dim(` falhou em: ${op.failedAt}`);
423
+ if (op.cancelledAt)
424
+ log.dim(` cancelada em: ${op.cancelledAt}`);
425
+ if (op.disabledAt)
426
+ log.dim(` desligada em: ${op.disabledAt}`);
427
+ if (op.lastError)
428
+ log.warn(describeFrontDoorError(op.lastError.code, op.lastError.message));
429
+ if (isInFlight(op.state)) {
430
+ log.warn('Esta ligação só anda enquanto o comando de ligar estiver rodando. Para concluir, rode de novo ' +
431
+ `\`neetru db frontdoor enable ${s.dbId} --app-server ${op.appServerId} --reason "<motivo>"\`; ` +
432
+ `para cancelar, \`neetru db frontdoor disable ${s.dbId} --reason "<motivo>"\`.`);
433
+ }
434
+ }
435
+ /** Uma pergunta no terminal (a confirmação forte do `disable --force`). */
436
+ function perguntar(pergunta) {
437
+ return new Promise((resolve) => {
438
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
439
+ rl.question(` ${pergunta} `, (resposta) => {
440
+ rl.close();
441
+ resolve(resposta.trim());
442
+ });
443
+ });
444
+ }
445
+ /**
446
+ * Desligar (desfazer) — regras do Core #727 (rodada 2), decididas na transação:
447
+ * - porta LIGADA por este comando pra um app noutra VM (o desfazer normal) ⇒
448
+ * exige `--force`; idem quando não se sabe em que VM o app roda;
449
+ * - ligação em curso sem endereço gravado ⇒ só cancela, sem `--force`;
450
+ * - endereço gravado por OUTRA origem (ex.: mudança de VM do banco) nunca é
451
+ * tirado por aqui: `host_kept` (cancelou a ligação em curso) ou
452
+ * `FRONT_DOOR_HOST_NOT_OWNED` (409, nem com `--force`).
453
+ * Com `--force` a confirmação é forte: digitar o id do banco (ou `--yes`).
454
+ */
455
+ export async function runDbFrontdoorDisable(dbId, opts = {}) {
456
+ if (!opts.json)
457
+ log.banner();
458
+ if (!dbId || !dbId.trim())
459
+ inputError('Informe o id do banco (pdb_…).', opts.json);
460
+ if (!opts.reason || !opts.reason.trim()) {
461
+ inputError('--reason "<texto>" é obrigatório (vai para o registro de auditoria).', opts.json);
462
+ }
463
+ await requireToken(opts.json);
464
+ let before = null;
465
+ try {
466
+ before = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/frontdoor`);
467
+ }
468
+ catch (err) {
469
+ if (!(err instanceof CliNetworkError))
470
+ failFrontDoor(err, opts.json);
471
+ }
472
+ const host = before?.ledger.frontDoorHost ?? null;
473
+ const dbServer = before?.ledger.serverId ?? null;
474
+ const opAntes = before?.op ?? null;
475
+ // A VM do app que a porta ligada atende (se a porta é deste comando e está ligada).
476
+ const appLigado = host && opAntes && opAntes.state === 'enabled' && opAntes.host === host && opAntes.dbServerId === dbServer
477
+ ? opAntes.appServerId
478
+ : null;
479
+ const appNoutraVm = appLigado && appLigado !== dbServer ? appLigado : null;
480
+ const vmDoBanco = dbServer ?? 'VM do banco';
481
+ // ── Desfazer uma porta ligada pede --force: explica ANTES de pedir MFA. ──
482
+ if (appNoutraVm && !opts.force) {
483
+ const msg = `A porta de entrada está ligada para a VM do app ${appNoutraVm}. Desligar exige --force: o app volta a ` +
484
+ `receber o endereço local da VM do banco ${vmDoBanco}, e um app que ainda rode em ${appNoutraVm} FICA SEM ` +
485
+ 'BANCO até voltar para a VM do banco.';
486
+ if (opts.json) {
487
+ console.log(JSON.stringify({
488
+ ok: false,
489
+ error: 'FRONT_DOOR_APP_ON_OTHER_VM',
490
+ message: msg,
491
+ guidance: FRONT_DOOR_GUIDANCE.FRONT_DOOR_APP_ON_OTHER_VM,
492
+ }));
493
+ }
494
+ else {
495
+ log.error(msg);
496
+ }
497
+ safeExit(1);
498
+ }
499
+ if (opts.force) {
500
+ const efeito = `Desligar com --force: o app deixa de receber o IP interno${host ? ` ${host}:5433` : ''} e volta para o ` +
501
+ `endereço local da VM ${vmDoBanco}. ` +
502
+ (appNoutraVm
503
+ ? `Se o app ainda roda em ${appNoutraVm}, ele CAI (fica sem banco) até voltar para a VM do banco.`
504
+ : 'Um app que rode em outra VM CAI (fica sem banco).');
505
+ if (!opts.yes) {
506
+ if (opts.json || !process.stdin.isTTY) {
507
+ const msg = 'Desligar com --force pede confirmação forte: digite o id do banco num terminal interativo, ' +
508
+ 'ou passe --yes junto com --force.';
509
+ if (opts.json)
510
+ console.log(JSON.stringify({ ok: false, error: 'confirmation_required', message: msg }));
511
+ else
512
+ log.error(msg);
513
+ safeExit(1);
514
+ }
515
+ log.warn(efeito);
516
+ const digitado = await perguntar(`Para confirmar, digite o id do banco (${dbId}):`);
517
+ if (digitado !== dbId) {
518
+ log.error('O id não conferiu. Nada foi alterado.');
519
+ safeExit(1);
520
+ }
521
+ }
522
+ else if (!opts.json) {
523
+ log.warn(efeito);
524
+ }
525
+ }
526
+ else {
527
+ await confirmDestructive(`Desligar a porta de entrada do banco ${dbId}? O app volta a receber o endereço local da VM ${vmDoBanco}` +
528
+ (host ? ` (hoje recebe ${host}:5433)` : '') +
529
+ '. Uma ligação em andamento é cancelada.', { yes: opts.yes, json: opts.json });
530
+ }
531
+ const spinner = opts.json ? null : ora({ text: 'Desligando a porta de entrada…', color: 'blue' }).start();
532
+ const r = await withStepUpRetry({ mfaToken: opts.mfaToken, json: opts.json, yes: opts.yes }, (headers) => apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/frontdoor/disable`, {
533
+ method: 'POST',
534
+ body: { reason: opts.reason.trim(), ...(opts.force ? { force: true } : {}) },
535
+ headers,
536
+ }), (err) => {
537
+ spinner?.fail('A porta de entrada NÃO foi desligada.');
538
+ return failFrontDoor(err, opts.json);
539
+ }, () => spinner?.stop());
540
+ spinner?.stop();
541
+ if (opts.json) {
542
+ console.log(JSON.stringify(r));
543
+ return;
544
+ }
545
+ const vmFinal = r.ledger?.serverId ?? vmDoBanco;
546
+ if (r.outcome === 'already_disabled') {
547
+ log.info('A porta de entrada já estava desligada — o endereço do app não mudou.');
548
+ }
549
+ else if (r.outcome === 'host_kept') {
550
+ log.warn(`A ligação em andamento (${r.opId}) foi cancelada, mas o endereço gravado no cadastro` +
551
+ `${r.previousFrontDoorHost ? ` (${r.previousFrontDoorHost}:5433)` : ''} não é desta porta de entrada ` +
552
+ '(veio de outra origem, por exemplo uma mudança de VM do banco) e FICOU como estava.');
553
+ }
554
+ else {
555
+ log.success(`Porta de entrada DESLIGADA. O app volta a receber o endereço local da VM ${vmFinal}` +
556
+ `${r.previousFrontDoorHost ? ` (antes: ${r.previousFrontDoorHost}:5433)` : ''}.`);
557
+ if (appNoutraVm) {
558
+ log.warn(`Se o app ainda roda em ${appNoutraVm}, ele está SEM BANCO agora — leve-o para a VM ${vmFinal}.`);
559
+ }
560
+ }
561
+ if (r.opTransition === 'cancelled' && r.outcome !== 'host_kept') {
562
+ log.warn(`A ligação que estava em andamento (${r.opId}) foi cancelada.`);
563
+ }
564
+ log.dim(' Na VM do banco o PgBouncer segue escutando no IP interno, liberado só para a VM do app — ' +
565
+ 'isso não é desfeito por este comando.');
566
+ }
567
+ //# sourceMappingURL=db-frontdoor.js.map
@@ -182,6 +182,11 @@ export interface DbMoveStartOptions {
182
182
  /** Somente testes: teto do wall-clock do poll (ms). Default 30min. */
183
183
  pollTimeoutMs?: number;
184
184
  }
185
+ /**
186
+ * Recusas do KICKOFF (`POST /db/{id}/relocate`) com código estável e o que
187
+ * fazer, em PT-BR. As outras recusas seguem pelo tratamento comum.
188
+ */
189
+ export declare const RELOCATE_START_REFUSAL_GUIDANCE: Record<string, string>;
185
190
  export declare function runDbMoveStart(opts: DbMoveStartOptions): Promise<void>;
186
191
  /**
187
192
  * Acompanha a máquina de estados até um estado terminal (done/rolled_back/failed)
@@ -283,6 +283,17 @@ async function resolveTargetDb(opts) {
283
283
  }
284
284
  return { dbId: dbs[0].id, source: dbs[0] };
285
285
  }
286
+ /**
287
+ * Recusas do KICKOFF (`POST /db/{id}/relocate`) com código estável e o que
288
+ * fazer, em PT-BR. As outras recusas seguem pelo tratamento comum.
289
+ */
290
+ export const RELOCATE_START_REFUSAL_GUIDANCE = {
291
+ // Core #727: o preflight do relocate recusa enquanto uma ligação da porta de
292
+ // entrada do banco (`neetru db frontdoor`) estiver em andamento.
293
+ RELOCATE_FRONT_DOOR_OP_IN_PROGRESS: 'Há uma ligação da porta de entrada deste banco em andamento. Espere ela terminar ' +
294
+ '(acompanhe com `neetru db frontdoor status <dbId>`) ou cancele com ' +
295
+ '`neetru db frontdoor disable <dbId> --reason "<motivo>"`, e então rode a mudança de VM de novo.',
296
+ };
286
297
  export async function runDbMoveStart(opts) {
287
298
  if (!opts.json)
288
299
  log.banner();
@@ -371,6 +382,22 @@ export async function runDbMoveStart(opts) {
371
382
  }
372
383
  catch (err) {
373
384
  spinner?.fail('Falha ao iniciar relocação.');
385
+ // Recusa conhecida do kickoff (409 com código estável) ⇒ o que significa +
386
+ // o que fazer, em vez da mensagem genérica.
387
+ if (err instanceof CliApiError && RELOCATE_START_REFUSAL_GUIDANCE[err.code]) {
388
+ if (opts.json) {
389
+ console.log(JSON.stringify({
390
+ ok: false,
391
+ error: err.code,
392
+ message: err.message,
393
+ guidance: RELOCATE_START_REFUSAL_GUIDANCE[err.code],
394
+ }));
395
+ }
396
+ else {
397
+ log.error(`${err.message}\n ${RELOCATE_START_REFUSAL_GUIDANCE[err.code]}`);
398
+ }
399
+ safeExit(1);
400
+ }
374
401
  handleApiError(err, opts.json);
375
402
  }
376
403
  // ── Dry-run: só o preflight. Nada foi provisionado. ─────────────────────────
@@ -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;
@@ -342,6 +342,73 @@ export async function runServersDeactivate(id, opts) {
342
342
  }
343
343
  printWriteResult(res.dryRun ? res : { serverId: res.serverId, status: res.status }, `Servidor ${res.serverId} → ${res.status}`, opts.json);
344
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
+ }
345
412
  export async function runServersForceCleanup(id, opts) {
346
413
  if (!id || !/^[a-zA-Z0-9_-]{1,64}$/.test(id)) {
347
414
  log.error('serverId inválido. Uso: neetru servers force-cleanup <serverId> --reason="<motivo min 10 chars>"');
@@ -351,6 +418,16 @@ export async function runServersForceCleanup(id, opts) {
351
418
  log.error('--reason obrigatório com pelo menos 10 caracteres (paper-trail audit).');
352
419
  safeExit(1);
353
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
+ }
354
431
  const token = await requireToken(opts.json);
355
432
  // Double-confirm (digitar o ID) — dry-run não aplica nada, então pula: nada
356
433
  // destrutivo acontece, não faz sentido travar numa confirmação interativa.
@@ -369,7 +446,11 @@ export async function runServersForceCleanup(id, opts) {
369
446
  }
370
447
  console.log(chalk.yellow.bold('\n⚠ FORCE-CLEANUP é IRREVERSÍVEL.'));
371
448
  console.log(chalk.dim(' Server stuck em provisioning/timeout que está bloqueando o pipeline.'));
372
- console.log(chalk.dim(' Audit pesado + notification all staff serão gerados.\n'));
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('');
373
454
  const inquirer = await carregarInquirer();
374
455
  const ans = await inquirer.prompt([
375
456
  {
@@ -396,12 +477,28 @@ export async function runServersForceCleanup(id, opts) {
396
477
  body: {
397
478
  reason: opts.reason.trim(),
398
479
  confirm: 'I-UNDERSTAND-FORCE-CLEANUP',
480
+ ...(confirmInstanceId ? { confirmInstanceId } : {}),
399
481
  },
400
482
  });
401
483
  spinner?.stop();
402
484
  }
403
485
  catch (error) {
404
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
+ }
405
502
  handleApiError(error, opts.json);
406
503
  }
407
504
  if (opts.json) {
@@ -414,6 +511,16 @@ export async function runServersForceCleanup(id, opts) {
414
511
  }
415
512
  log.success(`Server ${res.serverId} force-cleanup concluído.`);
416
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
+ }
417
524
  if (res.vmDeleted) {
418
525
  log.dim(' VM GCP: deletada com sucesso.');
419
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neetru/cli",
3
- "version": "2.32.1",
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": [