@actuate-media/cli 0.3.0 → 0.4.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/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +16 -7
- package/CHANGELOG.md +12 -0
- package/dist/__tests__/deployment-diagnostics.test.d.ts +2 -0
- package/dist/__tests__/deployment-diagnostics.test.d.ts.map +1 -0
- package/dist/__tests__/deployment-diagnostics.test.js +76 -0
- package/dist/__tests__/deployment-diagnostics.test.js.map +1 -0
- package/dist/__tests__/schema-fragment.test.d.ts +2 -0
- package/dist/__tests__/schema-fragment.test.d.ts.map +1 -0
- package/dist/__tests__/schema-fragment.test.js +40 -0
- package/dist/__tests__/schema-fragment.test.js.map +1 -0
- package/dist/__tests__/seed.test.js +68 -2
- package/dist/__tests__/seed.test.js.map +1 -1
- package/dist/commands/db-init.d.ts +4 -0
- package/dist/commands/db-init.d.ts.map +1 -1
- package/dist/commands/db-init.js +81 -2
- package/dist/commands/db-init.js.map +1 -1
- package/dist/commands/db-status.d.ts.map +1 -1
- package/dist/commands/db-status.js +2 -12
- package/dist/commands/db-status.js.map +1 -1
- package/dist/commands/doctor.d.ts +5 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +153 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/seed.d.ts +4 -0
- package/dist/commands/seed.d.ts.map +1 -1
- package/dist/commands/seed.js +126 -86
- package/dist/commands/seed.js.map +1 -1
- package/dist/deployment/diagnostics.d.ts +58 -0
- package/dist/deployment/diagnostics.d.ts.map +1 -0
- package/dist/deployment/diagnostics.js +154 -0
- package/dist/deployment/diagnostics.js.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/deployment-diagnostics.test.ts +88 -0
- package/src/__tests__/schema-fragment.test.ts +47 -0
- package/src/__tests__/seed.test.ts +75 -2
- package/src/commands/db-init.ts +85 -2
- package/src/commands/db-status.ts +2 -13
- package/src/commands/doctor.ts +159 -0
- package/src/commands/seed.ts +153 -88
- package/src/deployment/diagnostics.ts +189 -0
- package/src/index.ts +8 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { access, readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import {
|
|
6
|
+
buildDeploymentManifest,
|
|
7
|
+
createDiagnosticReport,
|
|
8
|
+
detectPackageManager,
|
|
9
|
+
type DiagnosticReport,
|
|
10
|
+
} from "../deployment/diagnostics.js";
|
|
11
|
+
|
|
12
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
13
|
+
try {
|
|
14
|
+
await access(path);
|
|
15
|
+
return true;
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function readSchemaModels(schemaPath: string): Promise<Set<string>> {
|
|
22
|
+
if (!(await fileExists(schemaPath))) return new Set();
|
|
23
|
+
const schema = await readFile(schemaPath, "utf-8");
|
|
24
|
+
const models = new Set<string>();
|
|
25
|
+
const modelRegex = /^model\s+(\w+)\s*\{/gm;
|
|
26
|
+
let match;
|
|
27
|
+
while ((match = modelRegex.exec(schema)) !== null) {
|
|
28
|
+
models.add(match[1]!);
|
|
29
|
+
}
|
|
30
|
+
return models;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readSchemaContent(schemaPath: string): Promise<string> {
|
|
34
|
+
if (!(await fileExists(schemaPath))) return "";
|
|
35
|
+
return readFile(schemaPath, "utf-8");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function detectProjectPackageManager(): Promise<string> {
|
|
39
|
+
const entries = new Set(await readdir(process.cwd()));
|
|
40
|
+
return detectPackageManager(entries);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function printReport(title: string, report: DiagnosticReport): void {
|
|
44
|
+
console.log();
|
|
45
|
+
console.log(chalk.bold(title));
|
|
46
|
+
console.log(chalk.dim("─────────────────────────────────"));
|
|
47
|
+
for (const check of report.checks) {
|
|
48
|
+
const icon = check.status === "pass" ? chalk.green("PASS") : check.status === "warn" ? chalk.yellow("WARN") : chalk.red("FAIL");
|
|
49
|
+
console.log(`${icon} ${check.label}: ${check.message}`);
|
|
50
|
+
if (check.fix) console.log(chalk.dim(` Fix: ${check.fix}`));
|
|
51
|
+
if (check.docs) console.log(chalk.dim(` Docs: ${check.docs}`));
|
|
52
|
+
}
|
|
53
|
+
console.log(chalk.dim("─────────────────────────────────"));
|
|
54
|
+
console.log(`Status: ${report.status.toUpperCase()}`);
|
|
55
|
+
console.log();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function buildReport(schemaPath: string, mode: "doctor" | "deploy" = "doctor") {
|
|
59
|
+
const resolvedSchema = resolve(process.cwd(), schemaPath);
|
|
60
|
+
const [schemaModels, schemaContent, packageManager] = await Promise.all([
|
|
61
|
+
readSchemaModels(resolvedSchema),
|
|
62
|
+
readSchemaContent(resolvedSchema),
|
|
63
|
+
detectProjectPackageManager(),
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
return createDiagnosticReport({
|
|
67
|
+
schemaModels,
|
|
68
|
+
schemaContent,
|
|
69
|
+
env: process.env,
|
|
70
|
+
packageManager,
|
|
71
|
+
schemaPath,
|
|
72
|
+
mode,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function registerDoctorCommand(program: Command): void {
|
|
77
|
+
program
|
|
78
|
+
.command("doctor")
|
|
79
|
+
.description("Run AI-friendly preflight checks for an Actuate CMS project")
|
|
80
|
+
.option("--schema <path>", "Path to schema.prisma", "prisma/schema.prisma")
|
|
81
|
+
.option("--json", "Print machine-readable JSON")
|
|
82
|
+
.action(async (opts: { schema: string; json?: boolean }) => {
|
|
83
|
+
const report = await buildReport(opts.schema, "doctor");
|
|
84
|
+
if (opts.json) {
|
|
85
|
+
console.log(JSON.stringify(report, null, 2));
|
|
86
|
+
} else {
|
|
87
|
+
printReport("Actuate Doctor", report);
|
|
88
|
+
}
|
|
89
|
+
if (report.status === "fail") process.exitCode = 1;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function registerDeployCheckCommand(program: Command): void {
|
|
94
|
+
program
|
|
95
|
+
.command("deploy:check")
|
|
96
|
+
.description("Check production deployment readiness for Actuate CMS")
|
|
97
|
+
.option("--schema <path>", "Path to schema.prisma", "prisma/schema.prisma")
|
|
98
|
+
.option("--json", "Print machine-readable JSON")
|
|
99
|
+
.action(async (opts: { schema: string; json?: boolean }) => {
|
|
100
|
+
const report = await buildReport(opts.schema, "deploy");
|
|
101
|
+
if (opts.json) {
|
|
102
|
+
console.log(JSON.stringify({ ...report, manifest: buildDeploymentManifest() }, null, 2));
|
|
103
|
+
} else {
|
|
104
|
+
printReport("Actuate Deploy Check", report);
|
|
105
|
+
console.log(chalk.dim("Run `actuate verify --full` after deployment succeeds."));
|
|
106
|
+
}
|
|
107
|
+
if (report.status === "fail") process.exitCode = 1;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function registerVerifyCommand(program: Command): void {
|
|
112
|
+
program
|
|
113
|
+
.command("verify")
|
|
114
|
+
.description("Verify an installed or deployed Actuate CMS project")
|
|
115
|
+
.option("--full", "Run the full verification checklist")
|
|
116
|
+
.option("--url <origin>", "Deployed site origin to verify, for example https://example.com")
|
|
117
|
+
.option("--json", "Print the deployment manifest as JSON")
|
|
118
|
+
.action(async (opts: { full?: boolean; url?: string; json?: boolean }) => {
|
|
119
|
+
const manifest = buildDeploymentManifest();
|
|
120
|
+
if (opts.json) {
|
|
121
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
console.log();
|
|
126
|
+
console.log(chalk.bold(opts.full ? "Actuate Full Verification" : "Actuate Verification"));
|
|
127
|
+
console.log(chalk.dim("─────────────────────────────────"));
|
|
128
|
+
console.log(`PASS Manifest generated for ${manifest.packageScope} packages.`);
|
|
129
|
+
if (!opts.url) {
|
|
130
|
+
console.log("WARN No --url provided, so remote health/admin endpoints were not checked.");
|
|
131
|
+
console.log(chalk.dim("Run `actuate verify --full --url https://your-site.com` after deployment."));
|
|
132
|
+
console.log();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const origin = opts.url.replace(/\/$/, "");
|
|
137
|
+
const healthUrl = `${origin}${manifest.routes.apiHealth}`;
|
|
138
|
+
const adminUrl = `${origin}${manifest.routes.adminDefault}`;
|
|
139
|
+
let failed = false;
|
|
140
|
+
|
|
141
|
+
for (const [label, url] of [['Health', healthUrl], ['Admin', adminUrl]] as const) {
|
|
142
|
+
try {
|
|
143
|
+
const response = await fetch(url, { method: 'GET' });
|
|
144
|
+
if (response.ok || response.status === 302 || response.status === 401) {
|
|
145
|
+
console.log(`PASS ${label} route responded at ${url}.`);
|
|
146
|
+
} else {
|
|
147
|
+
failed = true;
|
|
148
|
+
console.log(`FAIL ${label} route returned HTTP ${response.status} at ${url}.`);
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
failed = true;
|
|
152
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
153
|
+
console.log(`FAIL ${label} route could not be reached at ${url}: ${message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
console.log();
|
|
157
|
+
if (failed) process.exitCode = 1;
|
|
158
|
+
});
|
|
159
|
+
}
|
package/src/commands/seed.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { createInterface } from "node:readline/promises";
|
|
6
7
|
import { pathToFileURL } from "node:url";
|
|
@@ -280,9 +281,11 @@ async function runSeed(options: SeedOptions): Promise<void> {
|
|
|
280
281
|
process.exit(1);
|
|
281
282
|
}
|
|
282
283
|
|
|
284
|
+
let seededDb: { db: any; disconnect: () => Promise<void> } | null = null;
|
|
285
|
+
|
|
283
286
|
try {
|
|
284
|
-
|
|
285
|
-
const db =
|
|
287
|
+
seededDb = await getSeedDatabase();
|
|
288
|
+
const db = seededDb.db;
|
|
286
289
|
|
|
287
290
|
if (options.reset) {
|
|
288
291
|
const yes = await confirm(
|
|
@@ -295,7 +298,9 @@ async function runSeed(options: SeedOptions): Promise<void> {
|
|
|
295
298
|
|
|
296
299
|
const resetSpinner = ora("Clearing existing data…").start();
|
|
297
300
|
await db.version.deleteMany({});
|
|
298
|
-
|
|
301
|
+
if (db.mediaUsage?.deleteMany) {
|
|
302
|
+
await db.mediaUsage.deleteMany({});
|
|
303
|
+
}
|
|
299
304
|
await db.document.deleteMany({});
|
|
300
305
|
resetSpinner.succeed("Existing data cleared.");
|
|
301
306
|
}
|
|
@@ -311,71 +316,171 @@ async function runSeed(options: SeedOptions): Promise<void> {
|
|
|
311
316
|
const message = err instanceof Error ? err.message : String(err);
|
|
312
317
|
logger.error(`Seed failed: ${message}`);
|
|
313
318
|
process.exit(1);
|
|
319
|
+
} finally {
|
|
320
|
+
await seededDb?.disconnect();
|
|
314
321
|
}
|
|
315
322
|
}
|
|
316
323
|
|
|
317
|
-
async function
|
|
318
|
-
const
|
|
324
|
+
async function getSeedDatabase(): Promise<{ db: any; disconnect: () => Promise<void> }> {
|
|
325
|
+
const { getDB, initDB, isDBInitialized } = await import("@actuate-media/cms-core");
|
|
326
|
+
|
|
327
|
+
if (isDBInitialized()) {
|
|
328
|
+
return { db: getDB<any>(), disconnect: async () => {} };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const db = await createProjectPrismaClient();
|
|
332
|
+
initDB(db);
|
|
333
|
+
return {
|
|
334
|
+
db,
|
|
335
|
+
disconnect: async () => {
|
|
336
|
+
if (typeof db.$disconnect === "function") {
|
|
337
|
+
await db.$disconnect();
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function createProjectPrismaClient(): Promise<any> {
|
|
344
|
+
if (!process.env.DATABASE_URL) {
|
|
345
|
+
throw new Error("DATABASE_URL is required to run seed/populate.");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const requireFromProject = createRequire(path.join(process.cwd(), "package.json"));
|
|
349
|
+
const generatedClient = path.resolve("generated", "prisma", "client.ts");
|
|
350
|
+
|
|
351
|
+
if (existsSync(generatedClient)) {
|
|
352
|
+
const [{ tsImport }, adapterModule, pgModule] = await Promise.all([
|
|
353
|
+
import("tsx/esm/api"),
|
|
354
|
+
import(pathToFileURL(requireFromProject.resolve("@prisma/adapter-pg")).href),
|
|
355
|
+
import(pathToFileURL(requireFromProject.resolve("pg")).href),
|
|
356
|
+
]);
|
|
357
|
+
const { PrismaClient } = await tsImport(pathToFileURL(generatedClient).href, import.meta.url) as {
|
|
358
|
+
PrismaClient: new (options?: unknown) => any;
|
|
359
|
+
};
|
|
360
|
+
const { PrismaPg } = adapterModule as { PrismaPg: new (pool: unknown) => unknown };
|
|
361
|
+
const pg = (pgModule as { default?: typeof pgModule }).default ?? pgModule;
|
|
362
|
+
const pool = new (pg as any).Pool({ connectionString: process.env.DATABASE_URL });
|
|
363
|
+
const adapter = new PrismaPg(pool);
|
|
364
|
+
return new PrismaClient({ adapter } as any);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const clientModule = await import(pathToFileURL(requireFromProject.resolve("@prisma/client")).href) as {
|
|
368
|
+
PrismaClient: new () => any;
|
|
369
|
+
};
|
|
370
|
+
return new clientModule.PrismaClient();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function ensureSeedAdmin(db: any): Promise<{ id: string }> {
|
|
374
|
+
const existing = await db.user.findFirst({ where: { role: "ADMIN" } });
|
|
375
|
+
if (existing) return existing;
|
|
376
|
+
|
|
377
|
+
const email = process.env.CMS_ADMIN_EMAIL;
|
|
378
|
+
const password = process.env.CMS_ADMIN_PASSWORD;
|
|
379
|
+
const name = process.env.CMS_ADMIN_NAME ?? "Admin";
|
|
380
|
+
|
|
381
|
+
if (!email || !password) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
"No admin user exists. Set CMS_ADMIN_EMAIL and CMS_ADMIN_PASSWORD before running seed, or complete the setup wizard first.",
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const { createInitialAdmin } = await import("@actuate-media/cms-core");
|
|
388
|
+
const result = await createInitialAdmin(db, { email, password, name });
|
|
389
|
+
if (!result.success || !result.userId) {
|
|
390
|
+
throw new Error(result.error ?? "Failed to create initial admin user");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return { id: result.userId };
|
|
394
|
+
}
|
|
319
395
|
|
|
320
|
-
|
|
321
|
-
if (
|
|
322
|
-
|
|
396
|
+
function sanitizeSeedData(value: unknown, sanitizeHtml: (html: string) => string): unknown {
|
|
397
|
+
if (Array.isArray(value)) {
|
|
398
|
+
return value.map((item) => sanitizeSeedData(item, sanitizeHtml));
|
|
399
|
+
}
|
|
400
|
+
if (value && typeof value === "object") {
|
|
401
|
+
return Object.fromEntries(
|
|
402
|
+
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
|
|
403
|
+
key,
|
|
404
|
+
sanitizeSeedData(item, sanitizeHtml),
|
|
405
|
+
]),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
if (typeof value === "string" && /<[a-z][\s\S]*>/i.test(value)) {
|
|
409
|
+
return sanitizeHtml(value);
|
|
410
|
+
}
|
|
411
|
+
return value;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function createSeedDocument(
|
|
415
|
+
db: any,
|
|
416
|
+
userId: string,
|
|
417
|
+
doc: NormalizedSeedDocument,
|
|
418
|
+
): Promise<void> {
|
|
419
|
+
const { extractPlainText, hashContent, sanitizeHtml } = await import("@actuate-media/cms-core");
|
|
420
|
+
const data = sanitizeSeedData(doc.data, sanitizeHtml) as Record<string, unknown>;
|
|
421
|
+
const serialized = JSON.stringify(data);
|
|
422
|
+
const plainText = extractPlainText(serialized);
|
|
423
|
+
const contentHash = await hashContent(serialized);
|
|
424
|
+
|
|
425
|
+
await db.$transaction(async (tx: any) => {
|
|
426
|
+
const created = await tx.document.create({
|
|
323
427
|
data: {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
428
|
+
collection: doc.collection,
|
|
429
|
+
title: typeof data.title === "string" ? data.title : null,
|
|
430
|
+
slug: typeof data.slug === "string" ? data.slug : null,
|
|
431
|
+
data,
|
|
432
|
+
status: doc.status,
|
|
433
|
+
publishedAt: doc.status === "PUBLISHED" ? new Date() : null,
|
|
434
|
+
createdById: userId,
|
|
435
|
+
updatedById: userId,
|
|
436
|
+
plainText,
|
|
437
|
+
contentHash,
|
|
330
438
|
},
|
|
331
439
|
});
|
|
332
|
-
|
|
440
|
+
|
|
441
|
+
await tx.version.create({
|
|
442
|
+
data: {
|
|
443
|
+
documentId: created.id,
|
|
444
|
+
data,
|
|
445
|
+
changedById: userId,
|
|
446
|
+
changeType: "CREATE",
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function seedDemoData(db: any): Promise<void> {
|
|
453
|
+
const spinner = ora("Seeding demo data…").start();
|
|
454
|
+
|
|
455
|
+
const adminUser = await ensureSeedAdmin(db);
|
|
333
456
|
const userId = adminUser.id;
|
|
334
457
|
|
|
335
458
|
let pagesCreated = 0;
|
|
336
459
|
for (const page of DEMO_PAGES) {
|
|
337
|
-
await db
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
status: "PUBLISHED",
|
|
342
|
-
publishedAt: new Date(),
|
|
343
|
-
createdById: userId,
|
|
344
|
-
updatedById: userId,
|
|
345
|
-
plainText: `${page.title} ${page.slug}`,
|
|
346
|
-
},
|
|
460
|
+
await createSeedDocument(db, userId, {
|
|
461
|
+
collection: "pages",
|
|
462
|
+
data: page,
|
|
463
|
+
status: "PUBLISHED",
|
|
347
464
|
});
|
|
348
465
|
pagesCreated++;
|
|
349
466
|
}
|
|
350
467
|
|
|
351
468
|
let postsCreated = 0;
|
|
352
469
|
for (const post of DEMO_POSTS) {
|
|
353
|
-
await db
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
status: post.status,
|
|
358
|
-
publishedAt: post.status === "PUBLISHED" ? new Date() : null,
|
|
359
|
-
createdById: userId,
|
|
360
|
-
updatedById: userId,
|
|
361
|
-
plainText: `${post.title} ${post.excerpt}`,
|
|
362
|
-
},
|
|
470
|
+
await createSeedDocument(db, userId, {
|
|
471
|
+
collection: "posts",
|
|
472
|
+
data: post,
|
|
473
|
+
status: post.status,
|
|
363
474
|
});
|
|
364
475
|
postsCreated++;
|
|
365
476
|
}
|
|
366
477
|
|
|
367
478
|
let formsCreated = 0;
|
|
368
479
|
for (const form of DEMO_FORMS) {
|
|
369
|
-
await db
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
status: "PUBLISHED",
|
|
374
|
-
publishedAt: new Date(),
|
|
375
|
-
createdById: userId,
|
|
376
|
-
updatedById: userId,
|
|
377
|
-
plainText: `${form.title} ${form.slug}`,
|
|
378
|
-
},
|
|
480
|
+
await createSeedDocument(db, userId, {
|
|
481
|
+
collection: "forms",
|
|
482
|
+
data: form,
|
|
483
|
+
status: "PUBLISHED",
|
|
379
484
|
});
|
|
380
485
|
formsCreated++;
|
|
381
486
|
}
|
|
@@ -426,56 +531,16 @@ async function seedFromFile(db: any, filePath: string): Promise<void> {
|
|
|
426
531
|
process.exit(1);
|
|
427
532
|
}
|
|
428
533
|
|
|
429
|
-
|
|
430
|
-
if (!adminUser) {
|
|
431
|
-
adminUser = await db.user.create({
|
|
432
|
-
data: {
|
|
433
|
-
email: "admin@actuatecms.dev",
|
|
434
|
-
name: "Admin",
|
|
435
|
-
role: "ADMIN",
|
|
436
|
-
isActive: true,
|
|
437
|
-
isApproved: true,
|
|
438
|
-
emailVerified: true,
|
|
439
|
-
},
|
|
440
|
-
});
|
|
441
|
-
}
|
|
534
|
+
const adminUser = await ensureSeedAdmin(db);
|
|
442
535
|
const userId = adminUser.id;
|
|
443
536
|
|
|
444
537
|
const normalized = normalizeSeedPayload(seedData);
|
|
445
|
-
const {
|
|
538
|
+
const { updateGlobal } = await import("@actuate-media/cms-core");
|
|
446
539
|
const ctx = { userId, role: "ADMIN", db };
|
|
447
540
|
|
|
448
541
|
let documentCount = 0;
|
|
449
542
|
for (const doc of normalized.documents) {
|
|
450
|
-
|
|
451
|
-
const plainText = extractPlainText(serialized);
|
|
452
|
-
const contentHash = await hashContent(serialized);
|
|
453
|
-
|
|
454
|
-
await db.$transaction(async (tx: any) => {
|
|
455
|
-
const created = await tx.document.create({
|
|
456
|
-
data: {
|
|
457
|
-
collection: doc.collection,
|
|
458
|
-
title: typeof doc.data.title === "string" ? doc.data.title : null,
|
|
459
|
-
slug: typeof doc.data.slug === "string" ? doc.data.slug : null,
|
|
460
|
-
data: doc.data,
|
|
461
|
-
status: doc.status,
|
|
462
|
-
publishedAt: doc.status === "PUBLISHED" ? new Date() : null,
|
|
463
|
-
createdById: userId,
|
|
464
|
-
updatedById: userId,
|
|
465
|
-
plainText,
|
|
466
|
-
contentHash,
|
|
467
|
-
},
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
await tx.version.create({
|
|
471
|
-
data: {
|
|
472
|
-
documentId: created.id,
|
|
473
|
-
data: doc.data,
|
|
474
|
-
changedById: userId,
|
|
475
|
-
changeType: "CREATE",
|
|
476
|
-
},
|
|
477
|
-
});
|
|
478
|
-
});
|
|
543
|
+
await createSeedDocument(db, userId, doc);
|
|
479
544
|
documentCount++;
|
|
480
545
|
}
|
|
481
546
|
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
export type DiagnosticStatus = 'pass' | 'warn' | 'fail';
|
|
2
|
+
|
|
3
|
+
export interface DiagnosticCheck {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
status: DiagnosticStatus;
|
|
7
|
+
message: string;
|
|
8
|
+
fix?: string;
|
|
9
|
+
docs?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DiagnosticReport {
|
|
13
|
+
status: DiagnosticStatus;
|
|
14
|
+
checks: DiagnosticCheck[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const REQUIRED_CMS_MODELS = [
|
|
18
|
+
'User',
|
|
19
|
+
'Session',
|
|
20
|
+
'Document',
|
|
21
|
+
'Media',
|
|
22
|
+
'Version',
|
|
23
|
+
'Folder',
|
|
24
|
+
'Redirect',
|
|
25
|
+
'FormSubmission',
|
|
26
|
+
'AuditLog',
|
|
27
|
+
'PasswordResetToken',
|
|
28
|
+
'MediaUsage',
|
|
29
|
+
'ScriptTag',
|
|
30
|
+
'PageTemplate',
|
|
31
|
+
'SavedSection',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export const REQUIRED_ENV_VARS = [
|
|
35
|
+
'DATABASE_URL',
|
|
36
|
+
'CMS_SECRET',
|
|
37
|
+
'CMS_ENCRYPTION_KEY',
|
|
38
|
+
'NEXT_PUBLIC_SITE_URL',
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
export interface DiagnosticInput {
|
|
42
|
+
schemaModels: Set<string>;
|
|
43
|
+
schemaContent?: string;
|
|
44
|
+
env: Record<string, string | undefined>;
|
|
45
|
+
packageManager: string;
|
|
46
|
+
schemaPath: string;
|
|
47
|
+
mode?: 'doctor' | 'deploy';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function missingModels(schemaModels: Set<string>): string[] {
|
|
51
|
+
return REQUIRED_CMS_MODELS.filter((model) => !schemaModels.has(model));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function missingEnvVars(env: Record<string, string | undefined>): string[] {
|
|
55
|
+
return REQUIRED_ENV_VARS.filter((name) => !env[name]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function detectPackageManager(lockfiles: Set<string>): string {
|
|
59
|
+
if (lockfiles.has('pnpm-lock.yaml')) return 'pnpm';
|
|
60
|
+
if (lockfiles.has('yarn.lock')) return 'yarn';
|
|
61
|
+
if (lockfiles.has('package-lock.json')) return 'npm';
|
|
62
|
+
return 'npm';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createDiagnosticReport(input: DiagnosticInput): DiagnosticReport {
|
|
66
|
+
const checks: DiagnosticCheck[] = [];
|
|
67
|
+
const models = missingModels(input.schemaModels);
|
|
68
|
+
const envVars = input.mode === 'deploy'
|
|
69
|
+
? [...missingEnvVars(input.env), ...(!input.env.DIRECT_DATABASE_URL ? ['DIRECT_DATABASE_URL'] : [])]
|
|
70
|
+
: missingEnvVars(input.env);
|
|
71
|
+
const fieldProblems = input.schemaContent ? missingCriticalFields(input.schemaContent) : [];
|
|
72
|
+
|
|
73
|
+
checks.push({
|
|
74
|
+
id: 'schema-models',
|
|
75
|
+
label: 'Prisma schema models',
|
|
76
|
+
status: models.length === 0 ? 'pass' : 'fail',
|
|
77
|
+
message: models.length === 0
|
|
78
|
+
? 'All deploy-critical Actuate models are present.'
|
|
79
|
+
: `Missing deploy-critical Actuate models: ${models.join(', ')}.`,
|
|
80
|
+
fix: models.length === 0
|
|
81
|
+
? undefined
|
|
82
|
+
: `Run \`actuate db:init --schema ${input.schemaPath}\` for new schemas, or update the existing Actuate block from the database setup docs, then create and apply a Prisma migration.`,
|
|
83
|
+
docs: 'https://actuatecms.dev/docs/database-setup',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
checks.push({
|
|
87
|
+
id: 'schema-fields',
|
|
88
|
+
label: 'Prisma schema fields',
|
|
89
|
+
status: fieldProblems.length === 0 ? 'pass' : 'fail',
|
|
90
|
+
message: fieldProblems.length === 0
|
|
91
|
+
? 'Deploy-critical model fields are present.'
|
|
92
|
+
: `Missing deploy-critical model fields: ${fieldProblems.join(', ')}.`,
|
|
93
|
+
fix: fieldProblems.length === 0
|
|
94
|
+
? undefined
|
|
95
|
+
: 'Update the Actuate Prisma models from the database setup docs, then create and apply a Prisma migration.',
|
|
96
|
+
docs: 'https://actuatecms.dev/docs/database-setup',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
checks.push({
|
|
100
|
+
id: 'environment',
|
|
101
|
+
label: 'Required environment variables',
|
|
102
|
+
status: envVars.length === 0 ? 'pass' : 'fail',
|
|
103
|
+
message: envVars.length === 0
|
|
104
|
+
? 'Required deployment environment variables are set.'
|
|
105
|
+
: `Missing required environment variables: ${envVars.join(', ')}.`,
|
|
106
|
+
fix: envVars.length === 0
|
|
107
|
+
? undefined
|
|
108
|
+
: `Set missing environment variables before deploying: ${envVars.join(', ')}.`,
|
|
109
|
+
docs: 'https://actuatecms.dev/docs/environment-variables',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
checks.push({
|
|
113
|
+
id: 'package-manager',
|
|
114
|
+
label: 'Package manager',
|
|
115
|
+
status: 'pass',
|
|
116
|
+
message: `Detected ${input.packageManager}. Use the same package manager for install, build, and deploy checks.`,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const status: DiagnosticStatus = checks.some((check) => check.status === 'fail')
|
|
120
|
+
? 'fail'
|
|
121
|
+
: checks.some((check) => check.status === 'warn')
|
|
122
|
+
? 'warn'
|
|
123
|
+
: 'pass';
|
|
124
|
+
|
|
125
|
+
return { status, checks };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function buildDeploymentManifest() {
|
|
129
|
+
return {
|
|
130
|
+
schemaVersion: 1,
|
|
131
|
+
packageScope: '@actuate-media',
|
|
132
|
+
registry: 'https://registry.npmjs.org',
|
|
133
|
+
requiredModels: [...REQUIRED_CMS_MODELS],
|
|
134
|
+
requiredEnv: [...REQUIRED_ENV_VARS],
|
|
135
|
+
optionalEnv: [
|
|
136
|
+
'DIRECT_DATABASE_URL',
|
|
137
|
+
'CMS_ADMIN_EMAIL',
|
|
138
|
+
'CMS_ADMIN_PASSWORD',
|
|
139
|
+
'BLOB_READ_WRITE_TOKEN',
|
|
140
|
+
'UPSTASH_REDIS_REST_URL',
|
|
141
|
+
'UPSTASH_REDIS_REST_TOKEN',
|
|
142
|
+
'RESEND_API_KEY',
|
|
143
|
+
],
|
|
144
|
+
routes: {
|
|
145
|
+
apiHealth: '/api/cms/health',
|
|
146
|
+
apiBase: '/api/cms',
|
|
147
|
+
adminDefault: '/admin',
|
|
148
|
+
},
|
|
149
|
+
crons: {
|
|
150
|
+
scheduledPublish: '/api/cms/cron/publish',
|
|
151
|
+
cleanup: '/api/cms/cron/cleanup',
|
|
152
|
+
seoScan: '/api/cms/cron/seo-scan',
|
|
153
|
+
},
|
|
154
|
+
verification: {
|
|
155
|
+
preflight: 'actuate doctor',
|
|
156
|
+
production: 'actuate deploy:check',
|
|
157
|
+
postDeploy: 'actuate verify --full',
|
|
158
|
+
},
|
|
159
|
+
docs: {
|
|
160
|
+
deployment: 'https://actuatecms.dev/docs/deployment',
|
|
161
|
+
databaseSetup: 'https://actuatecms.dev/docs/database-setup',
|
|
162
|
+
environmentVariables: 'https://actuatecms.dev/docs/environment-variables',
|
|
163
|
+
aiRunbook: 'https://actuatecms.dev/docs/ai-runbook',
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function missingCriticalFields(schemaContent: string): string[] {
|
|
169
|
+
const requiredFields: Record<string, string[]> = {
|
|
170
|
+
ScriptTag: ['targetPaths', 'priority', 'enabled'],
|
|
171
|
+
PageTemplate: ['tree', 'builtIn', 'category'],
|
|
172
|
+
SavedSection: ['tree', 'usageCount', 'category'],
|
|
173
|
+
PasswordResetToken: ['tokenHash', 'expiresAt', 'usedAt'],
|
|
174
|
+
MediaUsage: ['mediaId', 'documentId', 'fieldPath'],
|
|
175
|
+
};
|
|
176
|
+
const missing: string[] = [];
|
|
177
|
+
|
|
178
|
+
for (const [model, fields] of Object.entries(requiredFields)) {
|
|
179
|
+
const body = schemaContent.match(new RegExp(`model\\s+${model}\\s+\\{([\\s\\S]*?)\\n\\}`))?.[1] ?? '';
|
|
180
|
+
if (!body) continue;
|
|
181
|
+
for (const field of fields) {
|
|
182
|
+
if (!new RegExp(`^\\s*${field}\\s+`, 'm').test(body)) {
|
|
183
|
+
missing.push(`${model}.${field}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return missing;
|
|
189
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,11 @@ import { registerUpdateCheckCommand } from "./commands/update-check.js";
|
|
|
10
10
|
import { registerDbInitCommand } from "./commands/db-init.js";
|
|
11
11
|
import { registerDbStatusCommand } from "./commands/db-status.js";
|
|
12
12
|
import { registerInitCommand } from "./commands/init.js";
|
|
13
|
+
import {
|
|
14
|
+
registerDeployCheckCommand,
|
|
15
|
+
registerDoctorCommand,
|
|
16
|
+
registerVerifyCommand,
|
|
17
|
+
} from "./commands/doctor.js";
|
|
13
18
|
|
|
14
19
|
const program = new Command();
|
|
15
20
|
|
|
@@ -28,5 +33,8 @@ registerUpdateCheckCommand(program);
|
|
|
28
33
|
registerDbInitCommand(program);
|
|
29
34
|
registerDbStatusCommand(program);
|
|
30
35
|
registerInitCommand(program);
|
|
36
|
+
registerDoctorCommand(program);
|
|
37
|
+
registerDeployCheckCommand(program);
|
|
38
|
+
registerVerifyCommand(program);
|
|
31
39
|
|
|
32
40
|
program.parse();
|