@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.
Files changed (44) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +16 -7
  3. package/CHANGELOG.md +12 -0
  4. package/dist/__tests__/deployment-diagnostics.test.d.ts +2 -0
  5. package/dist/__tests__/deployment-diagnostics.test.d.ts.map +1 -0
  6. package/dist/__tests__/deployment-diagnostics.test.js +76 -0
  7. package/dist/__tests__/deployment-diagnostics.test.js.map +1 -0
  8. package/dist/__tests__/schema-fragment.test.d.ts +2 -0
  9. package/dist/__tests__/schema-fragment.test.d.ts.map +1 -0
  10. package/dist/__tests__/schema-fragment.test.js +40 -0
  11. package/dist/__tests__/schema-fragment.test.js.map +1 -0
  12. package/dist/__tests__/seed.test.js +68 -2
  13. package/dist/__tests__/seed.test.js.map +1 -1
  14. package/dist/commands/db-init.d.ts +4 -0
  15. package/dist/commands/db-init.d.ts.map +1 -1
  16. package/dist/commands/db-init.js +81 -2
  17. package/dist/commands/db-init.js.map +1 -1
  18. package/dist/commands/db-status.d.ts.map +1 -1
  19. package/dist/commands/db-status.js +2 -12
  20. package/dist/commands/db-status.js.map +1 -1
  21. package/dist/commands/doctor.d.ts +5 -0
  22. package/dist/commands/doctor.d.ts.map +1 -0
  23. package/dist/commands/doctor.js +153 -0
  24. package/dist/commands/doctor.js.map +1 -0
  25. package/dist/commands/seed.d.ts +4 -0
  26. package/dist/commands/seed.d.ts.map +1 -1
  27. package/dist/commands/seed.js +126 -86
  28. package/dist/commands/seed.js.map +1 -1
  29. package/dist/deployment/diagnostics.d.ts +58 -0
  30. package/dist/deployment/diagnostics.d.ts.map +1 -0
  31. package/dist/deployment/diagnostics.js +154 -0
  32. package/dist/deployment/diagnostics.js.map +1 -0
  33. package/dist/index.js +4 -0
  34. package/dist/index.js.map +1 -1
  35. package/package.json +2 -2
  36. package/src/__tests__/deployment-diagnostics.test.ts +88 -0
  37. package/src/__tests__/schema-fragment.test.ts +47 -0
  38. package/src/__tests__/seed.test.ts +75 -2
  39. package/src/commands/db-init.ts +85 -2
  40. package/src/commands/db-status.ts +2 -13
  41. package/src/commands/doctor.ts +159 -0
  42. package/src/commands/seed.ts +153 -88
  43. package/src/deployment/diagnostics.ts +189 -0
  44. 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
+ }
@@ -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
- const { getDB } = await import("@actuate-media/cms-core");
285
- const db = getDB<any>();
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
- await db.mediaUsage.deleteMany({});
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 seedDemoData(db: any): Promise<void> {
318
- const spinner = ora("Seeding demo data…").start();
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
- let adminUser = await db.user.findFirst({ where: { role: "ADMIN" } });
321
- if (!adminUser) {
322
- adminUser = await db.user.create({
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
- email: "admin@actuatecms.dev",
325
- name: "Admin",
326
- role: "ADMIN",
327
- isActive: true,
328
- isApproved: true,
329
- emailVerified: true,
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.document.create({
338
- data: {
339
- collection: "pages",
340
- data: page,
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.document.create({
354
- data: {
355
- collection: "posts",
356
- data: post,
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.document.create({
370
- data: {
371
- collection: "forms",
372
- data: form,
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
- let adminUser = await db.user.findFirst({ where: { role: "ADMIN" } });
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 { extractPlainText, hashContent, updateGlobal } = await import("@actuate-media/cms-core");
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
- const serialized = JSON.stringify(doc.data);
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();