@neetru/cli 2.7.5 → 2.9.1

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 (105) hide show
  1. package/CHANGELOG.md +316 -220
  2. package/README.md +137 -137
  3. package/dist/cli-kit/format.d.ts +49 -0
  4. package/dist/cli-kit/format.js +88 -0
  5. package/dist/cli-kit/format.js.map +1 -0
  6. package/dist/cli-kit/glyphs.d.ts +22 -0
  7. package/dist/cli-kit/glyphs.js +22 -0
  8. package/dist/cli-kit/glyphs.js.map +1 -0
  9. package/dist/cli-kit/index.d.ts +13 -0
  10. package/dist/cli-kit/index.js +12 -0
  11. package/dist/cli-kit/index.js.map +1 -0
  12. package/dist/cli-kit/palette.d.ts +10 -0
  13. package/dist/cli-kit/palette.js +36 -0
  14. package/dist/cli-kit/palette.js.map +1 -0
  15. package/dist/commands/ai.js +8 -8
  16. package/dist/commands/autocomplete.js +34 -34
  17. package/dist/commands/bug.d.ts +87 -0
  18. package/dist/commands/bug.js +419 -0
  19. package/dist/commands/bug.js.map +1 -0
  20. package/dist/commands/customers.d.ts +17 -0
  21. package/dist/commands/customers.js +160 -0
  22. package/dist/commands/customers.js.map +1 -0
  23. package/dist/commands/db.d.ts +91 -7
  24. package/dist/commands/db.js +898 -123
  25. package/dist/commands/db.js.map +1 -1
  26. package/dist/commands/deploy.d.ts +5 -0
  27. package/dist/commands/deploy.js +68 -0
  28. package/dist/commands/deploy.js.map +1 -1
  29. package/dist/commands/dev.d.ts +68 -0
  30. package/dist/commands/dev.js +345 -0
  31. package/dist/commands/dev.js.map +1 -0
  32. package/dist/commands/docs.d.ts +4 -0
  33. package/dist/commands/docs.js +99 -7
  34. package/dist/commands/docs.js.map +1 -1
  35. package/dist/commands/doctor.js +4 -1
  36. package/dist/commands/doctor.js.map +1 -1
  37. package/dist/commands/init.js +121 -121
  38. package/dist/commands/marketplace.d.ts +36 -0
  39. package/dist/commands/marketplace.js +584 -0
  40. package/dist/commands/marketplace.js.map +1 -0
  41. package/dist/commands/new.d.ts +6 -0
  42. package/dist/commands/new.js +220 -40
  43. package/dist/commands/new.js.map +1 -1
  44. package/dist/commands/open.d.ts +8 -0
  45. package/dist/commands/open.js +61 -13
  46. package/dist/commands/open.js.map +1 -1
  47. package/dist/commands/products-db.d.ts +1 -1
  48. package/dist/commands/products-db.js +17 -4
  49. package/dist/commands/products-db.js.map +1 -1
  50. package/dist/commands/products.d.ts +23 -0
  51. package/dist/commands/products.js +39 -1
  52. package/dist/commands/products.js.map +1 -1
  53. package/dist/commands/tenants.js +15 -0
  54. package/dist/commands/tenants.js.map +1 -1
  55. package/dist/commands/ui.d.ts +1 -1
  56. package/dist/commands/ui.js +172 -2
  57. package/dist/commands/ui.js.map +1 -1
  58. package/dist/commands/workspaces.d.ts +10 -1
  59. package/dist/commands/workspaces.js +136 -22
  60. package/dist/commands/workspaces.js.map +1 -1
  61. package/dist/index.js +532 -44
  62. package/dist/index.js.map +1 -1
  63. package/dist/lib/ai/context.js +90 -90
  64. package/dist/lib/config-schema.d.ts +8 -8
  65. package/dist/lib/db-local/db-json.d.ts +63 -0
  66. package/dist/lib/db-local/db-json.js +189 -0
  67. package/dist/lib/db-local/db-json.js.map +1 -0
  68. package/dist/lib/db-local/env.d.ts +26 -0
  69. package/dist/lib/db-local/env.js +64 -0
  70. package/dist/lib/db-local/env.js.map +1 -0
  71. package/dist/lib/db-local/fingerprint.d.ts +8 -0
  72. package/dist/lib/db-local/fingerprint.js +28 -0
  73. package/dist/lib/db-local/fingerprint.js.map +1 -0
  74. package/dist/lib/db-local/index.d.ts +15 -0
  75. package/dist/lib/db-local/index.js +14 -0
  76. package/dist/lib/db-local/index.js.map +1 -0
  77. package/dist/lib/db-pipeline/build-deps.d.ts +14 -0
  78. package/dist/lib/db-pipeline/build-deps.js +158 -0
  79. package/dist/lib/db-pipeline/build-deps.js.map +1 -0
  80. package/dist/lib/db-pipeline/errors.d.ts +29 -0
  81. package/dist/lib/db-pipeline/errors.js +29 -0
  82. package/dist/lib/db-pipeline/errors.js.map +1 -0
  83. package/dist/lib/db-pipeline/index.d.ts +26 -0
  84. package/dist/lib/db-pipeline/index.js +25 -0
  85. package/dist/lib/db-pipeline/index.js.map +1 -0
  86. package/dist/lib/db-pipeline/pipeline.d.ts +13 -0
  87. package/dist/lib/db-pipeline/pipeline.js +119 -0
  88. package/dist/lib/db-pipeline/pipeline.js.map +1 -0
  89. package/dist/lib/db-pipeline/rehearse.d.ts +99 -0
  90. package/dist/lib/db-pipeline/rehearse.js +219 -0
  91. package/dist/lib/db-pipeline/rehearse.js.map +1 -0
  92. package/dist/lib/db-pipeline/types.d.ts +112 -0
  93. package/dist/lib/db-pipeline/types.js +20 -0
  94. package/dist/lib/db-pipeline/types.js.map +1 -0
  95. package/dist/lib/pickers.d.ts +12 -0
  96. package/dist/lib/pickers.js +34 -0
  97. package/dist/lib/pickers.js.map +1 -1
  98. package/package.json +66 -62
  99. package/templates/auth/callback.ts +22 -22
  100. package/templates/auth/sign-in.tsx +41 -41
  101. package/templates/billing/checkout.ts +22 -22
  102. package/templates/billing/page.tsx +43 -43
  103. package/templates/support/ticket-form.tsx +68 -68
  104. package/templates/usage/track.ts +30 -30
  105. package/templates/users/profile.tsx +43 -43
