@neetru/cli 2.7.4 → 2.8.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 (75) hide show
  1. package/CHANGELOG.md +220 -208
  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/db.d.ts +87 -7
  18. package/dist/commands/db.js +697 -126
  19. package/dist/commands/db.js.map +1 -1
  20. package/dist/commands/deploy.d.ts +5 -0
  21. package/dist/commands/deploy.js +68 -0
  22. package/dist/commands/deploy.js.map +1 -1
  23. package/dist/commands/dev.d.ts +68 -0
  24. package/dist/commands/dev.js +345 -0
  25. package/dist/commands/dev.js.map +1 -0
  26. package/dist/commands/init.js +121 -121
  27. package/dist/commands/new.d.ts +6 -0
  28. package/dist/commands/new.js +31 -10
  29. package/dist/commands/new.js.map +1 -1
  30. package/dist/commands/products-db.d.ts +1 -1
  31. package/dist/commands/products-db.js +17 -4
  32. package/dist/commands/products-db.js.map +1 -1
  33. package/dist/commands/upgrade.js +5 -2
  34. package/dist/commands/upgrade.js.map +1 -1
  35. package/dist/index.js +258 -42
  36. package/dist/index.js.map +1 -1
  37. package/dist/lib/ai/context.js +90 -90
  38. package/dist/lib/db-local/db-json.d.ts +63 -0
  39. package/dist/lib/db-local/db-json.js +189 -0
  40. package/dist/lib/db-local/db-json.js.map +1 -0
  41. package/dist/lib/db-local/env.d.ts +26 -0
  42. package/dist/lib/db-local/env.js +64 -0
  43. package/dist/lib/db-local/env.js.map +1 -0
  44. package/dist/lib/db-local/fingerprint.d.ts +8 -0
  45. package/dist/lib/db-local/fingerprint.js +28 -0
  46. package/dist/lib/db-local/fingerprint.js.map +1 -0
  47. package/dist/lib/db-local/index.d.ts +15 -0
  48. package/dist/lib/db-local/index.js +14 -0
  49. package/dist/lib/db-local/index.js.map +1 -0
  50. package/dist/lib/db-pipeline/build-deps.d.ts +14 -0
  51. package/dist/lib/db-pipeline/build-deps.js +158 -0
  52. package/dist/lib/db-pipeline/build-deps.js.map +1 -0
  53. package/dist/lib/db-pipeline/errors.d.ts +29 -0
  54. package/dist/lib/db-pipeline/errors.js +29 -0
  55. package/dist/lib/db-pipeline/errors.js.map +1 -0
  56. package/dist/lib/db-pipeline/index.d.ts +26 -0
  57. package/dist/lib/db-pipeline/index.js +25 -0
  58. package/dist/lib/db-pipeline/index.js.map +1 -0
  59. package/dist/lib/db-pipeline/pipeline.d.ts +13 -0
  60. package/dist/lib/db-pipeline/pipeline.js +119 -0
  61. package/dist/lib/db-pipeline/pipeline.js.map +1 -0
  62. package/dist/lib/db-pipeline/rehearse.d.ts +99 -0
  63. package/dist/lib/db-pipeline/rehearse.js +219 -0
  64. package/dist/lib/db-pipeline/rehearse.js.map +1 -0
  65. package/dist/lib/db-pipeline/types.d.ts +112 -0
  66. package/dist/lib/db-pipeline/types.js +20 -0
  67. package/dist/lib/db-pipeline/types.js.map +1 -0
  68. package/package.json +63 -62
  69. package/templates/auth/callback.ts +22 -22
  70. package/templates/auth/sign-in.tsx +41 -41
  71. package/templates/billing/checkout.ts +22 -22
  72. package/templates/billing/page.tsx +43 -43
  73. package/templates/support/ticket-form.tsx +68 -68
  74. package/templates/usage/track.ts +30 -30
  75. package/templates/users/profile.tsx +43 -43
