prisma-flow 0.1.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/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3737 -0
- package/package.json +69 -0
- package/public/404/index.html +1 -0
- package/public/404.html +1 -0
- package/public/__next.__PAGE__.txt +9 -0
- package/public/__next._full.txt +19 -0
- package/public/__next._head.txt +6 -0
- package/public/__next._index.txt +6 -0
- package/public/__next._tree.txt +2 -0
- package/public/_next/static/chunks/031d4hdncmqdj.js +1 -0
- package/public/_next/static/chunks/03q3r_bvmqrr8.js +2 -0
- package/public/_next/static/chunks/05-c3ty_6dwfk.js +1 -0
- package/public/_next/static/chunks/0_8y92xg60ach.js +31 -0
- package/public/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- package/public/_next/static/chunks/0drl6u3r7627z.js +1 -0
- package/public/_next/static/chunks/0m7w4t_yx6o6a.js +1 -0
- package/public/_next/static/chunks/0ov8yxc8yngji.js +1 -0
- package/public/_next/static/chunks/0p4y8c2fq43wu.js +1 -0
- package/public/_next/static/chunks/0s76k7hq9-wfk.css +1 -0
- package/public/_next/static/chunks/0zv5uyi-a4e2y.js +1 -0
- package/public/_next/static/chunks/1kk4shlfeass1.js +1 -0
- package/public/_next/static/chunks/2nykiepra7i1k.js +1 -0
- package/public/_next/static/chunks/2o1jw19xgqkzz.js +5 -0
- package/public/_next/static/chunks/2ohti557qol0g.js +1 -0
- package/public/_next/static/chunks/3cdvjzy78ylzj.js +1 -0
- package/public/_next/static/chunks/3w1qpcrpp5s4-.js +2 -0
- package/public/_next/static/chunks/3xrarg7xfb7_g.js +1 -0
- package/public/_next/static/chunks/turbopack-0r_p3gsi1hu9t.js +1 -0
- package/public/_next/static/media/favicon.2vob68tjqpejf.ico +0 -0
- package/public/_next/static/ySSMgkts9h6_mRnScxk9f/_buildManifest.js +11 -0
- package/public/_next/static/ySSMgkts9h6_mRnScxk9f/_clientMiddlewareManifest.js +1 -0
- package/public/_next/static/ySSMgkts9h6_mRnScxk9f/_ssgManifest.js +1 -0
- package/public/_not-found/__next._full.txt +17 -0
- package/public/_not-found/__next._head.txt +6 -0
- package/public/_not-found/__next._index.txt +6 -0
- package/public/_not-found/__next._not-found/__PAGE__.txt +5 -0
- package/public/_not-found/__next._not-found.txt +5 -0
- package/public/_not-found/__next._tree.txt +2 -0
- package/public/_not-found/index.html +1 -0
- package/public/_not-found/index.txt +17 -0
- package/public/drift/__next._full.txt +21 -0
- package/public/drift/__next._head.txt +6 -0
- package/public/drift/__next._index.txt +6 -0
- package/public/drift/__next._tree.txt +2 -0
- package/public/drift/__next.drift/__PAGE__.txt +9 -0
- package/public/drift/__next.drift.txt +5 -0
- package/public/drift/index.html +1 -0
- package/public/drift/index.txt +21 -0
- package/public/favicon.ico +0 -0
- package/public/index.html +1 -0
- package/public/index.txt +19 -0
- package/public/migrations/__next._full.txt +21 -0
- package/public/migrations/__next._head.txt +6 -0
- package/public/migrations/__next._index.txt +6 -0
- package/public/migrations/__next._tree.txt +2 -0
- package/public/migrations/__next.migrations/__PAGE__.txt +9 -0
- package/public/migrations/__next.migrations.txt +5 -0
- package/public/migrations/index.html +1 -0
- package/public/migrations/index.txt +21 -0
- package/public/risks/__next._full.txt +21 -0
- package/public/risks/__next._head.txt +6 -0
- package/public/risks/__next._index.txt +6 -0
- package/public/risks/__next._tree.txt +2 -0
- package/public/risks/__next.risks/__PAGE__.txt +9 -0
- package/public/risks/__next.risks.txt +5 -0
- package/public/risks/index.html +1 -0
- package/public/risks/index.txt +21 -0
- package/public/schema/__next._full.txt +21 -0
- package/public/schema/__next._head.txt +6 -0
- package/public/schema/__next._index.txt +6 -0
- package/public/schema/__next._tree.txt +2 -0
- package/public/schema/__next.schema/__PAGE__.txt +9 -0
- package/public/schema/__next.schema.txt +5 -0
- package/public/schema/index.html +1 -0
- package/public/schema/index.txt +21 -0
- package/public/simulate/__next._full.txt +21 -0
- package/public/simulate/__next._head.txt +6 -0
- package/public/simulate/__next._index.txt +6 -0
- package/public/simulate/__next._tree.txt +2 -0
- package/public/simulate/__next.simulate/__PAGE__.txt +9 -0
- package/public/simulate/__next.simulate.txt +5 -0
- package/public/simulate/index.html +1 -0
- package/public/simulate/index.txt +21 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3737 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command as Command14 } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/check.ts
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
|
|
10
|
+
// src/core/audit.ts
|
|
11
|
+
import fs from "fs/promises";
|
|
12
|
+
import path from "path";
|
|
13
|
+
|
|
14
|
+
// src/logger.ts
|
|
15
|
+
import { createRequire } from "module";
|
|
16
|
+
function makeConsoleLogger(level) {
|
|
17
|
+
const levels = {
|
|
18
|
+
trace: 10,
|
|
19
|
+
debug: 20,
|
|
20
|
+
info: 30,
|
|
21
|
+
warn: 40,
|
|
22
|
+
error: 50
|
|
23
|
+
};
|
|
24
|
+
const activeLevel = levels[level] ?? 30;
|
|
25
|
+
function write(severity, lvl, args) {
|
|
26
|
+
if (lvl < activeLevel) return;
|
|
27
|
+
const obj = typeof args[0] === "object" && args[0] !== null ? args[0] : {};
|
|
28
|
+
const msg = typeof args[0] === "string" ? args[0] : args[1] ?? "";
|
|
29
|
+
const line = JSON.stringify({
|
|
30
|
+
time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
31
|
+
level: severity,
|
|
32
|
+
...obj,
|
|
33
|
+
msg
|
|
34
|
+
});
|
|
35
|
+
if (lvl >= 40) process.stderr.write(`${line}
|
|
36
|
+
`);
|
|
37
|
+
else process.stdout.write(`${line}
|
|
38
|
+
`);
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
trace: (...a) => write("TRACE", 10, a),
|
|
42
|
+
debug: (...a) => write("DEBUG", 20, a),
|
|
43
|
+
info: (...a) => write("INFO", 30, a),
|
|
44
|
+
warn: (...a) => write("WARN", 40, a),
|
|
45
|
+
error: (...a) => write("ERROR", 50, a),
|
|
46
|
+
child: (_bindings) => makeConsoleLogger(level)
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function buildLogger() {
|
|
50
|
+
const level = (process.env.PRISMAFLOW_LOG_LEVEL ?? "info").toLowerCase();
|
|
51
|
+
const pretty = process.env.NODE_ENV !== "production";
|
|
52
|
+
try {
|
|
53
|
+
const req = createRequire(import.meta.url);
|
|
54
|
+
const pino = req("pino");
|
|
55
|
+
if (pretty) {
|
|
56
|
+
try {
|
|
57
|
+
const transport = pino.transport({
|
|
58
|
+
target: "pino-pretty",
|
|
59
|
+
options: {
|
|
60
|
+
colorize: true,
|
|
61
|
+
translateTime: "SYS:standard",
|
|
62
|
+
ignore: "pid,hostname"
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
return pino({ level }, transport);
|
|
66
|
+
} catch {
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return pino({ level });
|
|
70
|
+
} catch {
|
|
71
|
+
return makeConsoleLogger(level);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
var logger = buildLogger();
|
|
75
|
+
|
|
76
|
+
// src/core/audit.ts
|
|
77
|
+
var AUDIT_DIR = ".prismaflow";
|
|
78
|
+
var AUDIT_FILE = "audit.jsonl";
|
|
79
|
+
var AUDIT_ROTATED = "audit.1.jsonl";
|
|
80
|
+
var DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
|
|
81
|
+
async function ensureAuditDir(cwd) {
|
|
82
|
+
const dir = path.join(cwd, AUDIT_DIR);
|
|
83
|
+
await fs.mkdir(dir, { recursive: true });
|
|
84
|
+
return path.join(dir, AUDIT_FILE);
|
|
85
|
+
}
|
|
86
|
+
async function rotateIfNeeded(filePath, maxBytes) {
|
|
87
|
+
try {
|
|
88
|
+
const stat = await fs.stat(filePath);
|
|
89
|
+
if (stat.size >= maxBytes) {
|
|
90
|
+
const rotated = path.join(path.dirname(filePath), AUDIT_ROTATED);
|
|
91
|
+
await fs.rename(filePath, rotated);
|
|
92
|
+
logger.debug({ filePath, rotated }, "Audit log rotated");
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function writeAuditEntry(cwd, action, result, detail, maxMb = DEFAULT_MAX_BYTES / (1024 * 1024)) {
|
|
98
|
+
try {
|
|
99
|
+
const filePath = await ensureAuditDir(cwd);
|
|
100
|
+
await rotateIfNeeded(filePath, maxMb * 1024 * 1024);
|
|
101
|
+
const entry = {
|
|
102
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
103
|
+
action,
|
|
104
|
+
cwd,
|
|
105
|
+
result,
|
|
106
|
+
...detail !== void 0 ? { detail } : {}
|
|
107
|
+
};
|
|
108
|
+
await fs.appendFile(filePath, `${JSON.stringify(entry)}
|
|
109
|
+
`, "utf-8");
|
|
110
|
+
} catch (err) {
|
|
111
|
+
logger.warn({ err }, "Audit log write failed (non-fatal)");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/core/migration-analyzer.ts
|
|
116
|
+
import fs4 from "fs/promises";
|
|
117
|
+
|
|
118
|
+
// src/core/prisma-cli.ts
|
|
119
|
+
import { execFile } from "child_process";
|
|
120
|
+
import fs2 from "fs/promises";
|
|
121
|
+
import path2 from "path";
|
|
122
|
+
import { promisify } from "util";
|
|
123
|
+
var execFileAsync = promisify(execFile);
|
|
124
|
+
async function fileExists(filePath) {
|
|
125
|
+
try {
|
|
126
|
+
await fs2.access(filePath);
|
|
127
|
+
return true;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function resolveLocalPrismaCli(cwd) {
|
|
133
|
+
let current = path2.resolve(cwd);
|
|
134
|
+
while (true) {
|
|
135
|
+
const candidate = path2.join(current, "node_modules", "prisma", "build", "index.js");
|
|
136
|
+
if (await fileExists(candidate)) return candidate;
|
|
137
|
+
const parent = path2.dirname(current);
|
|
138
|
+
if (parent === current) return null;
|
|
139
|
+
current = parent;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function resolveBundledNpxCli() {
|
|
143
|
+
const candidate = path2.join(
|
|
144
|
+
path2.dirname(process.execPath),
|
|
145
|
+
"node_modules",
|
|
146
|
+
"npm",
|
|
147
|
+
"bin",
|
|
148
|
+
"npx-cli.js"
|
|
149
|
+
);
|
|
150
|
+
return await fileExists(candidate) ? candidate : null;
|
|
151
|
+
}
|
|
152
|
+
async function execPrisma(cwd, args, options = {}) {
|
|
153
|
+
const localCli = await resolveLocalPrismaCli(cwd);
|
|
154
|
+
if (localCli) {
|
|
155
|
+
return execFileAsync(process.execPath, [localCli, ...args], {
|
|
156
|
+
cwd,
|
|
157
|
+
env: options.env,
|
|
158
|
+
timeout: options.timeout
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (process.platform === "win32") {
|
|
162
|
+
const npxCli = await resolveBundledNpxCli();
|
|
163
|
+
if (npxCli) {
|
|
164
|
+
return execFileAsync(process.execPath, [npxCli, "prisma", ...args], {
|
|
165
|
+
cwd,
|
|
166
|
+
env: options.env,
|
|
167
|
+
timeout: options.timeout
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const command = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
172
|
+
return execFileAsync(command, ["prisma", ...args], {
|
|
173
|
+
cwd,
|
|
174
|
+
env: options.env,
|
|
175
|
+
timeout: options.timeout
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/core/prisma-detector.ts
|
|
180
|
+
import fs3 from "fs/promises";
|
|
181
|
+
import path3 from "path";
|
|
182
|
+
import dotenv from "dotenv";
|
|
183
|
+
function detectProviderFromSchema(schemaContent) {
|
|
184
|
+
const match = schemaContent.match(/datasource\s+\w+\s*\{[^}]*provider\s*=\s*"([^"]+)"/s);
|
|
185
|
+
if (!match) return null;
|
|
186
|
+
const raw = match[1]?.toLowerCase();
|
|
187
|
+
const providerMap = {
|
|
188
|
+
postgresql: "postgresql",
|
|
189
|
+
postgres: "postgresql",
|
|
190
|
+
mysql: "mysql",
|
|
191
|
+
sqlite: "sqlite",
|
|
192
|
+
sqlserver: "sqlserver",
|
|
193
|
+
mongodb: "mongodb"
|
|
194
|
+
};
|
|
195
|
+
return providerMap[raw ?? ""] ?? null;
|
|
196
|
+
}
|
|
197
|
+
async function tryAccess(p) {
|
|
198
|
+
try {
|
|
199
|
+
await fs3.access(p);
|
|
200
|
+
return true;
|
|
201
|
+
} catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function findSchemaPath(cwd) {
|
|
206
|
+
const candidates = [path3.join(cwd, "prisma", "schema.prisma"), path3.join(cwd, "schema.prisma")];
|
|
207
|
+
for (const p of candidates) {
|
|
208
|
+
if (await tryAccess(p)) return p;
|
|
209
|
+
}
|
|
210
|
+
const schemaDir = path3.join(cwd, "prisma", "schema");
|
|
211
|
+
if (await tryAccess(schemaDir)) {
|
|
212
|
+
const entries = await fs3.readdir(schemaDir).catch(() => []);
|
|
213
|
+
const prismaFiles = entries.filter((e) => e.endsWith(".prisma"));
|
|
214
|
+
if (prismaFiles.length > 0) {
|
|
215
|
+
return schemaDir;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
async function readSchemaContent(schemaPath) {
|
|
221
|
+
try {
|
|
222
|
+
const stat = await fs3.stat(schemaPath);
|
|
223
|
+
if (stat.isDirectory()) {
|
|
224
|
+
const files = (await fs3.readdir(schemaPath)).filter((f) => f.endsWith(".prisma")).sort();
|
|
225
|
+
const parts = await Promise.all(
|
|
226
|
+
files.map((f) => fs3.readFile(path3.join(schemaPath, f), "utf-8"))
|
|
227
|
+
);
|
|
228
|
+
return parts.join("\n");
|
|
229
|
+
}
|
|
230
|
+
return fs3.readFile(schemaPath, "utf-8");
|
|
231
|
+
} catch {
|
|
232
|
+
return "";
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
async function detectPackageManager(cwd) {
|
|
236
|
+
const lockfiles = [
|
|
237
|
+
{ file: "package-lock.json", manager: "npm" },
|
|
238
|
+
{ file: "pnpm-lock.yaml", manager: "pnpm" },
|
|
239
|
+
{ file: "yarn.lock", manager: "yarn" },
|
|
240
|
+
{ file: "bun.lockb", manager: "bun" }
|
|
241
|
+
];
|
|
242
|
+
for (const { file, manager } of lockfiles) {
|
|
243
|
+
if (await tryAccess(path3.join(cwd, file))) return manager;
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
async function detectPrismaVersion(cwd) {
|
|
248
|
+
try {
|
|
249
|
+
const content = await fs3.readFile(path3.join(cwd, "package.json"), "utf-8");
|
|
250
|
+
const pkg = JSON.parse(content);
|
|
251
|
+
return pkg.dependencies?.prisma ?? pkg.devDependencies?.prisma ?? null;
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async function detectPrismaProject(cwd) {
|
|
257
|
+
const schemaPath = await findSchemaPath(cwd);
|
|
258
|
+
if (!schemaPath) return null;
|
|
259
|
+
const schemaContent = await readSchemaContent(schemaPath);
|
|
260
|
+
let databaseUrl = "";
|
|
261
|
+
for (const envFile of [path3.join(cwd, ".env"), path3.join(cwd, "prisma", ".env")]) {
|
|
262
|
+
try {
|
|
263
|
+
const envContent = await fs3.readFile(envFile, "utf-8");
|
|
264
|
+
const parsed = dotenv.parse(envContent);
|
|
265
|
+
if (parsed.DATABASE_URL) {
|
|
266
|
+
databaseUrl = parsed.DATABASE_URL;
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const schemaDir = await fs3.stat(schemaPath).then((s) => s.isDirectory()).catch(() => false) ? schemaPath : path3.dirname(schemaPath);
|
|
273
|
+
const migrationsPath = path3.join(schemaDir, "migrations");
|
|
274
|
+
const migrations = [];
|
|
275
|
+
try {
|
|
276
|
+
const entries = await fs3.readdir(migrationsPath, { withFileTypes: true });
|
|
277
|
+
for (const entry of entries) {
|
|
278
|
+
if (!entry.isDirectory() || entry.name === "migration_lock.toml") continue;
|
|
279
|
+
const migrationDir = path3.join(migrationsPath, entry.name);
|
|
280
|
+
const sqlPath = path3.join(migrationDir, "migration.sql");
|
|
281
|
+
if (!await tryAccess(sqlPath)) continue;
|
|
282
|
+
const timestampStr = entry.name.slice(0, 14);
|
|
283
|
+
const year = Number.parseInt(timestampStr.slice(0, 4), 10);
|
|
284
|
+
const month = Number.parseInt(timestampStr.slice(4, 6), 10) - 1;
|
|
285
|
+
const day = Number.parseInt(timestampStr.slice(6, 8), 10);
|
|
286
|
+
const hour = Number.parseInt(timestampStr.slice(8, 10), 10);
|
|
287
|
+
const minute = Number.parseInt(timestampStr.slice(10, 12), 10);
|
|
288
|
+
const second = Number.parseInt(timestampStr.slice(12, 14), 10);
|
|
289
|
+
const timestamp = new Date(Date.UTC(year, month, day, hour, minute, second));
|
|
290
|
+
migrations.push({
|
|
291
|
+
name: entry.name,
|
|
292
|
+
timestamp: (Number.isNaN(timestamp.getTime()) ? /* @__PURE__ */ new Date() : timestamp).toISOString(),
|
|
293
|
+
createdAt: (Number.isNaN(timestamp.getTime()) ? /* @__PURE__ */ new Date() : timestamp).toISOString(),
|
|
294
|
+
status: "pending",
|
|
295
|
+
// overridden by getMigrations() after status check
|
|
296
|
+
sqlPath
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
} catch {
|
|
300
|
+
}
|
|
301
|
+
migrations.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
302
|
+
const provider = detectProviderFromSchema(schemaContent);
|
|
303
|
+
const [packageManager, prismaVersion] = await Promise.all([
|
|
304
|
+
detectPackageManager(cwd),
|
|
305
|
+
detectPrismaVersion(cwd)
|
|
306
|
+
]);
|
|
307
|
+
return {
|
|
308
|
+
schemaPath,
|
|
309
|
+
migrationsPath,
|
|
310
|
+
databaseUrl,
|
|
311
|
+
migrations,
|
|
312
|
+
schemaContent,
|
|
313
|
+
provider,
|
|
314
|
+
packageManager,
|
|
315
|
+
prismaVersion
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// src/core/drift-detector.ts
|
|
320
|
+
function classifyDriftSql(sql) {
|
|
321
|
+
const upper = sql.toUpperCase().trimStart();
|
|
322
|
+
if (upper.startsWith("CREATE TABLE")) return "table-missing";
|
|
323
|
+
if (upper.startsWith("DROP TABLE")) return "table-extra";
|
|
324
|
+
if (upper.startsWith("CREATE INDEX") || upper.startsWith("DROP INDEX")) return "index-change";
|
|
325
|
+
if (upper.startsWith("ALTER INDEX")) return "index-change";
|
|
326
|
+
if (upper.includes("CONSTRAINT")) return "constraint-change";
|
|
327
|
+
if (upper.startsWith("ALTER TABLE")) return "column-mismatch";
|
|
328
|
+
return "unknown";
|
|
329
|
+
}
|
|
330
|
+
function labelDriftType(type) {
|
|
331
|
+
const labels = {
|
|
332
|
+
"table-missing": "Table missing in database",
|
|
333
|
+
"table-extra": "Table exists in database but not in schema",
|
|
334
|
+
"column-mismatch": "Column or table structure mismatch",
|
|
335
|
+
"index-change": "Index difference detected",
|
|
336
|
+
"constraint-change": "Constraint difference detected",
|
|
337
|
+
unknown: "Unknown schema change",
|
|
338
|
+
missing_migration: "Migration applied to DB but missing from history",
|
|
339
|
+
extra_column: "Extra column present in DB not in schema",
|
|
340
|
+
extra_table: "Extra table present in DB not in schema",
|
|
341
|
+
modified_migration: "Migration SQL was modified after being applied"
|
|
342
|
+
};
|
|
343
|
+
return labels[type];
|
|
344
|
+
}
|
|
345
|
+
function parseSqlStatements(sql) {
|
|
346
|
+
const statements = [];
|
|
347
|
+
let current = "";
|
|
348
|
+
let inSingleQuote = false;
|
|
349
|
+
let inLineComment = false;
|
|
350
|
+
let inBlockComment = false;
|
|
351
|
+
let i = 0;
|
|
352
|
+
while (i < sql.length) {
|
|
353
|
+
const ch = sql[i];
|
|
354
|
+
const next = sql[i + 1] ?? "";
|
|
355
|
+
if (inLineComment) {
|
|
356
|
+
if (ch === "\n") inLineComment = false;
|
|
357
|
+
i++;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (inBlockComment) {
|
|
361
|
+
if (ch === "*" && next === "/") {
|
|
362
|
+
inBlockComment = false;
|
|
363
|
+
i += 2;
|
|
364
|
+
} else i++;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (!inSingleQuote && ch === "-" && next === "-") {
|
|
368
|
+
inLineComment = true;
|
|
369
|
+
i += 2;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (!inSingleQuote && ch === "/" && next === "*") {
|
|
373
|
+
inBlockComment = true;
|
|
374
|
+
i += 2;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (ch === "'" && !inBlockComment && !inLineComment) inSingleQuote = !inSingleQuote;
|
|
378
|
+
if (ch === ";" && !inSingleQuote) {
|
|
379
|
+
const trimmed2 = current.trim();
|
|
380
|
+
if (trimmed2.length > 0) statements.push(trimmed2);
|
|
381
|
+
current = "";
|
|
382
|
+
i++;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
current += ch;
|
|
386
|
+
i++;
|
|
387
|
+
}
|
|
388
|
+
const trimmed = current.trim();
|
|
389
|
+
if (trimmed.length > 0) statements.push(trimmed);
|
|
390
|
+
return statements;
|
|
391
|
+
}
|
|
392
|
+
async function detectDrift(cwd) {
|
|
393
|
+
const project = await detectPrismaProject(cwd);
|
|
394
|
+
if (!project) {
|
|
395
|
+
return {
|
|
396
|
+
items: [],
|
|
397
|
+
status: "error",
|
|
398
|
+
errorMessage: "No Prisma project found"
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
const { stdout } = await execPrisma(
|
|
403
|
+
cwd,
|
|
404
|
+
[
|
|
405
|
+
"migrate",
|
|
406
|
+
"diff",
|
|
407
|
+
"--from-schema-datamodel",
|
|
408
|
+
project.schemaPath,
|
|
409
|
+
"--to-schema-datasource",
|
|
410
|
+
project.schemaPath,
|
|
411
|
+
"--script"
|
|
412
|
+
],
|
|
413
|
+
{ timeout: 3e4 }
|
|
414
|
+
);
|
|
415
|
+
const output = stdout.trim();
|
|
416
|
+
if (!output) return { items: [], status: "clean" };
|
|
417
|
+
const items = parseSqlStatements(output).filter((s) => !s.toUpperCase().trimStart().startsWith("--")).map((sql) => {
|
|
418
|
+
const type = classifyDriftSql(sql);
|
|
419
|
+
return { sql, type, description: labelDriftType(type) };
|
|
420
|
+
});
|
|
421
|
+
return { items, status: items.length > 0 ? "drifted" : "clean" };
|
|
422
|
+
} catch (err) {
|
|
423
|
+
const error = err;
|
|
424
|
+
const stderr = error.stderr ?? "";
|
|
425
|
+
if (stderr.includes("P1001") || stderr.includes("Can't reach database server") || stderr.includes("Connection refused")) {
|
|
426
|
+
logger.debug("Drift detection skipped: database unreachable");
|
|
427
|
+
return {
|
|
428
|
+
items: [],
|
|
429
|
+
status: "error",
|
|
430
|
+
errorMessage: "Database unreachable"
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
434
|
+
logger.warn({ err }, "Drift detection failed");
|
|
435
|
+
return { items: [], status: "error", errorMessage: message };
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// src/core/migration-analyzer.ts
|
|
440
|
+
var RISK_PATTERNS = [
|
|
441
|
+
{
|
|
442
|
+
pattern: /DROP\s+TABLE/i,
|
|
443
|
+
label: "Drops table \u2014 irreversible data loss",
|
|
444
|
+
severity: "critical",
|
|
445
|
+
description: "Drops an entire table and all its data permanently.",
|
|
446
|
+
recommendation: "Ensure data has been migrated or backed up before applying.",
|
|
447
|
+
weight: 75
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
pattern: /TRUNCATE/i,
|
|
451
|
+
label: "Truncates table \u2014 full data loss",
|
|
452
|
+
severity: "critical",
|
|
453
|
+
description: "Removes all rows from a table. Cannot be rolled back with standard SQL.",
|
|
454
|
+
recommendation: "Export table data before applying this migration.",
|
|
455
|
+
weight: 65
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
pattern: /DROP\s+COLUMN/i,
|
|
459
|
+
label: "Drops column \u2014 potential data loss",
|
|
460
|
+
severity: "critical",
|
|
461
|
+
description: "Removes a column and all data stored in it.",
|
|
462
|
+
recommendation: "Verify no application code reads this column before deploying.",
|
|
463
|
+
weight: 60
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
pattern: /ALTER\s+COLUMN.+NOT\s+NULL/i,
|
|
467
|
+
label: "Adds NOT NULL constraint",
|
|
468
|
+
severity: "high",
|
|
469
|
+
description: "Adding a NOT NULL constraint will fail if any existing rows have NULL values.",
|
|
470
|
+
recommendation: "Backfill NULL values before adding the constraint.",
|
|
471
|
+
weight: 25
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
pattern: /ALTER\s+TABLE.+TYPE|ALTER\s+COLUMN.+TYPE/i,
|
|
475
|
+
label: "Changes column type",
|
|
476
|
+
severity: "high",
|
|
477
|
+
description: "Changing a column type can cause data loss or conversion failures.",
|
|
478
|
+
recommendation: "Test type conversion on a staging database first.",
|
|
479
|
+
weight: 25
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
pattern: /DELETE\s+FROM/i,
|
|
483
|
+
label: "Bulk data deletion",
|
|
484
|
+
severity: "medium",
|
|
485
|
+
description: "Deletes rows from a table. Scope depends on the WHERE clause.",
|
|
486
|
+
recommendation: "Review the WHERE clause carefully and test on staging first.",
|
|
487
|
+
weight: 20
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
pattern: /DROP\s+CONSTRAINT/i,
|
|
491
|
+
label: "Removes constraint \u2014 may allow invalid data",
|
|
492
|
+
severity: "medium",
|
|
493
|
+
description: "Removing a constraint allows data that was previously rejected.",
|
|
494
|
+
recommendation: "Audit existing data for constraint violations after applying.",
|
|
495
|
+
weight: 15
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
pattern: /DROP\s+INDEX/i,
|
|
499
|
+
label: "Removes index \u2014 may impact performance",
|
|
500
|
+
severity: "low",
|
|
501
|
+
description: "Dropping an index can degrade query performance on the affected table.",
|
|
502
|
+
recommendation: "Monitor query performance after applying in production.",
|
|
503
|
+
weight: 10
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
pattern: /ALTER\s+TABLE/i,
|
|
507
|
+
label: "Alters table structure",
|
|
508
|
+
severity: "low",
|
|
509
|
+
description: "Modifies table structure. Long-running on large tables.",
|
|
510
|
+
recommendation: "Consider table size and run during low-traffic windows.",
|
|
511
|
+
weight: 8
|
|
512
|
+
}
|
|
513
|
+
];
|
|
514
|
+
function extractTableName(sql) {
|
|
515
|
+
const match = sql.match(/(?:DROP|TRUNCATE|ALTER)\s+TABLE\s+(?:"?(\w+)"?\.)?"?(\w+)"?/i);
|
|
516
|
+
return match?.[2];
|
|
517
|
+
}
|
|
518
|
+
function analyzeMigrationRisks(sql) {
|
|
519
|
+
return RISK_PATTERNS.filter(({ pattern }) => pattern.test(sql)).map(({ label }) => label);
|
|
520
|
+
}
|
|
521
|
+
function scoreMigrationRisk(sql) {
|
|
522
|
+
const matchedPatterns = RISK_PATTERNS.filter(({ pattern }) => pattern.test(sql));
|
|
523
|
+
const factors = matchedPatterns.map((p) => {
|
|
524
|
+
const affectedTable = extractTableName(sql);
|
|
525
|
+
return {
|
|
526
|
+
pattern: p.pattern.source,
|
|
527
|
+
severity: p.severity,
|
|
528
|
+
description: p.description,
|
|
529
|
+
...affectedTable !== void 0 ? { affectedTable } : {},
|
|
530
|
+
recommendation: p.recommendation
|
|
531
|
+
};
|
|
532
|
+
});
|
|
533
|
+
const rawScore = matchedPatterns.reduce((acc, p) => acc + p.weight, 0);
|
|
534
|
+
const score = Math.min(100, rawScore);
|
|
535
|
+
let level = "low";
|
|
536
|
+
if (score >= 75) level = "critical";
|
|
537
|
+
else if (score >= 50) level = "high";
|
|
538
|
+
else if (score >= 20) level = "medium";
|
|
539
|
+
return { score, level, factors };
|
|
540
|
+
}
|
|
541
|
+
function calculateHealthScore(input) {
|
|
542
|
+
let score = 100;
|
|
543
|
+
if (!input.connected) score -= 35;
|
|
544
|
+
score -= Math.min(30, input.migrationsFailed * 25);
|
|
545
|
+
score -= Math.min(20, input.driftCount * 10);
|
|
546
|
+
score -= Math.min(15, input.migrationsPending * 5);
|
|
547
|
+
if (input.maxRiskScore >= 75) score -= 25;
|
|
548
|
+
else if (input.maxRiskScore >= 50) score -= 18;
|
|
549
|
+
else if (input.maxRiskScore >= 20) score -= 8;
|
|
550
|
+
return Math.max(0, Math.min(100, Math.round(score)));
|
|
551
|
+
}
|
|
552
|
+
function buildDeploymentReadiness(input) {
|
|
553
|
+
const hasCriticalRisk = input.maxRiskScore >= 75;
|
|
554
|
+
const checks = [
|
|
555
|
+
{
|
|
556
|
+
id: "database",
|
|
557
|
+
label: "Database reachable",
|
|
558
|
+
passed: input.connected,
|
|
559
|
+
message: input.connected ? "PrismaFlow can reach the configured datasource." : "Check DATABASE_URL and database network access before deploying."
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
id: "drift",
|
|
563
|
+
label: "No schema drift",
|
|
564
|
+
passed: input.driftCount === 0,
|
|
565
|
+
message: input.driftCount === 0 ? "The live database matches the Prisma schema." : `${input.driftCount} drift item${input.driftCount === 1 ? "" : "s"} must be reviewed.`
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
id: "failed-migrations",
|
|
569
|
+
label: "No failed migrations",
|
|
570
|
+
passed: input.migrationsFailed === 0,
|
|
571
|
+
message: input.migrationsFailed === 0 ? "Migration history has no failed entries." : `${input.migrationsFailed} failed migration${input.migrationsFailed === 1 ? "" : "s"} need recovery.`
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
id: "pending-migrations",
|
|
575
|
+
label: "No pending migrations",
|
|
576
|
+
passed: input.migrationsPending === 0,
|
|
577
|
+
message: input.migrationsPending === 0 ? "All local migrations are applied." : `${input.migrationsPending} migration${input.migrationsPending === 1 ? "" : "s"} still pending.`
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
id: "critical-risks",
|
|
581
|
+
label: "No critical migration risks",
|
|
582
|
+
passed: !hasCriticalRisk,
|
|
583
|
+
message: hasCriticalRisk ? "At least one migration contains a critical data-loss operation." : "No critical data-loss operations were detected."
|
|
584
|
+
}
|
|
585
|
+
];
|
|
586
|
+
const blockers = checks.filter(
|
|
587
|
+
(check) => !check.passed && ["database", "drift", "failed-migrations", "critical-risks"].includes(check.id)
|
|
588
|
+
);
|
|
589
|
+
const warnings = checks.filter((check) => !check.passed);
|
|
590
|
+
if (blockers.length > 0) {
|
|
591
|
+
return {
|
|
592
|
+
status: "blocked",
|
|
593
|
+
score: input.healthScore,
|
|
594
|
+
summary: "Not ready for deployment",
|
|
595
|
+
checks
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
if (warnings.length > 0) {
|
|
599
|
+
return {
|
|
600
|
+
status: "attention",
|
|
601
|
+
score: input.healthScore,
|
|
602
|
+
summary: "Review pending work before deployment",
|
|
603
|
+
checks
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
status: "ready",
|
|
608
|
+
score: input.healthScore,
|
|
609
|
+
summary: "Ready for deployment",
|
|
610
|
+
checks
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
async function getMigrationStatusMap(cwd, schemaPath) {
|
|
614
|
+
const map = /* @__PURE__ */ new Map();
|
|
615
|
+
let stdout = "";
|
|
616
|
+
try {
|
|
617
|
+
const result = await execPrisma(cwd, ["migrate", "status", "--schema", schemaPath], {
|
|
618
|
+
timeout: 3e4
|
|
619
|
+
});
|
|
620
|
+
stdout = result.stdout;
|
|
621
|
+
return map;
|
|
622
|
+
} catch (err) {
|
|
623
|
+
const error = err;
|
|
624
|
+
stdout = error.stdout ?? "";
|
|
625
|
+
const stderr = error.stderr ?? "";
|
|
626
|
+
if (stderr.includes("P1001") || stderr.includes("Can't reach database server") || stderr.includes("Connection refused")) {
|
|
627
|
+
throw new Error("DATABASE_UNREACHABLE");
|
|
628
|
+
}
|
|
629
|
+
logger.debug({ stdout, stderr }, "prisma migrate status exited non-zero \u2014 parsing output");
|
|
630
|
+
}
|
|
631
|
+
const lines = stdout.split("\n");
|
|
632
|
+
let mode = "none";
|
|
633
|
+
for (const rawLine of lines) {
|
|
634
|
+
const line = rawLine.trim();
|
|
635
|
+
if (line.includes("have not yet been applied")) {
|
|
636
|
+
mode = "pending";
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
if (line.match(/failed to apply|rolled back|migration.*failed/i)) {
|
|
640
|
+
mode = "failed";
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
if (line === "" || line.startsWith("\u2500") || line.startsWith("The following")) {
|
|
644
|
+
mode = "none";
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (mode === "none") continue;
|
|
648
|
+
const cleaned = line.replace(/^[•\-*]\s*/, "").trim();
|
|
649
|
+
if (cleaned.match(/^\d{14}/)) {
|
|
650
|
+
map.set(cleaned, mode === "pending" ? "pending" : "failed");
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return map;
|
|
654
|
+
}
|
|
655
|
+
async function getMigrationDetails(cwd, name) {
|
|
656
|
+
const project = await detectPrismaProject(cwd);
|
|
657
|
+
if (!project) return null;
|
|
658
|
+
const migration = project.migrations.find((m) => m.name === name);
|
|
659
|
+
if (!migration) return null;
|
|
660
|
+
let sql = "";
|
|
661
|
+
try {
|
|
662
|
+
sql = await fs4.readFile(migration.sqlPath, "utf-8");
|
|
663
|
+
} catch {
|
|
664
|
+
sql = "-- Could not read migration file";
|
|
665
|
+
}
|
|
666
|
+
const risks = analyzeMigrationRisks(sql);
|
|
667
|
+
const riskScore = scoreMigrationRisk(sql);
|
|
668
|
+
return { ...migration, sql, risks, riskScore };
|
|
669
|
+
}
|
|
670
|
+
async function getMigrations(cwd) {
|
|
671
|
+
const project = await detectPrismaProject(cwd);
|
|
672
|
+
if (!project) return [];
|
|
673
|
+
let statusMap;
|
|
674
|
+
try {
|
|
675
|
+
statusMap = await getMigrationStatusMap(cwd, project.schemaPath);
|
|
676
|
+
} catch {
|
|
677
|
+
statusMap = /* @__PURE__ */ new Map();
|
|
678
|
+
}
|
|
679
|
+
return Promise.all(
|
|
680
|
+
project.migrations.map(async (m) => {
|
|
681
|
+
const status = statusMap.get(m.name) ?? "applied";
|
|
682
|
+
let sql = "";
|
|
683
|
+
try {
|
|
684
|
+
sql = await fs4.readFile(m.sqlPath, "utf-8");
|
|
685
|
+
} catch {
|
|
686
|
+
}
|
|
687
|
+
const risks = analyzeMigrationRisks(sql);
|
|
688
|
+
const riskScore = scoreMigrationRisk(sql);
|
|
689
|
+
return { ...m, status, risks, riskScore };
|
|
690
|
+
})
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
async function getProjectStatus(cwd) {
|
|
694
|
+
const project = await detectPrismaProject(cwd);
|
|
695
|
+
if (!project) throw new Error("No Prisma project found");
|
|
696
|
+
let connected = false;
|
|
697
|
+
try {
|
|
698
|
+
await execPrisma(cwd, ["migrate", "status", "--schema", project.schemaPath], {
|
|
699
|
+
timeout: 3e4
|
|
700
|
+
});
|
|
701
|
+
connected = true;
|
|
702
|
+
} catch (err) {
|
|
703
|
+
const error = err;
|
|
704
|
+
const stderr = error.stderr ?? "";
|
|
705
|
+
if (stderr.includes("P1001") || stderr.includes("Can't reach database server") || stderr.includes("Connection refused")) {
|
|
706
|
+
connected = false;
|
|
707
|
+
} else {
|
|
708
|
+
connected = true;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const migrations = await getMigrations(cwd);
|
|
712
|
+
const migrationsPending = migrations.filter((m) => m.status === "pending").length;
|
|
713
|
+
const migrationsFailed = migrations.filter((m) => m.status === "failed").length;
|
|
714
|
+
const migrationsApplied = migrations.filter((m) => m.status === "applied").length;
|
|
715
|
+
let driftResult = { items: [], status: "clean" };
|
|
716
|
+
if (connected) {
|
|
717
|
+
driftResult = await detectDrift(cwd);
|
|
718
|
+
}
|
|
719
|
+
const hasDrift = driftResult.status === "drifted";
|
|
720
|
+
let riskLevel = "low";
|
|
721
|
+
if (migrationsFailed > 0) riskLevel = "high";
|
|
722
|
+
else if (hasDrift) riskLevel = "medium";
|
|
723
|
+
else if (migrationsPending > 0) riskLevel = "low";
|
|
724
|
+
const maxRiskScore = Math.max(0, ...migrations.map((m) => m.riskScore.score));
|
|
725
|
+
if (maxRiskScore >= 75) riskLevel = "critical";
|
|
726
|
+
else if (maxRiskScore >= 50) riskLevel = "high";
|
|
727
|
+
else if (maxRiskScore >= 20 && riskLevel === "low") riskLevel = "medium";
|
|
728
|
+
const healthScore = calculateHealthScore({
|
|
729
|
+
connected,
|
|
730
|
+
migrationsPending,
|
|
731
|
+
migrationsFailed,
|
|
732
|
+
driftCount: driftResult.items.length,
|
|
733
|
+
maxRiskScore
|
|
734
|
+
});
|
|
735
|
+
const deploymentReadiness = buildDeploymentReadiness({
|
|
736
|
+
connected,
|
|
737
|
+
migrationsPending,
|
|
738
|
+
migrationsFailed,
|
|
739
|
+
driftCount: driftResult.items.length,
|
|
740
|
+
maxRiskScore,
|
|
741
|
+
healthScore
|
|
742
|
+
});
|
|
743
|
+
return {
|
|
744
|
+
connected,
|
|
745
|
+
migrationsApplied: connected ? migrationsApplied : 0,
|
|
746
|
+
migrationsPending: connected ? migrationsPending : migrations.length,
|
|
747
|
+
migrationsFailed,
|
|
748
|
+
driftDetected: hasDrift,
|
|
749
|
+
driftCount: driftResult.items.length,
|
|
750
|
+
riskLevel,
|
|
751
|
+
healthScore,
|
|
752
|
+
deploymentReadiness,
|
|
753
|
+
lastSync: (/* @__PURE__ */ new Date()).toISOString(),
|
|
754
|
+
...project.provider ? { provider: project.provider } : {},
|
|
755
|
+
schemaPath: project.schemaPath,
|
|
756
|
+
migrationsPath: project.migrationsPath,
|
|
757
|
+
...project.prismaVersion ? { prismaVersion: project.prismaVersion } : {},
|
|
758
|
+
...project.packageManager ? { packageManager: project.packageManager } : {},
|
|
759
|
+
hasDatabaseUrl: project.databaseUrl.length > 0
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// src/core/telemetry.ts
|
|
764
|
+
var TELEMETRY_ENDPOINT = "https://telemetry.prismaflow.dev/v1/event";
|
|
765
|
+
function isEnabled() {
|
|
766
|
+
const val = process.env.PRISMAFLOW_TELEMETRY;
|
|
767
|
+
if (!val) return false;
|
|
768
|
+
return val.toLowerCase() === "on" || val.toLowerCase() === "true" || val === "1";
|
|
769
|
+
}
|
|
770
|
+
function bucketCount(n) {
|
|
771
|
+
if (n === 0) return "0";
|
|
772
|
+
if (n <= 10) return "1-10";
|
|
773
|
+
if (n <= 50) return "11-50";
|
|
774
|
+
return "50+";
|
|
775
|
+
}
|
|
776
|
+
async function trackEvent(event, migrationCount) {
|
|
777
|
+
if (!isEnabled()) return;
|
|
778
|
+
const payload = {
|
|
779
|
+
event,
|
|
780
|
+
migrationCountBucket: bucketCount(migrationCount),
|
|
781
|
+
nodeVersion: process.versions.node.split(".")[0] ?? "unknown",
|
|
782
|
+
platform: process.platform
|
|
783
|
+
};
|
|
784
|
+
try {
|
|
785
|
+
await fetch(TELEMETRY_ENDPOINT, {
|
|
786
|
+
method: "POST",
|
|
787
|
+
headers: { "Content-Type": "application/json" },
|
|
788
|
+
body: JSON.stringify(payload),
|
|
789
|
+
signal: AbortSignal.timeout(3e3)
|
|
790
|
+
// never block more than 3 s
|
|
791
|
+
});
|
|
792
|
+
} catch {
|
|
793
|
+
logger.debug("Telemetry event failed (non-fatal)");
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// src/commands/check.ts
|
|
798
|
+
function checkCommand() {
|
|
799
|
+
return new Command("check").description("Validate database migration state (CI-friendly)").option("--ci", "Exit with a non-zero code when issues are found (for CI pipelines)").option("--json", "Output result as JSON to stdout").option(
|
|
800
|
+
"--fail-on-risk <level>",
|
|
801
|
+
"Exit code 5 when risk meets or exceeds this level (low|medium|high|critical)"
|
|
802
|
+
).option("--quiet", "Suppress non-essential output").action(
|
|
803
|
+
async (options) => {
|
|
804
|
+
const cwd = process.cwd();
|
|
805
|
+
try {
|
|
806
|
+
const project = await detectPrismaProject(cwd);
|
|
807
|
+
if (!project) {
|
|
808
|
+
if (options.json) {
|
|
809
|
+
process.stdout.write(
|
|
810
|
+
`${JSON.stringify({ ok: false, error: "No Prisma project found" })}
|
|
811
|
+
`
|
|
812
|
+
);
|
|
813
|
+
} else if (!options.quiet) {
|
|
814
|
+
console.error(chalk.red("\u2716 No Prisma project found."));
|
|
815
|
+
console.error(chalk.dim(" Run prisma init to create a schema, then try again."));
|
|
816
|
+
}
|
|
817
|
+
process.exit(4);
|
|
818
|
+
}
|
|
819
|
+
const [status, migrations] = await Promise.all([
|
|
820
|
+
getProjectStatus(cwd),
|
|
821
|
+
getMigrations(cwd)
|
|
822
|
+
]);
|
|
823
|
+
const ok = status.migrationsFailed === 0 && !status.driftDetected && status.migrationsPending === 0;
|
|
824
|
+
const result = {
|
|
825
|
+
ok,
|
|
826
|
+
connected: status.connected,
|
|
827
|
+
pendingCount: status.migrationsPending,
|
|
828
|
+
failedCount: status.migrationsFailed,
|
|
829
|
+
driftDetected: status.driftDetected,
|
|
830
|
+
driftCount: status.driftCount,
|
|
831
|
+
riskLevel: status.riskLevel,
|
|
832
|
+
healthScore: status.healthScore,
|
|
833
|
+
deploymentReadiness: status.deploymentReadiness,
|
|
834
|
+
lastSync: status.lastSync,
|
|
835
|
+
provider: status.provider
|
|
836
|
+
};
|
|
837
|
+
if (options.json) {
|
|
838
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
839
|
+
`);
|
|
840
|
+
} else if (!options.quiet) {
|
|
841
|
+
const bar = chalk.dim("\u2501".repeat(50));
|
|
842
|
+
console.log();
|
|
843
|
+
console.log(chalk.bold.cyan(" \u{1F50D} PrismaFlow Check"));
|
|
844
|
+
console.log(bar);
|
|
845
|
+
if (status.connected) {
|
|
846
|
+
console.log(
|
|
847
|
+
chalk.green(" \u2714 Database connected") + (status.provider ? chalk.dim(` (${status.provider})`) : "")
|
|
848
|
+
);
|
|
849
|
+
} else {
|
|
850
|
+
console.log(chalk.red(" \u2716 Database unreachable \u2014 check DATABASE_URL"));
|
|
851
|
+
}
|
|
852
|
+
console.log(
|
|
853
|
+
chalk.green(" \u2714 Applied migrations: ") + chalk.bold(String(status.migrationsApplied))
|
|
854
|
+
);
|
|
855
|
+
if (status.migrationsPending > 0) {
|
|
856
|
+
console.log(
|
|
857
|
+
chalk.yellow(" \u26A0 Pending migrations: ") + chalk.bold.yellow(String(status.migrationsPending))
|
|
858
|
+
);
|
|
859
|
+
const pendingMigrations = migrations.filter((m) => m.status === "pending").slice(0, 3);
|
|
860
|
+
for (const m of pendingMigrations) {
|
|
861
|
+
console.log(chalk.dim(` \u2022 ${m.name}`));
|
|
862
|
+
}
|
|
863
|
+
if (status.migrationsPending > 3) {
|
|
864
|
+
console.log(chalk.dim(` \u2026 and ${status.migrationsPending - 3} more`));
|
|
865
|
+
}
|
|
866
|
+
} else {
|
|
867
|
+
console.log(chalk.green(" \u2714 No pending migrations"));
|
|
868
|
+
}
|
|
869
|
+
if (status.migrationsFailed > 0) {
|
|
870
|
+
console.log(
|
|
871
|
+
chalk.red(" \u2716 Failed migrations: ") + chalk.bold.red(String(status.migrationsFailed))
|
|
872
|
+
);
|
|
873
|
+
const failedMigrations = migrations.filter((m) => m.status === "failed").slice(0, 3);
|
|
874
|
+
for (const m of failedMigrations) {
|
|
875
|
+
console.log(chalk.dim(` \u2022 ${m.name}`));
|
|
876
|
+
}
|
|
877
|
+
} else {
|
|
878
|
+
console.log(chalk.green(" \u2714 No failed migrations"));
|
|
879
|
+
}
|
|
880
|
+
if (status.driftDetected) {
|
|
881
|
+
console.log(
|
|
882
|
+
chalk.red(" \u2716 Schema drift detected: ") + chalk.bold.red(
|
|
883
|
+
`${status.driftCount} difference${status.driftCount !== 1 ? "s" : ""}`
|
|
884
|
+
)
|
|
885
|
+
);
|
|
886
|
+
console.log(chalk.dim(" Review the dashboard Drift page before deploying"));
|
|
887
|
+
} else {
|
|
888
|
+
console.log(chalk.green(" \u2714 No schema drift detected"));
|
|
889
|
+
}
|
|
890
|
+
const riskColors = {
|
|
891
|
+
low: chalk.green,
|
|
892
|
+
medium: chalk.yellow,
|
|
893
|
+
high: chalk.red,
|
|
894
|
+
critical: chalk.red
|
|
895
|
+
};
|
|
896
|
+
const riskColor = riskColors[status.riskLevel] ?? chalk.white;
|
|
897
|
+
console.log(` \u{1F512} Risk level: ${riskColor(chalk.bold(status.riskLevel.toUpperCase()))}`);
|
|
898
|
+
console.log(
|
|
899
|
+
` \u{1F6A6} Readiness: ${riskColor(chalk.bold(`${status.healthScore}/100`))}${chalk.dim(
|
|
900
|
+
` \u2014 ${status.deploymentReadiness.summary}`
|
|
901
|
+
)}`
|
|
902
|
+
);
|
|
903
|
+
const highestRiskMigration = migrations.filter((m) => m.riskScore.score > 0).sort((a, b) => b.riskScore.score - a.riskScore.score)[0];
|
|
904
|
+
if (highestRiskMigration?.riskScore.factors.length) {
|
|
905
|
+
console.log(chalk.dim(`
|
|
906
|
+
Top risk in ${highestRiskMigration.name}:`));
|
|
907
|
+
for (const factor of highestRiskMigration.riskScore.factors.slice(0, 2)) {
|
|
908
|
+
console.log(chalk.dim(` \u2022 ${factor.description}`));
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
console.log(bar);
|
|
912
|
+
if (ok) {
|
|
913
|
+
console.log(chalk.bold.green(" \u2714 All checks passed"));
|
|
914
|
+
} else {
|
|
915
|
+
console.log(chalk.bold.red(" \u2716 Issues detected \u2014 review above"));
|
|
916
|
+
if (status.migrationsFailed > 0) {
|
|
917
|
+
console.log(chalk.dim(" \u2192 Fix: Run prisma migrate resolve"));
|
|
918
|
+
}
|
|
919
|
+
if (status.migrationsPending > 0) {
|
|
920
|
+
console.log(chalk.dim(" \u2192 Fix: Run prisma migrate deploy"));
|
|
921
|
+
}
|
|
922
|
+
if (status.driftDetected) {
|
|
923
|
+
console.log(
|
|
924
|
+
chalk.dim(" \u2192 Fix: Review drift details and reconcile Prisma schema")
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
console.log();
|
|
929
|
+
}
|
|
930
|
+
await Promise.all([
|
|
931
|
+
writeAuditEntry(cwd, "migration.check", ok ? "success" : "warning", {
|
|
932
|
+
pendingCount: status.migrationsPending,
|
|
933
|
+
failedCount: status.migrationsFailed,
|
|
934
|
+
driftDetected: status.driftDetected,
|
|
935
|
+
riskLevel: status.riskLevel
|
|
936
|
+
}),
|
|
937
|
+
trackEvent("check", migrations.length)
|
|
938
|
+
]).catch(() => {
|
|
939
|
+
});
|
|
940
|
+
if (options.failOnRisk) {
|
|
941
|
+
const riskOrder = {
|
|
942
|
+
low: 1,
|
|
943
|
+
medium: 2,
|
|
944
|
+
high: 3,
|
|
945
|
+
critical: 4
|
|
946
|
+
};
|
|
947
|
+
const threshold = riskOrder[options.failOnRisk] ?? 2;
|
|
948
|
+
const current = riskOrder[status.riskLevel] ?? 1;
|
|
949
|
+
if (current >= threshold) process.exit(5);
|
|
950
|
+
}
|
|
951
|
+
if (options.ci) {
|
|
952
|
+
if (status.migrationsFailed > 0) process.exit(3);
|
|
953
|
+
if (status.driftDetected) process.exit(2);
|
|
954
|
+
if (status.migrationsPending > 0) process.exit(1);
|
|
955
|
+
process.exit(0);
|
|
956
|
+
}
|
|
957
|
+
} catch (error) {
|
|
958
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
959
|
+
if (options.json) {
|
|
960
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: message })}
|
|
961
|
+
`);
|
|
962
|
+
} else if (!options.quiet) {
|
|
963
|
+
console.error(chalk.red(`\u2716 Error: ${message}`));
|
|
964
|
+
}
|
|
965
|
+
await writeAuditEntry(cwd, "migration.check", "failure", {
|
|
966
|
+
error: message
|
|
967
|
+
}).catch(() => {
|
|
968
|
+
});
|
|
969
|
+
process.exit(4);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// src/commands/compare.ts
|
|
976
|
+
import chalk2 from "chalk";
|
|
977
|
+
import { Command as Command2 } from "commander";
|
|
978
|
+
|
|
979
|
+
// src/core/config-loader.ts
|
|
980
|
+
import fs5 from "fs";
|
|
981
|
+
import path4 from "path";
|
|
982
|
+
import { pathToFileURL } from "url";
|
|
983
|
+
import { PrismaFlowConfigSchema } from "@prisma-flow/shared";
|
|
984
|
+
var _cached = null;
|
|
985
|
+
var _cachedCwd = null;
|
|
986
|
+
function applyEnvOverrides(config) {
|
|
987
|
+
const overrides = {};
|
|
988
|
+
const port = process.env.PRISMAFLOW_PORT;
|
|
989
|
+
if (port) {
|
|
990
|
+
const parsed = Number.parseInt(port, 10);
|
|
991
|
+
if (!Number.isNaN(parsed) && parsed > 0 && parsed < 65536) {
|
|
992
|
+
overrides.port = parsed;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
const logLevel = process.env.PRISMAFLOW_LOG_LEVEL;
|
|
996
|
+
if (logLevel) {
|
|
997
|
+
const result = PrismaFlowConfigSchema.shape.logLevel.safeParse(logLevel);
|
|
998
|
+
if (result.success) overrides.logLevel = result.data;
|
|
999
|
+
}
|
|
1000
|
+
const openBrowser = process.env.PRISMAFLOW_NO_OPEN;
|
|
1001
|
+
if (openBrowser === "1" || openBrowser?.toLowerCase() === "true") {
|
|
1002
|
+
overrides.openBrowser = false;
|
|
1003
|
+
}
|
|
1004
|
+
const riskThreshold = process.env.PRISMAFLOW_RISK_THRESHOLD;
|
|
1005
|
+
if (riskThreshold) {
|
|
1006
|
+
const result = PrismaFlowConfigSchema.shape.riskThreshold.safeParse(riskThreshold);
|
|
1007
|
+
if (result.success) overrides.riskThreshold = result.data;
|
|
1008
|
+
}
|
|
1009
|
+
if (process.env.SLACK_WEBHOOK_URL || process.env.DISCORD_WEBHOOK_URL) {
|
|
1010
|
+
const envWebhooks = [];
|
|
1011
|
+
if (process.env.SLACK_WEBHOOK_URL) {
|
|
1012
|
+
envWebhooks.push({ type: "slack", url: process.env.SLACK_WEBHOOK_URL });
|
|
1013
|
+
}
|
|
1014
|
+
if (process.env.DISCORD_WEBHOOK_URL) {
|
|
1015
|
+
envWebhooks.push({
|
|
1016
|
+
type: "discord",
|
|
1017
|
+
url: process.env.DISCORD_WEBHOOK_URL
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
overrides.webhooks = [...config.webhooks, ...envWebhooks];
|
|
1021
|
+
}
|
|
1022
|
+
return { ...config, ...overrides };
|
|
1023
|
+
}
|
|
1024
|
+
var CONFIG_CANDIDATES = [
|
|
1025
|
+
"prismaflow.config.js",
|
|
1026
|
+
"prismaflow.config.mjs",
|
|
1027
|
+
"prismaflow.config.cjs",
|
|
1028
|
+
// .ts is handled via tsx / ts-node at runtime (best-effort)
|
|
1029
|
+
"prismaflow.config.ts"
|
|
1030
|
+
];
|
|
1031
|
+
async function findConfigFile(cwd) {
|
|
1032
|
+
for (const name of CONFIG_CANDIDATES) {
|
|
1033
|
+
const candidate = path4.join(cwd, name);
|
|
1034
|
+
if (fs5.existsSync(candidate)) return candidate;
|
|
1035
|
+
}
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
1038
|
+
async function loadConfigFile(filePath) {
|
|
1039
|
+
try {
|
|
1040
|
+
const url = pathToFileURL(filePath).href;
|
|
1041
|
+
const mod = await import(url);
|
|
1042
|
+
return mod?.default ?? mod;
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
logger.debug({ err, filePath }, "Could not load config file \u2014 using defaults");
|
|
1045
|
+
return null;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
async function getConfig(cwd = process.cwd()) {
|
|
1049
|
+
if (_cached && _cachedCwd === cwd) return _cached;
|
|
1050
|
+
const configFile = await findConfigFile(cwd);
|
|
1051
|
+
let rawConfig = {};
|
|
1052
|
+
if (configFile) {
|
|
1053
|
+
logger.debug({ configFile }, "Loading PrismaFlow config file");
|
|
1054
|
+
rawConfig = await loadConfigFile(configFile);
|
|
1055
|
+
} else {
|
|
1056
|
+
logger.debug("No prismaflow.config.* found \u2014 using defaults");
|
|
1057
|
+
}
|
|
1058
|
+
const result = PrismaFlowConfigSchema.safeParse(rawConfig ?? {});
|
|
1059
|
+
if (!result.success) {
|
|
1060
|
+
logger.warn(
|
|
1061
|
+
{ errors: result.error.errors },
|
|
1062
|
+
"prismaflow.config.* has validation errors \u2014 falling back to defaults"
|
|
1063
|
+
);
|
|
1064
|
+
const defaults = PrismaFlowConfigSchema.parse({});
|
|
1065
|
+
const overridden2 = applyEnvOverrides(defaults);
|
|
1066
|
+
_cached = overridden2;
|
|
1067
|
+
_cachedCwd = cwd;
|
|
1068
|
+
return overridden2;
|
|
1069
|
+
}
|
|
1070
|
+
const overridden = applyEnvOverrides(result.data);
|
|
1071
|
+
_cached = overridden;
|
|
1072
|
+
_cachedCwd = cwd;
|
|
1073
|
+
return overridden;
|
|
1074
|
+
}
|
|
1075
|
+
function getConfigSync() {
|
|
1076
|
+
if (!_cached) {
|
|
1077
|
+
return PrismaFlowConfigSchema.parse({});
|
|
1078
|
+
}
|
|
1079
|
+
return _cached;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// src/core/env-comparator.ts
|
|
1083
|
+
import { EnvironmentComparisonError } from "@prisma-flow/shared";
|
|
1084
|
+
function parseStatusOutput(output) {
|
|
1085
|
+
const applied = [];
|
|
1086
|
+
const pending = [];
|
|
1087
|
+
const failed = [];
|
|
1088
|
+
let section = null;
|
|
1089
|
+
for (const line of output.split("\n")) {
|
|
1090
|
+
const trimmed = line.trim();
|
|
1091
|
+
if (/have been applied/i.test(trimmed)) {
|
|
1092
|
+
section = "applied";
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
if (/have not yet been applied/i.test(trimmed)) {
|
|
1096
|
+
section = "pending";
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
if (/failed/i.test(trimmed)) {
|
|
1100
|
+
section = "failed";
|
|
1101
|
+
continue;
|
|
1102
|
+
}
|
|
1103
|
+
if (/^\d{14}_/.test(trimmed)) {
|
|
1104
|
+
if (section === "applied") applied.push(trimmed);
|
|
1105
|
+
else if (section === "pending") pending.push(trimmed);
|
|
1106
|
+
else if (section === "failed") failed.push(trimmed);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
return { applied, pending, failed };
|
|
1110
|
+
}
|
|
1111
|
+
async function queryMigrationStatus(envName, databaseUrl, schemaPath, cwd) {
|
|
1112
|
+
try {
|
|
1113
|
+
const env = { ...process.env, DATABASE_URL: databaseUrl };
|
|
1114
|
+
const { stdout } = await execPrisma(cwd, ["migrate", "status", "--schema", schemaPath], {
|
|
1115
|
+
env,
|
|
1116
|
+
timeout: 3e4
|
|
1117
|
+
});
|
|
1118
|
+
return { ...parseStatusOutput(stdout), reachable: true };
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1121
|
+
if ("stdout" in err) {
|
|
1122
|
+
const output = err.stdout ?? "";
|
|
1123
|
+
if (output.length > 0) {
|
|
1124
|
+
return { ...parseStatusOutput(output), reachable: true };
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
if (error.message.includes("P1001") || error.message.includes("Connection refused") || error.message.includes("Can't reach database server")) {
|
|
1128
|
+
return { applied: [], pending: [], failed: [], reachable: false };
|
|
1129
|
+
}
|
|
1130
|
+
throw new EnvironmentComparisonError(envName, "source", error);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
async function fetchEnvironmentStates(environments, schemaPath, cwd) {
|
|
1134
|
+
const states = [];
|
|
1135
|
+
for (const env of environments) {
|
|
1136
|
+
const status = await queryMigrationStatus(env.name, env.databaseUrl, schemaPath, cwd);
|
|
1137
|
+
states.push({ name: env.name, databaseUrl: env.databaseUrl, ...status });
|
|
1138
|
+
}
|
|
1139
|
+
return states;
|
|
1140
|
+
}
|
|
1141
|
+
function diffEnvironments(source, target) {
|
|
1142
|
+
const sourceSet = new Set(source.applied);
|
|
1143
|
+
const targetSet = new Set(target.applied);
|
|
1144
|
+
const onlyInSource = source.applied.filter((m) => !targetSet.has(m));
|
|
1145
|
+
const onlyInTarget = target.applied.filter((m) => !sourceSet.has(m));
|
|
1146
|
+
let divergencePoint;
|
|
1147
|
+
for (let i = Math.min(source.applied.length, target.applied.length) - 1; i >= 0; i--) {
|
|
1148
|
+
if (source.applied[i] === target.applied[i]) {
|
|
1149
|
+
divergencePoint = source.applied[i];
|
|
1150
|
+
break;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return {
|
|
1154
|
+
sourceEnv: source.name,
|
|
1155
|
+
targetEnv: target.name,
|
|
1156
|
+
sourceApplied: source.applied.length,
|
|
1157
|
+
targetApplied: target.applied.length,
|
|
1158
|
+
onlyInSource,
|
|
1159
|
+
onlyInTarget,
|
|
1160
|
+
...divergencePoint !== void 0 ? { divergencePoint } : {},
|
|
1161
|
+
inSync: onlyInSource.length === 0 && onlyInTarget.length === 0
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
async function compareEnvironments(environments, schemaPath, cwd) {
|
|
1165
|
+
if (environments.length < 2) {
|
|
1166
|
+
throw new EnvironmentComparisonError(
|
|
1167
|
+
"configuration",
|
|
1168
|
+
"comparison",
|
|
1169
|
+
new Error("At least 2 environments are required for comparison")
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
const states = await fetchEnvironmentStates(environments, schemaPath, cwd);
|
|
1173
|
+
const [reference, ...targets] = states;
|
|
1174
|
+
if (!reference) {
|
|
1175
|
+
throw new EnvironmentComparisonError(
|
|
1176
|
+
"configuration",
|
|
1177
|
+
"comparison",
|
|
1178
|
+
new Error("No environment states were returned")
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
const diffs = [];
|
|
1182
|
+
for (const target of targets) {
|
|
1183
|
+
diffs.push(diffEnvironments(reference, target));
|
|
1184
|
+
}
|
|
1185
|
+
const allInSync = diffs.every((d) => d.inSync);
|
|
1186
|
+
return {
|
|
1187
|
+
referenceEnv: reference.name,
|
|
1188
|
+
environments: states.map((s) => ({
|
|
1189
|
+
name: s.name,
|
|
1190
|
+
reachable: s.reachable,
|
|
1191
|
+
appliedCount: s.applied.length,
|
|
1192
|
+
pendingCount: s.pending.length,
|
|
1193
|
+
failedCount: s.failed.length
|
|
1194
|
+
})),
|
|
1195
|
+
diffs,
|
|
1196
|
+
allInSync,
|
|
1197
|
+
comparedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// src/commands/compare.ts
|
|
1202
|
+
function compareCommand() {
|
|
1203
|
+
return new Command2("compare").description("Compare migration state across multiple environments").option("--envs <names>", "Comma-separated env names from config (e.g. dev,staging,prod)").option("--json", "Output as JSON").action(async (options) => {
|
|
1204
|
+
const cwd = process.cwd();
|
|
1205
|
+
try {
|
|
1206
|
+
const project = await detectPrismaProject(cwd);
|
|
1207
|
+
if (!project) {
|
|
1208
|
+
console.error(chalk2.red("\u2716 No Prisma project found."));
|
|
1209
|
+
process.exit(1);
|
|
1210
|
+
}
|
|
1211
|
+
const config = await getConfig(cwd);
|
|
1212
|
+
const configEnvList = config.environments ?? [];
|
|
1213
|
+
let envList = [];
|
|
1214
|
+
if (options.envs) {
|
|
1215
|
+
for (const name of options.envs.split(",").map((s) => s.trim())) {
|
|
1216
|
+
const envEntry = configEnvList.find((e) => e.name === name);
|
|
1217
|
+
const url = envEntry?.databaseUrl ?? process.env[`DATABASE_URL_${name.toUpperCase()}`] ?? process.env.DATABASE_URL;
|
|
1218
|
+
if (!url) {
|
|
1219
|
+
console.error(chalk2.red(`\u2716 No database URL for environment "${name}".`));
|
|
1220
|
+
console.error(
|
|
1221
|
+
chalk2.dim(
|
|
1222
|
+
` Add it to prismaflow.config.ts or set DATABASE_URL_${name.toUpperCase()}`
|
|
1223
|
+
)
|
|
1224
|
+
);
|
|
1225
|
+
process.exit(1);
|
|
1226
|
+
}
|
|
1227
|
+
envList.push({ name, databaseUrl: url });
|
|
1228
|
+
}
|
|
1229
|
+
} else if (configEnvList.length > 0) {
|
|
1230
|
+
for (const entry of configEnvList) {
|
|
1231
|
+
envList.push({ name: entry.name, databaseUrl: entry.databaseUrl });
|
|
1232
|
+
}
|
|
1233
|
+
} else {
|
|
1234
|
+
const url = project.databaseUrl ?? process.env.DATABASE_URL;
|
|
1235
|
+
if (!url) {
|
|
1236
|
+
console.error(chalk2.red("\u2716 No environments configured and no DATABASE_URL found."));
|
|
1237
|
+
console.error(
|
|
1238
|
+
chalk2.dim(" Add environments to prismaflow.config.ts or use --envs <name,name>")
|
|
1239
|
+
);
|
|
1240
|
+
process.exit(1);
|
|
1241
|
+
}
|
|
1242
|
+
envList = [{ name: "default", databaseUrl: url }];
|
|
1243
|
+
if (envList.length < 2) {
|
|
1244
|
+
console.error(chalk2.yellow("\u26A0 At least 2 environments are required for comparison."));
|
|
1245
|
+
process.exit(1);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
if (envList.length < 2) {
|
|
1249
|
+
console.error(chalk2.yellow("\u26A0 At least 2 environments are needed for comparison."));
|
|
1250
|
+
process.exit(1);
|
|
1251
|
+
}
|
|
1252
|
+
if (!options.json) {
|
|
1253
|
+
process.stdout.write(
|
|
1254
|
+
chalk2.dim(` Comparing ${envList.map((e) => e.name).join(" vs ")}...
|
|
1255
|
+
`)
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
const comparison = await compareEnvironments(envList, project.schemaPath, cwd);
|
|
1259
|
+
if (options.json) {
|
|
1260
|
+
process.stdout.write(`${JSON.stringify(comparison, null, 2)}
|
|
1261
|
+
`);
|
|
1262
|
+
process.exit(0);
|
|
1263
|
+
}
|
|
1264
|
+
console.log();
|
|
1265
|
+
console.log(chalk2.bold.cyan(" \u{1F310} Environment Comparison"));
|
|
1266
|
+
console.log(chalk2.dim("\u2501".repeat(55)));
|
|
1267
|
+
for (const env of comparison.environments) {
|
|
1268
|
+
const reach = env.reachable ? chalk2.green("\u25CF") : chalk2.red("\xD7");
|
|
1269
|
+
console.log(
|
|
1270
|
+
` ${reach} ${chalk2.bold(env.name.padEnd(15))} applied: ${env.appliedCount}, pending: ${env.pendingCount}${env.failedCount > 0 ? chalk2.red(`, failed: ${env.failedCount}`) : ""}`
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
console.log();
|
|
1274
|
+
for (const diff of comparison.diffs) {
|
|
1275
|
+
const icon = diff.inSync ? chalk2.green("\u2713") : chalk2.yellow("\u26A0");
|
|
1276
|
+
console.log(
|
|
1277
|
+
` ${icon} ${diff.sourceEnv} \u2194 ${diff.targetEnv}: ${diff.inSync ? chalk2.green("in sync") : chalk2.yellow("out of sync")}`
|
|
1278
|
+
);
|
|
1279
|
+
if (!diff.inSync) {
|
|
1280
|
+
if (diff.onlyInSource.length > 0) {
|
|
1281
|
+
console.log(
|
|
1282
|
+
` ${chalk2.dim("Only in")} ${diff.sourceEnv}: ${diff.onlyInSource.join(", ")}`
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
if (diff.onlyInTarget.length > 0) {
|
|
1286
|
+
console.log(
|
|
1287
|
+
` ${chalk2.dim("Only in")} ${diff.targetEnv}: ${diff.onlyInTarget.join(", ")}`
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
if (diff.divergencePoint) {
|
|
1291
|
+
console.log(` ${chalk2.dim("Diverged after:")} ${diff.divergencePoint}`);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
console.log();
|
|
1296
|
+
if (comparison.allInSync) {
|
|
1297
|
+
console.log(chalk2.bold.green(" \u2714 All environments are in sync."));
|
|
1298
|
+
} else {
|
|
1299
|
+
console.log(chalk2.bold.yellow(" \u26A0 Environments are out of sync \u2014 review diffs above."));
|
|
1300
|
+
}
|
|
1301
|
+
console.log();
|
|
1302
|
+
await Promise.all([
|
|
1303
|
+
writeAuditEntry(cwd, "env.compare", "success", {
|
|
1304
|
+
environments: envList.map((e) => e.name),
|
|
1305
|
+
allInSync: comparison.allInSync
|
|
1306
|
+
}),
|
|
1307
|
+
trackEvent("compare", envList.length)
|
|
1308
|
+
]).catch(() => {
|
|
1309
|
+
});
|
|
1310
|
+
process.exit(comparison.allInSync ? 0 : 2);
|
|
1311
|
+
} catch (err) {
|
|
1312
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1313
|
+
console.error(chalk2.red(`\u2716 ${message}`));
|
|
1314
|
+
process.exit(1);
|
|
1315
|
+
}
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// src/commands/dashboard.ts
|
|
1320
|
+
import chalk3 from "chalk";
|
|
1321
|
+
import { Command as Command3 } from "commander";
|
|
1322
|
+
import open from "open";
|
|
1323
|
+
|
|
1324
|
+
// src/server/index.ts
|
|
1325
|
+
import { randomBytes } from "crypto";
|
|
1326
|
+
import fs12 from "fs";
|
|
1327
|
+
import path11 from "path";
|
|
1328
|
+
import { fileURLToPath } from "url";
|
|
1329
|
+
import { serve } from "@hono/node-server";
|
|
1330
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
1331
|
+
import { Hono as Hono15 } from "hono";
|
|
1332
|
+
import { cors } from "hono/cors";
|
|
1333
|
+
import { secureHeaders } from "hono/secure-headers";
|
|
1334
|
+
|
|
1335
|
+
// src/server/routes/audit.ts
|
|
1336
|
+
import fs6 from "fs/promises";
|
|
1337
|
+
import path5 from "path";
|
|
1338
|
+
import { Hono } from "hono";
|
|
1339
|
+
var app = new Hono();
|
|
1340
|
+
app.get("/", async (c) => {
|
|
1341
|
+
const projectPath = c.get("projectPath");
|
|
1342
|
+
const limit = Number.parseInt(c.req.query("limit") ?? "100", 10);
|
|
1343
|
+
try {
|
|
1344
|
+
const auditFile = path5.join(projectPath, ".prismaflow", "audit.jsonl");
|
|
1345
|
+
try {
|
|
1346
|
+
await fs6.access(auditFile);
|
|
1347
|
+
} catch {
|
|
1348
|
+
return c.json({ success: true, data: [] });
|
|
1349
|
+
}
|
|
1350
|
+
const content = await fs6.readFile(auditFile, "utf-8");
|
|
1351
|
+
const lines = content.split("\n").filter((l) => l.trim().length > 0);
|
|
1352
|
+
const entries = lines.map((line) => {
|
|
1353
|
+
try {
|
|
1354
|
+
return JSON.parse(line);
|
|
1355
|
+
} catch {
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
}).filter(Boolean).reverse().slice(0, limit);
|
|
1359
|
+
return c.json({ success: true, data: entries });
|
|
1360
|
+
} catch (err) {
|
|
1361
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1362
|
+
return c.json({ success: false, error: message }, 500);
|
|
1363
|
+
}
|
|
1364
|
+
});
|
|
1365
|
+
var audit_default = app;
|
|
1366
|
+
|
|
1367
|
+
// src/server/routes/compare.ts
|
|
1368
|
+
import { Hono as Hono2 } from "hono";
|
|
1369
|
+
var app2 = new Hono2();
|
|
1370
|
+
app2.get("/", async (c) => {
|
|
1371
|
+
const projectPath = c.get("projectPath");
|
|
1372
|
+
try {
|
|
1373
|
+
const project = await detectPrismaProject(projectPath);
|
|
1374
|
+
if (!project) return c.json({ success: false, error: "No Prisma project found" }, 404);
|
|
1375
|
+
const config = await getConfig(projectPath);
|
|
1376
|
+
const configEnvs = config.environments ?? {};
|
|
1377
|
+
const envList = [];
|
|
1378
|
+
for (const [name, entry] of Object.entries(configEnvs)) {
|
|
1379
|
+
if (typeof entry === "object" && entry !== null && "databaseUrl" in entry) {
|
|
1380
|
+
envList.push({
|
|
1381
|
+
name,
|
|
1382
|
+
databaseUrl: entry.databaseUrl
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
if (envList.length < 2) {
|
|
1387
|
+
return c.json(
|
|
1388
|
+
{
|
|
1389
|
+
success: false,
|
|
1390
|
+
error: "At least 2 environments must be configured in prismaflow.config.ts"
|
|
1391
|
+
},
|
|
1392
|
+
400
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1395
|
+
const comparison = await compareEnvironments(envList, project.schemaPath, projectPath);
|
|
1396
|
+
return c.json({ success: true, data: comparison });
|
|
1397
|
+
} catch (err) {
|
|
1398
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1399
|
+
return c.json({ success: false, error: message }, 500);
|
|
1400
|
+
}
|
|
1401
|
+
});
|
|
1402
|
+
var compare_default = app2;
|
|
1403
|
+
|
|
1404
|
+
// src/server/routes/config.ts
|
|
1405
|
+
import { Hono as Hono3 } from "hono";
|
|
1406
|
+
var app3 = new Hono3();
|
|
1407
|
+
app3.get("/", (c) => {
|
|
1408
|
+
try {
|
|
1409
|
+
const config = getConfigSync();
|
|
1410
|
+
const { webhooks: _webhooks, environments: _environments, ...safeConfig } = config;
|
|
1411
|
+
return c.json({ success: true, data: safeConfig });
|
|
1412
|
+
} catch (err) {
|
|
1413
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1414
|
+
return c.json({ success: false, error: message }, 500);
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
var config_default = app3;
|
|
1418
|
+
|
|
1419
|
+
// src/server/routes/diff.ts
|
|
1420
|
+
import { Hono as Hono4 } from "hono";
|
|
1421
|
+
|
|
1422
|
+
// src/core/schema-differ.ts
|
|
1423
|
+
import fs7 from "fs/promises";
|
|
1424
|
+
async function diffSchemaVsDatabase(schemaPath, databaseUrl, cwd) {
|
|
1425
|
+
const env = { ...process.env, DATABASE_URL: databaseUrl };
|
|
1426
|
+
try {
|
|
1427
|
+
const { stdout } = await execPrisma(
|
|
1428
|
+
cwd,
|
|
1429
|
+
[
|
|
1430
|
+
"migrate",
|
|
1431
|
+
"diff",
|
|
1432
|
+
"--from-schema-datamodel",
|
|
1433
|
+
schemaPath,
|
|
1434
|
+
"--to-database-url",
|
|
1435
|
+
databaseUrl,
|
|
1436
|
+
"--script"
|
|
1437
|
+
],
|
|
1438
|
+
{ env, timeout: 3e4 }
|
|
1439
|
+
);
|
|
1440
|
+
const diffs = parseSqlDiff(stdout);
|
|
1441
|
+
return { sql: stdout, diffs };
|
|
1442
|
+
} catch (err) {
|
|
1443
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1444
|
+
if ("stdout" in err) {
|
|
1445
|
+
const stdout = err.stdout ?? "";
|
|
1446
|
+
if (stdout.includes("The migration is empty")) {
|
|
1447
|
+
return { sql: "", diffs: [] };
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
throw error;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
function parseSqlDiff(sql) {
|
|
1454
|
+
const diffs = [];
|
|
1455
|
+
for (const line of sql.split("\n")) {
|
|
1456
|
+
const trimmed = line.trim();
|
|
1457
|
+
const dropTable = trimmed.match(/DROP TABLE\s+"?(\w+)"?/i);
|
|
1458
|
+
if (dropTable) {
|
|
1459
|
+
diffs.push({
|
|
1460
|
+
type: "model_removed",
|
|
1461
|
+
modelName: dropTable[1] ?? "unknown",
|
|
1462
|
+
description: `Table "${dropTable[1]}" will be dropped`,
|
|
1463
|
+
breaking: true
|
|
1464
|
+
});
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
const createTable = trimmed.match(/CREATE TABLE\s+"?(\w+)"?/i);
|
|
1468
|
+
if (createTable) {
|
|
1469
|
+
diffs.push({
|
|
1470
|
+
type: "model_added",
|
|
1471
|
+
modelName: createTable[1] ?? "unknown",
|
|
1472
|
+
description: `Table "${createTable[1]}" will be created`,
|
|
1473
|
+
breaking: false
|
|
1474
|
+
});
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1477
|
+
const addColumn = trimmed.match(/ALTER TABLE\s+"?(\w+)"?\s+ADD COLUMN\s+"?(\w+)"?\s+(\w+)/i);
|
|
1478
|
+
if (addColumn) {
|
|
1479
|
+
diffs.push({
|
|
1480
|
+
type: "field_added",
|
|
1481
|
+
modelName: addColumn[1] ?? "unknown",
|
|
1482
|
+
fieldName: addColumn[2] ?? "unknown",
|
|
1483
|
+
newType: addColumn[3] ?? "unknown",
|
|
1484
|
+
description: `Column "${addColumn[2]}" added to "${addColumn[1]}"`,
|
|
1485
|
+
breaking: false
|
|
1486
|
+
});
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1489
|
+
const dropColumn = trimmed.match(/ALTER TABLE\s+"?(\w+)"?\s+DROP COLUMN\s+"?(\w+)"?/i);
|
|
1490
|
+
if (dropColumn) {
|
|
1491
|
+
diffs.push({
|
|
1492
|
+
type: "field_removed",
|
|
1493
|
+
modelName: dropColumn[1] ?? "unknown",
|
|
1494
|
+
fieldName: dropColumn[2] ?? "unknown",
|
|
1495
|
+
description: `Column "${dropColumn[2]}" dropped from "${dropColumn[1]}" \u2014 data lost`,
|
|
1496
|
+
breaking: true
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
return diffs;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// src/server/routes/diff.ts
|
|
1504
|
+
var app4 = new Hono4();
|
|
1505
|
+
app4.get("/", async (c) => {
|
|
1506
|
+
const projectPath = c.get("projectPath");
|
|
1507
|
+
try {
|
|
1508
|
+
const project = await detectPrismaProject(projectPath);
|
|
1509
|
+
if (!project) return c.json({ success: false, error: "No Prisma project found" }, 404);
|
|
1510
|
+
const dbUrl = c.req.query("url") ?? project.databaseUrl ?? process.env.DATABASE_URL;
|
|
1511
|
+
if (!dbUrl) {
|
|
1512
|
+
return c.json({ success: false, error: "No database URL available" }, 400);
|
|
1513
|
+
}
|
|
1514
|
+
const breakingOnly = c.req.query("breaking") === "true";
|
|
1515
|
+
const { sql, diffs } = await diffSchemaVsDatabase(project.schemaPath, dbUrl, projectPath);
|
|
1516
|
+
const filtered = breakingOnly ? diffs.filter((d) => d.breaking) : diffs;
|
|
1517
|
+
return c.json({
|
|
1518
|
+
success: true,
|
|
1519
|
+
data: {
|
|
1520
|
+
diffs: filtered,
|
|
1521
|
+
totalDiffs: diffs.length,
|
|
1522
|
+
breakingDiffs: diffs.filter((d) => d.breaking).length,
|
|
1523
|
+
hasSql: sql.length > 0
|
|
1524
|
+
}
|
|
1525
|
+
});
|
|
1526
|
+
} catch (err) {
|
|
1527
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1528
|
+
return c.json({ success: false, error: message }, 500);
|
|
1529
|
+
}
|
|
1530
|
+
});
|
|
1531
|
+
var diff_default = app4;
|
|
1532
|
+
|
|
1533
|
+
// src/server/routes/drift.ts
|
|
1534
|
+
import { Hono as Hono5 } from "hono";
|
|
1535
|
+
var app5 = new Hono5();
|
|
1536
|
+
var CACHE_TTL_MS = 1e4;
|
|
1537
|
+
var cacheMap = /* @__PURE__ */ new Map();
|
|
1538
|
+
function buildPayload(result, cachedAt) {
|
|
1539
|
+
return {
|
|
1540
|
+
hasDrift: result.status === "drifted",
|
|
1541
|
+
driftCount: result.items.length,
|
|
1542
|
+
differences: result.items,
|
|
1543
|
+
cachedAt: cachedAt ? new Date(cachedAt).toISOString() : null,
|
|
1544
|
+
status: result.status,
|
|
1545
|
+
...result.errorMessage !== void 0 ? { errorMessage: result.errorMessage } : {}
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
app5.get("/", async (c) => {
|
|
1549
|
+
try {
|
|
1550
|
+
const projectPath = c.get("projectPath");
|
|
1551
|
+
const cached = cacheMap.get(projectPath);
|
|
1552
|
+
if (!cached || Date.now() - cached.fetchedAt > CACHE_TTL_MS) {
|
|
1553
|
+
const data = await detectDrift(projectPath);
|
|
1554
|
+
const entry = { data, fetchedAt: Date.now() };
|
|
1555
|
+
cacheMap.set(projectPath, entry);
|
|
1556
|
+
return c.json({
|
|
1557
|
+
success: true,
|
|
1558
|
+
data: buildPayload(data, entry.fetchedAt)
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
return c.json({
|
|
1562
|
+
success: true,
|
|
1563
|
+
data: buildPayload(cached.data, cached.fetchedAt)
|
|
1564
|
+
});
|
|
1565
|
+
} catch (error) {
|
|
1566
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1567
|
+
return c.json({ success: false, error: message }, 500);
|
|
1568
|
+
}
|
|
1569
|
+
});
|
|
1570
|
+
app5.post("/check", async (c) => {
|
|
1571
|
+
try {
|
|
1572
|
+
const projectPath = c.get("projectPath");
|
|
1573
|
+
const data = await detectDrift(projectPath);
|
|
1574
|
+
const entry = { data, fetchedAt: Date.now() };
|
|
1575
|
+
cacheMap.set(projectPath, entry);
|
|
1576
|
+
return c.json({
|
|
1577
|
+
success: true,
|
|
1578
|
+
message: "Drift check completed",
|
|
1579
|
+
data: buildPayload(data, entry.fetchedAt)
|
|
1580
|
+
});
|
|
1581
|
+
} catch (error) {
|
|
1582
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1583
|
+
return c.json({ success: false, error: message }, 500);
|
|
1584
|
+
}
|
|
1585
|
+
});
|
|
1586
|
+
var drift_default = app5;
|
|
1587
|
+
|
|
1588
|
+
// src/server/routes/events.ts
|
|
1589
|
+
import { Hono as Hono6 } from "hono";
|
|
1590
|
+
import { streamSSE } from "hono/streaming";
|
|
1591
|
+
var app6 = new Hono6();
|
|
1592
|
+
var listeners = /* @__PURE__ */ new Set();
|
|
1593
|
+
app6.get("/", (c) => {
|
|
1594
|
+
return streamSSE(c, async (stream) => {
|
|
1595
|
+
await stream.writeSSE({
|
|
1596
|
+
event: "connected",
|
|
1597
|
+
data: JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString() })
|
|
1598
|
+
});
|
|
1599
|
+
const listener = async (event, data) => {
|
|
1600
|
+
await stream.writeSSE({
|
|
1601
|
+
event,
|
|
1602
|
+
data: JSON.stringify(data)
|
|
1603
|
+
});
|
|
1604
|
+
};
|
|
1605
|
+
listeners.add(listener);
|
|
1606
|
+
const heartbeat = setInterval(async () => {
|
|
1607
|
+
await stream.writeSSE({
|
|
1608
|
+
event: "heartbeat",
|
|
1609
|
+
data: JSON.stringify({ ts: (/* @__PURE__ */ new Date()).toISOString() })
|
|
1610
|
+
});
|
|
1611
|
+
}, 25e3);
|
|
1612
|
+
stream.onAbort(() => {
|
|
1613
|
+
clearInterval(heartbeat);
|
|
1614
|
+
listeners.delete(listener);
|
|
1615
|
+
});
|
|
1616
|
+
await stream.sleep(Number.MAX_SAFE_INTEGER);
|
|
1617
|
+
});
|
|
1618
|
+
});
|
|
1619
|
+
var events_default = app6;
|
|
1620
|
+
|
|
1621
|
+
// src/server/routes/git.ts
|
|
1622
|
+
import { Hono as Hono7 } from "hono";
|
|
1623
|
+
|
|
1624
|
+
// src/core/git-awareness.ts
|
|
1625
|
+
import { execFile as execFile2 } from "child_process";
|
|
1626
|
+
import fs8 from "fs/promises";
|
|
1627
|
+
import path6 from "path";
|
|
1628
|
+
import { promisify as promisify2 } from "util";
|
|
1629
|
+
import { GitAwarenessError } from "@prisma-flow/shared";
|
|
1630
|
+
var execAsync = promisify2(execFile2);
|
|
1631
|
+
async function git(args, cwd) {
|
|
1632
|
+
try {
|
|
1633
|
+
const { stdout } = await execAsync("git", args, { cwd, timeout: 1e4 });
|
|
1634
|
+
return stdout.trim();
|
|
1635
|
+
} catch (err) {
|
|
1636
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1637
|
+
throw new GitAwarenessError(error.message, error);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
async function isGitRepo(cwd) {
|
|
1641
|
+
try {
|
|
1642
|
+
await git(["rev-parse", "--git-dir"], cwd);
|
|
1643
|
+
return true;
|
|
1644
|
+
} catch {
|
|
1645
|
+
return false;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
async function getGitMigrationInfo(migrationsDir, cwd) {
|
|
1649
|
+
let entries;
|
|
1650
|
+
try {
|
|
1651
|
+
const raw = await fs8.readdir(migrationsDir, { withFileTypes: true });
|
|
1652
|
+
entries = raw.filter((d) => d.isDirectory() && /^\d{14}_/.test(d.name)).map((d) => d.name).sort();
|
|
1653
|
+
} catch {
|
|
1654
|
+
return [];
|
|
1655
|
+
}
|
|
1656
|
+
const results = [];
|
|
1657
|
+
for (const entry of entries) {
|
|
1658
|
+
const migrationPath = path6.join(migrationsDir, entry, "migration.sql");
|
|
1659
|
+
const relPath = path6.relative(cwd, migrationPath);
|
|
1660
|
+
try {
|
|
1661
|
+
const log = await git(
|
|
1662
|
+
["log", "--follow", "--diff-filter=A", "--format=%H|%an|%aI|%s", "--", relPath],
|
|
1663
|
+
cwd
|
|
1664
|
+
);
|
|
1665
|
+
if (!log) {
|
|
1666
|
+
results.push({
|
|
1667
|
+
migrationName: entry,
|
|
1668
|
+
committed: false
|
|
1669
|
+
});
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
const [hash, author, date, ...subjectParts] = log.split("|");
|
|
1673
|
+
const subject = subjectParts.join("|");
|
|
1674
|
+
results.push({
|
|
1675
|
+
migrationName: entry,
|
|
1676
|
+
committed: true,
|
|
1677
|
+
...hash?.trim() ? { commitHash: hash.trim() } : {},
|
|
1678
|
+
...author?.trim() ? { commitAuthor: author.trim() } : {},
|
|
1679
|
+
...date?.trim() ? { commitDate: date.trim() } : {},
|
|
1680
|
+
...subject?.trim() ? { commitMessage: subject.trim() } : {}
|
|
1681
|
+
});
|
|
1682
|
+
} catch {
|
|
1683
|
+
results.push({
|
|
1684
|
+
migrationName: entry,
|
|
1685
|
+
committed: false
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
return results;
|
|
1690
|
+
}
|
|
1691
|
+
async function detectMigrationConflicts(migrationsDir) {
|
|
1692
|
+
let entries;
|
|
1693
|
+
try {
|
|
1694
|
+
const raw = await fs8.readdir(migrationsDir, { withFileTypes: true });
|
|
1695
|
+
entries = raw.filter((d) => d.isDirectory() && /^\d{14}_/.test(d.name)).map((d) => d.name);
|
|
1696
|
+
} catch {
|
|
1697
|
+
return [];
|
|
1698
|
+
}
|
|
1699
|
+
const byTimestamp = /* @__PURE__ */ new Map();
|
|
1700
|
+
for (const entry of entries) {
|
|
1701
|
+
const ts = entry.slice(0, 14);
|
|
1702
|
+
const existing = byTimestamp.get(ts) ?? [];
|
|
1703
|
+
existing.push(entry);
|
|
1704
|
+
byTimestamp.set(ts, existing);
|
|
1705
|
+
}
|
|
1706
|
+
const conflicts = [];
|
|
1707
|
+
for (const [timestamp, names] of byTimestamp) {
|
|
1708
|
+
if (names.length > 1) {
|
|
1709
|
+
conflicts.push({
|
|
1710
|
+
timestamp,
|
|
1711
|
+
migrations: names,
|
|
1712
|
+
type: "duplicate_timestamp",
|
|
1713
|
+
description: `${names.length} migrations share timestamp ${timestamp} \u2014 likely from parallel branches`
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
return conflicts;
|
|
1718
|
+
}
|
|
1719
|
+
async function getUncommittedMigrations(migrationsDir, cwd) {
|
|
1720
|
+
let entries;
|
|
1721
|
+
try {
|
|
1722
|
+
const raw = await fs8.readdir(migrationsDir, { withFileTypes: true });
|
|
1723
|
+
entries = raw.filter((d) => d.isDirectory() && /^\d{14}_/.test(d.name)).map((d) => d.name).sort();
|
|
1724
|
+
} catch {
|
|
1725
|
+
return [];
|
|
1726
|
+
}
|
|
1727
|
+
const uncommitted = [];
|
|
1728
|
+
try {
|
|
1729
|
+
const untracked = await git(["ls-files", "--others", "--exclude-standard", migrationsDir], cwd);
|
|
1730
|
+
const untrackedSet = new Set(
|
|
1731
|
+
untracked.split("\n").map((s) => s.trim()).filter(Boolean)
|
|
1732
|
+
);
|
|
1733
|
+
for (const entry of entries) {
|
|
1734
|
+
const sqlFile = path6.relative(cwd, path6.join(migrationsDir, entry, "migration.sql"));
|
|
1735
|
+
if (untrackedSet.has(sqlFile)) {
|
|
1736
|
+
uncommitted.push(entry);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
} catch {
|
|
1740
|
+
}
|
|
1741
|
+
return uncommitted;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
// src/server/routes/git.ts
|
|
1745
|
+
var app7 = new Hono7();
|
|
1746
|
+
app7.get("/", async (c) => {
|
|
1747
|
+
const projectPath = c.get("projectPath");
|
|
1748
|
+
try {
|
|
1749
|
+
const project = await detectPrismaProject(projectPath);
|
|
1750
|
+
if (!project) return c.json({ success: false, error: "No Prisma project found" }, 404);
|
|
1751
|
+
const inRepo = await isGitRepo(projectPath);
|
|
1752
|
+
if (!inRepo) {
|
|
1753
|
+
return c.json({
|
|
1754
|
+
success: true,
|
|
1755
|
+
data: { inRepo: false, migrations: [], conflicts: [], uncommitted: [] }
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
const [migrations, conflicts, uncommitted] = await Promise.all([
|
|
1759
|
+
getGitMigrationInfo(project.migrationsPath, projectPath),
|
|
1760
|
+
detectMigrationConflicts(project.migrationsPath),
|
|
1761
|
+
getUncommittedMigrations(project.migrationsPath, projectPath)
|
|
1762
|
+
]);
|
|
1763
|
+
return c.json({
|
|
1764
|
+
success: true,
|
|
1765
|
+
data: { inRepo: true, migrations, conflicts, uncommitted }
|
|
1766
|
+
});
|
|
1767
|
+
} catch (err) {
|
|
1768
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1769
|
+
return c.json({ success: false, error: message }, 500);
|
|
1770
|
+
}
|
|
1771
|
+
});
|
|
1772
|
+
var git_default = app7;
|
|
1773
|
+
|
|
1774
|
+
// src/server/routes/migrations.ts
|
|
1775
|
+
import { Hono as Hono8 } from "hono";
|
|
1776
|
+
var app8 = new Hono8();
|
|
1777
|
+
app8.get("/", async (c) => {
|
|
1778
|
+
try {
|
|
1779
|
+
const projectPath = c.get("projectPath");
|
|
1780
|
+
const page = Math.max(1, Number.parseInt(c.req.query("page") ?? "1", 10));
|
|
1781
|
+
const limit = Math.min(100, Math.max(1, Number.parseInt(c.req.query("limit") ?? "20", 10)));
|
|
1782
|
+
const all = await getMigrations(projectPath);
|
|
1783
|
+
const total = all.length;
|
|
1784
|
+
const start = (page - 1) * limit;
|
|
1785
|
+
const items = all.slice(start, start + limit);
|
|
1786
|
+
return c.json({
|
|
1787
|
+
success: true,
|
|
1788
|
+
data: items,
|
|
1789
|
+
pagination: { page, limit, total, pages: Math.ceil(total / limit) }
|
|
1790
|
+
});
|
|
1791
|
+
} catch (error) {
|
|
1792
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1793
|
+
return c.json({ success: false, error: message }, 500);
|
|
1794
|
+
}
|
|
1795
|
+
});
|
|
1796
|
+
app8.get("/:name", async (c) => {
|
|
1797
|
+
try {
|
|
1798
|
+
const projectPath = c.get("projectPath");
|
|
1799
|
+
const name = c.req.param("name");
|
|
1800
|
+
const migration = await getMigrationDetails(projectPath, name);
|
|
1801
|
+
if (!migration) {
|
|
1802
|
+
return c.json({ success: false, error: "Migration not found" }, 404);
|
|
1803
|
+
}
|
|
1804
|
+
return c.json({ success: true, data: migration });
|
|
1805
|
+
} catch (error) {
|
|
1806
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1807
|
+
return c.json({ success: false, error: message }, 500);
|
|
1808
|
+
}
|
|
1809
|
+
});
|
|
1810
|
+
var migrations_default = app8;
|
|
1811
|
+
|
|
1812
|
+
// src/server/routes/repair.ts
|
|
1813
|
+
import { Hono as Hono9 } from "hono";
|
|
1814
|
+
|
|
1815
|
+
// src/core/drift-recovery.ts
|
|
1816
|
+
import fs9 from "fs/promises";
|
|
1817
|
+
import path7 from "path";
|
|
1818
|
+
import { DriftRepairError } from "@prisma-flow/shared";
|
|
1819
|
+
function generateRepairSuggestions(driftItems, migrationsDir) {
|
|
1820
|
+
void migrationsDir;
|
|
1821
|
+
const suggestions = [];
|
|
1822
|
+
for (const item of driftItems) {
|
|
1823
|
+
const strategy = pickStrategy(item);
|
|
1824
|
+
const repairSql = generateRepairSql(item, strategy);
|
|
1825
|
+
suggestions.push({
|
|
1826
|
+
driftItem: item,
|
|
1827
|
+
strategy,
|
|
1828
|
+
description: describeStrategy(item, strategy),
|
|
1829
|
+
...repairSql !== void 0 ? { sql: repairSql } : {},
|
|
1830
|
+
automated: strategy !== "MANUAL_SQL",
|
|
1831
|
+
risk: assessRisk(item)
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
return suggestions;
|
|
1835
|
+
}
|
|
1836
|
+
function pickStrategy(item) {
|
|
1837
|
+
switch (item.type) {
|
|
1838
|
+
case "missing_migration":
|
|
1839
|
+
return "APPLY_MIGRATION";
|
|
1840
|
+
case "extra_column":
|
|
1841
|
+
case "extra_table":
|
|
1842
|
+
return "SQUASH";
|
|
1843
|
+
case "modified_migration":
|
|
1844
|
+
return "MANUAL_SQL";
|
|
1845
|
+
default:
|
|
1846
|
+
return "MANUAL_SQL";
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
function describeStrategy(item, strategy) {
|
|
1850
|
+
switch (strategy) {
|
|
1851
|
+
case "APPLY_MIGRATION":
|
|
1852
|
+
return `Apply missing migration "${item.migrationName ?? item.identifier}" to the database`;
|
|
1853
|
+
case "SQUASH":
|
|
1854
|
+
return `Create a new migration that incorporates the "${item.identifier}" drift (extra ${item.type.replace("extra_", "")} in DB)`;
|
|
1855
|
+
case "MANUAL_SQL":
|
|
1856
|
+
return `Manually review and apply SQL to reconcile "${item.identifier}" \u2014 automated repair not safe`;
|
|
1857
|
+
case "IGNORE":
|
|
1858
|
+
return `"${item.identifier}" is safe to ignore (documentation-only drift)`;
|
|
1859
|
+
default:
|
|
1860
|
+
return `Review drift for "${item.identifier}"`;
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
function generateRepairSql(item, strategy) {
|
|
1864
|
+
if (strategy === "APPLY_MIGRATION" && item.migrationName) {
|
|
1865
|
+
return `-- Run: npx prisma migrate resolve --applied "${item.migrationName}"`;
|
|
1866
|
+
}
|
|
1867
|
+
if (strategy === "SQUASH" && item.type === "extra_table") {
|
|
1868
|
+
return `-- Extra table detected. To squash into schema:
|
|
1869
|
+
-- 1. Add the model to prisma/schema.prisma
|
|
1870
|
+
-- 2. Run: npx prisma migrate dev --name squash_${item.identifier}`;
|
|
1871
|
+
}
|
|
1872
|
+
if (strategy === "SQUASH" && item.type === "extra_column") {
|
|
1873
|
+
const identifier = item.identifier ?? "unknown.unknown";
|
|
1874
|
+
const [table, column] = identifier.split(".");
|
|
1875
|
+
return `-- Extra column ${column} on ${table}. To squash:
|
|
1876
|
+
-- 1. Add field to model in prisma/schema.prisma
|
|
1877
|
+
-- 2. Run: npx prisma migrate dev --name squash_${column}_on_${table}`;
|
|
1878
|
+
}
|
|
1879
|
+
if (item.sql) {
|
|
1880
|
+
return `-- Suggested SQL (review carefully before running):
|
|
1881
|
+
${item.sql}`;
|
|
1882
|
+
}
|
|
1883
|
+
return void 0;
|
|
1884
|
+
}
|
|
1885
|
+
function assessRisk(item) {
|
|
1886
|
+
if (item.type === "modified_migration") return "high";
|
|
1887
|
+
if (item.type === "extra_table") return "medium";
|
|
1888
|
+
if (item.type === "extra_column") return "low";
|
|
1889
|
+
if (item.type === "missing_migration") return "medium";
|
|
1890
|
+
return "medium";
|
|
1891
|
+
}
|
|
1892
|
+
async function applyRepairs(suggestions, schemaPath, cwd) {
|
|
1893
|
+
const results = [];
|
|
1894
|
+
for (const suggestion of suggestions) {
|
|
1895
|
+
if (suggestion.strategy !== "APPLY_MIGRATION") continue;
|
|
1896
|
+
if (!suggestion.driftItem.migrationName) continue;
|
|
1897
|
+
const name = suggestion.driftItem.migrationName;
|
|
1898
|
+
try {
|
|
1899
|
+
await execPrisma(cwd, ["migrate", "resolve", "--applied", name, "--schema", schemaPath], {
|
|
1900
|
+
timeout: 3e4
|
|
1901
|
+
});
|
|
1902
|
+
results.push({ migrationName: name, success: true });
|
|
1903
|
+
} catch (err) {
|
|
1904
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
1905
|
+
results.push({
|
|
1906
|
+
migrationName: name,
|
|
1907
|
+
success: false,
|
|
1908
|
+
error: error.message
|
|
1909
|
+
});
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
return results;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
// src/server/routes/repair.ts
|
|
1916
|
+
var app9 = new Hono9();
|
|
1917
|
+
app9.get("/", async (c) => {
|
|
1918
|
+
const projectPath = c.get("projectPath");
|
|
1919
|
+
try {
|
|
1920
|
+
const project = await detectPrismaProject(projectPath);
|
|
1921
|
+
if (!project) return c.json({ success: false, error: "No Prisma project found" }, 404);
|
|
1922
|
+
const driftResult = await detectDrift(projectPath);
|
|
1923
|
+
if (driftResult.status === "error") {
|
|
1924
|
+
return c.json(
|
|
1925
|
+
{
|
|
1926
|
+
success: false,
|
|
1927
|
+
error: driftResult.errorMessage ?? "Drift detection failed"
|
|
1928
|
+
},
|
|
1929
|
+
502
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
const suggestions = generateRepairSuggestions(driftResult.items, project.migrationsPath);
|
|
1933
|
+
return c.json({
|
|
1934
|
+
success: true,
|
|
1935
|
+
data: { drifted: driftResult.status === "drifted", suggestions }
|
|
1936
|
+
});
|
|
1937
|
+
} catch (err) {
|
|
1938
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1939
|
+
return c.json({ success: false, error: message }, 500);
|
|
1940
|
+
}
|
|
1941
|
+
});
|
|
1942
|
+
app9.post("/apply", async (c) => {
|
|
1943
|
+
const projectPath = c.get("projectPath");
|
|
1944
|
+
try {
|
|
1945
|
+
const project = await detectPrismaProject(projectPath);
|
|
1946
|
+
if (!project) return c.json({ success: false, error: "No Prisma project found" }, 404);
|
|
1947
|
+
const driftResult = await detectDrift(projectPath);
|
|
1948
|
+
if (driftResult.status === "error") {
|
|
1949
|
+
return c.json(
|
|
1950
|
+
{
|
|
1951
|
+
success: false,
|
|
1952
|
+
error: driftResult.errorMessage ?? "Drift detection failed"
|
|
1953
|
+
},
|
|
1954
|
+
502
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
const suggestions = generateRepairSuggestions(driftResult.items, project.migrationsPath);
|
|
1958
|
+
const results = await applyRepairs(suggestions, project.schemaPath, projectPath);
|
|
1959
|
+
return c.json({ success: true, data: results });
|
|
1960
|
+
} catch (err) {
|
|
1961
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1962
|
+
return c.json({ success: false, error: message }, 500);
|
|
1963
|
+
}
|
|
1964
|
+
});
|
|
1965
|
+
var repair_default = app9;
|
|
1966
|
+
|
|
1967
|
+
// src/server/routes/risks.ts
|
|
1968
|
+
import { Hono as Hono10 } from "hono";
|
|
1969
|
+
var app10 = new Hono10();
|
|
1970
|
+
app10.get("/", async (c) => {
|
|
1971
|
+
const projectPath = c.get("projectPath");
|
|
1972
|
+
try {
|
|
1973
|
+
const project = await detectPrismaProject(projectPath);
|
|
1974
|
+
if (!project) {
|
|
1975
|
+
return c.json({ success: false, error: "No Prisma project found" }, 404);
|
|
1976
|
+
}
|
|
1977
|
+
const migrations = await getMigrations(projectPath);
|
|
1978
|
+
const risks = migrations.map((m) => ({
|
|
1979
|
+
name: m.name,
|
|
1980
|
+
timestamp: m.timestamp,
|
|
1981
|
+
riskScore: m.riskScore
|
|
1982
|
+
}));
|
|
1983
|
+
return c.json({ success: true, data: risks });
|
|
1984
|
+
} catch (err) {
|
|
1985
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1986
|
+
return c.json({ success: false, error: message }, 500);
|
|
1987
|
+
}
|
|
1988
|
+
});
|
|
1989
|
+
app10.get("/:migration", async (c) => {
|
|
1990
|
+
const projectPath = c.get("projectPath");
|
|
1991
|
+
const migrationQuery = c.req.param("migration");
|
|
1992
|
+
try {
|
|
1993
|
+
const project = await detectPrismaProject(projectPath);
|
|
1994
|
+
if (!project) return c.json({ success: false, error: "No Prisma project found" }, 404);
|
|
1995
|
+
const migrations = await getMigrations(projectPath);
|
|
1996
|
+
const match = migrations.find(
|
|
1997
|
+
(m) => m.name === migrationQuery || m.name.startsWith(migrationQuery)
|
|
1998
|
+
);
|
|
1999
|
+
if (!match) return c.json({ success: false, error: "Migration not found" }, 404);
|
|
2000
|
+
return c.json({ success: true, data: match.riskScore });
|
|
2001
|
+
} catch (err) {
|
|
2002
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2003
|
+
return c.json({ success: false, error: message }, 500);
|
|
2004
|
+
}
|
|
2005
|
+
});
|
|
2006
|
+
var risks_default = app10;
|
|
2007
|
+
|
|
2008
|
+
// src/server/routes/rollback.ts
|
|
2009
|
+
import path9 from "path";
|
|
2010
|
+
import { Hono as Hono11 } from "hono";
|
|
2011
|
+
|
|
2012
|
+
// src/core/rollback-generator.ts
|
|
2013
|
+
import fs11 from "fs/promises";
|
|
2014
|
+
import { RollbackError } from "@prisma-flow/shared";
|
|
2015
|
+
|
|
2016
|
+
// src/core/simulator.ts
|
|
2017
|
+
import { execFile as execFile3 } from "child_process";
|
|
2018
|
+
import fs10 from "fs/promises";
|
|
2019
|
+
import os from "os";
|
|
2020
|
+
import path8 from "path";
|
|
2021
|
+
import { promisify as promisify3 } from "util";
|
|
2022
|
+
import { SimulationError } from "@prisma-flow/shared";
|
|
2023
|
+
var execAsync2 = promisify3(execFile3);
|
|
2024
|
+
var DESTRUCTIVE_PATTERNS = [
|
|
2025
|
+
{
|
|
2026
|
+
pattern: /drop\s+table/i,
|
|
2027
|
+
warning: "Drops a table \u2014 all data will be lost"
|
|
2028
|
+
},
|
|
2029
|
+
{
|
|
2030
|
+
pattern: /truncate\s+table/i,
|
|
2031
|
+
warning: "Truncates a table \u2014 all rows will be deleted"
|
|
2032
|
+
},
|
|
2033
|
+
{
|
|
2034
|
+
pattern: /drop\s+column/i,
|
|
2035
|
+
warning: "Drops a column \u2014 data in that column will be lost"
|
|
2036
|
+
},
|
|
2037
|
+
{
|
|
2038
|
+
pattern: /alter\s+column.*not\s+null/i,
|
|
2039
|
+
warning: "Adds NOT NULL \u2014 will fail if existing rows have NULLs"
|
|
2040
|
+
},
|
|
2041
|
+
{ pattern: /delete\s+from/i, warning: "Deletes rows \u2014 data will be lost" },
|
|
2042
|
+
{
|
|
2043
|
+
pattern: /drop\s+index/i,
|
|
2044
|
+
warning: "Drops an index \u2014 query performance may degrade"
|
|
2045
|
+
},
|
|
2046
|
+
{
|
|
2047
|
+
pattern: /drop\s+constraint/i,
|
|
2048
|
+
warning: "Drops a constraint \u2014 data integrity may be affected"
|
|
2049
|
+
}
|
|
2050
|
+
];
|
|
2051
|
+
var DDL_TYPES = [
|
|
2052
|
+
{ pattern: /^\s*create\s+table/i, type: "CREATE_TABLE" },
|
|
2053
|
+
{ pattern: /^\s*alter\s+table/i, type: "ALTER_TABLE" },
|
|
2054
|
+
{ pattern: /^\s*drop\s+table/i, type: "DROP_TABLE" },
|
|
2055
|
+
{ pattern: /^\s*create\s+(unique\s+)?index/i, type: "CREATE_INDEX" },
|
|
2056
|
+
{ pattern: /^\s*drop\s+index/i, type: "DROP_INDEX" },
|
|
2057
|
+
{ pattern: /^\s*insert\s+into/i, type: "INSERT" },
|
|
2058
|
+
{ pattern: /^\s*update\s+/i, type: "UPDATE" },
|
|
2059
|
+
{ pattern: /^\s*delete\s+from/i, type: "DELETE" },
|
|
2060
|
+
{ pattern: /^\s*truncate/i, type: "TRUNCATE" }
|
|
2061
|
+
];
|
|
2062
|
+
function classifyStatement(sql) {
|
|
2063
|
+
for (const { pattern, type } of DDL_TYPES) {
|
|
2064
|
+
if (pattern.test(sql)) return type;
|
|
2065
|
+
}
|
|
2066
|
+
return "OTHER";
|
|
2067
|
+
}
|
|
2068
|
+
function estimateRowsAffected(sql) {
|
|
2069
|
+
if (/delete\s+from.*where/i.test(sql)) return void 0;
|
|
2070
|
+
if (/delete\s+from\s+\w+\s*;?\s*$/i.test(sql)) return Number.POSITIVE_INFINITY;
|
|
2071
|
+
if (/truncate/i.test(sql)) return Number.POSITIVE_INFINITY;
|
|
2072
|
+
return void 0;
|
|
2073
|
+
}
|
|
2074
|
+
function splitStatements(sql) {
|
|
2075
|
+
return sql.split(";").map((s) => s.trim()).filter((s) => s.length > 0 && !s.startsWith("--"));
|
|
2076
|
+
}
|
|
2077
|
+
function analyseStatically(migrationName, statements) {
|
|
2078
|
+
const parsed = statements.map((sql, index) => {
|
|
2079
|
+
const warnings = [];
|
|
2080
|
+
for (const { pattern, warning } of DESTRUCTIVE_PATTERNS) {
|
|
2081
|
+
if (pattern.test(sql)) warnings.push(warning);
|
|
2082
|
+
}
|
|
2083
|
+
const rowsEst = estimateRowsAffected(sql);
|
|
2084
|
+
return {
|
|
2085
|
+
index,
|
|
2086
|
+
sql,
|
|
2087
|
+
type: classifyStatement(sql),
|
|
2088
|
+
isDestructive: warnings.length > 0,
|
|
2089
|
+
warnings,
|
|
2090
|
+
...rowsEst !== void 0 ? { estimatedRowsAffected: rowsEst } : {}
|
|
2091
|
+
};
|
|
2092
|
+
});
|
|
2093
|
+
const destructive = parsed.filter((s) => s.isDestructive);
|
|
2094
|
+
const allWarnings = destructive.flatMap((s) => s.warnings);
|
|
2095
|
+
return {
|
|
2096
|
+
migrationName,
|
|
2097
|
+
statements: parsed,
|
|
2098
|
+
wouldSucceed: true,
|
|
2099
|
+
// static analysis optimistic
|
|
2100
|
+
destructiveStatements: destructive.length,
|
|
2101
|
+
warnings: allWarnings,
|
|
2102
|
+
simulatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2103
|
+
mode: "static"
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
async function simulateSqlite(migrationName, sqlFilePath, dbFilePath) {
|
|
2107
|
+
const tmpDir = await fs10.mkdtemp(path8.join(os.tmpdir(), "prismaflow-sim-"));
|
|
2108
|
+
const shadowDb = path8.join(tmpDir, "shadow.db");
|
|
2109
|
+
try {
|
|
2110
|
+
await fs10.copyFile(dbFilePath, shadowDb);
|
|
2111
|
+
const sql = await fs10.readFile(sqlFilePath, "utf-8");
|
|
2112
|
+
const statements = splitStatements(sql);
|
|
2113
|
+
try {
|
|
2114
|
+
await execAsync2("sqlite3", [shadowDb, sql], { timeout: 3e4 });
|
|
2115
|
+
const staticResult = analyseStatically(migrationName, statements);
|
|
2116
|
+
return { ...staticResult, wouldSucceed: true, mode: "shadow" };
|
|
2117
|
+
} catch (err) {
|
|
2118
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
2119
|
+
const staticResult = analyseStatically(migrationName, statements);
|
|
2120
|
+
return {
|
|
2121
|
+
...staticResult,
|
|
2122
|
+
wouldSucceed: false,
|
|
2123
|
+
error: error.message,
|
|
2124
|
+
mode: "shadow"
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
} catch {
|
|
2128
|
+
const sql = await fs10.readFile(sqlFilePath, "utf-8").catch(() => "");
|
|
2129
|
+
return analyseStatically(migrationName, splitStatements(sql));
|
|
2130
|
+
} finally {
|
|
2131
|
+
await fs10.rm(tmpDir, { recursive: true, force: true }).catch(() => {
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
async function simulate(migrationName, sqlFilePath, dbFilePath) {
|
|
2136
|
+
try {
|
|
2137
|
+
const sql = await fs10.readFile(sqlFilePath, "utf-8");
|
|
2138
|
+
const statements = splitStatements(sql);
|
|
2139
|
+
if (dbFilePath && dbFilePath !== ":memory:") {
|
|
2140
|
+
try {
|
|
2141
|
+
await fs10.access(dbFilePath);
|
|
2142
|
+
return await simulateSqlite(migrationName, sqlFilePath, dbFilePath);
|
|
2143
|
+
} catch {
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
return analyseStatically(migrationName, statements);
|
|
2147
|
+
} catch (err) {
|
|
2148
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
2149
|
+
throw new SimulationError(migrationName, error);
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
// src/core/rollback-generator.ts
|
|
2154
|
+
function invertStatement(sql) {
|
|
2155
|
+
const trimmed = sql.trim();
|
|
2156
|
+
const createTable = trimmed.match(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);
|
|
2157
|
+
if (createTable) {
|
|
2158
|
+
return {
|
|
2159
|
+
sql: `DROP TABLE IF EXISTS "${createTable[1]}";`,
|
|
2160
|
+
automated: true
|
|
2161
|
+
};
|
|
2162
|
+
}
|
|
2163
|
+
const createIndex = trimmed.match(
|
|
2164
|
+
/^CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i
|
|
2165
|
+
);
|
|
2166
|
+
if (createIndex) {
|
|
2167
|
+
return {
|
|
2168
|
+
sql: `DROP INDEX IF EXISTS "${createIndex[1]}";`,
|
|
2169
|
+
automated: true
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
const addColumn = trimmed.match(
|
|
2173
|
+
/^ALTER\s+TABLE\s+["'`]?(\w+)["'`]?\s+ADD\s+COLUMN\s+["'`]?(\w+)["'`]?/i
|
|
2174
|
+
);
|
|
2175
|
+
if (addColumn) {
|
|
2176
|
+
return {
|
|
2177
|
+
sql: `ALTER TABLE "${addColumn[1]}" DROP COLUMN "${addColumn[2]}";`,
|
|
2178
|
+
automated: true
|
|
2179
|
+
};
|
|
2180
|
+
}
|
|
2181
|
+
const dropColumn = trimmed.match(
|
|
2182
|
+
/^ALTER\s+TABLE\s+["'`]?(\w+)["'`]?\s+DROP\s+COLUMN\s+["'`]?(\w+)["'`]?/i
|
|
2183
|
+
);
|
|
2184
|
+
if (dropColumn) {
|
|
2185
|
+
return {
|
|
2186
|
+
sql: `-- MANUAL: Restore column "${dropColumn[2]}" on table "${dropColumn[1]}" from backup`,
|
|
2187
|
+
automated: false,
|
|
2188
|
+
warning: `DROP COLUMN is irreversible \u2014 column "${dropColumn[2]}" data is lost unless backed up`
|
|
2189
|
+
};
|
|
2190
|
+
}
|
|
2191
|
+
const addConstraint = trimmed.match(
|
|
2192
|
+
/^ALTER\s+TABLE\s+["'`]?(\w+)["'`]?\s+ADD\s+CONSTRAINT\s+["'`]?(\w+)["'`]?/i
|
|
2193
|
+
);
|
|
2194
|
+
if (addConstraint) {
|
|
2195
|
+
return {
|
|
2196
|
+
sql: `ALTER TABLE "${addConstraint[1]}" DROP CONSTRAINT "${addConstraint[2]}";`,
|
|
2197
|
+
automated: true
|
|
2198
|
+
};
|
|
2199
|
+
}
|
|
2200
|
+
const dropTable = trimmed.match(/^DROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?["'`]?(\w+)["'`]?/i);
|
|
2201
|
+
if (dropTable) {
|
|
2202
|
+
return {
|
|
2203
|
+
sql: `-- MANUAL: Recreate table "${dropTable[1]}" and restore data from backup`,
|
|
2204
|
+
automated: false,
|
|
2205
|
+
warning: `DROP TABLE is irreversible \u2014 table "${dropTable[1]}" and all its data are gone unless backed up`
|
|
2206
|
+
};
|
|
2207
|
+
}
|
|
2208
|
+
if (/^TRUNCATE/i.test(trimmed)) {
|
|
2209
|
+
return {
|
|
2210
|
+
sql: "-- MANUAL: Restore truncated rows from backup",
|
|
2211
|
+
automated: false,
|
|
2212
|
+
warning: "TRUNCATE is irreversible \u2014 all rows are deleted"
|
|
2213
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
if (/^DELETE\s+FROM/i.test(trimmed)) {
|
|
2216
|
+
return {
|
|
2217
|
+
sql: "-- MANUAL: Restore deleted rows from backup",
|
|
2218
|
+
automated: false,
|
|
2219
|
+
warning: "DELETE is irreversible \u2014 rows are gone unless backed up"
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
if (/^INSERT\s+INTO/i.test(trimmed)) {
|
|
2223
|
+
return {
|
|
2224
|
+
sql: "-- MANUAL: DELETE the rows inserted by this migration (unknown primary keys)",
|
|
2225
|
+
automated: false,
|
|
2226
|
+
warning: "INSERT rollback requires knowing inserted primary keys"
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
if (/^UPDATE\s+/i.test(trimmed)) {
|
|
2230
|
+
return {
|
|
2231
|
+
sql: "-- MANUAL: Restore original column values from backup",
|
|
2232
|
+
automated: false,
|
|
2233
|
+
warning: "UPDATE rollback requires a snapshot of pre-update values"
|
|
2234
|
+
};
|
|
2235
|
+
}
|
|
2236
|
+
return {
|
|
2237
|
+
sql: `-- MANUAL: Undo: ${trimmed.slice(0, 80)}`,
|
|
2238
|
+
automated: false,
|
|
2239
|
+
warning: "Unrecognised statement \u2014 manual rollback required"
|
|
2240
|
+
};
|
|
2241
|
+
}
|
|
2242
|
+
async function generateRollbackPlan(migrationName, sqlFilePath) {
|
|
2243
|
+
let sql;
|
|
2244
|
+
try {
|
|
2245
|
+
sql = await fs11.readFile(sqlFilePath, "utf-8");
|
|
2246
|
+
} catch (err) {
|
|
2247
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
2248
|
+
throw new RollbackError(migrationName, `Cannot read SQL file: ${error.message}`);
|
|
2249
|
+
}
|
|
2250
|
+
const statements = splitStatements(sql);
|
|
2251
|
+
const steps = [];
|
|
2252
|
+
for (let i = 0; i < statements.length; i++) {
|
|
2253
|
+
const fwd = statements[i];
|
|
2254
|
+
if (!fwd) continue;
|
|
2255
|
+
const inv = invertStatement(fwd);
|
|
2256
|
+
steps.push({
|
|
2257
|
+
index: i,
|
|
2258
|
+
forwardSql: fwd,
|
|
2259
|
+
rollbackSql: inv.sql,
|
|
2260
|
+
automated: inv.automated,
|
|
2261
|
+
...inv.warning !== void 0 ? { warning: inv.warning } : {}
|
|
2262
|
+
});
|
|
2263
|
+
}
|
|
2264
|
+
const reversedSteps = [...steps].reverse();
|
|
2265
|
+
const manualSteps = reversedSteps.filter((s) => !s.automated);
|
|
2266
|
+
const hasManualSteps = manualSteps.length > 0;
|
|
2267
|
+
const warnings = reversedSteps.flatMap((s) => s.warning ? [s.warning] : []);
|
|
2268
|
+
return {
|
|
2269
|
+
migrationName,
|
|
2270
|
+
steps: reversedSteps,
|
|
2271
|
+
hasManualSteps,
|
|
2272
|
+
warnings,
|
|
2273
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2274
|
+
automated: !hasManualSteps
|
|
2275
|
+
};
|
|
2276
|
+
}
|
|
2277
|
+
function renderRollbackSql(plan, includeManual = true) {
|
|
2278
|
+
const header = [
|
|
2279
|
+
`-- Rollback plan for migration: ${plan.migrationName}`,
|
|
2280
|
+
`-- Generated at: ${plan.generatedAt}`,
|
|
2281
|
+
`-- Steps: ${plan.steps.length} (${plan.hasManualSteps ? "contains MANUAL steps" : "fully automated"})`,
|
|
2282
|
+
plan.warnings.length > 0 ? `-- Warnings:
|
|
2283
|
+
${plan.warnings.map((w) => `-- ! ${w}`).join("\n")}` : null,
|
|
2284
|
+
""
|
|
2285
|
+
].filter(Boolean).join("\n");
|
|
2286
|
+
const body = plan.steps.filter((s) => includeManual || s.automated).map((s) => s.rollbackSql).join("\n");
|
|
2287
|
+
return `${header}${body}`;
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
// src/server/routes/rollback.ts
|
|
2291
|
+
var app11 = new Hono11();
|
|
2292
|
+
app11.get("/:migration", async (c) => {
|
|
2293
|
+
const projectPath = c.get("projectPath");
|
|
2294
|
+
const migrationQuery = c.req.param("migration");
|
|
2295
|
+
try {
|
|
2296
|
+
const project = await detectPrismaProject(projectPath);
|
|
2297
|
+
if (!project) return c.json({ success: false, error: "No Prisma project found" }, 404);
|
|
2298
|
+
const match = project.migrations.find(
|
|
2299
|
+
(m) => m.name === migrationQuery || m.name.startsWith(migrationQuery)
|
|
2300
|
+
);
|
|
2301
|
+
if (!match) return c.json({ success: false, error: "Migration not found" }, 404);
|
|
2302
|
+
const sqlFile = path9.join(project.migrationsPath, match.name, "migration.sql");
|
|
2303
|
+
const plan = await generateRollbackPlan(match.name, sqlFile);
|
|
2304
|
+
const format = c.req.query("format");
|
|
2305
|
+
if (format === "sql") {
|
|
2306
|
+
return c.text(renderRollbackSql(plan, true), 200, {
|
|
2307
|
+
"Content-Type": "text/plain",
|
|
2308
|
+
"Content-Disposition": `attachment; filename="${match.name}.rollback.sql"`
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
return c.json({ success: true, data: plan });
|
|
2312
|
+
} catch (err) {
|
|
2313
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2314
|
+
return c.json({ success: false, error: message }, 500);
|
|
2315
|
+
}
|
|
2316
|
+
});
|
|
2317
|
+
var rollback_default = app11;
|
|
2318
|
+
|
|
2319
|
+
// src/server/routes/schema.ts
|
|
2320
|
+
import { Hono as Hono12 } from "hono";
|
|
2321
|
+
|
|
2322
|
+
// src/core/schema-parser.ts
|
|
2323
|
+
async function loadGetDMMF() {
|
|
2324
|
+
const mod = await import("@prisma/internals");
|
|
2325
|
+
const fn = mod.getDMMF ?? mod.default?.getDMMF;
|
|
2326
|
+
if (typeof fn !== "function") {
|
|
2327
|
+
throw new Error("@prisma/internals did not export getDMMF \u2014 check the installed Prisma version");
|
|
2328
|
+
}
|
|
2329
|
+
return fn;
|
|
2330
|
+
}
|
|
2331
|
+
async function parseSchema(cwd) {
|
|
2332
|
+
const project = await detectPrismaProject(cwd);
|
|
2333
|
+
if (!project) return null;
|
|
2334
|
+
try {
|
|
2335
|
+
const getDMMF = await loadGetDMMF();
|
|
2336
|
+
const dmmf = await getDMMF({ datamodel: project.schemaContent });
|
|
2337
|
+
return dmmf.datamodel;
|
|
2338
|
+
} catch (err) {
|
|
2339
|
+
logger.error({ err }, "Schema parse error");
|
|
2340
|
+
throw err;
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
// src/server/routes/schema.ts
|
|
2345
|
+
var app12 = new Hono12();
|
|
2346
|
+
app12.get("/", async (c) => {
|
|
2347
|
+
try {
|
|
2348
|
+
const projectPath = c.get("projectPath");
|
|
2349
|
+
const schema = await parseSchema(projectPath);
|
|
2350
|
+
return c.json({ success: true, data: schema });
|
|
2351
|
+
} catch (error) {
|
|
2352
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2353
|
+
return c.json({ success: false, error: message }, 500);
|
|
2354
|
+
}
|
|
2355
|
+
});
|
|
2356
|
+
var schema_default = app12;
|
|
2357
|
+
|
|
2358
|
+
// src/server/routes/simulate.ts
|
|
2359
|
+
import path10 from "path";
|
|
2360
|
+
import { Hono as Hono13 } from "hono";
|
|
2361
|
+
var app13 = new Hono13();
|
|
2362
|
+
app13.get("/:migration", async (c) => {
|
|
2363
|
+
const projectPath = c.get("projectPath");
|
|
2364
|
+
const migrationQuery = c.req.param("migration");
|
|
2365
|
+
try {
|
|
2366
|
+
const project = await detectPrismaProject(projectPath);
|
|
2367
|
+
if (!project) return c.json({ success: false, error: "No Prisma project found" }, 404);
|
|
2368
|
+
const match = project.migrations.find(
|
|
2369
|
+
(m) => m.name === migrationQuery || m.name.startsWith(migrationQuery)
|
|
2370
|
+
);
|
|
2371
|
+
if (!match) return c.json({ success: false, error: "Migration not found" }, 404);
|
|
2372
|
+
const sqlFile = path10.join(project.migrationsPath, match.name, "migration.sql");
|
|
2373
|
+
const dbPath = project.provider === "sqlite" && project.databaseUrl ? project.databaseUrl.replace(/^file:/, "") : void 0;
|
|
2374
|
+
const result = await simulate(match.name, sqlFile, dbPath);
|
|
2375
|
+
return c.json({ success: true, data: result });
|
|
2376
|
+
} catch (err) {
|
|
2377
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2378
|
+
return c.json({ success: false, error: message }, 500);
|
|
2379
|
+
}
|
|
2380
|
+
});
|
|
2381
|
+
var simulate_default = app13;
|
|
2382
|
+
|
|
2383
|
+
// src/server/routes/status.ts
|
|
2384
|
+
import { Hono as Hono14 } from "hono";
|
|
2385
|
+
var app14 = new Hono14();
|
|
2386
|
+
app14.get("/", async (c) => {
|
|
2387
|
+
try {
|
|
2388
|
+
const projectPath = c.get("projectPath");
|
|
2389
|
+
const status = await getProjectStatus(projectPath);
|
|
2390
|
+
return c.json({ success: true, data: status });
|
|
2391
|
+
} catch (error) {
|
|
2392
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2393
|
+
return c.json({ success: false, error: message }, 500);
|
|
2394
|
+
}
|
|
2395
|
+
});
|
|
2396
|
+
var status_default = app14;
|
|
2397
|
+
|
|
2398
|
+
// src/server/index.ts
|
|
2399
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
2400
|
+
var __dirname = path11.dirname(__filename);
|
|
2401
|
+
function createServer(projectPath) {
|
|
2402
|
+
const app15 = new Hono15();
|
|
2403
|
+
const token = randomBytes(24).toString("hex");
|
|
2404
|
+
app15.use("*", secureHeaders());
|
|
2405
|
+
app15.use(
|
|
2406
|
+
"*",
|
|
2407
|
+
cors({
|
|
2408
|
+
origin: (origin) => {
|
|
2409
|
+
if (!origin) return null;
|
|
2410
|
+
try {
|
|
2411
|
+
const url = new URL(origin);
|
|
2412
|
+
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") return origin;
|
|
2413
|
+
} catch {
|
|
2414
|
+
}
|
|
2415
|
+
return null;
|
|
2416
|
+
},
|
|
2417
|
+
allowMethods: ["GET", "POST", "OPTIONS"],
|
|
2418
|
+
allowHeaders: ["Content-Type", "Authorization"]
|
|
2419
|
+
})
|
|
2420
|
+
);
|
|
2421
|
+
app15.use("*", async (c, next) => {
|
|
2422
|
+
c.set("requestId", randomBytes(8).toString("hex"));
|
|
2423
|
+
c.res.headers.set("X-Request-Id", c.get("requestId"));
|
|
2424
|
+
await next();
|
|
2425
|
+
});
|
|
2426
|
+
app15.use("*", async (c, next) => {
|
|
2427
|
+
const started = Date.now();
|
|
2428
|
+
await next();
|
|
2429
|
+
logger.debug(
|
|
2430
|
+
{
|
|
2431
|
+
method: c.req.method,
|
|
2432
|
+
path: new URL(c.req.url).pathname,
|
|
2433
|
+
status: c.res.status,
|
|
2434
|
+
durationMs: Date.now() - started,
|
|
2435
|
+
requestId: c.get("requestId")
|
|
2436
|
+
},
|
|
2437
|
+
"HTTP request"
|
|
2438
|
+
);
|
|
2439
|
+
});
|
|
2440
|
+
app15.use("/api/*", async (c, next) => {
|
|
2441
|
+
const authHeader = c.req.header("Authorization") ?? "";
|
|
2442
|
+
const queryToken = c.req.query("token") ?? "";
|
|
2443
|
+
const provided = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : queryToken;
|
|
2444
|
+
if (provided !== token) {
|
|
2445
|
+
return c.json({ success: false, error: "Unauthorized" }, 401);
|
|
2446
|
+
}
|
|
2447
|
+
return next();
|
|
2448
|
+
});
|
|
2449
|
+
app15.use("*", async (c, next) => {
|
|
2450
|
+
c.set("projectPath", projectPath);
|
|
2451
|
+
await next();
|
|
2452
|
+
});
|
|
2453
|
+
app15.route("/api/migrations", migrations_default);
|
|
2454
|
+
app15.route("/api/status", status_default);
|
|
2455
|
+
app15.route("/api/schema", schema_default);
|
|
2456
|
+
app15.route("/api/drift", drift_default);
|
|
2457
|
+
app15.route("/api/risks", risks_default);
|
|
2458
|
+
app15.route("/api/rollback", rollback_default);
|
|
2459
|
+
app15.route("/api/simulate", simulate_default);
|
|
2460
|
+
app15.route("/api/repair", repair_default);
|
|
2461
|
+
app15.route("/api/diff", diff_default);
|
|
2462
|
+
app15.route("/api/compare", compare_default);
|
|
2463
|
+
app15.route("/api/git", git_default);
|
|
2464
|
+
app15.route("/api/audit", audit_default);
|
|
2465
|
+
app15.route("/api/config", config_default);
|
|
2466
|
+
app15.route("/api/events", events_default);
|
|
2467
|
+
app15.get("/health", (c) => {
|
|
2468
|
+
return c.json({
|
|
2469
|
+
status: "ok",
|
|
2470
|
+
service: "prisma-flow",
|
|
2471
|
+
version: process.env.npm_package_version ?? "0.0.0",
|
|
2472
|
+
uptime: process.uptime()
|
|
2473
|
+
});
|
|
2474
|
+
});
|
|
2475
|
+
const candidates = [
|
|
2476
|
+
path11.resolve(__dirname, "../public"),
|
|
2477
|
+
// dist/public
|
|
2478
|
+
path11.resolve(__dirname, "../../public"),
|
|
2479
|
+
// packages/cli/public (source)
|
|
2480
|
+
path11.resolve(__dirname, "../../..", "packages", "cli", "public"),
|
|
2481
|
+
// monorepo relative
|
|
2482
|
+
path11.resolve(process.cwd(), "public"),
|
|
2483
|
+
// cwd public
|
|
2484
|
+
path11.resolve(process.cwd(), "packages", "cli", "public"),
|
|
2485
|
+
path11.resolve(process.cwd(), "..", "packages", "cli", "public")
|
|
2486
|
+
];
|
|
2487
|
+
let publicDir = null;
|
|
2488
|
+
for (const cand of candidates) {
|
|
2489
|
+
if (fs12.existsSync(cand)) {
|
|
2490
|
+
publicDir = cand;
|
|
2491
|
+
break;
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
if (!publicDir) {
|
|
2495
|
+
publicDir = path11.resolve(__dirname, "../public");
|
|
2496
|
+
}
|
|
2497
|
+
if (!fs12.existsSync(publicDir)) {
|
|
2498
|
+
logger.warn({ publicDir }, "serveStatic: public directory not found");
|
|
2499
|
+
}
|
|
2500
|
+
app15.use(
|
|
2501
|
+
"/*",
|
|
2502
|
+
serveStatic({
|
|
2503
|
+
root: publicDir,
|
|
2504
|
+
rewriteRequestPath: (p) => p === "/" ? "/index.html" : p
|
|
2505
|
+
})
|
|
2506
|
+
);
|
|
2507
|
+
app15.get("*", async (c) => {
|
|
2508
|
+
try {
|
|
2509
|
+
const indexPath = path11.join(publicDir, "index.html");
|
|
2510
|
+
if (fs12.existsSync(indexPath)) {
|
|
2511
|
+
const content = await fs12.promises.readFile(indexPath, "utf-8");
|
|
2512
|
+
return c.html(content);
|
|
2513
|
+
}
|
|
2514
|
+
return c.text("Dashboard not built \u2014 run `npm run build` first", 404);
|
|
2515
|
+
} catch {
|
|
2516
|
+
return c.text("Error serving dashboard", 500);
|
|
2517
|
+
}
|
|
2518
|
+
});
|
|
2519
|
+
return { app: app15, token };
|
|
2520
|
+
}
|
|
2521
|
+
function startServer(app15, port = 5555) {
|
|
2522
|
+
const server = serve({ fetch: app15.fetch, port }, (info) => {
|
|
2523
|
+
logger.info({ port: info.port }, "PrismaFlow API running");
|
|
2524
|
+
});
|
|
2525
|
+
const shutdown = () => {
|
|
2526
|
+
logger.info("Shutting down PrismaFlow server...");
|
|
2527
|
+
server.close(() => {
|
|
2528
|
+
logger.info("Server closed");
|
|
2529
|
+
process.exit(0);
|
|
2530
|
+
});
|
|
2531
|
+
setTimeout(() => process.exit(1), 5e3).unref();
|
|
2532
|
+
};
|
|
2533
|
+
process.once("SIGTERM", shutdown);
|
|
2534
|
+
process.once("SIGINT", shutdown);
|
|
2535
|
+
return server;
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
// src/commands/dashboard.ts
|
|
2539
|
+
function dashboardCommand() {
|
|
2540
|
+
return new Command3("dashboard").description("Launch PrismaFlow dashboard").option("-p, --port <port>", "Port to run dashboard on").option("--no-open", "Do not open browser automatically").action(async (options) => {
|
|
2541
|
+
const cwd = process.cwd();
|
|
2542
|
+
try {
|
|
2543
|
+
const config = await getConfig(cwd);
|
|
2544
|
+
const project = await detectPrismaProject(cwd);
|
|
2545
|
+
if (!project) {
|
|
2546
|
+
console.error(chalk3.red("\u2716 No Prisma project found in the current directory."));
|
|
2547
|
+
console.error(chalk3.dim(" Make sure prisma/schema.prisma exists."));
|
|
2548
|
+
process.exit(1);
|
|
2549
|
+
}
|
|
2550
|
+
const port = options.port ? Number.parseInt(options.port, 10) : config.port ?? 5555;
|
|
2551
|
+
const shouldOpen = options.open !== false && config.openBrowser;
|
|
2552
|
+
const { app: app15, token } = createServer(cwd);
|
|
2553
|
+
const url = `http://localhost:${port}?token=${token}`;
|
|
2554
|
+
startServer(app15, port);
|
|
2555
|
+
console.log();
|
|
2556
|
+
console.log(chalk3.bold.cyan(" \u{1F680} PrismaFlow Dashboard"));
|
|
2557
|
+
console.log(chalk3.dim("\u2501".repeat(40)));
|
|
2558
|
+
console.log(` ${chalk3.bold("URL:")} ${chalk3.underline(url)}`);
|
|
2559
|
+
console.log(` ${chalk3.bold("Project:")} ${project.schemaPath}`);
|
|
2560
|
+
if (project.provider) {
|
|
2561
|
+
console.log(` ${chalk3.bold("Provider:")} ${project.provider}`);
|
|
2562
|
+
}
|
|
2563
|
+
console.log();
|
|
2564
|
+
console.log(chalk3.dim(" Press Ctrl+C to stop the server."));
|
|
2565
|
+
console.log();
|
|
2566
|
+
if (shouldOpen) {
|
|
2567
|
+
await open(url);
|
|
2568
|
+
}
|
|
2569
|
+
await Promise.all([
|
|
2570
|
+
writeAuditEntry(cwd, "dashboard.start", "success", { port }),
|
|
2571
|
+
trackEvent("dashboard", project.migrations.length)
|
|
2572
|
+
]).catch(() => {
|
|
2573
|
+
});
|
|
2574
|
+
} catch (error) {
|
|
2575
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2576
|
+
console.error(chalk3.red(`\u2716 Failed to start dashboard: ${message}`));
|
|
2577
|
+
process.exit(1);
|
|
2578
|
+
}
|
|
2579
|
+
});
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
// src/commands/diff.ts
|
|
2583
|
+
import chalk4 from "chalk";
|
|
2584
|
+
import { Command as Command4 } from "commander";
|
|
2585
|
+
function diffCommand() {
|
|
2586
|
+
return new Command4("diff").description("Show schema diff between your Prisma schema and the live database").option("--from <url>", "Source database URL (defaults to DATABASE_URL)").option("--json", "Output as JSON").option("--breaking-only", "Show only breaking changes").action(
|
|
2587
|
+
async (options) => {
|
|
2588
|
+
const cwd = process.cwd();
|
|
2589
|
+
try {
|
|
2590
|
+
const project = await detectPrismaProject(cwd);
|
|
2591
|
+
if (!project) {
|
|
2592
|
+
console.error(chalk4.red("\u2716 No Prisma project found."));
|
|
2593
|
+
process.exit(1);
|
|
2594
|
+
}
|
|
2595
|
+
const dbUrl = options.from ?? project.databaseUrl ?? process.env.DATABASE_URL;
|
|
2596
|
+
if (!dbUrl) {
|
|
2597
|
+
console.error(chalk4.red("\u2716 No database URL found."));
|
|
2598
|
+
console.error(chalk4.dim(" Set DATABASE_URL or use --from <url>"));
|
|
2599
|
+
process.exit(1);
|
|
2600
|
+
}
|
|
2601
|
+
if (!options.json) {
|
|
2602
|
+
process.stdout.write(chalk4.dim(" Computing schema diff...\n"));
|
|
2603
|
+
}
|
|
2604
|
+
const { sql, diffs } = await diffSchemaVsDatabase(project.schemaPath, dbUrl, cwd);
|
|
2605
|
+
const filtered = options.breakingOnly ? diffs.filter((d) => d.breaking) : diffs;
|
|
2606
|
+
if (options.json) {
|
|
2607
|
+
process.stdout.write(`${JSON.stringify({ diffs: filtered, sql }, null, 2)}
|
|
2608
|
+
`);
|
|
2609
|
+
process.exit(0);
|
|
2610
|
+
}
|
|
2611
|
+
console.log();
|
|
2612
|
+
console.log(chalk4.bold.cyan(" \u{1F4D0} Schema Diff"));
|
|
2613
|
+
console.log(chalk4.dim("\u2501".repeat(50)));
|
|
2614
|
+
if (filtered.length === 0) {
|
|
2615
|
+
console.log(chalk4.green(" \u2714 Schema is in sync with database \u2014 no differences found."));
|
|
2616
|
+
console.log();
|
|
2617
|
+
process.exit(0);
|
|
2618
|
+
}
|
|
2619
|
+
for (const diff of filtered) {
|
|
2620
|
+
const icon = diff.breaking ? chalk4.red("\u2716") : chalk4.green("+");
|
|
2621
|
+
const label = diff.breaking ? chalk4.red("BREAKING") : chalk4.green("safe");
|
|
2622
|
+
const name = diff.modelName ? diff.fieldName ? `${diff.modelName}.${diff.fieldName}` : diff.modelName : diff.entity ?? "unknown";
|
|
2623
|
+
console.log(` ${icon} [${label}] ${chalk4.bold(name)}`);
|
|
2624
|
+
console.log(` ${chalk4.dim(diff.description)}`);
|
|
2625
|
+
if (diff.oldType || diff.newType) {
|
|
2626
|
+
console.log(
|
|
2627
|
+
` ${chalk4.dim(diff.oldType ?? "?")} \u2192 ${chalk4.dim(diff.newType ?? "?")}`
|
|
2628
|
+
);
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
const breaking = filtered.filter((d) => d.breaking).length;
|
|
2632
|
+
console.log();
|
|
2633
|
+
console.log(
|
|
2634
|
+
chalk4.dim(
|
|
2635
|
+
` ${filtered.length} difference(s) found \u2014 ${breaking} breaking, ${filtered.length - breaking} safe`
|
|
2636
|
+
)
|
|
2637
|
+
);
|
|
2638
|
+
console.log();
|
|
2639
|
+
await Promise.all([
|
|
2640
|
+
writeAuditEntry(cwd, "schema.diff", "success", {
|
|
2641
|
+
diffs: filtered.length,
|
|
2642
|
+
breaking
|
|
2643
|
+
}),
|
|
2644
|
+
trackEvent("diff", filtered.length)
|
|
2645
|
+
]).catch(() => {
|
|
2646
|
+
});
|
|
2647
|
+
process.exit(breaking > 0 ? 2 : 0);
|
|
2648
|
+
} catch (err) {
|
|
2649
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2650
|
+
console.error(chalk4.red(`\u2716 ${message}`));
|
|
2651
|
+
process.exit(1);
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
);
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
// src/commands/doctor.ts
|
|
2658
|
+
import { execFile as execFile4 } from "child_process";
|
|
2659
|
+
import fs13 from "fs/promises";
|
|
2660
|
+
import path12 from "path";
|
|
2661
|
+
import { promisify as promisify4 } from "util";
|
|
2662
|
+
import chalk5 from "chalk";
|
|
2663
|
+
import { Command as Command5 } from "commander";
|
|
2664
|
+
var execFileAsync2 = promisify4(execFile4);
|
|
2665
|
+
async function runChecks(checks) {
|
|
2666
|
+
let passed = 0;
|
|
2667
|
+
let failed = 0;
|
|
2668
|
+
for (const check of checks) {
|
|
2669
|
+
process.stdout.write(` ${chalk5.gray("\u2022")} ${check.label} ... `);
|
|
2670
|
+
try {
|
|
2671
|
+
const { ok, detail } = await check.run();
|
|
2672
|
+
if (ok) {
|
|
2673
|
+
process.stdout.write(`${chalk5.green("OK") + (detail ? chalk5.gray(` (${detail})`) : "")}
|
|
2674
|
+
`);
|
|
2675
|
+
passed++;
|
|
2676
|
+
} else {
|
|
2677
|
+
process.stdout.write(`${chalk5.red("FAIL") + (detail ? chalk5.gray(` \u2014 ${detail}`) : "")}
|
|
2678
|
+
`);
|
|
2679
|
+
failed++;
|
|
2680
|
+
}
|
|
2681
|
+
} catch (err) {
|
|
2682
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2683
|
+
process.stdout.write(`${chalk5.red("ERROR") + chalk5.gray(` \u2014 ${msg}`)}
|
|
2684
|
+
`);
|
|
2685
|
+
failed++;
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
return { passed, failed };
|
|
2689
|
+
}
|
|
2690
|
+
function doctorCommand() {
|
|
2691
|
+
return new Command5("doctor").description("Validate the environment and project setup").option("--json", "Output result as JSON").action(async (options) => {
|
|
2692
|
+
const cwd = process.cwd();
|
|
2693
|
+
let totalPassed = 0;
|
|
2694
|
+
let totalFailed = 0;
|
|
2695
|
+
const merge = (r) => {
|
|
2696
|
+
totalPassed += r.passed;
|
|
2697
|
+
totalFailed += r.failed;
|
|
2698
|
+
};
|
|
2699
|
+
if (!options.json) {
|
|
2700
|
+
console.log();
|
|
2701
|
+
console.log(chalk5.bold.cyan(" \u{1FA7A} PrismaFlow Doctor"));
|
|
2702
|
+
console.log(chalk5.dim("\u2501".repeat(50)));
|
|
2703
|
+
console.log(chalk5.dim(" Checking environment..."));
|
|
2704
|
+
}
|
|
2705
|
+
merge(
|
|
2706
|
+
await runChecks([
|
|
2707
|
+
{
|
|
2708
|
+
label: "Node.js version \u2265 20",
|
|
2709
|
+
run: async () => {
|
|
2710
|
+
const [major] = process.versions.node.split(".").map(Number);
|
|
2711
|
+
return { ok: (major ?? 0) >= 20, detail: process.versions.node };
|
|
2712
|
+
}
|
|
2713
|
+
},
|
|
2714
|
+
{
|
|
2715
|
+
label: "prisma CLI available",
|
|
2716
|
+
run: async () => {
|
|
2717
|
+
const { stdout } = await execPrisma(cwd, ["--version"], {
|
|
2718
|
+
timeout: 15e3
|
|
2719
|
+
});
|
|
2720
|
+
const version = stdout.match(/prisma\s+:\s+([\d.]+)/i)?.[1] ?? stdout.trim().split("\n")[0];
|
|
2721
|
+
return { ok: true, detail: version ?? "unknown" };
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
])
|
|
2725
|
+
);
|
|
2726
|
+
const project = await detectPrismaProject(cwd);
|
|
2727
|
+
merge(
|
|
2728
|
+
await runChecks([
|
|
2729
|
+
{
|
|
2730
|
+
label: "prisma schema found",
|
|
2731
|
+
run: async () => ({
|
|
2732
|
+
ok: project !== null,
|
|
2733
|
+
detail: project ? path12.relative(cwd, project.schemaPath) : "not found"
|
|
2734
|
+
})
|
|
2735
|
+
},
|
|
2736
|
+
{
|
|
2737
|
+
label: "DATABASE_URL configured",
|
|
2738
|
+
run: async () => ({
|
|
2739
|
+
ok: Boolean(project?.databaseUrl || process.env.DATABASE_URL),
|
|
2740
|
+
detail: project?.databaseUrl || process.env.DATABASE_URL ? "set" : "not found in .env or environment"
|
|
2741
|
+
})
|
|
2742
|
+
},
|
|
2743
|
+
{
|
|
2744
|
+
label: "migrations directory exists",
|
|
2745
|
+
run: async () => {
|
|
2746
|
+
if (!project) return { ok: false, detail: "no project" };
|
|
2747
|
+
try {
|
|
2748
|
+
await fs13.access(project.migrationsPath);
|
|
2749
|
+
return {
|
|
2750
|
+
ok: true,
|
|
2751
|
+
detail: `${project.migrations.length} migration(s)`
|
|
2752
|
+
};
|
|
2753
|
+
} catch {
|
|
2754
|
+
return {
|
|
2755
|
+
ok: false,
|
|
2756
|
+
detail: `${project.migrationsPath} not found`
|
|
2757
|
+
};
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
},
|
|
2761
|
+
{
|
|
2762
|
+
label: "database provider detected",
|
|
2763
|
+
run: async () => ({
|
|
2764
|
+
ok: project?.provider !== null && project?.provider !== void 0,
|
|
2765
|
+
detail: project?.provider ?? "unknown \u2014 check datasource block"
|
|
2766
|
+
})
|
|
2767
|
+
},
|
|
2768
|
+
{
|
|
2769
|
+
label: "prismaflow.config.ts present",
|
|
2770
|
+
run: async () => {
|
|
2771
|
+
const configCandidates = [
|
|
2772
|
+
path12.join(cwd, "prismaflow.config.ts"),
|
|
2773
|
+
path12.join(cwd, "prismaflow.config.js"),
|
|
2774
|
+
path12.join(cwd, "prismaflow.config.mjs")
|
|
2775
|
+
];
|
|
2776
|
+
for (const c of configCandidates) {
|
|
2777
|
+
try {
|
|
2778
|
+
await fs13.access(c);
|
|
2779
|
+
return { ok: true, detail: path12.relative(cwd, c) };
|
|
2780
|
+
} catch {
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
return { ok: false, detail: "not found \u2014 run: prisma-flow init" };
|
|
2784
|
+
}
|
|
2785
|
+
},
|
|
2786
|
+
{
|
|
2787
|
+
label: "git repository detected",
|
|
2788
|
+
run: async () => {
|
|
2789
|
+
try {
|
|
2790
|
+
await execFileAsync2("git", ["rev-parse", "--git-dir"], {
|
|
2791
|
+
cwd,
|
|
2792
|
+
timeout: 5e3
|
|
2793
|
+
});
|
|
2794
|
+
const { stdout } = await execFileAsync2(
|
|
2795
|
+
"git",
|
|
2796
|
+
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
2797
|
+
{
|
|
2798
|
+
cwd,
|
|
2799
|
+
timeout: 5e3
|
|
2800
|
+
}
|
|
2801
|
+
);
|
|
2802
|
+
return { ok: true, detail: `branch: ${stdout.trim()}` };
|
|
2803
|
+
} catch {
|
|
2804
|
+
return {
|
|
2805
|
+
ok: false,
|
|
2806
|
+
detail: "not a git repo \u2014 git-awareness features unavailable"
|
|
2807
|
+
};
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
])
|
|
2812
|
+
);
|
|
2813
|
+
if (project) {
|
|
2814
|
+
merge(
|
|
2815
|
+
await runChecks([
|
|
2816
|
+
{
|
|
2817
|
+
label: "database reachable",
|
|
2818
|
+
run: async () => {
|
|
2819
|
+
try {
|
|
2820
|
+
await execPrisma(cwd, ["migrate", "status", "--schema", project.schemaPath], {
|
|
2821
|
+
timeout: 2e4
|
|
2822
|
+
});
|
|
2823
|
+
return { ok: true, detail: "all migrations applied" };
|
|
2824
|
+
} catch (err) {
|
|
2825
|
+
const error = err;
|
|
2826
|
+
const stderr = error.stderr ?? "";
|
|
2827
|
+
const stdout = error.stdout ?? "";
|
|
2828
|
+
if (stderr.includes("P1001") || stderr.includes("Can't reach database server") || stderr.includes("Connection refused")) {
|
|
2829
|
+
return {
|
|
2830
|
+
ok: false,
|
|
2831
|
+
detail: "database unreachable (P1001)"
|
|
2832
|
+
};
|
|
2833
|
+
}
|
|
2834
|
+
const pending = stdout.match(/have not yet been applied/i);
|
|
2835
|
+
return {
|
|
2836
|
+
ok: true,
|
|
2837
|
+
detail: pending ? "connected \u2014 pending migrations exist" : "connected"
|
|
2838
|
+
};
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
])
|
|
2843
|
+
);
|
|
2844
|
+
} else if (!options.json) {
|
|
2845
|
+
console.log(chalk5.dim(" Skipping database checks \u2014 no Prisma project found"));
|
|
2846
|
+
}
|
|
2847
|
+
const allOk = totalFailed === 0;
|
|
2848
|
+
if (options.json) {
|
|
2849
|
+
process.stdout.write(
|
|
2850
|
+
`${JSON.stringify({ ok: allOk, passed: totalPassed, failed: totalFailed })}
|
|
2851
|
+
`
|
|
2852
|
+
);
|
|
2853
|
+
} else {
|
|
2854
|
+
console.log(chalk5.dim("\u2501".repeat(50)));
|
|
2855
|
+
if (allOk) {
|
|
2856
|
+
console.log(
|
|
2857
|
+
chalk5.bold.green(` \u2714 All ${totalPassed} checks passed \u2014 environment healthy`)
|
|
2858
|
+
);
|
|
2859
|
+
} else {
|
|
2860
|
+
console.log(
|
|
2861
|
+
chalk5.bold.red(` \u2716 ${totalFailed} check${totalFailed !== 1 ? "s" : ""} failed`) + chalk5.dim(` (${totalPassed} passed)`)
|
|
2862
|
+
);
|
|
2863
|
+
}
|
|
2864
|
+
console.log();
|
|
2865
|
+
}
|
|
2866
|
+
await Promise.all([
|
|
2867
|
+
writeAuditEntry(cwd, "doctor.run", allOk ? "success" : "warning", {
|
|
2868
|
+
passed: totalPassed,
|
|
2869
|
+
failed: totalFailed
|
|
2870
|
+
}),
|
|
2871
|
+
trackEvent("doctor", project?.migrations.length ?? 0)
|
|
2872
|
+
]).catch(() => {
|
|
2873
|
+
});
|
|
2874
|
+
process.exit(allOk ? 0 : 1);
|
|
2875
|
+
});
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
// src/commands/history.ts
|
|
2879
|
+
import chalk6 from "chalk";
|
|
2880
|
+
import { Command as Command6 } from "commander";
|
|
2881
|
+
function historyCommand() {
|
|
2882
|
+
return new Command6("history").description("Show migration history with risk and git metadata").option("--limit <n>", "Show last N migrations", "20").option("--json", "Output as JSON").option("--git", "Include git information (slower)").action(async (options) => {
|
|
2883
|
+
const cwd = process.cwd();
|
|
2884
|
+
try {
|
|
2885
|
+
const project = await detectPrismaProject(cwd);
|
|
2886
|
+
if (!project) {
|
|
2887
|
+
console.error(chalk6.red("\u2716 No Prisma project found."));
|
|
2888
|
+
process.exit(1);
|
|
2889
|
+
}
|
|
2890
|
+
const limit = Number.parseInt(options.limit ?? "20", 10);
|
|
2891
|
+
const all = await getMigrations(cwd);
|
|
2892
|
+
const migrations = all.slice(-limit).reverse();
|
|
2893
|
+
const gitInfo = /* @__PURE__ */ new Map();
|
|
2894
|
+
if (options.git) {
|
|
2895
|
+
const inRepo = await isGitRepo(cwd);
|
|
2896
|
+
if (inRepo) {
|
|
2897
|
+
const raw = await getGitMigrationInfo(project.migrationsPath, cwd);
|
|
2898
|
+
for (const info of raw) {
|
|
2899
|
+
gitInfo.set(info.migrationName, info);
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
if (options.json) {
|
|
2904
|
+
const output = migrations.map((m) => ({
|
|
2905
|
+
...m,
|
|
2906
|
+
git: gitInfo.get(m.name)
|
|
2907
|
+
}));
|
|
2908
|
+
process.stdout.write(`${JSON.stringify(output, null, 2)}
|
|
2909
|
+
`);
|
|
2910
|
+
process.exit(0);
|
|
2911
|
+
}
|
|
2912
|
+
console.log();
|
|
2913
|
+
console.log(chalk6.bold.cyan(` \u{1F4DC} Migration History (last ${limit})`));
|
|
2914
|
+
console.log(chalk6.dim("\u2501".repeat(65)));
|
|
2915
|
+
for (const m of migrations) {
|
|
2916
|
+
const status = m.status ?? "applied";
|
|
2917
|
+
const statusColor = status === "applied" ? chalk6.green : status === "pending" ? chalk6.yellow : status === "failed" ? chalk6.red : chalk6.gray;
|
|
2918
|
+
const riskLabel = m.riskScore ? m.riskScore.level === "critical" || m.riskScore.level === "high" ? chalk6.red(`[${m.riskScore.level}]`) : m.riskScore.level === "medium" ? chalk6.yellow(`[${m.riskScore.level}]`) : chalk6.dim(`[${m.riskScore.level}]`) : "";
|
|
2919
|
+
console.log(` ${statusColor("\u25CF")} ${chalk6.bold(m.name)} ${riskLabel}`);
|
|
2920
|
+
console.log(
|
|
2921
|
+
` ${chalk6.dim("Applied:")} ${m.appliedAt ?? m.timestamp} ${chalk6.dim("Status:")} ${statusColor(status)}`
|
|
2922
|
+
);
|
|
2923
|
+
if (options.git) {
|
|
2924
|
+
const git2 = gitInfo.get(m.name);
|
|
2925
|
+
if (git2?.committed) {
|
|
2926
|
+
console.log(
|
|
2927
|
+
` ${chalk6.dim("Author:")} ${git2.commitAuthor ?? "unknown"} ${chalk6.dim("Commit:")} ${git2.commitHash?.slice(0, 8) ?? "-"}`
|
|
2928
|
+
);
|
|
2929
|
+
if (git2.commitMessage) {
|
|
2930
|
+
console.log(` ${chalk6.dim("Message:")} ${git2.commitMessage}`);
|
|
2931
|
+
}
|
|
2932
|
+
} else if (git2) {
|
|
2933
|
+
console.log(` ${chalk6.yellow("\u26A0")} Not committed to git`);
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
console.log();
|
|
2938
|
+
console.log(chalk6.dim(` Showing ${migrations.length} of ${all.length} total migrations`));
|
|
2939
|
+
console.log();
|
|
2940
|
+
await Promise.all([
|
|
2941
|
+
writeAuditEntry(cwd, "migration.history", "success", {
|
|
2942
|
+
count: migrations.length
|
|
2943
|
+
}),
|
|
2944
|
+
trackEvent("history", migrations.length)
|
|
2945
|
+
]).catch(() => {
|
|
2946
|
+
});
|
|
2947
|
+
process.exit(0);
|
|
2948
|
+
} catch (err) {
|
|
2949
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2950
|
+
console.error(chalk6.red(`\u2716 ${message}`));
|
|
2951
|
+
process.exit(1);
|
|
2952
|
+
}
|
|
2953
|
+
});
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
// src/commands/init.ts
|
|
2957
|
+
import fs14 from "fs/promises";
|
|
2958
|
+
import path13 from "path";
|
|
2959
|
+
import chalk7 from "chalk";
|
|
2960
|
+
import { Command as Command7 } from "commander";
|
|
2961
|
+
var CONFIG_TEMPLATE = `import type { PrismaFlowConfig } from 'prisma-flow'
|
|
2962
|
+
|
|
2963
|
+
const config: PrismaFlowConfig = {
|
|
2964
|
+
/**
|
|
2965
|
+
* Port for the local dashboard server.
|
|
2966
|
+
* Can also be set via PRISMAFLOW_PORT env var.
|
|
2967
|
+
*/
|
|
2968
|
+
port: 5555,
|
|
2969
|
+
|
|
2970
|
+
/**
|
|
2971
|
+
* Log level: 'trace' | 'debug' | 'info' | 'warn' | 'error'
|
|
2972
|
+
* Can also be set via PRISMAFLOW_LOG_LEVEL env var.
|
|
2973
|
+
*/
|
|
2974
|
+
logLevel: 'info',
|
|
2975
|
+
|
|
2976
|
+
/**
|
|
2977
|
+
* Automatically open the browser when the dashboard starts.
|
|
2978
|
+
*/
|
|
2979
|
+
openBrowser: true,
|
|
2980
|
+
|
|
2981
|
+
/**
|
|
2982
|
+
* V1 local-first feature flags. Core safety features are free.
|
|
2983
|
+
*/
|
|
2984
|
+
features: {
|
|
2985
|
+
riskAnalysis: true,
|
|
2986
|
+
simulation: true,
|
|
2987
|
+
ciAnnotations: true,
|
|
2988
|
+
},
|
|
2989
|
+
|
|
2990
|
+
/**
|
|
2991
|
+
* Named environments are reserved for the roadmap.
|
|
2992
|
+
* environments: [
|
|
2993
|
+
* { name: 'staging', databaseUrl: process.env.STAGING_DATABASE_URL! },
|
|
2994
|
+
* { name: 'production', databaseUrl: process.env.PROD_DATABASE_URL! },
|
|
2995
|
+
* ],
|
|
2996
|
+
*/
|
|
2997
|
+
environments: [],
|
|
2998
|
+
|
|
2999
|
+
/**
|
|
3000
|
+
* Risk level that triggers warnings in CI output.
|
|
3001
|
+
* One of: 'low' | 'medium' | 'high' | 'critical'
|
|
3002
|
+
*/
|
|
3003
|
+
riskThreshold: 'medium',
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
export default config
|
|
3007
|
+
`;
|
|
3008
|
+
function initCommand() {
|
|
3009
|
+
return new Command7("init").description("Create a prismaflow.config.ts in the current project").option("-f, --force", "Overwrite existing config file").action(async (options) => {
|
|
3010
|
+
const cwd = process.cwd();
|
|
3011
|
+
const dest = path13.join(cwd, "prismaflow.config.ts");
|
|
3012
|
+
try {
|
|
3013
|
+
await fs14.access(dest);
|
|
3014
|
+
if (!options.force) {
|
|
3015
|
+
console.log(chalk7.yellow("\u26A0 prismaflow.config.ts already exists."));
|
|
3016
|
+
console.log(chalk7.dim(" Use --force to overwrite: prisma-flow init --force"));
|
|
3017
|
+
process.exit(0);
|
|
3018
|
+
}
|
|
3019
|
+
await fs14.writeFile(dest, CONFIG_TEMPLATE, "utf-8");
|
|
3020
|
+
console.log(chalk7.green("\u2714 prismaflow.config.ts overwritten."));
|
|
3021
|
+
} catch {
|
|
3022
|
+
try {
|
|
3023
|
+
await fs14.writeFile(dest, CONFIG_TEMPLATE, "utf-8");
|
|
3024
|
+
console.log(chalk7.green("\u2714 Created prismaflow.config.ts"));
|
|
3025
|
+
console.log(chalk7.dim(" Edit it to customise PrismaFlow for your project."));
|
|
3026
|
+
console.log();
|
|
3027
|
+
console.log(chalk7.dim(" Next steps:"));
|
|
3028
|
+
console.log(chalk7.dim(" 1. Confirm DATABASE_URL is set"));
|
|
3029
|
+
console.log(chalk7.dim(" 2. Run: prisma-flow doctor"));
|
|
3030
|
+
console.log(chalk7.dim(" 3. Run: prisma-flow dashboard"));
|
|
3031
|
+
} catch (writeErr) {
|
|
3032
|
+
const message = writeErr instanceof Error ? writeErr.message : String(writeErr);
|
|
3033
|
+
console.error(chalk7.red(`\u2716 Failed to write config: ${message}`));
|
|
3034
|
+
process.exit(4);
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
await trackEvent("init", 0).catch(() => {
|
|
3038
|
+
});
|
|
3039
|
+
});
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
// src/commands/inspect.ts
|
|
3043
|
+
import fs15 from "fs/promises";
|
|
3044
|
+
import path14 from "path";
|
|
3045
|
+
import chalk8 from "chalk";
|
|
3046
|
+
import { Command as Command8 } from "commander";
|
|
3047
|
+
function inspectCommand() {
|
|
3048
|
+
return new Command8("inspect").description("Show detailed information about a specific migration").argument("<migration>", "Migration name or timestamp prefix").option("--json", "Output as JSON").option("--sql", "Print migration SQL").option("--rollback", "Generate and show rollback plan").action(
|
|
3049
|
+
async (migrationQuery, options) => {
|
|
3050
|
+
const cwd = process.cwd();
|
|
3051
|
+
try {
|
|
3052
|
+
const project = await detectPrismaProject(cwd);
|
|
3053
|
+
if (!project) {
|
|
3054
|
+
console.error(chalk8.red("\u2716 No Prisma project found."));
|
|
3055
|
+
process.exit(1);
|
|
3056
|
+
}
|
|
3057
|
+
const match = project.migrations.find(
|
|
3058
|
+
(m) => m.name === migrationQuery || m.name.startsWith(migrationQuery) || m.timestamp.startsWith(migrationQuery)
|
|
3059
|
+
);
|
|
3060
|
+
if (!match) {
|
|
3061
|
+
console.error(chalk8.red(`\u2716 Migration "${migrationQuery}" not found.`));
|
|
3062
|
+
console.error(
|
|
3063
|
+
chalk8.dim(` Available: ${project.migrations.map((m) => m.name).join(", ")}`)
|
|
3064
|
+
);
|
|
3065
|
+
process.exit(1);
|
|
3066
|
+
}
|
|
3067
|
+
const sqlFile = path14.join(project.migrationsPath, match.name, "migration.sql");
|
|
3068
|
+
let sql = "";
|
|
3069
|
+
try {
|
|
3070
|
+
sql = await fs15.readFile(sqlFile, "utf-8");
|
|
3071
|
+
} catch {
|
|
3072
|
+
sql = "-- SQL file not found";
|
|
3073
|
+
}
|
|
3074
|
+
const statements = splitStatements(sql);
|
|
3075
|
+
const riskScore = scoreMigrationRisk(sql);
|
|
3076
|
+
const simulation = analyseStatically(match.name, statements);
|
|
3077
|
+
let rollbackPlan = null;
|
|
3078
|
+
if (options.rollback) {
|
|
3079
|
+
rollbackPlan = await generateRollbackPlan(match.name, sqlFile);
|
|
3080
|
+
}
|
|
3081
|
+
if (options.json) {
|
|
3082
|
+
process.stdout.write(
|
|
3083
|
+
`${JSON.stringify({ migration: match, riskScore, simulation, rollbackPlan }, null, 2)}
|
|
3084
|
+
`
|
|
3085
|
+
);
|
|
3086
|
+
process.exit(0);
|
|
3087
|
+
}
|
|
3088
|
+
const riskColor = riskScore.level === "critical" ? chalk8.red : riskScore.level === "high" ? chalk8.yellow : riskScore.level === "medium" ? chalk8.cyan : chalk8.green;
|
|
3089
|
+
console.log();
|
|
3090
|
+
console.log(chalk8.bold.cyan(` \u{1F50D} Migration: ${match.name}`));
|
|
3091
|
+
console.log(chalk8.dim("\u2501".repeat(60)));
|
|
3092
|
+
console.log(` ${chalk8.bold("Status:")} ${chalk8.green(match.status ?? "applied")}`);
|
|
3093
|
+
console.log(` ${chalk8.bold("Applied:")} ${match.appliedAt ?? match.timestamp}`);
|
|
3094
|
+
console.log(
|
|
3095
|
+
` ${chalk8.bold("Risk:")} ${riskColor(riskScore.level.toUpperCase())} (score: ${riskScore.score})`
|
|
3096
|
+
);
|
|
3097
|
+
console.log(` ${chalk8.bold("Statements:")} ${statements.length}`);
|
|
3098
|
+
if (riskScore.factors && riskScore.factors.length > 0) {
|
|
3099
|
+
console.log();
|
|
3100
|
+
console.log(chalk8.bold(" Risk Factors:"));
|
|
3101
|
+
for (const f of riskScore.factors) {
|
|
3102
|
+
console.log(` ${chalk8.red("\u2022")} ${f.description} ${chalk8.dim(`[${f.severity}]`)}`);
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
if (simulation.warnings.length > 0) {
|
|
3106
|
+
console.log();
|
|
3107
|
+
console.log(chalk8.bold(" Warnings:"));
|
|
3108
|
+
for (const w of simulation.warnings) {
|
|
3109
|
+
console.log(` ${chalk8.yellow("\u26A0")} ${w}`);
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
if (options.sql) {
|
|
3113
|
+
console.log();
|
|
3114
|
+
console.log(chalk8.bold(" SQL:"));
|
|
3115
|
+
console.log(chalk8.dim(` ${"\u2500".repeat(56)}`));
|
|
3116
|
+
console.log(
|
|
3117
|
+
sql.split("\n").map((l) => ` ${chalk8.gray(l)}`).join("\n")
|
|
3118
|
+
);
|
|
3119
|
+
}
|
|
3120
|
+
if (rollbackPlan) {
|
|
3121
|
+
console.log();
|
|
3122
|
+
console.log(chalk8.bold(" Rollback Plan:"));
|
|
3123
|
+
const auto = rollbackPlan.steps.filter((s) => s.automated).length;
|
|
3124
|
+
const manual = rollbackPlan.steps.filter((s) => !s.automated).length;
|
|
3125
|
+
console.log(
|
|
3126
|
+
` ${chalk8.green(`${auto} automated`)} / ${chalk8.yellow(`${manual} manual`)} steps`
|
|
3127
|
+
);
|
|
3128
|
+
if (rollbackPlan.warnings.length > 0) {
|
|
3129
|
+
for (const w of rollbackPlan.warnings) {
|
|
3130
|
+
console.log(` ${chalk8.yellow("\u26A0")} ${w}`);
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
console.log();
|
|
3135
|
+
await Promise.all([
|
|
3136
|
+
writeAuditEntry(cwd, "migration.inspect", "success", {
|
|
3137
|
+
migration: match.name
|
|
3138
|
+
}),
|
|
3139
|
+
trackEvent("inspect", 1)
|
|
3140
|
+
]).catch(() => {
|
|
3141
|
+
});
|
|
3142
|
+
} catch (err) {
|
|
3143
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3144
|
+
console.error(chalk8.red(`\u2716 ${message}`));
|
|
3145
|
+
process.exit(1);
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
);
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
// src/commands/repair.ts
|
|
3152
|
+
import chalk9 from "chalk";
|
|
3153
|
+
import { Command as Command9 } from "commander";
|
|
3154
|
+
function repairCommand() {
|
|
3155
|
+
return new Command9("repair").description("Detect drift and generate (or apply) repair suggestions").option("--apply", "Auto-apply safe repair steps (APPLY_MIGRATION only)").option("--json", "Output as JSON").action(async (options) => {
|
|
3156
|
+
const cwd = process.cwd();
|
|
3157
|
+
try {
|
|
3158
|
+
const project = await detectPrismaProject(cwd);
|
|
3159
|
+
if (!project) {
|
|
3160
|
+
console.error(chalk9.red("\u2716 No Prisma project found."));
|
|
3161
|
+
process.exit(1);
|
|
3162
|
+
}
|
|
3163
|
+
if (!options.json) {
|
|
3164
|
+
process.stdout.write(chalk9.dim(" Detecting drift...\n"));
|
|
3165
|
+
}
|
|
3166
|
+
const driftResult = await detectDrift(cwd);
|
|
3167
|
+
if (driftResult.status === "error") {
|
|
3168
|
+
if (options.json) {
|
|
3169
|
+
process.stdout.write(
|
|
3170
|
+
`${JSON.stringify({ ok: false, error: driftResult.errorMessage })}
|
|
3171
|
+
`
|
|
3172
|
+
);
|
|
3173
|
+
} else {
|
|
3174
|
+
console.error(chalk9.red(`\u2716 Drift detection failed: ${driftResult.errorMessage}`));
|
|
3175
|
+
}
|
|
3176
|
+
process.exit(1);
|
|
3177
|
+
}
|
|
3178
|
+
if (driftResult.status === "clean" || driftResult.items.length === 0) {
|
|
3179
|
+
if (options.json) {
|
|
3180
|
+
process.stdout.write(
|
|
3181
|
+
`${JSON.stringify({ ok: true, drifted: false, suggestions: [] })}
|
|
3182
|
+
`
|
|
3183
|
+
);
|
|
3184
|
+
} else {
|
|
3185
|
+
console.log(chalk9.green(" \u2714 No drift detected \u2014 database is in sync."));
|
|
3186
|
+
}
|
|
3187
|
+
process.exit(0);
|
|
3188
|
+
}
|
|
3189
|
+
const suggestions = generateRepairSuggestions(driftResult.items, project.migrationsPath);
|
|
3190
|
+
if (options.json) {
|
|
3191
|
+
process.stdout.write(
|
|
3192
|
+
`${JSON.stringify({ ok: true, drifted: true, suggestions }, null, 2)}
|
|
3193
|
+
`
|
|
3194
|
+
);
|
|
3195
|
+
if (options.apply) {
|
|
3196
|
+
const results = await applyRepairs(suggestions, project.schemaPath, cwd);
|
|
3197
|
+
const out = JSON.stringify({ applied: results }, null, 2);
|
|
3198
|
+
process.stdout.write(`${out}
|
|
3199
|
+
`);
|
|
3200
|
+
}
|
|
3201
|
+
process.exit(0);
|
|
3202
|
+
}
|
|
3203
|
+
console.log();
|
|
3204
|
+
console.log(chalk9.bold.cyan(" \u{1F527} Drift Repair"));
|
|
3205
|
+
console.log(chalk9.dim("\u2501".repeat(50)));
|
|
3206
|
+
console.log(
|
|
3207
|
+
` ${chalk9.bold("Drift items found:")} ${chalk9.yellow(driftResult.items.length.toString())}`
|
|
3208
|
+
);
|
|
3209
|
+
console.log();
|
|
3210
|
+
for (const suggestion of suggestions) {
|
|
3211
|
+
const riskColor = suggestion.risk === "high" ? chalk9.red : suggestion.risk === "medium" ? chalk9.yellow : chalk9.green;
|
|
3212
|
+
console.log(
|
|
3213
|
+
` ${riskColor("\u25CF")} [${riskColor(suggestion.risk.toUpperCase())}] ${suggestion.description}`
|
|
3214
|
+
);
|
|
3215
|
+
console.log(
|
|
3216
|
+
` Strategy: ${chalk9.cyan(suggestion.strategy)} (${suggestion.automated ? chalk9.green("automated") : chalk9.yellow("manual")})`
|
|
3217
|
+
);
|
|
3218
|
+
if (suggestion.sql) {
|
|
3219
|
+
console.log(chalk9.dim(` ${suggestion.sql.split("\n")[0]}`));
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
if (options.apply) {
|
|
3223
|
+
console.log();
|
|
3224
|
+
console.log(chalk9.bold(" Applying auto-repair steps..."));
|
|
3225
|
+
const results = await applyRepairs(suggestions, project.schemaPath, cwd);
|
|
3226
|
+
for (const r of results) {
|
|
3227
|
+
console.log(
|
|
3228
|
+
` ${r.success ? chalk9.green("\u2713") : chalk9.red("\u2716")} ${r.migrationName}${r.error ? chalk9.dim(` \u2014 ${r.error}`) : ""}`
|
|
3229
|
+
);
|
|
3230
|
+
}
|
|
3231
|
+
} else {
|
|
3232
|
+
console.log();
|
|
3233
|
+
console.log(chalk9.dim(" To auto-apply safe repairs: prisma-flow repair --apply"));
|
|
3234
|
+
}
|
|
3235
|
+
console.log();
|
|
3236
|
+
await Promise.all([
|
|
3237
|
+
writeAuditEntry(cwd, "drift.repair", "success", {
|
|
3238
|
+
items: driftResult.items.length,
|
|
3239
|
+
applied: options.apply
|
|
3240
|
+
}),
|
|
3241
|
+
trackEvent("repair", driftResult.items.length)
|
|
3242
|
+
]).catch(() => {
|
|
3243
|
+
});
|
|
3244
|
+
process.exit(driftResult.items.length > 0 && !options.apply ? 2 : 0);
|
|
3245
|
+
} catch (err) {
|
|
3246
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3247
|
+
console.error(chalk9.red(`\u2716 ${message}`));
|
|
3248
|
+
process.exit(1);
|
|
3249
|
+
}
|
|
3250
|
+
});
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
// src/commands/report.ts
|
|
3254
|
+
import fs16 from "fs/promises";
|
|
3255
|
+
import path15 from "path";
|
|
3256
|
+
import chalk10 from "chalk";
|
|
3257
|
+
import { Command as Command10 } from "commander";
|
|
3258
|
+
function formatDate(value) {
|
|
3259
|
+
if (!value) return "Not recorded";
|
|
3260
|
+
const date = new Date(value);
|
|
3261
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
3262
|
+
return date.toISOString();
|
|
3263
|
+
}
|
|
3264
|
+
function formatDuration(ms) {
|
|
3265
|
+
if (ms === void 0) return "Not recorded";
|
|
3266
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
3267
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
3268
|
+
}
|
|
3269
|
+
function uniqueRecommendations(factors) {
|
|
3270
|
+
return Array.from(new Set(factors.map((factor) => factor.recommendation)));
|
|
3271
|
+
}
|
|
3272
|
+
function buildRecommendations(status, migrations, driftCount) {
|
|
3273
|
+
const readinessActions = status.deploymentReadiness.checks.filter((check) => !check.passed).map((check) => `${check.label}: ${check.message}`);
|
|
3274
|
+
const topRiskFactors = migrations.flatMap((migration) => migration.riskScore.factors).filter((factor) => factor.severity === "high" || factor.severity === "critical");
|
|
3275
|
+
const recommendations = [
|
|
3276
|
+
...readinessActions,
|
|
3277
|
+
...uniqueRecommendations(topRiskFactors),
|
|
3278
|
+
...driftCount > 0 ? ["Review drift details and reconcile the Prisma schema before deployment."] : []
|
|
3279
|
+
];
|
|
3280
|
+
return recommendations.length > 0 ? recommendations : ["No blocking issues detected. Keep reviewing generated SQL before production deploys."];
|
|
3281
|
+
}
|
|
3282
|
+
function renderMarkdown(report) {
|
|
3283
|
+
const lines = [
|
|
3284
|
+
"# PrismaFlow Report",
|
|
3285
|
+
"",
|
|
3286
|
+
`Generated: ${report.generatedAt}`,
|
|
3287
|
+
"",
|
|
3288
|
+
"## Project",
|
|
3289
|
+
"",
|
|
3290
|
+
`- Schema: ${report.project.schemaPath}`,
|
|
3291
|
+
`- Migrations: ${report.project.migrationsPath}`,
|
|
3292
|
+
`- Provider: ${report.project.provider ?? "unknown"}`,
|
|
3293
|
+
`- Prisma version: ${report.project.prismaVersion ?? "unknown"}`,
|
|
3294
|
+
`- Package manager: ${report.project.packageManager ?? "unknown"}`,
|
|
3295
|
+
`- DATABASE_URL detected: ${report.project.hasDatabaseUrl ? "yes" : "no"}`,
|
|
3296
|
+
"",
|
|
3297
|
+
"## Summary",
|
|
3298
|
+
"",
|
|
3299
|
+
`- Database connected: ${report.summary.connected ? "yes" : "no"}`,
|
|
3300
|
+
`- Applied migrations: ${report.summary.migrationsApplied}`,
|
|
3301
|
+
`- Pending migrations: ${report.summary.migrationsPending}`,
|
|
3302
|
+
`- Failed migrations: ${report.summary.migrationsFailed}`,
|
|
3303
|
+
`- Drift: ${report.summary.driftDetected ? `${report.summary.driftCount} item(s)` : "none"}`,
|
|
3304
|
+
`- Risk level: ${report.summary.riskLevel}`,
|
|
3305
|
+
`- Health score: ${report.summary.healthScore}/100`,
|
|
3306
|
+
`- Deployment readiness: ${report.readiness.summary} (${report.readiness.score}/100)`,
|
|
3307
|
+
"",
|
|
3308
|
+
"## Readiness Checks",
|
|
3309
|
+
"",
|
|
3310
|
+
"| Check | Status | Message |",
|
|
3311
|
+
"| --- | --- | --- |",
|
|
3312
|
+
...report.readiness.checks.map(
|
|
3313
|
+
(check) => `| ${check.label} | ${check.passed ? "pass" : "fail"} | ${check.message} |`
|
|
3314
|
+
),
|
|
3315
|
+
"",
|
|
3316
|
+
"## Migration Timeline",
|
|
3317
|
+
"",
|
|
3318
|
+
"| Migration | Status | Created | Applied | Duration | Risk |",
|
|
3319
|
+
"| --- | --- | --- | --- | --- | --- |",
|
|
3320
|
+
...report.migrations.map(
|
|
3321
|
+
(migration) => `| ${migration.name} | ${migration.status} | ${formatDate(migration.createdAt)} | ${formatDate(
|
|
3322
|
+
migration.appliedAt
|
|
3323
|
+
)} | ${formatDuration(migration.durationMs)} | ${migration.riskScore.level} (${migration.riskScore.score}) |`
|
|
3324
|
+
),
|
|
3325
|
+
"",
|
|
3326
|
+
"## Drift",
|
|
3327
|
+
""
|
|
3328
|
+
];
|
|
3329
|
+
if (report.drift.items.length === 0) {
|
|
3330
|
+
lines.push("No drift detected.");
|
|
3331
|
+
} else {
|
|
3332
|
+
for (const item of report.drift.items) {
|
|
3333
|
+
lines.push(`- ${item.type}: ${item.description}`);
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
lines.push("", "## Recommendations", "");
|
|
3337
|
+
for (const recommendation of report.recommendations) {
|
|
3338
|
+
lines.push(`- ${recommendation}`);
|
|
3339
|
+
}
|
|
3340
|
+
return `${lines.join("\n")}
|
|
3341
|
+
`;
|
|
3342
|
+
}
|
|
3343
|
+
async function writeReport(content, outputPath) {
|
|
3344
|
+
if (!outputPath) {
|
|
3345
|
+
process.stdout.write(content);
|
|
3346
|
+
return null;
|
|
3347
|
+
}
|
|
3348
|
+
const resolved = path15.resolve(process.cwd(), outputPath);
|
|
3349
|
+
await fs16.mkdir(path15.dirname(resolved), { recursive: true });
|
|
3350
|
+
await fs16.writeFile(resolved, content, "utf-8");
|
|
3351
|
+
return resolved;
|
|
3352
|
+
}
|
|
3353
|
+
function normalizeFormat(format, json) {
|
|
3354
|
+
if (json) return "json";
|
|
3355
|
+
if (format === "md" || format === "markdown") return "markdown";
|
|
3356
|
+
return "json";
|
|
3357
|
+
}
|
|
3358
|
+
function reportCommand() {
|
|
3359
|
+
return new Command10("report").description("Generate a local PrismaFlow report for review or CI artifacts").option("--format <format>", "Output format: json or markdown", "json").option("--json", "Shortcut for --format json").option("-o, --output <path>", "Write the report to a file instead of stdout").action(async (options) => {
|
|
3360
|
+
const cwd = process.cwd();
|
|
3361
|
+
const format = normalizeFormat(options.format, options.json);
|
|
3362
|
+
try {
|
|
3363
|
+
const project = await detectPrismaProject(cwd);
|
|
3364
|
+
if (!project) {
|
|
3365
|
+
process.stderr.write(`${chalk10.red("\u2716 No Prisma project found.")}
|
|
3366
|
+
`);
|
|
3367
|
+
process.exit(4);
|
|
3368
|
+
}
|
|
3369
|
+
const [status, migrations, drift] = await Promise.all([
|
|
3370
|
+
getProjectStatus(cwd),
|
|
3371
|
+
getMigrations(cwd),
|
|
3372
|
+
detectDrift(cwd)
|
|
3373
|
+
]);
|
|
3374
|
+
const reportMigrations = migrations.map((migration) => ({
|
|
3375
|
+
name: migration.name,
|
|
3376
|
+
status: migration.status,
|
|
3377
|
+
...migration.createdAt ? { createdAt: migration.createdAt } : {},
|
|
3378
|
+
...migration.appliedAt ? { appliedAt: migration.appliedAt } : {},
|
|
3379
|
+
...migration.durationMs !== void 0 ? { durationMs: migration.durationMs } : {},
|
|
3380
|
+
riskScore: migration.riskScore
|
|
3381
|
+
}));
|
|
3382
|
+
const report = {
|
|
3383
|
+
schemaVersion: "prismaflow-report/v1",
|
|
3384
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3385
|
+
project: {
|
|
3386
|
+
schemaPath: project.schemaPath,
|
|
3387
|
+
migrationsPath: project.migrationsPath,
|
|
3388
|
+
...project.provider ? { provider: project.provider } : {},
|
|
3389
|
+
...project.prismaVersion ? { prismaVersion: project.prismaVersion } : {},
|
|
3390
|
+
...project.packageManager ? { packageManager: project.packageManager } : {},
|
|
3391
|
+
hasDatabaseUrl: project.databaseUrl.length > 0
|
|
3392
|
+
},
|
|
3393
|
+
summary: {
|
|
3394
|
+
connected: status.connected,
|
|
3395
|
+
migrationsApplied: status.migrationsApplied,
|
|
3396
|
+
migrationsPending: status.migrationsPending,
|
|
3397
|
+
migrationsFailed: status.migrationsFailed,
|
|
3398
|
+
driftDetected: status.driftDetected,
|
|
3399
|
+
driftCount: status.driftCount,
|
|
3400
|
+
riskLevel: status.riskLevel,
|
|
3401
|
+
healthScore: status.healthScore
|
|
3402
|
+
},
|
|
3403
|
+
readiness: status.deploymentReadiness,
|
|
3404
|
+
migrations: reportMigrations,
|
|
3405
|
+
drift: {
|
|
3406
|
+
status: drift.status,
|
|
3407
|
+
driftCount: drift.items.length,
|
|
3408
|
+
items: drift.items.map((item) => ({
|
|
3409
|
+
type: item.type,
|
|
3410
|
+
description: item.description,
|
|
3411
|
+
...item.identifier ? { identifier: item.identifier } : {},
|
|
3412
|
+
...item.migrationName ? { migrationName: item.migrationName } : {},
|
|
3413
|
+
sql: item.sql
|
|
3414
|
+
})),
|
|
3415
|
+
...drift.errorMessage ? { errorMessage: drift.errorMessage } : {}
|
|
3416
|
+
},
|
|
3417
|
+
recommendations: buildRecommendations(status, reportMigrations, drift.items.length)
|
|
3418
|
+
};
|
|
3419
|
+
const content = format === "markdown" ? renderMarkdown(report) : `${JSON.stringify(report, null, 2)}
|
|
3420
|
+
`;
|
|
3421
|
+
const writtenPath = await writeReport(content, options.output);
|
|
3422
|
+
if (writtenPath) {
|
|
3423
|
+
process.stdout.write(`${chalk10.green("\u2714 Report written:")} ${writtenPath}
|
|
3424
|
+
`);
|
|
3425
|
+
}
|
|
3426
|
+
} catch (error) {
|
|
3427
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3428
|
+
process.stderr.write(`${chalk10.red(`\u2716 Report failed: ${message}`)}
|
|
3429
|
+
`);
|
|
3430
|
+
process.exit(4);
|
|
3431
|
+
}
|
|
3432
|
+
});
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
// src/commands/rollback.ts
|
|
3436
|
+
import path16 from "path";
|
|
3437
|
+
import chalk11 from "chalk";
|
|
3438
|
+
import { Command as Command11 } from "commander";
|
|
3439
|
+
function rollbackCommand() {
|
|
3440
|
+
return new Command11("rollback").description("Generate a rollback plan for a migration").argument("<migration>", "Migration name or timestamp prefix").option("--json", "Output as JSON").option("--print-sql", "Print the rollback SQL to stdout").option("--include-manual", "Include manual-only steps in SQL output", true).action(
|
|
3441
|
+
async (migrationQuery, options) => {
|
|
3442
|
+
const cwd = process.cwd();
|
|
3443
|
+
try {
|
|
3444
|
+
const project = await detectPrismaProject(cwd);
|
|
3445
|
+
if (!project) {
|
|
3446
|
+
console.error(chalk11.red("\u2716 No Prisma project found."));
|
|
3447
|
+
process.exit(1);
|
|
3448
|
+
}
|
|
3449
|
+
const match = project.migrations.find(
|
|
3450
|
+
(m) => m.name === migrationQuery || m.name.startsWith(migrationQuery) || m.timestamp.startsWith(migrationQuery)
|
|
3451
|
+
);
|
|
3452
|
+
if (!match) {
|
|
3453
|
+
console.error(chalk11.red(`\u2716 Migration "${migrationQuery}" not found.`));
|
|
3454
|
+
process.exit(1);
|
|
3455
|
+
}
|
|
3456
|
+
const sqlFile = path16.join(project.migrationsPath, match.name, "migration.sql");
|
|
3457
|
+
const plan = await generateRollbackPlan(match.name, sqlFile);
|
|
3458
|
+
if (options.json) {
|
|
3459
|
+
process.stdout.write(`${JSON.stringify(plan, null, 2)}
|
|
3460
|
+
`);
|
|
3461
|
+
process.exit(0);
|
|
3462
|
+
}
|
|
3463
|
+
if (options.printSql) {
|
|
3464
|
+
process.stdout.write(`${renderRollbackSql(plan, options.includeManual !== false)}
|
|
3465
|
+
`);
|
|
3466
|
+
process.exit(0);
|
|
3467
|
+
}
|
|
3468
|
+
const autoCount = plan.steps.filter((s) => s.automated).length;
|
|
3469
|
+
const manualCount = plan.steps.filter((s) => !s.automated).length;
|
|
3470
|
+
console.log();
|
|
3471
|
+
console.log(chalk11.bold.cyan(` \u21A9 Rollback Plan: ${match.name}`));
|
|
3472
|
+
console.log(chalk11.dim("\u2501".repeat(60)));
|
|
3473
|
+
console.log(
|
|
3474
|
+
` ${chalk11.bold("Steps:")} ${autoCount} automated, ${manualCount > 0 ? chalk11.yellow(`${manualCount} manual`) : chalk11.green("0 manual")}`
|
|
3475
|
+
);
|
|
3476
|
+
console.log(
|
|
3477
|
+
` ${chalk11.bold("Fully automated:")} ${plan.automated ? chalk11.green("Yes") : chalk11.yellow("No \u2014 manual review required")}`
|
|
3478
|
+
);
|
|
3479
|
+
console.log();
|
|
3480
|
+
for (let i = 0; i < plan.steps.length; i++) {
|
|
3481
|
+
const step = plan.steps[i];
|
|
3482
|
+
const icon = step.automated ? chalk11.green("\u2713") : chalk11.yellow("\u26A0");
|
|
3483
|
+
const label = step.automated ? chalk11.dim("[auto]") : chalk11.yellow("[manual]");
|
|
3484
|
+
console.log(` ${icon} Step ${i + 1} ${label}`);
|
|
3485
|
+
console.log(` ${chalk11.dim("Forward:")} ${step.forwardSql.slice(0, 60)}`);
|
|
3486
|
+
console.log(` ${chalk11.dim("Rollback:")} ${step.rollbackSql.slice(0, 60)}`);
|
|
3487
|
+
if (step.warning) {
|
|
3488
|
+
console.log(` ${chalk11.yellow("\u26A0")} ${step.warning}`);
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3491
|
+
if (plan.warnings.length > 0) {
|
|
3492
|
+
console.log();
|
|
3493
|
+
console.log(chalk11.bold(" Warnings:"));
|
|
3494
|
+
for (const w of plan.warnings) {
|
|
3495
|
+
console.log(` ${chalk11.yellow("\u26A0")} ${w}`);
|
|
3496
|
+
}
|
|
3497
|
+
}
|
|
3498
|
+
console.log();
|
|
3499
|
+
console.log(chalk11.dim(" To generate rollback SQL:"));
|
|
3500
|
+
console.log(chalk11.dim(` prisma-flow rollback ${match.name} --print-sql`));
|
|
3501
|
+
console.log();
|
|
3502
|
+
await Promise.all([
|
|
3503
|
+
writeAuditEntry(cwd, "migration.rollback", "success", {
|
|
3504
|
+
migration: match.name
|
|
3505
|
+
}),
|
|
3506
|
+
trackEvent("rollback", plan.steps.length)
|
|
3507
|
+
]).catch(() => {
|
|
3508
|
+
});
|
|
3509
|
+
process.exit(plan.hasManualSteps ? 1 : 0);
|
|
3510
|
+
} catch (err) {
|
|
3511
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3512
|
+
console.error(chalk11.red(`\u2716 ${message}`));
|
|
3513
|
+
process.exit(1);
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
);
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
// src/commands/simulate.ts
|
|
3520
|
+
import path17 from "path";
|
|
3521
|
+
import chalk12 from "chalk";
|
|
3522
|
+
import { Command as Command12 } from "commander";
|
|
3523
|
+
function simulateCommand() {
|
|
3524
|
+
return new Command12("simulate").description("Dry-run a migration to preview its effect without applying it").argument("<migration>", "Migration name or timestamp prefix").option("--json", "Output as JSON").option("--fail-on-destructive", "Exit with code 2 if destructive statements are found").action(
|
|
3525
|
+
async (migrationQuery, options) => {
|
|
3526
|
+
const cwd = process.cwd();
|
|
3527
|
+
try {
|
|
3528
|
+
const project = await detectPrismaProject(cwd);
|
|
3529
|
+
if (!project) {
|
|
3530
|
+
console.error(chalk12.red("\u2716 No Prisma project found."));
|
|
3531
|
+
process.exit(1);
|
|
3532
|
+
}
|
|
3533
|
+
const match = project.migrations.find(
|
|
3534
|
+
(m) => m.name === migrationQuery || m.name.startsWith(migrationQuery) || m.timestamp.startsWith(migrationQuery)
|
|
3535
|
+
);
|
|
3536
|
+
if (!match) {
|
|
3537
|
+
console.error(chalk12.red(`\u2716 Migration "${migrationQuery}" not found.`));
|
|
3538
|
+
process.exit(1);
|
|
3539
|
+
}
|
|
3540
|
+
const sqlFile = path17.join(project.migrationsPath, match.name, "migration.sql");
|
|
3541
|
+
const dbPath = project.provider === "sqlite" && project.databaseUrl ? project.databaseUrl.replace(/^file:/, "") : void 0;
|
|
3542
|
+
const result = await simulate(match.name, sqlFile, dbPath);
|
|
3543
|
+
if (options.json) {
|
|
3544
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
3545
|
+
`);
|
|
3546
|
+
process.exit(0);
|
|
3547
|
+
}
|
|
3548
|
+
console.log();
|
|
3549
|
+
console.log(chalk12.bold.cyan(` \u{1F52C} Simulation: ${match.name}`));
|
|
3550
|
+
console.log(chalk12.dim("\u2501".repeat(60)));
|
|
3551
|
+
console.log(` ${chalk12.bold("Mode:")} ${result.mode}`);
|
|
3552
|
+
console.log(
|
|
3553
|
+
` ${chalk12.bold("Result:")} ${result.wouldSucceed ? chalk12.green("Would succeed") : chalk12.red("Would fail")}`
|
|
3554
|
+
);
|
|
3555
|
+
console.log(
|
|
3556
|
+
` ${chalk12.bold("Statements:")} ${result.statements.length} (${result.destructiveStatements} destructive)`
|
|
3557
|
+
);
|
|
3558
|
+
console.log();
|
|
3559
|
+
for (const stmt of result.statements) {
|
|
3560
|
+
const icon = stmt.isDestructive ? chalk12.red("\u26A0") : chalk12.green("\u2713");
|
|
3561
|
+
const typeLabel = chalk12.dim(`[${stmt.type}]`);
|
|
3562
|
+
const preview = stmt.sql.slice(0, 70).replace(/\n/g, " ");
|
|
3563
|
+
console.log(
|
|
3564
|
+
` ${icon} ${typeLabel} ${preview}${stmt.sql.length > 70 ? chalk12.dim("\u2026") : ""}`
|
|
3565
|
+
);
|
|
3566
|
+
for (const w of stmt.warnings) {
|
|
3567
|
+
console.log(` ${chalk12.yellow("\u2192")} ${chalk12.yellow(w)}`);
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
if (result.warnings.length > 0) {
|
|
3571
|
+
console.log();
|
|
3572
|
+
console.log(chalk12.bold(" Warnings:"));
|
|
3573
|
+
for (const w of result.warnings) {
|
|
3574
|
+
console.log(` ${chalk12.yellow("\u26A0")} ${w}`);
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
if (!result.wouldSucceed && result.error) {
|
|
3578
|
+
console.log();
|
|
3579
|
+
console.log(chalk12.red(` \u2716 Error: ${result.error}`));
|
|
3580
|
+
}
|
|
3581
|
+
console.log();
|
|
3582
|
+
await Promise.all([
|
|
3583
|
+
writeAuditEntry(
|
|
3584
|
+
cwd,
|
|
3585
|
+
"migration.simulate",
|
|
3586
|
+
result.wouldSucceed ? "success" : "warning",
|
|
3587
|
+
{
|
|
3588
|
+
migration: match.name,
|
|
3589
|
+
destructive: result.destructiveStatements
|
|
3590
|
+
}
|
|
3591
|
+
),
|
|
3592
|
+
trackEvent("simulate", result.statements.length)
|
|
3593
|
+
]).catch(() => {
|
|
3594
|
+
});
|
|
3595
|
+
if (!result.wouldSucceed) process.exit(1);
|
|
3596
|
+
if (options.failOnDestructive && result.destructiveStatements > 0) process.exit(2);
|
|
3597
|
+
process.exit(0);
|
|
3598
|
+
} catch (err) {
|
|
3599
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3600
|
+
console.error(chalk12.red(`\u2716 ${message}`));
|
|
3601
|
+
process.exit(1);
|
|
3602
|
+
}
|
|
3603
|
+
}
|
|
3604
|
+
);
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
// src/commands/status.ts
|
|
3608
|
+
import chalk13 from "chalk";
|
|
3609
|
+
import { Command as Command13 } from "commander";
|
|
3610
|
+
function statusCommand() {
|
|
3611
|
+
return new Command13("status").description("Show current migration and database status").option("--json", "Output as JSON").option("--quiet", "Suppress decorative output").action(async (options) => {
|
|
3612
|
+
const cwd = process.cwd();
|
|
3613
|
+
try {
|
|
3614
|
+
const project = await detectPrismaProject(cwd);
|
|
3615
|
+
if (!project) {
|
|
3616
|
+
if (options.json) {
|
|
3617
|
+
process.stdout.write(`${JSON.stringify({ error: "No Prisma project found" })}
|
|
3618
|
+
`);
|
|
3619
|
+
} else {
|
|
3620
|
+
console.error(chalk13.red("\u2716 No Prisma schema found in the current directory."));
|
|
3621
|
+
console.error(chalk13.dim(" Run prisma init to create one, then try again."));
|
|
3622
|
+
}
|
|
3623
|
+
process.exit(1);
|
|
3624
|
+
}
|
|
3625
|
+
const [status, migrations] = await Promise.all([getProjectStatus(cwd), getMigrations(cwd)]);
|
|
3626
|
+
if (options.json) {
|
|
3627
|
+
process.stdout.write(`${JSON.stringify(status, null, 2)}
|
|
3628
|
+
`);
|
|
3629
|
+
} else {
|
|
3630
|
+
const bar = chalk13.dim("\u2501".repeat(50));
|
|
3631
|
+
const riskColor = status.riskLevel === "critical" ? chalk13.red : status.riskLevel === "high" ? chalk13.red : status.riskLevel === "medium" ? chalk13.yellow : chalk13.green;
|
|
3632
|
+
if (!options.quiet) console.log();
|
|
3633
|
+
console.log(chalk13.bold.cyan(" \u{1F4CA} PrismaFlow Status"));
|
|
3634
|
+
console.log(bar);
|
|
3635
|
+
console.log(
|
|
3636
|
+
chalk13.bold(" Schema: ") + chalk13.dim(status.schemaPath ?? project.schemaPath)
|
|
3637
|
+
);
|
|
3638
|
+
if (status.provider) {
|
|
3639
|
+
console.log(chalk13.bold(" Provider: ") + chalk13.cyan(status.provider));
|
|
3640
|
+
}
|
|
3641
|
+
console.log(
|
|
3642
|
+
chalk13.bold(" Database: "),
|
|
3643
|
+
status.connected ? chalk13.green("\u2714 Connected") : chalk13.red("\u2716 Disconnected")
|
|
3644
|
+
);
|
|
3645
|
+
console.log(
|
|
3646
|
+
chalk13.bold(" Migrations: ") + chalk13.green(`${status.migrationsApplied} applied`) + chalk13.dim(" / ") + (status.migrationsPending > 0 ? chalk13.yellow(`${status.migrationsPending} pending`) : chalk13.dim("0 pending")) + chalk13.dim(" / ") + (status.migrationsFailed > 0 ? chalk13.red(`${status.migrationsFailed} failed`) : chalk13.dim("0 failed"))
|
|
3647
|
+
);
|
|
3648
|
+
console.log(
|
|
3649
|
+
chalk13.bold(" Drift: "),
|
|
3650
|
+
status.driftDetected ? chalk13.red(
|
|
3651
|
+
`\u2716 ${status.driftCount} issue${status.driftCount !== 1 ? "s" : ""} detected`
|
|
3652
|
+
) : chalk13.green("\u2714 None")
|
|
3653
|
+
);
|
|
3654
|
+
console.log(
|
|
3655
|
+
chalk13.bold(" Risk Level: ") + riskColor(chalk13.bold(status.riskLevel.toUpperCase()))
|
|
3656
|
+
);
|
|
3657
|
+
console.log(
|
|
3658
|
+
chalk13.bold(" Health Score: ") + riskColor(chalk13.bold(`${status.healthScore}/100`)) + chalk13.dim(` (${status.deploymentReadiness.summary})`)
|
|
3659
|
+
);
|
|
3660
|
+
const lastMigration = migrations.filter((m) => m.status === "applied").pop();
|
|
3661
|
+
if (lastMigration) {
|
|
3662
|
+
const date = new Date(lastMigration.timestamp).toLocaleDateString(void 0, {
|
|
3663
|
+
year: "numeric",
|
|
3664
|
+
month: "short",
|
|
3665
|
+
day: "numeric"
|
|
3666
|
+
});
|
|
3667
|
+
console.log(
|
|
3668
|
+
chalk13.bold(" Last Applied: ") + chalk13.dim(`${lastMigration.name} (${date})`)
|
|
3669
|
+
);
|
|
3670
|
+
}
|
|
3671
|
+
console.log(
|
|
3672
|
+
chalk13.bold(" Last Sync: ") + chalk13.dim(new Date(status.lastSync).toLocaleString())
|
|
3673
|
+
);
|
|
3674
|
+
console.log(bar);
|
|
3675
|
+
if (status.migrationsPending > 0) {
|
|
3676
|
+
console.log(chalk13.yellow(" \u26A0 Pending migrations \u2014 run: prisma migrate deploy"));
|
|
3677
|
+
}
|
|
3678
|
+
if (status.migrationsFailed > 0) {
|
|
3679
|
+
console.log(chalk13.red(" \u2716 Failed migrations \u2014 run: prisma migrate resolve"));
|
|
3680
|
+
}
|
|
3681
|
+
if (status.driftDetected) {
|
|
3682
|
+
console.log(chalk13.red(" \u2716 Schema drift \u2014 review the dashboard Drift page"));
|
|
3683
|
+
}
|
|
3684
|
+
if (status.deploymentReadiness.status === "ready") {
|
|
3685
|
+
console.log(chalk13.green(" \u2714 Deployment readiness checks passed"));
|
|
3686
|
+
} else {
|
|
3687
|
+
for (const check of status.deploymentReadiness.checks.filter((item) => !item.passed)) {
|
|
3688
|
+
console.log(chalk13.yellow(` \u26A0 ${check.label}: ${check.message}`));
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
if (!options.quiet) console.log();
|
|
3692
|
+
}
|
|
3693
|
+
await Promise.all([
|
|
3694
|
+
writeAuditEntry(cwd, "status.check", "success", {
|
|
3695
|
+
migrationsApplied: status.migrationsApplied,
|
|
3696
|
+
migrationsPending: status.migrationsPending,
|
|
3697
|
+
migrationsFailed: status.migrationsFailed,
|
|
3698
|
+
driftDetected: status.driftDetected
|
|
3699
|
+
}),
|
|
3700
|
+
trackEvent("status", migrations.length)
|
|
3701
|
+
]).catch(() => {
|
|
3702
|
+
});
|
|
3703
|
+
process.exit(0);
|
|
3704
|
+
} catch (error) {
|
|
3705
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3706
|
+
if (options.json) {
|
|
3707
|
+
process.stdout.write(`${JSON.stringify({ error: message })}
|
|
3708
|
+
`);
|
|
3709
|
+
} else {
|
|
3710
|
+
console.error(chalk13.red(`\u2716 Error: ${message}`));
|
|
3711
|
+
}
|
|
3712
|
+
await writeAuditEntry(cwd, "status.check", "failure", {
|
|
3713
|
+
error: message
|
|
3714
|
+
}).catch(() => {
|
|
3715
|
+
});
|
|
3716
|
+
process.exit(1);
|
|
3717
|
+
}
|
|
3718
|
+
});
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
// src/index.ts
|
|
3722
|
+
var program = new Command14();
|
|
3723
|
+
program.name("prisma-flow").description("Visual Prisma migration management \u2014 safe, observable, production-ready").version(process.env.npm_package_version ?? "0.1.0");
|
|
3724
|
+
program.addCommand(dashboardCommand(), { isDefault: true });
|
|
3725
|
+
program.addCommand(statusCommand());
|
|
3726
|
+
program.addCommand(checkCommand());
|
|
3727
|
+
program.addCommand(reportCommand());
|
|
3728
|
+
program.addCommand(initCommand());
|
|
3729
|
+
program.addCommand(doctorCommand());
|
|
3730
|
+
program.addCommand(inspectCommand());
|
|
3731
|
+
program.addCommand(diffCommand());
|
|
3732
|
+
program.addCommand(simulateCommand());
|
|
3733
|
+
program.addCommand(rollbackCommand());
|
|
3734
|
+
program.addCommand(repairCommand());
|
|
3735
|
+
program.addCommand(compareCommand());
|
|
3736
|
+
program.addCommand(historyCommand());
|
|
3737
|
+
program.parse(process.argv);
|