@@ -1,187 +1,962 @@
1
1
  /**
2
- * `neetru db` — gerência de schema/migrations/seed do produto.
2
+ * `neetru db` — árvore de comandos de banco de dados por produto (M1).
3
3
  *
4
- * Sprint 10 (CLI 1.4). Subcomandos:
5
- * - `neetru db init` Lê neetru.config.json + cria manifest local stub.
6
- * - `neetru db migrate <v>` Chama API Core (`/cli/v1/db/migrate`) que internamente
7
- * dispara `runMigrations` (Sprint 8 build-hook).
8
- * - `neetru db seed` Roda `db/seed.ts` no projeto (NEETRU_ENV=dev).
4
+ * Esta árvore é DEVELOPER-FACING: schema, migrations e banco isolado do produto.
5
+ * Para o plano de controle STAFF (fleet-wide), use `neetru admin database`.
9
6
  *
10
- * O CLI não toca Firestore direto — toda operação passa pelo Core (audit + RBAC).
11
- * Para `seed` o CLI apenas executa o script local.
7
+ * Subcomandos:
8
+ * neetru db list — lista bancos do produto (GET /api/cli/v1/db)
9
+ * neetru db status <dbId> — READ-ONLY: detalha banco + status (D-2)
10
+ * neetru db init — registra banco local + scaffolda db/schema.ts
11
+ * neetru db apply [--dry-run] — pipeline de migração + envia ao Core
12
+ * neetru db migrations list [--db <id>]
13
+ * neetru db migrations confirm <id> — confirma migração destrutiva (exige --mfa)
14
+ *
15
+ * Todos os comandos que chamam o Core usam `apiRequest` do `api-client` —
16
+ * autenticação via Bearer token resolvido automaticamente do config local.
17
+ *
18
+ * Plain PT-BR em todas as strings visíveis ao usuário.
12
19
  */
13
20
  import * as fs from 'node:fs/promises';
14
21
  import * as fsSync from 'node:fs';
15
22
  import * as path from 'node:path';
16
- import { spawn } from 'node:child_process';
23
+ import inquirer from 'inquirer';
17
24
  import chalk from 'chalk';
25
+ import ora from 'ora';
18
26
  import { log } from '../utils/logger.js';
19
27
  import { apiRequest, CliApiError, CliNetworkError } from '../lib/api-client.js';