@@ -1,187 +1,758 @@
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
+ { header: 'Env', value: (r) => r.env, maxWidth: 12 },
151
+ { header: 'Status', value: (r) => statusColor(r.status), maxWidth: 16 },
152
+ { header: 'Criado', value: (r) => fmtTimestamp(r.createdAt), maxWidth: 18 },
153
+ ], resp.databases);
154
+ }
155
+ export async function runDbStatus(dbId, opts = {}) {
156
+ log.banner();
157
+ if (!dbId) {
158
+ log.error('<dbId> é obrigatório.');
57
159
  process.exit(1);
58
160
  return;
59
161
  }
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);
162
+ const spinner = ora({ text: `Buscando banco ${dbId}…`, color: 'blue' }).start();
163
+ let resp;
164
+ try {
165
+ // BL-9 fix: `/status` é POST-only (atualização de status). O endpoint de
166
+ // leitura de detalhe é `/get` (GET).
167
+ resp = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/get`);
168
+ spinner.stop();
169
+ }
170
+ catch (err) {
171
+ spinner.fail('Falha ao buscar status do banco.');
172
+ handleApiError(err);
173
+ }
174
+ if (opts.json) {
175
+ console.log(JSON.stringify(resp, null, 2));
65
176
  return;
66
177
  }
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>');
178
+ const db = resp.database;
179
+ log.heading(`Banco: ${chalk.bold(db.label ?? db.id)}`);
180
+ log.dim(` ID: ${db.id}`);
181
+ log.dim(` Produto: ${db.productId}`);
182
+ log.dim(` Engine: ${db.engine}`);
183
+ log.dim(` Env: ${db.env}`);
184
+ log.dim(` Status: ${statusColor(db.status)}`);
185
+ if (db.region)
186
+ log.dim(` Região: ${db.region}`);
187
+ if (db.serverId)
188
+ log.dim(` VM: ${db.serverId}`);
189
+ if (db.healthy !== null && db.healthy !== undefined) {
190
+ log.dim(` Saúde: ${db.healthy ? chalk.green('ok') : chalk.red('falha')}`);
191
+ }
192
+ if (db.healthLastCheckedAt) {
193
+ log.dim(` Verificado: ${fmtTimestamp(db.healthLastCheckedAt)}`);
194
+ }
195
+ if (db.provisioningError) {
196
+ log.warn(` Erro de provisionamento: ${db.provisioningError}`);
197
+ }
198
+ log.dim(` Criado: ${fmtTimestamp(db.createdAt)}`);
199
+ log.dim(` Atualizado: ${fmtTimestamp(db.updatedAt)}`);
73
200
  }
74
- export async function runDbMigrate(opts) {
201
+ /** Conteúdo do `db/schema.ts` gerado durante o init. */
202
+ const SCHEMA_STARTER = (productId, engine) => `/**
203
+ * Schema do banco "${productId}" — ${ENGINE_LABELS[engine] ?? engine}.
204
+ *
205
+ * Gerado por \`neetru db init\`.
206
+ * Edite este arquivo e rode \`neetru db apply\` pra enviar a migração ao Core.
207
+ */
208
+
209
+ // Exemplo com DrizzleORM (PostgreSQL):
210
+ // import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core';
211
+ //
212
+ // export const usuarios = pgTable('usuarios', {
213
+ // id: serial('id').primaryKey(),
214
+ // email: text('email').notNull().unique(),
215
+ // criadoEm: timestamp('criado_em').defaultNow(),
216
+ // });
217
+
218
+ // Remova o comentário acima e adapte ao seu schema.
219
+ export {};
220
+ `;
221
+ export async function runDbInit(opts = {}) {
75
222
  log.banner();
76
- log.heading('neetru db migrate');
223
+ log.heading('neetru db init');
224
+ // ── D-3: opções legadas removidas (Sprint 10 — schema.manifest.json) ────
225
+ // `--out` e `--force` eram usados para gerar schema.manifest.json.
226
+ // O branch legado foi cortado. Passar essas flags agora é um erro.
227
+ if (opts.out !== undefined || opts.force !== undefined) {
228
+ log.error('As opções --out e --force foram removidas (D-3).\n' +
229
+ 'O `neetru db init` agora faz UMA coisa: registra o banco no Core + ' +
230
+ 'grava .neetru/db.json + scaffolda db/schema.ts.\n' +
231
+ 'Para inicializar o banco, use:\n' +
232
+ ' neetru db init --name <nome> --engine <engine> --env dev-local');
233
+ process.exit(1);
234
+ return;
235
+ }
77
236
  const cfg = await loadProductConfig();
78
237
  if (!cfg) {
79
- log.error('neetru.config.json não encontrado.');
238
+ log.error('neetru.config.json não encontrado. Rode `neetru init` primeiro.');
80
239
  process.exit(1);
81
240
  return;
82
241
  }
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;
242
+ const productId = opts.productId ?? cfg.slug;
243
+ const cwd = process.cwd();
244
+ const dbJsonPath = path.resolve(cwd, DB_JSON_REL);
245
+ const dbJson = readDbJson(dbJsonPath);
246
+ // ── nome do banco ────────────────────────────────────────────────────────
247
+ let dbName = opts.name;
248
+ if (!dbName && !opts.yes) {
249
+ const existing = dbJson.databases.map((d) => d.name);
250
+ const { n } = await inquirer.prompt([
251
+ {
252
+ type: 'input',
253
+ name: 'n',
254
+ message: 'Nome do banco (ex: principal, analytics):',
255
+ default: existing.length === 0 ? 'principal' : `db${existing.length + 1}`,
256
+ validate: (v) => {
257
+ if (!v.trim())
258
+ return 'Nome obrigatório.';
259
+ if (dbJson.databases.some((d) => d.name === v.trim())) {
260
+ return `Banco "${v.trim()}" já registrado em .neetru/db.json.`;
261
+ }
262
+ return true;
263
+ },
264
+ },
265
+ ]);
266
+ dbName = n.trim();
267
+ }
268
+ else if (!dbName) {
269
+ dbName = dbJson.databases.length === 0 ? 'principal' : `db${dbJson.databases.length + 1}`;
88
270
  }
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.');
271
+ if (dbJson.databases.some((d) => d.name === dbName)) {
272
+ log.error(`Banco "${dbName}" já registrado. Use outro nome ou edite .neetru/db.json.`);
94
273
  process.exit(2);
95
274
  return;
96
275
  }
97
- if (toVersion <= fromVersion) {
98
- log.error(`toVersion (${toVersion}) deve ser maior que fromVersion (${fromVersion}).`);
99
- process.exit(2);
276
+ // ── engine ───────────────────────────────────────────────────────────────
277
+ let engine;
278
+ if (opts.engine && ENGINES.includes(opts.engine)) {
279
+ engine = opts.engine;
280
+ }
281
+ else if (opts.yes) {
282
+ engine = 'cloud-sql-postgres';
283
+ }
284
+ else {
285
+ const { e } = await inquirer.prompt([
286
+ {
287
+ type: 'list',
288
+ name: 'e',
289
+ message: 'Engine do banco:',
290
+ default: 'cloud-sql-postgres',
291
+ choices: ENGINES.map((eng) => ({
292
+ name: `${ENGINE_LABELS[eng]} ${chalk.dim('(' + eng + ')')}`,
293
+ value: eng,
294
+ })),
295
+ },
296
+ ]);
297
+ engine = e;
298
+ }
299
+ // ── ambiente (D-4: dev-local é o valor canônico) ─────────────────────────
300
+ let env;
301
+ try {
302
+ env = opts.env ? normalizeEnv(opts.env) : 'dev-local';
303
+ }
304
+ catch {
305
+ log.error(`Ambiente inválido: "${opts.env}". Use dev-local, staging ou production.`);
306
+ process.exit(1);
100
307
  return;
101
308
  }
102
- log.info(`Migrating ${chalk.bold(cfg.slug)} v${fromVersion} v${toVersion} ...`);
309
+ // ── grava .neetru/db.json (stub inicial ids preenchidos depois do register)
310
+ const stubEntry = { name: dbName, productId, engine, ids: {} };
311
+ const withStub = {
312
+ ...dbJson,
313
+ databases: [...dbJson.databases, stubEntry],
314
+ };
315
+ writeDbJson(dbJsonPath, withStub);
316
+ log.success(`Banco "${dbName}" registrado localmente em ${chalk.bold(DB_JSON_REL)}`);
317
+ // ── BL-6 fix: registra o banco no Core e persiste os IDs por ambiente ────
318
+ const registerSpinner = ora({ text: 'Registrando banco no Core…', color: 'blue' }).start();
319
+ let registerResp;
103
320
  try {
104
- const r = await apiRequest('/cli/v1/db/migrate', {
321
+ registerResp = await apiRequest('/api/cli/v1/db/register', {
105
322
  method: 'POST',
106
- body: { productId: cfg.slug, fromVersion, toVersion },
323
+ body: {
324
+ productId,
325
+ label: dbName,
326
+ engine,
327
+ provisioning: { type: engine.startsWith('vm-') ? 'vm' : 'managed' },
328
+ },
107
329
  });
108
- log.success(`Migration aplicada: ${r.migrationsApplied} step(s).`);
109
- log.dim(` v${r.fromVersion} → v${r.toVersion}`);
330
+ registerSpinner.succeed('Banco registrado no Core.');
110
331
  }
111
332
  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}`);
