@neetru/cli 2.32.1 → 2.34.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +38 -3
  3. package/dist/commands/admin/sdk-credential.js +3 -1
  4. package/dist/commands/ai-models.d.ts +2 -1
  5. package/dist/commands/ai-models.js +4 -5
  6. package/dist/commands/ai.d.ts +12 -2
  7. package/dist/commands/ai.js +47 -38
  8. package/dist/commands/auth.d.ts +3 -2
  9. package/dist/commands/auth.js +96 -57
  10. package/dist/commands/build.js +35 -4
  11. package/dist/commands/chat.d.ts +24 -6
  12. package/dist/commands/chat.js +43 -7
  13. package/dist/commands/code.js +11 -2
  14. package/dist/commands/config.js +15 -13
  15. package/dist/commands/db-frontdoor.d.ts +116 -0
  16. package/dist/commands/db-frontdoor.js +572 -0
  17. package/dist/commands/db-move.d.ts +5 -0
  18. package/dist/commands/db-move.js +27 -0
  19. package/dist/commands/db.d.ts +8 -2
  20. package/dist/commands/db.js +13 -6
  21. package/dist/commands/deploy.d.ts +14 -5
  22. package/dist/commands/deploy.js +376 -55
  23. package/dist/commands/dev-context.js +1 -1
  24. package/dist/commands/environments-repoint.d.ts +49 -0
  25. package/dist/commands/environments-repoint.js +219 -0
  26. package/dist/commands/init.js +7 -1
  27. package/dist/commands/new.js +14 -0
  28. package/dist/commands/secrets.d.ts +6 -4
  29. package/dist/commands/secrets.js +14 -7
  30. package/dist/commands/servers.d.ts +29 -0
  31. package/dist/commands/servers.js +108 -1
  32. package/dist/commands/ui.js +1 -1
  33. package/dist/commands/workspaces.js +16 -1
  34. package/dist/index.js +103 -7
  35. package/dist/lib/ai/auth-discovery.d.ts +9 -12
  36. package/dist/lib/ai/auth-discovery.js +43 -145
  37. package/dist/lib/ai/orchestrator.d.ts +13 -6
  38. package/dist/lib/ai/orchestrator.js +46 -246
  39. package/dist/lib/ai/provedores.d.ts +57 -0
  40. package/dist/lib/ai/provedores.js +66 -0
  41. package/dist/lib/api-client.d.ts +8 -1
  42. package/dist/lib/api-client.js +18 -2
  43. package/dist/lib/build-targets.d.ts +48 -10
  44. package/dist/lib/build-targets.js +64 -10
  45. package/dist/lib/cli-write.js +6 -3
  46. package/dist/lib/code-agent-gateway.d.ts +2 -2
  47. package/dist/lib/code-agent-gateway.js +2 -2
  48. package/dist/lib/code-agent-settings.d.ts +2 -2
  49. package/dist/lib/code-agent-settings.js +2 -4
  50. package/dist/lib/code-agent-types.d.ts +3 -1
  51. package/dist/lib/config.d.ts +4 -7
  52. package/dist/lib/config.js +9 -6
  53. package/dist/lib/deploy-recusa-de-destino.d.ts +9 -0
  54. package/dist/lib/deploy-recusa-de-destino.js +98 -0
  55. package/dist/lib/deploy-render-probe.d.ts +117 -0
  56. package/dist/lib/deploy-render-probe.js +475 -0
  57. package/dist/lib/deploy-vm-um-app.d.ts +62 -0
  58. package/dist/lib/deploy-vm-um-app.js +83 -0
  59. package/dist/lib/segredo-produto.d.ts +25 -5
  60. package/dist/lib/segredo-produto.js +45 -10
  61. package/dist/lib/sse.d.ts +4 -5
  62. package/dist/lib/sse.js +4 -5
  63. package/package.json +1 -2
