@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.
- package/CHANGELOG.md +316 -220
- 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/bug.d.ts +87 -0
- package/dist/commands/bug.js +419 -0
- package/dist/commands/bug.js.map +1 -0
- package/dist/commands/customers.d.ts +17 -0
- package/dist/commands/customers.js +160 -0
- package/dist/commands/customers.js.map +1 -0
- package/dist/commands/db.d.ts +91 -7
- package/dist/commands/db.js +898 -123
- 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/docs.d.ts +4 -0
- package/dist/commands/docs.js +99 -7
- package/dist/commands/docs.js.map +1 -1
- package/dist/commands/doctor.js +4 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.js +121 -121
- package/dist/commands/marketplace.d.ts +36 -0
- package/dist/commands/marketplace.js +584 -0
- package/dist/commands/marketplace.js.map +1 -0
- package/dist/commands/new.d.ts +6 -0
- package/dist/commands/new.js +220 -40
- package/dist/commands/new.js.map +1 -1
- package/dist/commands/open.d.ts +8 -0
- package/dist/commands/open.js +61 -13
- package/dist/commands/open.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/products.d.ts +23 -0
- package/dist/commands/products.js +39 -1
- package/dist/commands/products.js.map +1 -1
- package/dist/commands/tenants.js +15 -0
- package/dist/commands/tenants.js.map +1 -1
- package/dist/commands/ui.d.ts +1 -1
- package/dist/commands/ui.js +172 -2
- package/dist/commands/ui.js.map +1 -1
- package/dist/commands/workspaces.d.ts +10 -1
- package/dist/commands/workspaces.js +136 -22
- package/dist/commands/workspaces.js.map +1 -1
- package/dist/index.js +532 -44
- package/dist/index.js.map +1 -1
- package/dist/lib/ai/context.js +90 -90
- package/dist/lib/config-schema.d.ts +8 -8
- 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/dist/lib/pickers.d.ts +12 -0
- package/dist/lib/pickers.js +34 -0
- package/dist/lib/pickers.js.map +1 -1
- package/package.json +66 -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,962 @@
|
|
|
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
|
+
// 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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
log.
|
|
71
|
-
log.dim(
|
|
72
|
-
log.dim(
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
+
registerResp = await apiRequest('/api/cli/v1/db/register', {
|
|
105
403
|
method: 'POST',
|
|
106
|
-
body: {
|
|
404
|
+
body: {
|
|
405
|
+
productId,
|
|
406
|
+
label: dbName,
|
|
407
|
+
engine,
|
|
408
|
+
provisioning: { type: engine.startsWith('vm-') ? 'vm' : 'managed' },
|
|
409
|
+
},
|
|
107
410
|
});
|
|
108
|
-
|
|
109
|
-
log.dim(` v${r.fromVersion} → v${r.toVersion}`);
|
|
411
|
+
registerSpinner?.succeed('Banco registrado no Core.');
|
|
110
412
|
}
|
|
111
413
|
catch (err) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
512
|
+
else {
|
|
513
|
+
log.error(msg);
|
|
514
|
+
}
|
|
515
|
+
process.exit(1);
|
|
516
|
+
return;
|
|
146
517
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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 (
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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(`
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|