119
- process.exit(4);
120
- return;
121
- }
122
- log.error(err instanceof Error ? err.message : String(err));
123
- process.exit(5);
333
+ registerSpinner.fail('Falha ao registrar banco no Core.');
334
+ handleApiError(err);
335
+ }
336
+ // Persiste os IDs retornados pelo Core em `.neetru/db.json`.
337
+ const ids = {
338
+ 'dev-local': registerResp.ids.devLocal,
339
+ staging: registerResp.ids.staging,
340
+ production: registerResp.ids.production,
341
+ };
342
+ const finalEntry = { name: dbName, productId, engine, ids };
343
+ const withIds = {
344
+ version: 2,
345
+ databases: [...withStub.databases.slice(0, -1), finalEntry],
346
+ };
347
+ writeDbJson(dbJsonPath, withIds);
348
+ log.success(`IDs de ambiente gravados em ${chalk.bold(DB_JSON_REL)}`);
349
+ // ── scaffolda db/schema.ts se não existir ─────────────────────────────────
350
+ const schemaPath = path.resolve(cwd, DEFAULT_SCHEMA_PATH);
351
+ if (!fsSync.existsSync(schemaPath)) {
352
+ await fs.mkdir(path.dirname(schemaPath), { recursive: true });
353
+ await fs.writeFile(schemaPath, SCHEMA_STARTER(productId, engine), 'utf8');
354
+ log.success(`Schema gerado em ${chalk.bold(DEFAULT_SCHEMA_PATH)}`);
355
+ }
356
+ else {
357
+ log.dim(` Schema existente em ${DEFAULT_SCHEMA_PATH} — não sobrescrito.`);
124
358
  }