@@ -0,0 +1,49 @@
1
+ import { type TenantEnvironmentId } from '../lib/envs.js';
2
+ /** Ambientes que dá pra re-apontar: os do Core menos o dev-local (roda na máquina do dev). */
3
+ export type RepointEnvironment = Exclude<TenantEnvironmentId, 'dev-local'>;
4
+ export declare const REPOINT_ENVIRONMENTS: readonly RepointEnvironment[];
5
+ export interface RepointCheck {
6
+ name: string;
7
+ ok: boolean;
8
+ detail?: string;
9
+ }
10
+ export interface RepointResponse {
11
+ ok: true;
12
+ productId: string;
13
+ slug: string | null;
14
+ dryRun: boolean;
15
+ changed: boolean;
16
+ alreadyPointed: boolean;
17
+ env: RepointEnvironment;
18
+ from: string | null;
19
+ to: string;
20
+ preflight: {
21
+ ok: boolean;
22
+ checks: RepointCheck[];
23
+ };
24
+ appMoved: false;
25
+ pendingDrain: string[];
26
+ auditId: string | null;
27
+ nextStep: string;
28
+ }
29
+ /** Nome legível de cada requisito do `--dry-run` (o Core manda o nome técnico). */
30
+ export declare const REPOINT_CHECK_LABELS: Record<string, string>;
31
+ /** Rótulo de um requisito (os de banco vêm como `database:<dbId>`). */
32
+ export declare function checkLabel(name: string): string;
33
+ /**
34
+ * O que fazer para cada código `REPOINT_*` que a rota devolve. A mensagem do
35
+ * Core (com VMs, bancos e o motivo) vem antes; isto é o "e agora?".
36
+ */
37
+ export declare const REPOINT_GUIDANCE: Record<string, string>;
38
+ /** Mensagem do Core primeiro (dados reais), depois o que fazer. Pura. */
39
+ export declare function describeRepointError(code: string, serverMessage: string): string;
40
+ export interface EnvironmentsRepointOptions {
41
+ to?: string;
42
+ expectFrom?: string;
43
+ reason?: string;
44
+ dryRun?: boolean;
45
+ yes?: boolean;
46
+ mfaToken?: string;
47
+ json?: boolean;
48
+ }
49
+ export declare function runEnvironmentsRepoint(slug: string, env: string, opts?: EnvironmentsRepointOptions): Promise<void>;
@@ -0,0 +1,219 @@
1
+ /**
2
+ * `neetru environments repoint <slug> <env> --to <serverId> --expect-from <serverId> --reason "<motivo>"`
3
+ * — RE-APONTA o ambiente (`staging` | `prod`) de um produto para outra VM
4
+ * (Core bug_7446f7f2).
5
+ *
6
+ * Antes não havia comando para isso: só um PATCH manual no Firestore pelo Dev
7
+ * Core, e o go-live de um produto travava quando ele estava fora.
8
+ *
9
+ * O que muda: SÓ o ponteiro do ambiente (a VM que o ambiente aponta). O app NÃO
10
+ * muda de VM: o próximo `neetru deploy --target vm --env <env> --server <id>`
11
+ * é que leva o app, acerta a porta e o domínio. A promoção de staging para prod
12
+ * passa a usar a VM apontada. A VM antiga continua rodando o que estava lá e
13
+ * fica contada como ocupada por este ambiente.
14
+ *
15
+ * `--dry-run` confere os requisitos sem gravar e sem MFA. Fora dele: admin +
16
+ * MFA (`--mfa-token` ou a sessão de 1h de `neetru auth mfa-session`; sessão
17
+ * recusada pelo Core ⇒ pede um TOTP fresco UMA vez).
18
+ *
19
+ * Endpoint (Core): POST /api/cli/v1/products/{slug}/environments/{env}/server
20
+ * body `{ toServerId, expectedServerId, reason, dryRun? }`.
21
+ *
22
+ * Plain PT-BR em toda string visível ao usuário. O grupo `neetru env` é do
23
+ * NEETRU_ENV local e das variáveis do Cloud Run — por isso o nome `environments`.
24
+ */
25
+ import chalk from 'chalk';
26
+ import { apiRequest, CliApiError } from '../lib/api-client.js';
27
+ import { handleApiError } from '../lib/api-error.js';
28
+ import { requireToken } from '../lib/cli-read.js';
29
+ import { confirmDestructive, resolveStepUp, withStepUpRetry } from '../lib/cli-write.js';
30
+ import { TENANT_ENVIRONMENT_IDS } from '../lib/envs.js';
31
+ import { log } from '../utils/logger.js';
32
+ import { isSafeExitHalt, safeExit, setSafeExitCode } from '../utils/safe-exit.js';
33
+ export const REPOINT_ENVIRONMENTS = TENANT_ENVIRONMENT_IDS.filter((e) => e !== 'dev-local');
34
+ const SERVER_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
35
+ const MAX_REASON = 500;
36
+ // ---------------------------------------------------------------------------
37
+ // Textos (puros — testáveis sem rede)
38
+ // ---------------------------------------------------------------------------
39
+ /** Nome legível de cada requisito do `--dry-run` (o Core manda o nome técnico). */
40
+ export const REPOINT_CHECK_LABELS = {
41
+ environment: 'o ambiente existe',
42
+ environment_target_vm: 'o ambiente roda em VM',
43
+ environment_active: 'o ambiente não está arquivado nem suspenso',
44
+ expected_server: 'o ambiente aponta a VM informada em --expect-from',
45
+ no_deploy_in_flight: 'nenhum lançamento em andamento',
46
+ target_ready: 'VM nova no ar e pronta',
47
+ target_fleet: 'VM nova é da frota da Neetru',
48
+ target_role: 'VM nova aceita app e é compartilhada',
49
+ target_public_ip: 'VM nova tem IP público (o ambiente tem domínio público)',
50
+ target_capacity: 'VM nova tem espaço para mais um app',
51
+ same_vm: 'nenhum outro ambiente do produto na VM nova',
52
+ databases: 'bancos do ambiente',
53
+ };
54
+ /** Rótulo de um requisito (os de banco vêm como `database:<dbId>`). */
55
+ export function checkLabel(name) {
56
+ if (name.startsWith('database:'))
57
+ return `banco ${name.slice('database:'.length)} alcançável da VM nova`;
58
+ return REPOINT_CHECK_LABELS[name] ?? name;
59
+ }
60
+ /**
61
+ * O que fazer para cada código `REPOINT_*` que a rota devolve. A mensagem do
62
+ * Core (com VMs, bancos e o motivo) vem antes; isto é o "e agora?".
63
+ */
64
+ export const REPOINT_GUIDANCE = {
65
+ REPOINT_INPUT_INVALID: 'Algum dado do pedido está fora do formato: o ambiente é staging ou prod, e as VMs são ids de servidor (letras, números, _ ou -).',
66
+ REPOINT_PRODUCT_NOT_FOUND: 'Produto não encontrado. Confira o slug ou o id do produto.',
67
+ REPOINT_ENV_NOT_FOUND: 'O produto não tem esse ambiente. Ele nasce no primeiro deploy.',
68
+ REPOINT_ENV_NOT_VM: 'Esse ambiente não roda em VM, então não há VM para re-apontar.',
69
+ REPOINT_ENV_INACTIVE: 'O ambiente está arquivado ou suspenso. Re-apontar só vale para ambiente ativo.',
70
+ REPOINT_EXPECTED_MISMATCH: 'O ambiente não aponta a VM que você informou: alguém mudou desde que você olhou. Veja a VM atual no requisito ' +
71
+ '"o ambiente aponta a VM informada" rodando ' +
72
+ '`neetru environments repoint <slug> <env> --to <serverId> --expect-from <serverId> --reason "<motivo>" --dry-run`, ' +
73
+ 'e repita com o valor certo. Nada foi gravado.',
74
+ REPOINT_DEPLOY_IN_PROGRESS: 'Há um lançamento em andamento neste ambiente. Espere ele terminar e repita. Nada foi gravado.',
75
+ REPOINT_TARGET_NOT_READY: 'A VM nova não existe, não está no ar, está sem dar sinal há mais de 5 minutos ou ainda não terminou de se preparar.',
76
+ REPOINT_TARGET_NOT_FLEET: 'A VM nova não é uma VM da Neetru (é de cliente ou não tem identidade no Google Cloud do projeto).',
77
+ REPOINT_TARGET_ROLE: 'A VM nova não recebe apps (é só de banco) ou é exclusiva. Escolha uma VM que aceite apps.',
78
+ REPOINT_TARGET_NO_CAPACITY: 'A VM nova está sem espaço para mais um app. Escolha outra VM ou libere espaço nela.',
79
+ REPOINT_TARGET_IP_NOT_PUBLIC: 'O ambiente tem domínio público e a VM nova não tem IP público — o domínio não teria para onde apontar.',
80
+ REPOINT_SAME_VM_COLLISION: 'Outro ambiente do mesmo produto está (ou pode estar) rodando na VM nova. Os dois se sobrescreveriam: use uma VM por ambiente.',
81
+ REPOINT_DB_UNREACHABLE: 'Um banco deste ambiente fica em outra VM e não tem a porta de entrada ligada para a VM nova — o app ficaria sem banco. ' +
82
+ 'Ligue antes com `neetru db frontdoor enable <dbId> --app-server <serverId> --reason "<motivo>"` e repita.',
83
+ REPOINT_DB_ENGINE_UNSUPPORTED: 'Um banco deste ambiente fica em outra VM e é de um tipo sem porta de entrada (MySQL, Mongo ou cluster). ' +
84
+ 'O app precisa ficar na mesma VM desse banco. Fale com o Dev Core.',
85
+ REPOINT_DB_OP_IN_PROGRESS: 'Um banco deste ambiente está mudando de VM ou ligando a porta de entrada. Espere terminar e repita.',
86
+ REPOINT_READ_FAILED: 'Não foi possível ler um dos requisitos agora. Nada foi gravado — repita em instantes.',
87
+ REPOINT_STATE_CHANGED: 'O ambiente ou um requisito mudou entre a conferência e a gravação. Nada foi gravado — confira e repita.',
88
+ REPOINT_COMMIT_FAILED: 'A gravação não conseguiu terminar. Nada foi gravado — repita em instantes.',
89
+ };
90
+ /** Mensagem do Core primeiro (dados reais), depois o que fazer. Pura. */
91
+ export function describeRepointError(code, serverMessage) {
92
+ const guidance = REPOINT_GUIDANCE[code];
93
+ return guidance ? `${serverMessage}\n ${guidance}` : serverMessage;
94
+ }
95
+ /** Erro da rota: `REPOINT_*` vira mensagem + o que fazer; o resto vai pro handler comum. */
96
+ function failRepoint(err, json) {
97
+ if (isSafeExitHalt(err))
98
+ throw err;
99
+ if (err instanceof CliApiError && typeof err.code === 'string' && err.code.startsWith('REPOINT_')) {
100
+ if (json) {
101
+ console.log(JSON.stringify({
102
+ ok: false,
103
+ error: err.code,
104
+ message: err.message,
105
+ guidance: REPOINT_GUIDANCE[err.code] ?? null,
106
+ }));
107
+ }
108
+ else {
109
+ log.error(describeRepointError(err.code, err.message));
110
+ }
111
+ return safeExit(1);
112
+ }
113
+ return handleApiError(err, json);
114
+ }
115
+ function inputError(message, json) {
116
+ if (json)
117
+ console.log(JSON.stringify({ ok: false, error: 'input', message }));
118
+ else
119
+ log.error(message);
120
+ return safeExit(1);
121
+ }
122
+ function printChecks(checks) {
123
+ log.heading('Requisitos');
124
+ for (const c of checks) {
125
+ const mark = c.ok ? chalk.green('✓') : chalk.red('✗');
126
+ log.dim(` ${mark} ${checkLabel(c.name)}${c.detail ? ` — ${c.detail}` : ''}`);
127
+ }
128
+ }
129
+ export async function runEnvironmentsRepoint(slug, env, opts = {}) {
130
+ if (!opts.json)
131
+ log.banner();
132
+ if (!slug || !slug.trim() || slug.includes('/'))
133
+ inputError('Informe o produto (slug ou id).', opts.json);
134
+ if (!REPOINT_ENVIRONMENTS.includes(env)) {
135
+ inputError(`Ambiente inválido: "${env}". Use ${REPOINT_ENVIRONMENTS.join(' ou ')}.`, opts.json);
136
+ }
137
+ // Literal aqui (não num helper): a guarda de step-up da CLI precisa enxergar a rota.
138
+ const rota = `/api/cli/v1/products/${encodeURIComponent(slug)}/environments/${encodeURIComponent(env)}/server`;
139
+ const to = (opts.to ?? '').trim();
140
+ const expectFrom = (opts.expectFrom ?? '').trim();
141
+ const reason = (opts.reason ?? '').trim();
142
+ if (!SERVER_ID_RE.test(to))
143
+ inputError('--to <serverId> é obrigatório (o id da VM nova).', opts.json);
144
+ if (!SERVER_ID_RE.test(expectFrom)) {
145
+ inputError('--expect-from <serverId> é obrigatório (a VM que o ambiente aponta hoje — se mudou, nada é gravado).', opts.json);
146
+ }
147
+ if (!reason || reason.length > MAX_REASON) {
148
+ inputError(`--reason "<texto>" é obrigatório (1 a ${MAX_REASON} caracteres; vai para a auditoria).`, opts.json);
149
+ }
150
+ await requireToken(opts.json);
151
+ if (!opts.json) {
152
+ log.heading(opts.dryRun ? 'Re-apontar a VM do ambiente (só conferência)' : 'Re-apontar a VM do ambiente');
153
+ log.info(`Produto: ${chalk.cyan(slug)}`);
154
+ log.info(`Ambiente: ${chalk.bold(env)}`);
155
+ log.info(`Aponta hoje: ${expectFrom}`);
156
+ log.info(`Vai apontar: ${chalk.bold(to)}`);
157
+ log.dim(' Só o ponteiro muda — o app não muda de VM até o próximo deploy na VM nova.');
158
+ }
159
+ const body = { toServerId: to, expectedServerId: expectFrom, reason };
160
+ // ── Só conferência: nada é gravado, sem MFA. ──────────────────────────────
161
+ if (opts.dryRun) {
162
+ const { headers } = await resolveStepUp({ dryRun: true, json: opts.json, mfaToken: opts.mfaToken });
163
+ let r;
164
+ try {
165
+ r = await apiRequest(rota, {
166
+ method: 'POST',
167
+ body: { ...body, dryRun: true },
168
+ headers,
169
+ });
170
+ }
171
+ catch (err) {
172
+ failRepoint(err, opts.json);
173
+ }
174
+ if (opts.json) {
175
+ console.log(JSON.stringify({ ...r, ok: r.preflight.ok || r.alreadyPointed }));
176
+ if (!r.preflight.ok && !r.alreadyPointed)
177
+ setSafeExitCode(1);
178
+ return;
179
+ }
180
+ if (r.alreadyPointed) {
181
+ log.info(`O ambiente ${env} já aponta ${to} — não há o que re-apontar.`);
182
+ return;
183
+ }
184
+ printChecks(r.preflight.checks);
185
+ if (r.preflight.ok) {
186
+ log.success('Tudo certo para re-apontar. Nada foi alterado. Rode de novo sem --dry-run para gravar.');
187
+ }
188
+ else {
189
+ log.error('Algum requisito falhou — o ambiente não seria re-apontado. Nada foi alterado.');
190
+ setSafeExitCode(1);
191
+ }
192
+ return;
193
+ }
194
+ await confirmDestructive(`Re-apontar o ambiente ${env} do produto ${slug} de ${expectFrom} para ${to}? Só o ponteiro muda: o app ` +
195
+ 'continua onde está até o próximo deploy na VM nova, e a promoção de staging para prod passa a usar a VM nova.', { yes: opts.yes, json: opts.json });
196
+ const r = await withStepUpRetry({ mfaToken: opts.mfaToken, json: opts.json, yes: opts.yes }, (headers) => apiRequest(rota, { method: 'POST', body, headers }), (err) => failRepoint(err, opts.json));
197
+ if (opts.json) {
198
+ console.log(JSON.stringify(r));
199
+ return;
200
+ }
201
+ if (r.alreadyPointed) {
202
+ log.info(`O ambiente ${env} já apontava ${to} — nada foi gravado.`);
203
+ return;
204
+ }
205
+ if (!r.changed) {
206
+ // Não deveria acontecer (o Core responde erro quando não grava), mas sem mentir:
207
+ log.error('O Core respondeu sem gravar o ponteiro. Nada mudou — confira com --dry-run.');
208
+ setSafeExitCode(1);
209
+ return;
210
+ }
211
+ log.success(`Ambiente ${env} agora aponta ${r.to} (antes: ${r.from ?? 'nenhuma VM'}).`);
212
+ log.warn('O app NÃO mudou de VM: só o ponteiro do ambiente.');
213
+ if (r.pendingDrain.length > 0) {
214
+ log.dim(` Continua rodando o que estava em ${r.pendingDrain.join(', ')} (nada é desligado), e essa VM segue ` +
215
+ 'contada como ocupada por este ambiente.');
216
+ }
217
+ log.info(`Próximo passo: ${r.nextStep}`);
218
+ }
219
+ //# sourceMappingURL=environments-repoint.js.map
@@ -80,7 +80,13 @@ async function resolveTarget(name, cwd, opts) {
80
80
  // Default: cria subdir <cwd>/<name>
81
81
  const insp = await inspectDir(subDir);
82
82
  if (insp.exists && !insp.isEmpty) {
83
- log.error(`Diretório "${name}" já existe com arquivos. Use --here ou --path ou --force.`);
83
+ // bug_89d2497e (etapa 1): o `neetru new` chega aqui pelo `runInit` e não
84
+ // declara --here/--path/--force — "Use --here" mandava o usuário repetir
85
+ // o `neetru new` com uma flag que ele recusa. O conselho cita o comando
86
+ // inteiro. E `--force` com `--yes` cai de novo neste mesmo ramo (ele não
87
+ // lê `opts.force`); sobrescrever é `--path <dir> --force`.
88
+ log.error(`Diretório "${name}" já existe com arquivos. Rode \`neetru init ${name} --here\` (mescla no diretório atual), ` +
89
+ `\`neetru init ${name} --path <outro-diretório>\` ou, para sobrescrever, \`neetru init ${name} --path ${name} --force\`.`);
84
90
  safeExit(1); // erro hard em non-interactive — consistente com pre-2.2.0
85
91
  }
86
92
  return { dir: subDir, mode: 'new', force: false };
@@ -195,6 +195,20 @@ export async function runNewMacro(name, opts = {}) {
195
195
  safeExit(1);
196
196
  }
197
197
  let slug = (opts.productSlug ?? deriveSlug(name)) || deriveSlug(name);
198
+ // bug_89d2497e (etapa 1): nome sem letra nem dígito ("!!!") deriva slug
199
+ // vazio, e quem recusava era o `products create`, com "informe --slug" —
200
+ // flag que o `neetru new` não tem (a dele é --product-slug). Recusa aqui,
201
+ // antes de autenticar e de criar qualquer coisa no Core.
202
+ if (!slug) {
203
+ const msg = `Não dá para derivar um slug de "${name}" — informe --product-slug <slug>.`;
204
+ if (opts.json) {
205
+ console.log(JSON.stringify({ ok: false, error: 'invalid_input', message: msg }));
206
+ }
207
+ else {
208
+ log.error(msg);
209
+ }
210
+ safeExit(1);
211
+ }
198
212
  // bug_7dcb68e2666a43f69ef362d2cbff94ed: o padrão era 'dev', token que o Core
199
213
  // não conhece — a macro morria no passo [2/5] com 400 validation_failed.
200
214
  const env = opts.env
@@ -28,10 +28,12 @@
28
28
  * '--value=sk_live…'", "unknown command 'sk_live…'") imprimiria o segredo de
29
29
  * volta no terminal.
30
30
  *
31
- * ATENÇÃO (2026-10-16): a entrega ao app na VM depende do lado do AGENTE
32
- * (Parte 2 de bug_8b85353b, `neetru-agent-service-vm`), que ainda não foi
33
- * liberado. Até lá o registro funciona mas o segredo não chega ao app — e a
34
- * CLI diz isso ao gravar, em vez de deixar o dev descobrir em produção.
31
+ * Entrega ao app na VM: é o lado do AGENTE (Parte 2 de bug_8b85353b,
32
+ * `neetru-agent-service-vm`), publicado na 1.6.80 (2026-10-20). No deploy, o
33
+ * agente busca os valores no Core e grava `/etc/neetru/<slug>.product.env`
34
+ * (root, 0600). Por isso o app só vê uma gravação no PRÓXIMO deploy — e a CLI
35
+ * diz isso ao gravar (`notaEntregaDoSegredo`). Até a 2.33.0 ela dizia que o
36
+ * agente "ainda não entrega", frase falsa desde a 1.6.80.
35
37
  */
36
38
  import type { Command } from 'commander';
37
39
  import { type StepUpOptions } from '../lib/cli-write.js';
@@ -5,7 +5,7 @@ import { renderTable, fmtTimestamp } from '../lib/render.js';
5
5
  import { requireToken, handleApiError, confirmDestructive, printWriteResult, resolveStepUp, isMfaRequiredError, } from '../lib/cli-write.js';
6
6
  import { loadProductConfig } from '../lib/product-config.js';
7
7
  import { listarAmbientesTenant, resolverAmbienteTenant, } from '../lib/envs.js';
8
- import { AVISO_AGENTE_PENDENTE, CodificacaoInvalida, PedidoCancelado, lerValorDeArquivo, lerValorDeFluxo, perguntarSemEco, problemaNaChave, problemaNaChaveReservada, problemaNoTextoDoTerminal, problemaNoValor, problemaNosBytes, } from '../lib/segredo-produto.js';
8
+ import { CodificacaoInvalida, PedidoCancelado, lerValorDeArquivo, lerValorDeFluxo, notaEntregaDoSegredo, perguntarSemEco, problemaNaChave, problemaNaChaveReservada, problemaNoTextoDoTerminal, problemaNoValor, problemaNosBytes, } from '../lib/segredo-produto.js';
9
9
  import { log } from '../utils/logger.js';
10
10
  import { safeExit } from '../utils/safe-exit.js';
11
11
  function entradaPadrao() {
@@ -61,8 +61,13 @@ function exigirAmbiente(env, json) {
61
61
  }
62
62
  return resolverAmbienteTenant(env, json);
63
63
  }
64
- function exigirChave(chave, json) {
65
- const problema = problemaNaChave(chave);
64
+ /**
65
+ * `comoMandarOValor`: só o `set` passa (é ele que recebe valor, por --stdin,
66
+ * --from-file ou pelo pedido na tela). O `archive` não passa: lá o conselho é
67
+ * só "passe só o nome" (bug_89d2497e, etapa 1).
68
+ */
69
+ function exigirChave(chave, json, comoMandarOValor) {
70
+ const problema = problemaNaChave(chave, comoMandarOValor);
66
71
  if (problema)
67
72
  return falhar(json, 'invalid_key', problema);
68
73
  return chave;
@@ -170,7 +175,7 @@ function cabeRepetirComTotp(ambiente, headers, erro) {
170
175
  }
171
176
  export async function runSecretsSet(opts, entrada = entradaPadrao()) {
172
177
  recusarArgumentosExtras(opts.extras, opts.json);
173
- const chave = exigirChave(opts.key, opts.json);
178
+ const chave = exigirChave(opts.key, opts.json, 'por --stdin, --from-file ou pelo pedido na tela');
174
179
  exigirChaveRegistravel(chave, opts.json);
175
180
  const ambiente = exigirAmbiente(opts.env, opts.json);
176
181
  const origens = [!!opts.generate, opts.fromFile !== undefined, !!opts.stdin].filter(Boolean).length;
@@ -245,7 +250,7 @@ export async function runSecretsSet(opts, entrada = entradaPadrao()) {
245
250
  revision: res.revision,
246
251
  created: res.created,
247
252
  secretRef: res.secretRef,
248
- note: AVISO_AGENTE_PENDENTE,
253
+ note: notaEntregaDoSegredo(res.environmentId),
249
254
  }, `Segredo ${chalk.bold(res.key)} ${acao} em ${res.environmentId} (revisão ${res.revision})` +
250
255
  (gerado ? ' — o Core sorteou o valor; ninguém o vê, nem você.' : '.'), opts.json);
251
256
  if (opts.json)
@@ -253,8 +258,10 @@ export async function runSecretsSet(opts, entrada = entradaPadrao()) {
253
258
  if (!res.created)
254
259
  log.dim('O valor anterior deixa de ser entregue a partir do próximo deploy.');
255
260
  log.dim(`Nome no Secret Manager: ${res.secretRef} (é o nome, não o valor).`);
256
- log.dim(`Para o app receber: faça um novo deploy em ${res.environmentId}.`);
257
- log.warn(AVISO_AGENTE_PENDENTE);
261
+ // Até a 2.33.0 saía aqui um aviso de que o agente da VM "ainda não entrega"
262
+ // segredos do produto — falso desde a 1.6.80 (bug_8b85353b). Fica só o fato:
263
+ // o app recebe no próximo deploy.
264
+ log.info(notaEntregaDoSegredo(res.environmentId));
258
265
  }
259
266
  export async function runSecretsList(opts) {
260
267
  const ambiente = opts.env?.trim() ? resolverAmbienteTenant(opts.env, opts.json) : undefined;
@@ -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
  }
@@ -1080,7 +1080,7 @@ async function authMenu() {
1080
1080
  }
1081
1081
  export async function runTerminalUi() {
1082
1082
  if (!canPrompt()) {
1083
- log.error('A interface interativa exige um terminal TTY. Use comandos diretos ou --json em CI.');
1083
+ log.error('A interface interativa exige um terminal TTY. Em CI, use os comandos diretos (ex.: `neetru products list --json`).');
1084
1084
  safeExit(1);
1085
1085
  }
1086
1086
  // Blindagem contra auto-encerramento: enquanto o menu interativo roda, um
@@ -31,6 +31,7 @@ import { renderTable, fmtTimestamp } from '../lib/render.js';
31
31
  import { pickProductId, pickProductSlug, pickWorkspace, resolveProductIdentifier, isInteractive, promptChoice, promptText, resolveCustomer, } from '../lib/pickers.js';
32
32
  import { DEFAULT_TENANT_ENVIRONMENT_ID, ESCOLHAS_AMBIENTE_TENANT, resolverAmbienteTenant, erroAmbienteTenantInvalido, normalizarAmbienteTenant, } from '../lib/envs.js';
33
33
  import { exitWithCliError } from '../lib/cli-error.js';
34
+ import { adviseFlag } from '../cli-kit/command-context.js';
34
35
  import { log } from '../utils/logger.js';
35
36
  import { safeExit } from '../utils/safe-exit.js';
36
37
  /** Formata o erro 400 "Nenhum customer ativo" de forma acionável. */
@@ -100,11 +101,25 @@ export async function runWorkspacesCreate(opts) {
100
101
  const label = existingTenant.name ?? existingTenant.id ?? '(sem nome)';
101
102
  // Modo script/JSON (--json ou --yes implícito): bloqueia com 409.
102
103
  if (opts.json) {
104
+ // bug_89d2497e (etapa 1): o `neetru new` chama este runner com
105
+ // `json: true` e este envelope é o que o usuário dele lê — e o `new`
106
+ // não declara --force-duplicate. O texto sai conforme o comando em
107
+ // execução: só quem declara a flag (o `workspaces create`) ouve "Use
108
+ // --force-duplicate"; os outros recebem o caminho que existe — o
109
+ // `workspaces create` num terminal interativo pergunta se reusa o
110
+ // ambiente existente ou cria outro.
111
+ const message = adviseFlag({
112
+ flag: '--force-duplicate',
113
+ withFlag: `Já existe ambiente ativo '${label}' para produto=${product} env=${env}. Use --force-duplicate para criar mesmo assim.`,
114
+ withoutFlag: `Já existe ambiente ativo '${label}' para produto=${product} env=${env}. Para criar um segundo, rode ` +
115
+ `\`neetru workspaces create --product ${product} --env ${env}\` num terminal interativo: ele pergunta se ` +
116
+ 'reusa o existente ou cria outro.',
117
+ });
103
118
  console.log(JSON.stringify({
104
119
  ok: false,
105
120
  error: 'tenant_already_exists',
106
121
  status: 409,
107
- message: `Já existe ambiente ativo '${label}' para produto=${product} env=${env}. Use --force-duplicate para criar mesmo assim.`,
122
+ message,
108
123
  existing: existingTenant,
109
124
  }));
110
125
  safeExit(1);