20
- const CONFIG_FILENAMES = ['neetru.config.json', '.neetru.json'];
28
+ import { renderTable, fmtTimestamp } from '../lib/render.js';
29
+ import { readDbJson, writeDbJson, normalizeEnv, } from '../lib/db-local/index.js';
30
+ import { runApplyPipeline, buildPipelineDeps, isPipelineError, } from '../lib/db-pipeline/index.js';
31
+ // ---------------------------------------------------------------------------
32
+ // Constantes e tipos locais
33
+ // ---------------------------------------------------------------------------
34
+ const CONFIG_FILES = ['neetru.config.json', '.neetru.json'];
35
+ /** Caminho canônico do registro local de bancos. */
36
+ const DB_JSON_REL = '.neetru/db.json';
37
+ /** Caminho default do arquivo de schema DrizzleORM do produto. */
38
+ const DEFAULT_SCHEMA_PATH = 'db/schema.ts';
39
+ /** Engines disponíveis (espelha os 7 engines do Core). */
40
+ const ENGINES = [
41
+ 'firestore-instance',
42
+ 'cloud-sql-postgres',
43
+ 'cloud-sql-mysql',
44
+ 'vm-postgres-single',
45
+ 'vm-postgres-cluster',
46
+ 'vm-mysql-single',
47
+ 'vm-mysql-cluster',
48
+ ];
49
+ /** Labels amigáveis por engine pra exibição no prompt. */
50
+ const ENGINE_LABELS = {
51
+ 'firestore-instance': 'Firestore (instância isolada)',
52
+ 'cloud-sql-postgres': 'Cloud SQL — PostgreSQL gerenciado',
53
+ 'cloud-sql-mysql': 'Cloud SQL — MySQL gerenciado',
54
+ 'vm-postgres-single': 'VM — PostgreSQL (nó único)',
55
+ 'vm-postgres-cluster': 'VM — PostgreSQL (cluster HA)',
56
+ 'vm-mysql-single': 'VM — MySQL (nó único)',
57
+ 'vm-mysql-cluster': 'VM — MySQL (cluster HA)',
58
+ };
59
+ // ---------------------------------------------------------------------------
60
+ // Helpers internos
61
+ // ---------------------------------------------------------------------------
21
62
  async function loadProductConfig() {
22
- for (const name of CONFIG_FILENAMES) {
63
+ for (const name of CONFIG_FILES) {
23
64
  const filePath = path.resolve(process.cwd(), name);
24
- if (fsSync.existsSync(filePath)) {
25
- try {
26
- const raw = await fs.readFile(filePath, 'utf8');
27
- const parsed = JSON.parse(raw);
28
- if (typeof parsed.slug !== 'string' || !parsed.slug)
29
- return null;
30
- return parsed;
31
- }
32
- catch {
65
+ if (!fsSync.existsSync(filePath))
66
+ continue;
67
+ try {
68
+ const raw = await fs.readFile(filePath, 'utf8');
69
+ const parsed = JSON.parse(raw);
70
+ if (typeof parsed.slug !== 'string' || !parsed.slug)
33
71
  return null;
34
- }
72
+ return parsed;
73
+ }
74
+ catch {
75
+ return null;
35
76
  }
36
77
  }
37
78
  return null;
38
79
  }
39
- const DEFAULT_MANIFEST_PATH = 'db/schema.manifest.json';
40
- const STARTER_MANIFEST = (slug) => ({
41
- productId: slug,
42
- schemaVersion: 1,
43
- collections: [
44
- {
45
- name: 'profiles',
46
- fields: { email: 'string', createdAt: 'timestamp' },
47
- indexes: [],
48
- },
49
- ],
50
- });
51
- export async function runDbInit(opts = {}) {
80
+ function handleApiError(err) {
81
+ if (err instanceof CliApiError) {
82
+ if (err.status === 401) {
83
+ log.error('Token inválido ou expirado. Execute: neetru login');
84
+ process.exit(2);
85
+ }
86
+ if (err.status === 403) {
87
+ log.error(`Permissão negada: ${err.message}`);
88
+ process.exit(3);
89
+ }
90
+ log.error(`Erro do servidor (${err.status}): ${err.message}`);
91
+ process.exit(4);
92
+ }
93
+ if (err instanceof CliNetworkError) {
94
+ log.error(`Falha de rede: ${err.message}`);
95
+ process.exit(4);
96
+ }
97
+ log.error(err.message ?? String(err));
98
+ process.exit(4);
99
+ }
100
+ function statusColor(status) {
101
+ switch (status) {
102
+ case 'ativo':
103
+ case 'active': return chalk.green(status);
104
+ case 'falha':
105
+ case 'failed': return chalk.red(status);
106
+ case 'provisionando':
107
+ case 'provisioning': return chalk.yellow(status);
108
+ case 'arquivado':
109
+ case 'archived': return chalk.dim(status);
110
+ default: return chalk.dim(status);
111
+ }
112
+ }
113
+ function severityColor(severity) {
114
+ if (!severity)
115
+ return '—';
116
+ if (severity === 'destrutiva')
117
+ return chalk.red(severity);
118
+ return chalk.green(severity);
119
+ }
120
+ export async function runDbList(opts = {}) {
52
121
  log.banner();
53
- log.heading('neetru db init');
54
- const cfg = await loadProductConfig();
55
- if (!cfg) {
56
- log.error('neetru.config.json não encontrado ou inválido. Rode `neetru init` primeiro.');
122
+ const spinner = ora({ text: 'Buscando bancos…', color: 'blue' }).start();
123
+ const params = new URLSearchParams();
124
+ if (opts.productId)
125
+ params.set('productId', opts.productId);
126
+ if (opts.engine)
127
+ params.set('engine', opts.engine);
128
+ if (opts.status)
129
+ params.set('status', opts.status);
130
+ const qs = params.toString() ? `?${params.toString()}` : '';
131
+ let resp;
132
+ try {
133
+ resp = await apiRequest(`/api/cli/v1/db${qs}`);
134
+ spinner.stop();
135
+ }
136
+ catch (err) {
137
+ spinner.fail('Falha ao buscar bancos.');
138
+ handleApiError(err);
139
+ }
140
+ if (opts.json) {
141
+ console.log(JSON.stringify(resp, null, 2));
142
+ return;
143
+ }
144
+ log.heading(`Bancos (${resp.count})`);
145
+ renderTable([
146
+ { header: 'ID', value: (r) => r.id, maxWidth: 28 },
147
+ { header: 'Produto', value: (r) => r.productId, maxWidth: 20 },
148
+ { header: 'Label', value: (r) => r.label ?? '—', maxWidth: 24 },
149
+ { header: 'Engine', value: (r) => r.engine, maxWidth: 22 },
150
+ // gestovendas #11 fix: Core retorna `environment`; `env` é alias legado.
151
+ { header: 'Env', value: (r) => r.environment ?? r.env ?? '—', maxWidth: 12 },
152
+ { header: 'Status', value: (r) => statusColor(r.status), maxWidth: 16 },
153
+ { header: 'Criado', value: (r) => fmtTimestamp(r.createdAt), maxWidth: 18 },
154
+ ], resp.databases);
155
+ }
156
+ export async function runDbStatus(dbId, opts = {}) {
157
+ log.banner();
158
+ if (!dbId) {
159
+ log.error('<dbId> é obrigatório.');
57
160
  process.exit(1);
58
161
  return;
59
162
  }
60
- const outRel = opts.out ?? DEFAULT_MANIFEST_PATH;
61
- const outAbs = path.resolve(process.cwd(), outRel);
62
- if (fsSync.existsSync(outAbs) && !opts.force) {
63
- log.warn(`Manifest existe em ${chalk.bold(outRel)}. Use --force para sobrescrever.`);
64
- process.exit(2);
163
+ const spinner = ora({ text: `Buscando banco ${dbId}…`, color: 'blue' }).start();
164
+ let resp;
165
+ try {
166
+ // BL-9 fix: `/status` é POST-only (atualização de status). O endpoint de
167
+ // leitura de detalhe é `/get` (GET).
168
+ resp = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/get`);
169
+ spinner.stop();
170
+ }
171
+ catch (err) {
172
+ spinner.fail('Falha ao buscar status do banco.');
173
+ handleApiError(err);
174
+ }
175
+ if (opts.json) {
176
+ console.log(JSON.stringify(resp, null, 2));
65
177
  return;
66
178
  }
67
- await fs.mkdir(path.dirname(outAbs), { recursive: true });
68
- const manifest = STARTER_MANIFEST(cfg.slug);
69
- await fs.writeFile(outAbs, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
70
- log.success(`Manifest criado em ${chalk.bold(outRel)}`);
71
- log.dim(' Edite os campos e adicione collections conforme necessário.');
72
- log.dim(' Após editar, rode: neetru db migrate <toVersion>');
179
+ const db = resp.database;
180
+ log.heading(`Banco: ${chalk.bold(db.label ?? db.id)}`);
181
+ log.dim(` ID: ${db.id}`);
182
+ log.dim(` Produto: ${db.productId}`);
183
+ log.dim(` Engine: ${db.engine}`);
184
+ log.dim(` Env: ${db.environment ?? db.env ?? '—'}`);
185
+ log.dim(` Status: ${statusColor(db.status)}`);
186
+ if (db.region)
187
+ log.dim(` Região: ${db.region}`);
188
+ if (db.serverId)
189
+ log.dim(` VM: ${db.serverId}`);
190
+ if (db.healthy !== null && db.healthy !== undefined) {
191
+ log.dim(` Saúde: ${db.healthy ? chalk.green('ok') : chalk.red('falha')}`);
192
+ }
193
+ if (db.healthLastCheckedAt) {
194
+ log.dim(` Verificado: ${fmtTimestamp(db.healthLastCheckedAt)}`);
195
+ }
196
+ if (db.provisioningError) {
197
+ log.warn(` Erro de provisionamento: ${db.provisioningError}`);
198
+ }
199
+ log.dim(` Criado: ${fmtTimestamp(db.createdAt)}`);
200
+ log.dim(` Atualizado: ${fmtTimestamp(db.updatedAt)}`);
73
201
  }
74
- export async function runDbMigrate(opts) {
75
- log.banner();
76
- log.heading('neetru db migrate');
202
+ // pdv #7 fix: template diferenciado por família de engine.
203
+ /** Template para engines NoSQL (firestore-instance). */
204
+ const SCHEMA_STARTER_NOSQL = (productId, engine) => `/**
205
+ * Schema do banco "${productId}" — ${ENGINE_LABELS[engine] ?? engine}.
206
+ *
207
+ * Gerado por \`neetru db init\`.
208
+ * Edite este arquivo com as collections do seu produto.
209
+ * Rode \`neetru db apply\` pra enviar a definição ao Core.
210
+ *
211
+ * Para bancos NoSQL (Firestore), o schema define a estrutura esperada
212
+ * dos documentos. O Core usa essa definição para validação e auditoria —
213
+ * o banco em si é schemaless (primeira escrita cria a estrutura).
214
+ *
215
+ * Documentação: https://core.neetru.com/docs/dev/db/nosql-schema
216
+ */
217
+
218
+ // Exemplo de collection com tipagem TypeScript pura:
219
+
220
+ export interface Produto {
221
+ id: string;
222
+ nome: string;
223
+ preco: number;
224
+ ativo: boolean;
225
+ criadoEm: Date;
226
+ }
227
+
228
+ export interface Pedido {
229
+ id: string;
230
+ produtoId: string;
231
+ quantidade: number;
232
+ total: number;
233
+ status: 'pendente' | 'pago' | 'cancelado';
234
+ criadoEm: Date;
235
+ }
236
+
237
+ // Exporte as collections que o SDK vai usar:
238
+ // import { createNeetruClient } from '@neetru/sdk';
239
+ // const neetru = createNeetruClient({ ... });
240
+ // const produtos = neetru.db.collection<Produto>('produtos');
241
+ // const pedidos = neetru.db.collection<Pedido>('pedidos');
242
+ `;
243
+ /** Template padrão para engines SQL (PostgreSQL/MySQL). */
244
+ const SCHEMA_STARTER_SQL = (productId, engine) => `/**
245
+ * Schema do banco "${productId}" — ${ENGINE_LABELS[engine] ?? engine}.
246
+ *
247
+ * Gerado por \`neetru db init\`.
248
+ * Edite este arquivo e rode \`neetru db apply\` pra enviar a migração ao Core.
249
+ */
250
+
251
+ // Exemplo com DrizzleORM (PostgreSQL):
252
+ // import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core';
253
+ //
254
+ // export const usuarios = pgTable('usuarios', {
255
+ // id: serial('id').primaryKey(),
256
+ // email: text('email').notNull().unique(),
257
+ // criadoEm: timestamp('criado_em').defaultNow(),
258
+ // });
259
+
260
+ // Remova o comentário acima e adapte ao seu schema.
261
+ export {};
262
+ `;
263
+ /** Conteúdo do `db/schema.ts` gerado durante o init. */
264
+ const SCHEMA_STARTER = (productId, engine) => {
265
+ // Engines NoSQL recebem template diferente.
266
+ if (engine === 'firestore-instance') {
267
+ return SCHEMA_STARTER_NOSQL(productId, engine);
268
+ }
269
+ return SCHEMA_STARTER_SQL(productId, engine);
270
+ };
271
+ // gestovendas #9: helper para saída JSON uniforme em db init / db apply.
272
+ function jsonError(message, error = 'error') {
273
+ console.log(JSON.stringify({ ok: false, error, message }));
274
+ }
275
+ export async function runDbInit(opts = {}) {
276
+ if (!opts.json) {
277
+ log.banner();
278
+ log.heading('neetru db init');
279
+ }
280
+ // ── D-3: opções legadas removidas (Sprint 10 — schema.manifest.json) ────
281
+ // `--out` e `--force` eram usados para gerar schema.manifest.json.
282
+ // O branch legado foi cortado. Passar essas flags agora é um erro.
283
+ if (opts.out !== undefined || opts.force !== undefined) {
284
+ const msg = 'As opções --out e --force foram removidas (D-3). Use: neetru db init --name <nome> --engine <engine> --env dev-local';
285
+ if (opts.json) {
286
+ jsonError(msg, 'invalid_option');
287
+ }
288
+ else {
289
+ log.error(msg);
290
+ }
291
+ process.exit(1);
292
+ return;
293
+ }
77
294
  const cfg = await loadProductConfig();
78
295
  if (!cfg) {
79
- log.error('neetru.config.json não encontrado.');
296
+ const msg = 'neetru.config.json não encontrado. Rode `neetru init` primeiro.';
297
+ if (opts.json) {
298
+ jsonError(msg, 'config_not_found');
299
+ }
300
+ else {
301
+ log.error(msg);
302
+ }
80
303
  process.exit(1);
81
304
  return;
82
305
  }
83
- const toVersion = Number(opts.toVersion);
84
- if (!Number.isFinite(toVersion) || toVersion < 1) {
85
- log.error('toVersion inválido. Use um inteiro positivo.');
86
- process.exit(2);
87
- return;
306
+ const productId = opts.productId ?? cfg.slug;
307
+ const cwd = process.cwd();
308
+ const dbJsonPath = path.resolve(cwd, DB_JSON_REL);
309
+ const dbJson = readDbJson(dbJsonPath);
310
+ // ── nome do banco ────────────────────────────────────────────────────────
311
+ let dbName = opts.name;
312
+ if (!dbName && !opts.yes) {
313
+ const existing = dbJson.databases.map((d) => d.name);
314
+ const { n } = await inquirer.prompt([
315
+ {
316
+ type: 'input',
317
+ name: 'n',
318
+ message: 'Nome do banco (ex: principal, analytics):',
319
+ default: existing.length === 0 ? 'principal' : `db${existing.length + 1}`,
320
+ validate: (v) => {
321
+ if (!v.trim())
322
+ return 'Nome obrigatório.';
323
+ if (dbJson.databases.some((d) => d.name === v.trim())) {
324
+ return `Banco "${v.trim()}" já registrado em .neetru/db.json.`;
325
+ }
326
+ return true;
327
+ },
328
+ },
329
+ ]);
330
+ dbName = n.trim();
331
+ }
332
+ else if (!dbName) {
333
+ dbName = dbJson.databases.length === 0 ? 'principal' : `db${dbJson.databases.length + 1}`;
88
334
  }
89
- const fromVersion = opts.fromVersion !== undefined
90
- ? Number(opts.fromVersion)
91
- : (cfg.schemaVersion ?? 1);
92
- if (!Number.isFinite(fromVersion) || fromVersion < 0) {
93
- log.error('fromVersion inválido.');
335
+ if (dbJson.databases.some((d) => d.name === dbName)) {
336
+ const msg = `Banco "${dbName}" já registrado. Use outro nome ou edite .neetru/db.json.`;
337
+ if (opts.json) {
338
+ jsonError(msg, 'duplicate_name');
339
+ }
340
+ else {
341
+ log.error(msg);
342
+ }
94
343
  process.exit(2);
95
344
  return;
96
345
  }
97
- if (toVersion <= fromVersion) {
98
- log.error(`toVersion (${toVersion}) deve ser maior que fromVersion (${fromVersion}).`);
99
- process.exit(2);
346
+ // ── engine ───────────────────────────────────────────────────────────────
347
+ // pdv #4 fix: quando --engine é passado mas não reconhecido, abortar com
348
+ // mensagem clara em vez de ignorar silenciosamente e usar o default.
349
+ let engine;
350
+ if (opts.engine) {
351
+ if (!ENGINES.includes(opts.engine)) {
352
+ const msg = `Engine inválido: "${opts.engine}". Engines disponíveis: ${ENGINES.join(', ')}\n` +
353
+ `Nota: o campo "engine" no SDK (rest | firestore | nosql-vm) é o TRANSPORTE da conexão, ` +
354
+ `diferente do engine de ARMAZENAMENTO passado aqui para o plano de controle.`;
355
+ if (opts.json) {
356
+ jsonError(msg, 'invalid_engine');
357
+ }
358
+ else {
359
+ log.error(msg);
360
+ }
361
+ process.exit(1);
362
+ return;
363
+ }
364
+ engine = opts.engine;
365
+ }
366
+ else if (opts.yes) {
367
+ engine = 'cloud-sql-postgres';
368
+ }
369
+ else {
370
+ const { e } = await inquirer.prompt([
371
+ {
372
+ type: 'list',
373
+ name: 'e',
374
+ message: 'Engine do banco:',
375
+ default: 'cloud-sql-postgres',
376
+ choices: ENGINES.map((eng) => ({
377
+ name: `${ENGINE_LABELS[eng]} ${chalk.dim('(' + eng + ')')}`,
378
+ value: eng,
379
+ })),
380
+ },
381
+ ]);
382
+ engine = e;
383
+ }
384
+ // ── ambiente (D-4: dev-local é o valor canônico) ─────────────────────────
385
+ let env;
386
+ try {
387
+ env = opts.env ? normalizeEnv(opts.env) : 'dev-local';
388
+ }
389
+ catch {
390
+ log.error(`Ambiente inválido: "${opts.env}". Use dev-local, staging ou production.`);
391
+ process.exit(1);
100
392
  return;
101
393
  }
102
- log.info(`Migrating ${chalk.bold(cfg.slug)} v${fromVersion} v${toVersion} ...`);
394
+ // ── HIGH-3: registra o banco no Core ANTES de gravar o stub local.
395
+ // Gravar localmente antes e deixar o registro falhar resultava em entrada
396
+ // órfã em .neetru/db.json — re-rodar db init adicionava banco duplicado.
397
+ const registerSpinner = opts.json
398
+ ? null
399
+ : ora({ text: 'Registrando banco no Core…', color: 'blue' }).start();
400
+ let registerResp;
103
401
  try {
104
- const r = await apiRequest('/cli/v1/db/migrate', {
402
+ registerResp = await apiRequest('/api/cli/v1/db/register', {
105
403
  method: 'POST',
106
- body: { productId: cfg.slug, fromVersion, toVersion },
404
+ body: {
405
+ productId,
406
+ label: dbName,
407
+ engine,
408
+ provisioning: { type: engine.startsWith('vm-') ? 'vm' : 'managed' },
409
+ },
107
410
  });
108
- log.success(`Migration aplicada: ${r.migrationsApplied} step(s).`);
109
- log.dim(` v${r.fromVersion} → v${r.toVersion}`);
411
+ registerSpinner?.succeed('Banco registrado no Core.');
110
412
  }
111
413
  catch (err) {
112
- if (err instanceof CliApiError) {
113
- log.error(`API ${err.status}: ${err.message}`);
114
- process.exit(3);
115
- return;
116
- }
117
- if (err instanceof CliNetworkError) {
118
- log.error(`Conexão falhou: ${err.message}`);
414
+ registerSpinner?.fail('Falha ao registrar banco no Core.');
415
+ if (opts.json) {
416
+ jsonError(err.message ?? String(err), 'register_failed');
119
417
  process.exit(4);
120
- return;
121
418
  }
122
- log.error(err instanceof Error ? err.message : String(err));
123
- process.exit(5);
419
+ handleApiError(err);
420
+ return; // handleApiError nunca retorna mas TS não sabe disso
421
+ }
422
+ // ── Grava .neetru/db.json somente após sucesso do Core ───────────────────
423
+ if (!opts.json)
424
+ log.success(`Banco "${dbName}" registrado localmente em ${chalk.bold(DB_JSON_REL)}`);
425
+ // Persiste os IDs retornados pelo Core em `.neetru/db.json`.
426
+ const ids = {
427
+ 'dev-local': registerResp.ids.devLocal,
428
+ staging: registerResp.ids.staging,
429
+ production: registerResp.ids.production,
430
+ };
431
+ const finalEntry = { name: dbName, productId, engine, ids };
432
+ const withIds = {
433
+ version: 2,
434
+ databases: [...dbJson.databases, finalEntry],
435
+ };
436
+ writeDbJson(dbJsonPath, withIds);
437
+ if (!opts.json)
438
+ log.success(`IDs de ambiente gravados em ${chalk.bold(DB_JSON_REL)}`);
439
+ // ── scaffolda db/schema.ts se não existir ─────────────────────────────────
440
+ const schemaPath = path.resolve(cwd, DEFAULT_SCHEMA_PATH);
441
+ let schemaCreated = false;
442
+ if (!fsSync.existsSync(schemaPath)) {
443
+ await fs.mkdir(path.dirname(schemaPath), { recursive: true });
444
+ await fs.writeFile(schemaPath, SCHEMA_STARTER(productId, engine), 'utf8');
445
+ schemaCreated = true;
446
+ if (!opts.json)
447
+ log.success(`Schema gerado em ${chalk.bold(DEFAULT_SCHEMA_PATH)}`);
448
+ }
449
+ else {
450
+ if (!opts.json)
451
+ log.dim(` Schema existente em ${DEFAULT_SCHEMA_PATH} — não sobrescrito.`);
452
+ }
453
+ // gestovendas #9: saída JSON uniforme.
454
+ if (opts.json) {
455
+ console.log(JSON.stringify({
456
+ ok: true,
457
+ name: dbName,
458
+ productId,
459
+ engine,
460
+ env,
461
+ ids: {
462
+ devLocal: registerResp.ids.devLocal,
463
+ staging: registerResp.ids.staging,
464
+ production: registerResp.ids.production,
465
+ },
466
+ schemaPath: DEFAULT_SCHEMA_PATH,
467
+ schemaCreated,
468
+ }));
469
+ return;
470
+ }
471
+ log.dim('');
472
+ log.dim('Próximos passos:');
473
+ log.dim(` 1. Edite ${DEFAULT_SCHEMA_PATH} com as estruturas do seu produto.`);
474
+ if (engine === 'firestore-instance') {
475
+ // pdv #11 context: pipeline db apply é SQL-only; NoSQL usa SDK diretamente.
476
+ log.dim(` 2. Para Firestore, o banco é schemaless — não há migração SQL.`);
477
+ log.dim(` Use o SDK para criar collections diretamente:`);
478
+ log.dim(` neetru.db.collection<Produto>('produtos')`);
479
+ log.dim(` 3. O Core irá provisionar a instância ${chalk.bold(engine)} em ${chalk.bold(env)}.`);
480
+ log.warn(` Nota: \`neetru db apply\` não suporta engine Firestore (pdv #11 — pipeline SQL-only).`);
481
+ }
482
+ else {
483
+ log.dim(` 2. Execute \`neetru db apply\` pra enviar a migração ao Core.`);
484
+ log.dim(` 3. O Core irá provisionar o banco ${chalk.bold(engine)} em ${chalk.bold(env)}.`);
124
485
  }
125
486
  }
126
- const DEFAULT_SEED_PATHS = ['db/seed.ts', 'db/seed.js', 'scripts/seed.ts', 'scripts/seed.js'];
127
- export async function runDbSeed(opts = {}) {
128
- log.banner();
129
- log.heading('neetru db seed');
487
+ export async function runDbApply(opts = {}) {
488
+ if (!opts.json) {
489
+ log.banner();
490
+ log.heading('neetru db apply');
491
+ }
130
492
  const cfg = await loadProductConfig();
131
493
  if (!cfg) {
132
- log.error('neetru.config.json não encontrado.');
494
+ const msg = 'neetru.config.json não encontrado. Rode `neetru init` primeiro.';
495
+ if (opts.json) {
496
+ jsonError(msg, 'config_not_found');
497
+ }
498
+ else {
499
+ log.error(msg);
500
+ }
133
501
  process.exit(1);
134
502
  return;
135
503
  }
136
- // Resolve path do seed script.
137
- let scriptPath = null;
138
- if (opts.script) {
139
- const abs = path.resolve(process.cwd(), opts.script);
140
- if (!fsSync.existsSync(abs)) {
141
- log.error(`Script não encontrado: ${opts.script}`);
142
- process.exit(2);
143
- return;
504
+ const cwd = process.cwd();
505
+ const dbJsonPath = path.resolve(cwd, DB_JSON_REL);
506
+ const dbJson = readDbJson(dbJsonPath);
507
+ if (dbJson.databases.length === 0) {
508
+ const msg = 'Nenhum banco registrado em .neetru/db.json. Rode `neetru db init` primeiro.';
509
+ if (opts.json) {
510
+ jsonError(msg, 'no_databases');
144
511
  }
145
- scriptPath = abs;
512
+ else {
513
+ log.error(msg);
514
+ }
515
+ process.exit(1);
516
+ return;
146
517
  }
147
- else {
148
- for (const candidate of DEFAULT_SEED_PATHS) {
149
- const abs = path.resolve(process.cwd(), candidate);
150
- if (fsSync.existsSync(abs)) {
151
- scriptPath = abs;
152
- break;
518
+ // Resolve o banco alvo.
519
+ let dbEntry = dbJson.databases[0];
520
+ if (opts.dbName) {
521
+ const found = dbJson.databases.find((d) => d.name === opts.dbName);
522
+ if (!found) {
523
+ const msg = `Banco "${opts.dbName}" não encontrado em .neetru/db.json. Disponíveis: ${dbJson.databases.map((d) => d.name).join(', ')}`;
524
+ if (opts.json) {
525
+ jsonError(msg, 'db_not_found');
526
+ }
527
+ else {
528
+ log.error(msg);
153
529
  }
530
+ process.exit(1);
531
+ return;
154
532
  }
533
+ dbEntry = found;
155
534
  }
156
- if (!scriptPath) {
157
- log.error('Nenhum seed script encontrado. Tente: db/seed.ts ou passe --script <path>.');
158
- process.exit(2);
535
+ else if (dbJson.databases.length > 1 && (opts.json || opts.yes)) {
536
+ // HIGH-1: modo não-interativo (--json ou --yes) com múltiplos bancos e sem
537
+ // --db explícito é ambíguo — aplicar no primeiro silenciosamente pode causar
538
+ // migração no banco errado em CI. Emite erro estruturado ao invés de assumir.
539
+ const msg = 'Múltiplos bancos em .neetru/db.json. Use --db <nome>.';
540
+ if (opts.json) {
541
+ console.log(JSON.stringify({
542
+ ok: false,
543
+ error: 'db_required',
544
+ message: msg,
545
+ available: dbJson.databases.map((d) => d.name),
546
+ }));
547
+ }
548
+ else {
549
+ log.error(msg);
550
+ log.dim(` Bancos disponíveis: ${dbJson.databases.map((d) => d.name).join(', ')}`);
551
+ }
552
+ process.exit(1);
159
553
  return;
160
554
  }
161
- log.info(`Executando ${chalk.bold(path.relative(process.cwd(), scriptPath))} (NEETRU_ENV=dev)`);
162
- const isTs = scriptPath.endsWith('.ts');
163
- const cmd = isTs ? 'npx' : 'node';
164
- const args = isTs ? ['tsx', scriptPath] : [scriptPath];
165
- await new Promise((resolve) => {
166
- const child = spawn(cmd, args, {
167
- stdio: 'inherit',
168
- env: { ...process.env, NEETRU_ENV: 'dev' },
169
- shell: process.platform === 'win32',
555
+ else if (dbJson.databases.length > 1 && !opts.yes && !opts.json) {
556
+ const { chosen } = await inquirer.prompt([
557
+ {
558
+ type: 'list',
559
+ name: 'chosen',
560
+ message: 'Qual banco aplicar a migração?',
561
+ choices: dbJson.databases.map((d) => ({
562
+ name: `${d.name} ${chalk.dim('(' + d.engine + ')')}`,
563
+ value: d.name,
564
+ })),
565
+ },
566
+ ]);
567
+ const found = dbJson.databases.find((d) => d.name === chosen);
568
+ if (!found) {
569
+ log.error('Banco não encontrado.');
570
+ process.exit(1);
571
+ return;
572
+ }
573
+ dbEntry = found;
574
+ }
575
+ // Resolve ambiente (D-4: default canônico é dev-local, nunca dev).
576
+ let env;
577
+ try {
578
+ env = normalizeEnv(opts.env ?? 'dev-local');
579
+ }
580
+ catch {
581
+ const msg = `Ambiente inválido: "${opts.env}". Use dev-local, staging ou production.`;
582
+ if (opts.json) {
583
+ jsonError(msg, 'invalid_env');
584
+ }
585
+ else {
586
+ log.error(msg);
587
+ }
588
+ process.exit(1);
589
+ return;
590
+ }
591
+ // Resolve schema path.
592
+ const schemaFile = opts.schemaPath ?? DEFAULT_SCHEMA_PATH;
593
+ const schemaAbs = path.resolve(cwd, schemaFile);
594
+ if (!fsSync.existsSync(schemaAbs)) {
595
+ const msg = `Schema não encontrado em ${schemaFile}. Rode \`neetru db init\` pra criar.`;
596
+ if (opts.json) {
597
+ jsonError(msg, 'schema_not_found');
598
+ }
599
+ else {
600
+ log.error(msg);
601
+ }
602
+ process.exit(1);
603
+ return;
604
+ }
605
+ // BL-5 fix: resolve o dbId do env alvo. O ID deve estar em `.neetru/db.json`
606
+ // (preenchido pelo `runDbInit` via `POST /api/cli/v1/db/register`).
607
+ const canonicalEnv = env;
608
+ const dbId = dbEntry.ids[canonicalEnv];
609
+ if (!dbId) {
610
+ const msg = `Banco "${dbEntry.name}" não tem ID registrado para o ambiente "${env}". ` +
611
+ `Execute \`neetru db init\` novamente ou verifique .neetru/db.json.`;
612
+ if (opts.json) {
613
+ jsonError(msg, 'missing_db_id');
614
+ }
615
+ else {
616
+ log.error(msg);
617
+ }
618
+ process.exit(1);
619
+ return;
620
+ }
621
+ if (!opts.json) {
622
+ log.dim(` Banco: ${dbEntry.name} (${dbEntry.engine})`);
623
+ log.dim(` Schema: ${schemaFile}`);
624
+ log.dim(` Env: ${env}`);
625
+ log.dim(` DB ID: ${dbId}`);
626
+ if (opts.dryRun)
627
+ log.warn(' Modo --dry-run: nenhuma alteração será enviada ao Core.');
628
+ }
629
+ // Executa o pipeline.
630
+ const phaseLabels = {
631
+ fingerprint: 'Calculando fingerprint…',
632
+ generate: 'Gerando SQL (drizzle-kit)…',
633
+ rehearse: 'Ensaiando em Postgres efêmero…',
634
+ classify: 'Classificando migração…',
635
+ push: 'Enviando ao Core…',
636
+ };
637
+ let spinner = opts.json ? null : ora({ text: 'Iniciando pipeline…', color: 'blue' }).start();
638
+ let result;
639
+ try {
640
+ const deps = buildPipelineDeps({
641
+ onPhase: (phase) => {
642
+ if (spinner)
643
+ spinner.text = phaseLabels[phase] ?? phase;
644
+ },
645
+ ...(opts.dryRun
646
+ ? {
647
+ pushToCore: async () => {
648
+ return { migrationId: '(dry-run)', status: 'dry_run' };
649
+ },
650
+ }
651
+ : {}),
170
652
  });
171
- child.on('exit', (code) => {
172
- if (code === 0) {
173
- log.success('Seed completo.');
174
- resolve();
653
+ // BL-5 fix: passa `dbId` resolvido para o pipeline.
654
+ result = await runApplyPipeline({
655
+ schemaPath: schemaAbs,
656
+ env: canonicalEnv,
657
+ dbId,
658
+ }, deps);
659
+ spinner?.stop();
660
+ }
661
+ catch (err) {
662
+ spinner?.fail('Pipeline falhou.');
663
+ if (isPipelineError(err)) {
664
+ const pe = err;
665
+ if (opts.json) {
666
+ jsonError(`Fase "${pe.phase}": ${pe.message}`, 'pipeline_failed');
175
667
  }
176
668
  else {
177
- log.error(`Seed falhou (exit ${code ?? '?'}).`);
178
- process.exit(6);
669
+ log.error(`Fase "${pe.phase}": ${pe.message}`);
179
670
  }
671
+ process.exit(5);
672
+ return;
673
+ }
674
+ if (opts.json) {
675
+ jsonError(err.message ?? String(err), 'pipeline_failed');
676
+ }
677
+ else {
678
+ log.error(err.message ?? String(err));
679
+ }
680
+ process.exit(5);
681
+ return;
682
+ }
683
+ // H-7 fix: persiste o fingerprint após push bem-sucedido (não dry-run).
684
+ if (!opts.dryRun && result.fingerprint) {
685
+ const dbJsonAfter = readDbJson(dbJsonPath);
686
+ const updatedDatabases = dbJsonAfter.databases.map((d) => d.name === dbEntry.name
687
+ ? { ...d, lastAppliedFingerprint: result.fingerprint }
688
+ : d);
689
+ writeDbJson(dbJsonPath, { ...dbJsonAfter, databases: updatedDatabases });
690
+ if (!opts.json)
691
+ log.dim(` Fingerprint gravado em ${chalk.bold(DB_JSON_REL)}`);
692
+ }
693
+ // gestovendas #9: saída JSON uniforme no caminho de sucesso.
694
+ if (opts.json) {
695
+ console.log(JSON.stringify({
696
+ ok: true,
697
+ status: result.status,
698
+ migrationId: result.migrationId ?? null,
699
+ severity: result.severity ?? null,
700
+ fingerprint: result.fingerprint ?? null,
701
+ dryRun: !!opts.dryRun,
702
+ }));
703
+ return;
704
+ }
705
+ if (result.status === 'no_changes') {
706
+ log.success('Schema sem alterações — nenhuma migração necessária.');
707
+ return;
708
+ }
709
+ log.dim('');
710
+ log.dim(` Severidade: ${severityColor(result.severity)}`);
711
+ log.dim(` Migração ID: ${result.migrationId}`);
712
+ log.dim(` Status: ${result.status}`);
713
+ if (result.severity === 'destrutiva' && !opts.dryRun) {
714
+ log.warn('Esta migração é destrutiva e requer confirmação antes de ser aplicada.');
715
+ log.dim(` Execute: neetru db migrations confirm ${result.migrationId} --mfa <token>`);
716
+ }
717
+ else if (!opts.dryRun) {
718
+ log.success(`Migração ${chalk.bold(result.migrationId)} enviada ao Core (${result.status}).`);
719
+ }
720
+ }
721
+ export async function runDbMigrationsList(opts = {}) {
722
+ log.banner();
723
+ const qs = opts.dbId ? `?dbId=${encodeURIComponent(opts.dbId)}` : '';
724
+ const spinner = ora({ text: 'Buscando migrações…', color: 'blue' }).start();
725
+ let resp;
726
+ try {
727
+ resp = await apiRequest(`/api/cli/v1/db/migrations${qs}`);
728
+ spinner.stop();
729
+ }
730
+ catch (err) {
731
+ spinner.fail('Falha ao buscar migrações.');
732
+ handleApiError(err);
733
+ }
734
+ if (opts.json) {
735
+ console.log(JSON.stringify(resp, null, 2));
736
+ return;
737
+ }
738
+ log.heading(`Migrações (${resp.count})`);
739
+ renderTable([
740
+ { header: 'ID', value: (r) => r.id, maxWidth: 28 },
741
+ { header: 'Banco', value: (r) => r.dbId ?? '—', maxWidth: 20 },
742
+ { header: 'Status', value: (r) => r.status, maxWidth: 20 },
743
+ { header: 'Severidade', value: (r) => severityColor(r.severity), maxWidth: 14 },
744
+ { header: 'Aplicado', value: (r) => fmtTimestamp(r.appliedAt ?? r.createdAt), maxWidth: 18 },
745
+ ], resp.migrations);
746
+ }
747
+ export async function runDbMigrationsConfirm(migrationId, opts) {
748
+ log.banner();
749
+ if (!migrationId) {
750
+ log.error('<migrationId> é obrigatório.');
751
+ process.exit(1);
752
+ return;
753
+ }
754
+ if (!opts.mfa) {
755
+ log.error('--mfa <token> é obrigatório para confirmar migrações destrutivas.');
756
+ process.exit(1);
757
+ return;
758
+ }
759
+ const spinner = ora({ text: `Confirmando migração ${migrationId}…`, color: 'yellow' }).start();
760
+ let resp;
761
+ try {
762
+ resp = await apiRequest(`/api/cli/v1/db/migrations/${encodeURIComponent(migrationId)}/confirm`, {
763
+ method: 'POST',
764
+ headers: { 'X-Neetru-MFA-Token': opts.mfa },
765
+ body: { migrationId },
180
766
  });
181
- child.on('error', (err) => {
182
- log.error(`Falha ao executar: ${err.message}`);
183
- process.exit(7);
767
+ spinner.succeed(`Migração ${chalk.bold(resp.migrationId)} confirmada — status: ${resp.status}`);
768
+ }
769
+ catch (err) {
770
+ spinner.fail('Falha ao confirmar migração.');
771
+ handleApiError(err);
772
+ }
773
+ if (opts.json) {
774
+ console.log(JSON.stringify(resp, null, 2));
775
+ }
776
+ }
777
+ /**
778
+ * Lista os backups disponíveis de um banco específico.
779
+ *
780
+ * Endpoint: GET /api/cli/v1/db/[id]/backups
781
+ * Rota implementada em `src/app/api/cli/v1/db/[id]/backups/route.ts`.
782
+ */
783
+ export async function runDbBackups(dbId, opts = {}) {
784
+ log.banner();
785
+ if (!dbId) {
786
+ log.error('<dbId> é obrigatório.');
787
+ process.exit(1);
788
+ return;
789
+ }
790
+ const spinner = ora({ text: `Buscando backups do banco ${dbId}…`, color: 'blue' }).start();
791
+ let resp;
792
+ try {
793
+ resp = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/backups`);
794
+ spinner.stop();
795
+ }
796
+ catch (err) {
797
+ spinner.fail('Falha ao buscar backups.');
798
+ handleApiError(err);
799
+ }
800
+ if (opts.json) {
801
+ console.log(JSON.stringify(resp, null, 2));
802
+ return;
803
+ }
804
+ log.heading(`Backups do banco ${chalk.bold(dbId)} (${resp.count})`);
805
+ renderTable([
806
+ { header: 'ID', value: (r) => r.id, maxWidth: 28 },
807
+ { header: 'Status', value: (r) => statusColor(r.status), maxWidth: 16 },
808
+ { header: 'Data/Hora', value: (r) => fmtTimestamp(r.timestamp), maxWidth: 22 },
809
+ { header: 'Tamanho (MB)', value: (r) => r.sizeMb != null ? String(r.sizeMb) : '—', maxWidth: 14 },
810
+ { header: 'GCS Path', value: (r) => r.gcsPath ?? '—', maxWidth: 60 },
811
+ ], resp.backups);
812
+ }
813
+ /**
814
+ * Dispara um restore de banco a partir de um backup selecionado interativamente.
815
+ *
816
+ * Fluxo:
817
+ * 1. Lista backups disponíveis via GET /api/cli/v1/db/[id]/backups
818
+ * 2. Operador escolhe qual backup restaurar
819
+ * 3. Exige confirmação textual "RESTAURAR" (operação destrutiva)
820
+ * 4. POST /api/cli/v1/db/[id]/restore com MFA step-up
821
+ *
822
+ * Endpoints implementados em:
823
+ * GET src/app/api/cli/v1/db/[id]/backups/route.ts
824
+ * POST src/app/api/cli/v1/db/[id]/restore/route.ts
825
+ */
826
+ export async function runDbRestore(dbId, opts) {
827
+ log.banner();
828
+ if (!dbId) {
829
+ log.error('<dbId> é obrigatório.');
830
+ process.exit(1);
831
+ return;
832
+ }
833
+ if (!opts.mfa) {
834
+ log.error('--mfa <token> é obrigatório para restaurar banco (operação destrutiva).');
835
+ process.exit(1);
836
+ return;
837
+ }
838
+ // 1. Lista backups disponíveis
839
+ const spinnerList = ora({ text: `Buscando backups do banco ${dbId}…`, color: 'blue' }).start();
840
+ let backupsResp;
841
+ try {
842
+ backupsResp = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/backups`);
843
+ spinnerList.stop();
844
+ }
845
+ catch (err) {
846
+ spinnerList.fail('Falha ao buscar backups.');
847
+ handleApiError(err);
848
+ }
849
+ if (backupsResp.count === 0 || backupsResp.backups.length === 0) {
850
+ log.warn('Nenhum backup disponível para este banco.');
851
+ process.exit(0);
852
+ return;
853
+ }
854
+ // 2. Operador escolhe backup
855
+ const { backupId } = await inquirer.prompt([
856
+ {
857
+ type: 'list',
858
+ name: 'backupId',
859
+ message: 'Escolha o backup para restaurar:',
860
+ choices: backupsResp.backups.map((b) => ({
861
+ name: `${b.id} ${fmtTimestamp(b.timestamp)} ${b.sizeMb != null ? b.sizeMb + ' MB' : ''} ${statusColor(b.status)}`,
862
+ value: b.id,
863
+ })),
864
+ },
865
+ ]);
866
+ const chosen = backupsResp.backups.find((b) => b.id === backupId);
867
+ log.warn('');
868
+ log.warn(`ATENÇÃO: Restore de banco é destrutivo — o banco ${chalk.bold(dbId)} será sobrescrito`);
869
+ log.warn(`pelo backup ${chalk.bold(backupId)} (${fmtTimestamp(chosen?.timestamp)}).`);
870
+ log.warn('Esta operação NÃO pode ser desfeita.');
871
+ log.warn('');
872
+ // 3. Confirmação textual obrigatória
873
+ const { confirmText } = await inquirer.prompt([
874
+ {
875
+ type: 'input',
876
+ name: 'confirmText',
877
+ message: `Digite ${chalk.bold('RESTAURAR')} para confirmar:`,
878
+ },
879
+ ]);
880
+ if (confirmText !== 'RESTAURAR') {
881
+ log.dim('Operação cancelada.');
882
+ return;
883
+ }
884
+ // 4. POST restore com MFA step-up
885
+ const spinnerRestore = ora({ text: 'Disparando restore…', color: 'yellow' }).start();
886
+ let resp;
887
+ try {
888
+ resp = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/restore`, {
889
+ method: 'POST',
890
+ headers: { 'X-Neetru-MFA-Token': opts.mfa },
891
+ body: { backupId, dbId },
184
892
  });
185
- });
893
+ spinnerRestore.succeed(`Restore iniciado — job ${chalk.bold(resp.jobId ?? '—')} · status: ${resp.status}`);
894
+ }
895
+ catch (err) {
896
+ spinnerRestore.fail('Falha ao iniciar restore.');
897
+ handleApiError(err);
898
+ }
899
+ if (opts.json) {
900
+ console.log(JSON.stringify(resp, null, 2));
901
+ }
902
+ }
903
+ function dbDensityLabel(databases) {
904
+ if (!databases || databases.length === 0)
905
+ return chalk.dim('(vazio)');
906
+ const names = databases.map((d) => d.label ?? d.id).join(', ');
907
+ const label = `${databases.length} banco${databases.length > 1 ? 's' : ''}`;
908
+ return `${chalk.bold(String(databases.length))} — ${chalk.dim(names.slice(0, 50))}${names.length > 50 ? '…' : ''}`;
909
+ }
910
+ function ramLabel(total, available) {
911
+ if (total == null)
912
+ return '—';
913
+ if (available == null)
914
+ return `${total} MB`;
915
+ return `${available}/${total} MB livre`;
916
+ }
917
+ /**
918
+ * Lista as VMs host e quantos bancos de produto cada uma hospeda.
919
+ *
920
+ * Usa o endpoint de servers com `?capacity=true&db=true` para incluir
921
+ * o inventário de bancos por servidor. O Core agrega o campo `databases`
922
+ * quando `db=true` é passado.
923
+ *
924
+ * Endpoint: GET /api/cli/v1/servers?capacity=true&db=true
925
+ *
926
+ * NOTA: O parâmetro `db=true` (que instrui o Core a enriquecer cada server
927
+ * com o array `databases`) não está implementado ainda no endpoint de servers
928
+ * do Core (GAP: parâmetro db=true em /api/cli/v1/servers ausente). O comando
929
+ * funciona com a resposta atual — apenas o campo `databases` por server
930
+ * ficará ausente/vazio até o Core implementar o enriquecimento.
931
+ */
932
+ export async function runDbHosts(opts = {}) {
933
+ log.banner();
934
+ const spinner = ora({ text: 'Buscando VM hosts e densidade de bancos…', color: 'blue' }).start();
935
+ let resp;
936
+ try {
937
+ resp = await apiRequest('/api/cli/v1/servers?capacity=true&db=true');
938
+ spinner.stop();
939
+ }
940
+ catch (err) {
941
+ spinner.fail('Falha ao buscar hosts.');
942
+ handleApiError(err);
943
+ }
944
+ if (opts.json) {
945
+ console.log(JSON.stringify(resp, null, 2));
946
+ return;
947
+ }
948
+ log.heading(`VM Hosts de Banco (${resp.count})`);
949
+ renderTable([
950
+ { header: 'ID', value: (r) => r.id, maxWidth: 20 },
951
+ { header: 'Nome', value: (r) => r.name ?? '—', maxWidth: 22 },
952
+ { header: 'Região', value: (r) => r.region ?? '—', maxWidth: 14 },
953
+ { header: 'Status', value: (r) => statusColor(r.status ?? '—'), maxWidth: 14 },
954
+ { header: 'RAM (livre/total)', value: (r) => ramLabel(r.totalRamMb, r.availableRamMb), maxWidth: 22 },
955
+ { header: 'Bancos', value: (r) => dbDensityLabel(r.databases), maxWidth: 50 },
956
+ ], resp.servers);
186
957
  }
958
+ // ── D-3: runDbMigrate e runDbSeed removidos (legado Sprint 10) ──────────────
959
+ // Esses comandos operavam via schema.manifest.json (Sprint 10) e foram cortados
960
+ // junto com o branch legado do `db init`. O fluxo canônico é:
961
+ // neetru db init → neetru db apply → neetru db migrations confirm (se destrutiva)
187
962
  //# sourceMappingURL=db.js.map