359
+ log.dim('');
360
+ log.dim('Próximos passos:');
361
+ log.dim(` 1. Edite ${DEFAULT_SCHEMA_PATH} com as tabelas do seu produto.`);
362
+ log.dim(` 2. Execute \`neetru db apply\` pra enviar a migração ao Core.`);
363
+ log.dim(` 3. O Core irá provisionar o banco ${chalk.bold(engine)} em ${chalk.bold(env)}.`);
125
364
  }
126
- const DEFAULT_SEED_PATHS = ['db/seed.ts', 'db/seed.js', 'scripts/seed.ts', 'scripts/seed.js'];
127
- export async function runDbSeed(opts = {}) {
365
+ export async function runDbApply(opts = {}) {
128
366
  log.banner();
129
- log.heading('neetru db seed');
367
+ log.heading('neetru db apply');
130
368
  const cfg = await loadProductConfig();
131
369
  if (!cfg) {
132
- log.error('neetru.config.json não encontrado.');
370
+ log.error('neetru.config.json não encontrado. Rode `neetru init` primeiro.');
133
371
  process.exit(1);
134
372
  return;
135
373
  }
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);
374
+ const cwd = process.cwd();
375
+ const dbJsonPath = path.resolve(cwd, DB_JSON_REL);
376
+ const dbJson = readDbJson(dbJsonPath);
377
+ if (dbJson.databases.length === 0) {
378
+ log.error('Nenhum banco registrado em .neetru/db.json. Rode `neetru db init` primeiro.');
379
+ process.exit(1);
380
+ return;
381
+ }
382
+ // Resolve o banco alvo.
383
+ let dbEntry = dbJson.databases[0];
384
+ if (opts.dbName) {
385
+ const found = dbJson.databases.find((d) => d.name === opts.dbName);
386
+ if (!found) {
387
+ log.error(`Banco "${opts.dbName}" não encontrado em .neetru/db.json.`);
388
+ log.dim(` Bancos disponíveis: ${dbJson.databases.map((d) => d.name).join(', ')}`);
389
+ process.exit(1);
143
390
  return;
144
391
  }
145
- scriptPath = abs;
392
+ dbEntry = found;
146
393
  }
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;
153
- }
394
+ else if (dbJson.databases.length > 1 && !opts.yes) {
395
+ const { chosen } = await inquirer.prompt([
396
+ {
397
+ type: 'list',
398
+ name: 'chosen',
399
+ message: 'Qual banco aplicar a migração?',
400
+ choices: dbJson.databases.map((d) => ({
401
+ name: `${d.name} ${chalk.dim('(' + d.engine + ')')}`,
402
+ value: d.name,
403
+ })),
404
+ },
405
+ ]);
406
+ const found = dbJson.databases.find((d) => d.name === chosen);
407
+ if (!found) {
408
+ log.error('Banco não encontrado.');
409
+ process.exit(1);
410
+ return;
154
411
  }
412
+ dbEntry = found;
155
413
  }
