@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.
- package/CHANGELOG.md +220 -208
- package/README.md +137 -137
- package/dist/cli-kit/format.d.ts +49 -0
- package/dist/cli-kit/format.js +88 -0
- package/dist/cli-kit/format.js.map +1 -0
- package/dist/cli-kit/glyphs.d.ts +22 -0
- package/dist/cli-kit/glyphs.js +22 -0
- package/dist/cli-kit/glyphs.js.map +1 -0
- package/dist/cli-kit/index.d.ts +13 -0
- package/dist/cli-kit/index.js +12 -0
- package/dist/cli-kit/index.js.map +1 -0
- package/dist/cli-kit/palette.d.ts +10 -0
- package/dist/cli-kit/palette.js +36 -0
- package/dist/cli-kit/palette.js.map +1 -0
- package/dist/commands/ai.js +8 -8
- package/dist/commands/autocomplete.js +34 -34
- package/dist/commands/db.d.ts +87 -7
- package/dist/commands/db.js +697 -126
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/deploy.d.ts +5 -0
- package/dist/commands/deploy.js +68 -0
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.d.ts +68 -0
- package/dist/commands/dev.js +345 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/init.js +121 -121
- package/dist/commands/new.d.ts +6 -0
- package/dist/commands/new.js +31 -10
- package/dist/commands/new.js.map +1 -1
- package/dist/commands/products-db.d.ts +1 -1
- package/dist/commands/products-db.js +17 -4
- package/dist/commands/products-db.js.map +1 -1
- package/dist/commands/upgrade.js +5 -2
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/index.js +258 -42
- package/dist/index.js.map +1 -1
- package/dist/lib/ai/context.js +90 -90
- package/dist/lib/db-local/db-json.d.ts +63 -0
- package/dist/lib/db-local/db-json.js +189 -0
- package/dist/lib/db-local/db-json.js.map +1 -0
- package/dist/lib/db-local/env.d.ts +26 -0
- package/dist/lib/db-local/env.js +64 -0
- package/dist/lib/db-local/env.js.map +1 -0
- package/dist/lib/db-local/fingerprint.d.ts +8 -0
- package/dist/lib/db-local/fingerprint.js +28 -0
- package/dist/lib/db-local/fingerprint.js.map +1 -0
- package/dist/lib/db-local/index.d.ts +15 -0
- package/dist/lib/db-local/index.js +14 -0
- package/dist/lib/db-local/index.js.map +1 -0
- package/dist/lib/db-pipeline/build-deps.d.ts +14 -0
- package/dist/lib/db-pipeline/build-deps.js +158 -0
- package/dist/lib/db-pipeline/build-deps.js.map +1 -0
- package/dist/lib/db-pipeline/errors.d.ts +29 -0
- package/dist/lib/db-pipeline/errors.js +29 -0
- package/dist/lib/db-pipeline/errors.js.map +1 -0
- package/dist/lib/db-pipeline/index.d.ts +26 -0
- package/dist/lib/db-pipeline/index.js +25 -0
- package/dist/lib/db-pipeline/index.js.map +1 -0
- package/dist/lib/db-pipeline/pipeline.d.ts +13 -0
- package/dist/lib/db-pipeline/pipeline.js +119 -0
- package/dist/lib/db-pipeline/pipeline.js.map +1 -0
- package/dist/lib/db-pipeline/rehearse.d.ts +99 -0
- package/dist/lib/db-pipeline/rehearse.js +219 -0
- package/dist/lib/db-pipeline/rehearse.js.map +1 -0
- package/dist/lib/db-pipeline/types.d.ts +112 -0
- package/dist/lib/db-pipeline/types.js +20 -0
- package/dist/lib/db-pipeline/types.js.map +1 -0
- package/package.json +63 -62
- package/templates/auth/callback.ts +22 -22
- package/templates/auth/sign-in.tsx +41 -41
- package/templates/billing/checkout.ts +22 -22
- package/templates/billing/page.tsx +43 -43
- package/templates/support/ticket-form.tsx +68 -68
- package/templates/usage/track.ts +30 -30
- package/templates/users/profile.tsx +43 -43
package/dist/commands/db.js
CHANGED
|
@@ -1,187 +1,758 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `neetru db` —
|
|
2
|
+
* `neetru db` — árvore de comandos de banco de dados por produto (M1).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
63
|
+
for (const name of CONFIG_FILES) {
|
|
23
64
|
const filePath = path.resolve(process.cwd(), name);
|
|
24
|
-
if (fsSync.existsSync(filePath))
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
const
|
|
55
|
-
if (
|
|
56
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
log.
|
|
71
|
-
log.dim(
|
|
72
|
-
log.dim(
|
|
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
|
-
|
|
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
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
+
registerResp = await apiRequest('/api/cli/v1/db/register', {
|
|
105
322
|
method: 'POST',
|
|
106
|
-
body: {
|
|
323
|
+
body: {
|
|
324
|
+
productId,
|
|
325
|
+
label: dbName,
|
|
326
|
+
engine,
|
|
327
|
+
provisioning: { type: engine.startsWith('vm-') ? 'vm' : 'managed' },
|
|
328
|
+
},
|
|
107
329
|
});
|
|
108
|
-
|
|
109
|
-
log.dim(` v${r.fromVersion} → v${r.toVersion}`);
|
|
330
|
+
registerSpinner.succeed('Banco registrado no Core.');
|
|
110
331
|
}
|
|
111
332
|
catch (err) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
export async function runDbSeed(opts = {}) {
|
|
365
|
+
export async function runDbApply(opts = {}) {
|
|
128
366
|
log.banner();
|
|
129
|
-
log.heading('neetru db
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
392
|
+
dbEntry = found;
|
|
146
393
|
}
|
|
147
|
-
else {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|