156
- if (!scriptPath) {
157
- log.error('Nenhum seed script encontrado. Tente: db/seed.ts ou passe --script <path>.');
158
- process.exit(2);
414
+ // Resolve ambiente (D-4: default canônico é dev-local, nunca dev).
415
+ let env;
416
+ try {
417
+ env = normalizeEnv(opts.env ?? 'dev-local');
418
+ }
419
+ catch {
420
+ log.error(`Ambiente inválido: "${opts.env}". Use dev-local, staging ou production.`);
421
+ process.exit(1);
159
422
  return;
160
423
  }
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',
424
+ // Resolve schema path.
425
+ const schemaFile = opts.schemaPath ?? DEFAULT_SCHEMA_PATH;
426
+ const schemaAbs = path.resolve(cwd, schemaFile);
427
+ if (!fsSync.existsSync(schemaAbs)) {
428
+ log.error(`Schema não encontrado em ${schemaFile}. Rode \`neetru db init\` pra criar.`);
429
+ process.exit(1);
430
+ return;
431
+ }
432
+ // BL-5 fix: resolve o dbId do env alvo. O ID deve estar em `.neetru/db.json`
433
+ // (preenchido pelo `runDbInit` via `POST /api/cli/v1/db/register`).
434
+ const canonicalEnv = env;
435
+ const dbId = dbEntry.ids[canonicalEnv];
436
+ if (!dbId) {
437
+ log.error(`Banco "${dbEntry.name}" não tem ID registrado para o ambiente "${env}". ` +
438
+ `Execute \`neetru db init\` novamente ou verifique .neetru/db.json.`);
439
+ process.exit(1);
440
+ return;
441
+ }
442
+ log.dim(` Banco: ${dbEntry.name} (${dbEntry.engine})`);
443
+ log.dim(` Schema: ${schemaFile}`);
444
+ log.dim(` Env: ${env}`);
445
+ log.dim(` DB ID: ${dbId}`);
446
+ if (opts.dryRun)
447
+ log.warn(' Modo --dry-run: nenhuma alteração será enviada ao Core.');
448
+ // Executa o pipeline.
449
+ const phaseLabels = {
450
+ fingerprint: 'Calculando fingerprint…',
451
+ generate: 'Gerando SQL (drizzle-kit)…',
452
+ rehearse: 'Ensaiando em Postgres efêmero…',
453
+ classify: 'Classificando migração…',
454
+ push: 'Enviando ao Core…',
455
+ };
456
+ let spinner = ora({ text: 'Iniciando pipeline…', color: 'blue' }).start();
457
+ let result;
458
+ try {
459
+ const deps = buildPipelineDeps({
460
+ onPhase: (phase) => {
461
+ spinner.text = phaseLabels[phase] ?? phase;
462
+ },
463
+ ...(opts.dryRun
464
+ ? {
465
+ pushToCore: async () => {
466
+ return { migrationId: '(dry-run)', status: 'dry_run' };
467
+ },
468
+ }
469
+ : {}),
170
470
  });
171
- child.on('exit', (code) => {
172
- if (code === 0) {
173
- log.success('Seed completo.');
174
- resolve();
175
- }
176
- else {
177
- log.error(`Seed falhou (exit ${code ?? '?'}).`);
178
- process.exit(6);
179
- }
471
+ // BL-5 fix: passa `dbId` resolvido para o pipeline.
472
+ result = await runApplyPipeline({
473
+ schemaPath: schemaAbs,
474
+ env: canonicalEnv,
475
+ dbId,
476
+ }, deps);
477
+ spinner.stop();
478
+ }
479
+ catch (err) {
480
+ spinner.fail('Pipeline falhou.');
481
+ if (isPipelineError(err)) {
482
+ const pe = err;
483
+ log.error(`Fase "${pe.phase}": ${pe.message}`);
484
+ process.exit(5);
485
+ return;
486
+ }
487
+ log.error(err.message ?? String(err));
488
+ process.exit(5);
489
+ return;
490
+ }
491
+ if (result.status === 'no_changes') {
492
+ log.success('Schema sem alterações — nenhuma migração necessária.');
493
+ return;
494
+ }
495
+ // H-7 fix: persiste o fingerprint após push bem-sucedido (não dry-run) para
496
+ // que o gate de deploy (`checkSchemaGate` em deploy.ts) consiga detectar drift.
497
+ if (!opts.dryRun && result.fingerprint) {
498
+ const dbJsonAfter = readDbJson(dbJsonPath);
499
+ const updatedDatabases = dbJsonAfter.databases.map((d) => d.name === dbEntry.name
500
+ ? { ...d, lastAppliedFingerprint: result.fingerprint }
501
+ : d);
502
+ writeDbJson(dbJsonPath, { ...dbJsonAfter, databases: updatedDatabases });
503
+ log.dim(` Fingerprint gravado em ${chalk.bold(DB_JSON_REL)}`);
504
+ }
505
+ log.dim('');
506
+ log.dim(` Severidade: ${severityColor(result.severity)}`);
507
+ log.dim(` Migração ID: ${result.migrationId}`);
508
+ log.dim(` Status: ${result.status}`);
509
+ if (result.severity === 'destrutiva' && !opts.dryRun) {
510
+ log.warn('Esta migração é destrutiva e requer confirmação antes de ser aplicada.');
511
+ log.dim(` Execute: neetru db migrations confirm ${result.migrationId} --mfa <token>`);
512
+ }
513
+ else if (!opts.dryRun) {
514
+ log.success(`Migração ${chalk.bold(result.migrationId)} enviada ao Core (${result.status}).`);
515
+ }
516
+ }
517
+ export async function runDbMigrationsList(opts = {}) {
518
+ log.banner();
519
+ const qs = opts.dbId ? `?dbId=${encodeURIComponent(opts.dbId)}` : '';
520
+ const spinner = ora({ text: 'Buscando migrações…', color: 'blue' }).start();
521
+ let resp;
522
+ try {
523
+ resp = await apiRequest(`/api/cli/v1/db/migrations${qs}`);
524
+ spinner.stop();
525
+ }
526
+ catch (err) {
527
+ spinner.fail('Falha ao buscar migrações.');
528
+ handleApiError(err);
529
+ }
530
+ if (opts.json) {
531
+ console.log(JSON.stringify(resp, null, 2));
532
+ return;
533
+ }
534
+ log.heading(`Migrações (${resp.count})`);
535
+ renderTable([
536
+ { header: 'ID', value: (r) => r.id, maxWidth: 28 },
537
+ { header: 'Banco', value: (r) => r.dbId ?? '—', maxWidth: 20 },
538
+ { header: 'Status', value: (r) => r.status, maxWidth: 20 },
539
+ { header: 'Severidade', value: (r) => severityColor(r.severity), maxWidth: 14 },
540
+ { header: 'Aplicado', value: (r) => fmtTimestamp(r.appliedAt ?? r.createdAt), maxWidth: 18 },
541
+ ], resp.migrations);
542
+ }
543
+ export async function runDbMigrationsConfirm(migrationId, opts) {
544
+ log.banner();
545
+ if (!migrationId) {
546
+ log.error('<migrationId> é obrigatório.');
547
+ process.exit(1);
548
+ return;
549
+ }
550
+ if (!opts.mfa) {
551
+ log.error('--mfa <token> é obrigatório para confirmar migrações destrutivas.');
552
+ process.exit(1);
553
+ return;
554
+ }
555
+ const spinner = ora({ text: `Confirmando migração ${migrationId}…`, color: 'yellow' }).start();
556
+ let resp;
557
+ try {
558
+ resp = await apiRequest(`/api/cli/v1/db/migrations/${encodeURIComponent(migrationId)}/confirm`, {
559
+ method: 'POST',
560
+ headers: { 'X-Neetru-MFA-Token': opts.mfa },
561
+ body: { migrationId },
180
562
  });
181
- child.on('error', (err) => {
182
- log.error(`Falha ao executar: ${err.message}`);
183
- process.exit(7);
563
+ spinner.succeed(`Migração ${chalk.bold(resp.migrationId)} confirmada — status: ${resp.status}`);
564
+ }
565
+ catch (err) {
566
+ spinner.fail('Falha ao confirmar migração.');
567
+ handleApiError(err);
568
+ }
569
+ if (opts.json) {
570
+ console.log(JSON.stringify(resp, null, 2));
571
+ }
572
+ }
573
+ /**
574
+ * Lista os backups disponíveis de um banco específico.
575
+ *
576
+ * Endpoint: GET /api/cli/v1/db/[id]/backups
577
+ * Rota implementada em `src/app/api/cli/v1/db/[id]/backups/route.ts`.
578
+ */
579
+ export async function runDbBackups(dbId, opts = {}) {
580
+ log.banner();
581
+ if (!dbId) {
582
+ log.error('<dbId> é obrigatório.');
583
+ process.exit(1);
584
+ return;
585
+ }
586
+ const spinner = ora({ text: `Buscando backups do banco ${dbId}…`, color: 'blue' }).start();
587
+ let resp;
588
+ try {
589
+ resp = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/backups`);
590
+ spinner.stop();
591
+ }
592
+ catch (err) {
593
+ spinner.fail('Falha ao buscar backups.');
594
+ handleApiError(err);
595
+ }
596
+ if (opts.json) {
597
+ console.log(JSON.stringify(resp, null, 2));
598
+ return;
599
+ }
600
+ log.heading(`Backups do banco ${chalk.bold(dbId)} (${resp.count})`);
601
+ renderTable([
602
+ { header: 'ID', value: (r) => r.id, maxWidth: 28 },
603
+ { header: 'Status', value: (r) => statusColor(r.status), maxWidth: 16 },
604
+ { header: 'Data/Hora', value: (r) => fmtTimestamp(r.timestamp), maxWidth: 22 },
605
+ { header: 'Tamanho (MB)', value: (r) => r.sizeMb != null ? String(r.sizeMb) : '—', maxWidth: 14 },
606
+ { header: 'GCS Path', value: (r) => r.gcsPath ?? '—', maxWidth: 60 },
607
+ ], resp.backups);
608
+ }
609
+ /**
610
+ * Dispara um restore de banco a partir de um backup selecionado interativamente.
611
+ *
612
+ * Fluxo:
613
+ * 1. Lista backups disponíveis via GET /api/cli/v1/db/[id]/backups
614
+ * 2. Operador escolhe qual backup restaurar
615
+ * 3. Exige confirmação textual "RESTAURAR" (operação destrutiva)
616
+ * 4. POST /api/cli/v1/db/[id]/restore com MFA step-up
617
+ *
618
+ * Endpoints implementados em:
619
+ * GET src/app/api/cli/v1/db/[id]/backups/route.ts
620
+ * POST src/app/api/cli/v1/db/[id]/restore/route.ts
621
+ */
622
+ export async function runDbRestore(dbId, opts) {
623
+ log.banner();
624
+ if (!dbId) {
625
+ log.error('<dbId> é obrigatório.');
626
+ process.exit(1);
627
+ return;
628
+ }
629
+ if (!opts.mfa) {
630
+ log.error('--mfa <token> é obrigatório para restaurar banco (operação destrutiva).');
631
+ process.exit(1);
632
+ return;
633
+ }
634
+ // 1. Lista backups disponíveis
635
+ const spinnerList = ora({ text: `Buscando backups do banco ${dbId}…`, color: 'blue' }).start();
636
+ let backupsResp;
637
+ try {
638
+ backupsResp = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/backups`);
639
+ spinnerList.stop();
640
+ }
641
+ catch (err) {
642
+ spinnerList.fail('Falha ao buscar backups.');
643
+ handleApiError(err);
644
+ }
645
+ if (backupsResp.count === 0 || backupsResp.backups.length === 0) {
646
+ log.warn('Nenhum backup disponível para este banco.');
647
+ process.exit(0);
648
+ return;
649
+ }
650
+ // 2. Operador escolhe backup
651
+ const { backupId } = await inquirer.prompt([
652
+ {
653
+ type: 'list',
654
+ name: 'backupId',
655
+ message: 'Escolha o backup para restaurar:',
656
+ choices: backupsResp.backups.map((b) => ({
657
+ name: `${b.id} ${fmtTimestamp(b.timestamp)} ${b.sizeMb != null ? b.sizeMb + ' MB' : ''} ${statusColor(b.status)}`,
658
+ value: b.id,
659
+ })),
660
+ },
661
+ ]);
662
+ const chosen = backupsResp.backups.find((b) => b.id === backupId);
663
+ log.warn('');
664
+ log.warn(`ATENÇÃO: Restore de banco é destrutivo — o banco ${chalk.bold(dbId)} será sobrescrito`);
665
+ log.warn(`pelo backup ${chalk.bold(backupId)} (${fmtTimestamp(chosen?.timestamp)}).`);
666
+ log.warn('Esta operação NÃO pode ser desfeita.');
667
+ log.warn('');
668
+ // 3. Confirmação textual obrigatória
669
+ const { confirmText } = await inquirer.prompt([
670
+ {
671
+ type: 'input',
672
+ name: 'confirmText',
673
+ message: `Digite ${chalk.bold('RESTAURAR')} para confirmar:`,
674
+ },
675
+ ]);
676
+ if (confirmText !== 'RESTAURAR') {
677
+ log.dim('Operação cancelada.');
678
+ return;
679
+ }
680
+ // 4. POST restore com MFA step-up
681
+ const spinnerRestore = ora({ text: 'Disparando restore…', color: 'yellow' }).start();
682
+ let resp;
683
+ try {
684
+ resp = await apiRequest(`/api/cli/v1/db/${encodeURIComponent(dbId)}/restore`, {
685
+ method: 'POST',
686
+ headers: { 'X-Neetru-MFA-Token': opts.mfa },
687
+ body: { backupId, dbId },
184
688
  });
185
- });
689
+ spinnerRestore.succeed(`Restore iniciado — job ${chalk.bold(resp.jobId ?? '—')} · status: ${resp.status}`);
690
+ }
691
+ catch (err) {
692
+ spinnerRestore.fail('Falha ao iniciar restore.');
693
+ handleApiError(err);
694
+ }
695
+ if (opts.json) {
696
+ console.log(JSON.stringify(resp, null, 2));
697
+ }
698
+ }
699
+ function dbDensityLabel(databases) {
700
+ if (!databases || databases.length === 0)
701
+ return chalk.dim('(vazio)');
702
+ const names = databases.map((d) => d.label ?? d.id).join(', ');
703
+ const label = `${databases.length} banco${databases.length > 1 ? 's' : ''}`;
704
+ return `${chalk.bold(String(databases.length))} — ${chalk.dim(names.slice(0, 50))}${names.length > 50 ? '…' : ''}`;
705
+ }
706
+ function ramLabel(total, available) {
707
+ if (total == null)
708
+ return '—';
709
+ if (available == null)
710
+ return `${total} MB`;
711
+ return `${available}/${total} MB livre`;
712
+ }
713
+ /**
714
+ * Lista as VMs host e quantos bancos de produto cada uma hospeda.
715
+ *
716
+ * Usa o endpoint de servers com `?capacity=true&db=true` para incluir
717
+ * o inventário de bancos por servidor. O Core agrega o campo `databases`
718
+ * quando `db=true` é passado.
719
+ *
720
+ * Endpoint: GET /api/cli/v1/servers?capacity=true&db=true
721
+ *
722
+ * NOTA: O parâmetro `db=true` (que instrui o Core a enriquecer cada server
723
+ * com o array `databases`) não está implementado ainda no endpoint de servers
724
+ * do Core (GAP: parâmetro db=true em /api/cli/v1/servers ausente). O comando
725
+ * funciona com a resposta atual — apenas o campo `databases` por server
726
+ * ficará ausente/vazio até o Core implementar o enriquecimento.
727
+ */
728
+ export async function runDbHosts(opts = {}) {
729
+ log.banner();
730
+ const spinner = ora({ text: 'Buscando VM hosts e densidade de bancos…', color: 'blue' }).start();
731
+ let resp;
732
+ try {
733
+ resp = await apiRequest('/api/cli/v1/servers?capacity=true&db=true');
734
+ spinner.stop();
735
+ }
736
+ catch (err) {
737
+ spinner.fail('Falha ao buscar hosts.');
738
+ handleApiError(err);
739
+ }
740
+ if (opts.json) {
741
+ console.log(JSON.stringify(resp, null, 2));
742
+ return;
743
+ }
744
+ log.heading(`VM Hosts de Banco (${resp.count})`);
745
+ renderTable([
746
+ { header: 'ID', value: (r) => r.id, maxWidth: 20 },
747
+ { header: 'Nome', value: (r) => r.name ?? '—', maxWidth: 22 },
748
+ { header: 'Região', value: (r) => r.region ?? '—', maxWidth: 14 },
749
+ { header: 'Status', value: (r) => statusColor(r.status ?? '—'), maxWidth: 14 },
750
+ { header: 'RAM (livre/total)', value: (r) => ramLabel(r.totalRamMb, r.availableRamMb), maxWidth: 22 },
751
+ { header: 'Bancos', value: (r) => dbDensityLabel(r.databases), maxWidth: 50 },
752
+ ], resp.servers);
186
753
  }
754
+ // ── D-3: runDbMigrate e runDbSeed removidos (legado Sprint 10) ──────────────
755
+ // Esses comandos operavam via schema.manifest.json (Sprint 10) e foram cortados
756
+ // junto com o branch legado do `db init`. O fluxo canônico é:
757
+ // neetru db init → neetru db apply → neetru db migrations confirm (se destrutiva)
187
758
  //# sourceMappingURL=db.js.map