@actuate-media/cli 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) 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 +116 -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/commands/db-init.d.ts +4 -0
  13. package/dist/commands/db-init.d.ts.map +1 -1
  14. package/dist/commands/db-init.js +81 -2
  15. package/dist/commands/db-init.js.map +1 -1
  16. package/dist/commands/db-status.d.ts.map +1 -1
  17. package/dist/commands/db-status.js +2 -12
  18. package/dist/commands/db-status.js.map +1 -1
  19. package/dist/commands/doctor.d.ts +5 -0
  20. package/dist/commands/doctor.d.ts.map +1 -0
  21. package/dist/commands/doctor.js +163 -0
  22. package/dist/commands/doctor.js.map +1 -0
  23. package/dist/commands/seed.d.ts.map +1 -1
  24. package/dist/commands/seed.js +3 -1
  25. package/dist/commands/seed.js.map +1 -1
  26. package/dist/deployment/diagnostics.d.ts +60 -0
  27. package/dist/deployment/diagnostics.d.ts.map +1 -0
  28. package/dist/deployment/diagnostics.js +198 -0
  29. package/dist/deployment/diagnostics.js.map +1 -0
  30. package/dist/index.js +4 -0
  31. package/dist/index.js.map +1 -1
  32. package/package.json +2 -2
  33. package/src/__tests__/deployment-diagnostics.test.ts +130 -0
  34. package/src/__tests__/schema-fragment.test.ts +47 -0
  35. package/src/commands/db-init.ts +85 -2
  36. package/src/commands/db-status.ts +2 -13
  37. package/src/commands/doctor.ts +169 -0
  38. package/src/commands/seed.ts +3 -1
  39. package/src/deployment/diagnostics.ts +238 -0
  40. package/src/index.ts +8 -0
@@ -7,6 +7,20 @@ import { logger } from "../utils/logger.js";
7
7
 
8
8
  const CMS_SCHEMA_MARKER = "// ── Actuate CMS models";
9
9
 
10
+ export let runDbInitCommand = (command: string, options: ExecSyncOptions): void => {
11
+ execSync(command, options);
12
+ };
13
+
14
+ const defaultDbInitCommandRunner = runDbInitCommand;
15
+
16
+ export function setDbInitCommandRunner(runner: typeof runDbInitCommand): void {
17
+ runDbInitCommand = runner;
18
+ }
19
+
20
+ export function resetDbInitCommandRunner(): void {
21
+ runDbInitCommand = defaultDbInitCommandRunner;
22
+ }
23
+
10
24
  async function fileExists(filePath: string): Promise<boolean> {
11
25
  try {
12
26
  await access(filePath);
@@ -219,6 +233,75 @@ model AuditLog {
219
233
  @@index([userId])
220
234
  @@index([createdAt])
221
235
  }
236
+
237
+ model PasswordResetToken {
238
+ id String @id @default(cuid())
239
+ userId String
240
+ tokenHash String
241
+ expiresAt DateTime
242
+ usedAt DateTime?
243
+ createdAt DateTime @default(now())
244
+
245
+ @@index([tokenHash])
246
+ @@index([userId])
247
+ }
248
+
249
+ model MediaUsage {
250
+ id String @id @default(cuid())
251
+ mediaId String
252
+ documentId String
253
+ fieldPath String?
254
+ createdAt DateTime @default(now())
255
+
256
+ @@unique([mediaId, documentId, fieldPath])
257
+ @@index([mediaId])
258
+ @@index([documentId])
259
+ }
260
+
261
+ model ScriptTag {
262
+ id String @id @default(cuid())
263
+ name String
264
+ code String @db.Text
265
+ placement String
266
+ scope String
267
+ targetPaths String[]
268
+ priority Int @default(100)
269
+ enabled Boolean @default(true)
270
+ createdAt DateTime @default(now())
271
+ updatedAt DateTime @updatedAt
272
+
273
+ @@index([enabled])
274
+ @@index([placement])
275
+ }
276
+
277
+ model PageTemplate {
278
+ id String @id @default(cuid())
279
+ name String
280
+ description String?
281
+ category String @default("content")
282
+ tree Json
283
+ thumbnail String?
284
+ builtIn Boolean @default(false)
285
+ createdAt DateTime @default(now())
286
+ updatedAt DateTime @updatedAt
287
+
288
+ @@index([category])
289
+ @@index([builtIn])
290
+ }
291
+
292
+ model SavedSection {
293
+ id String @id @default(cuid())
294
+ name String
295
+ description String?
296
+ category String @default("content")
297
+ tree Json
298
+ thumbnail String?
299
+ usageCount Int @default(0)
300
+ createdAt DateTime @default(now())
301
+ updatedAt DateTime @updatedAt
302
+
303
+ @@index([category])
304
+ }
222
305
  `;
223
306
  }
224
307
 
@@ -279,7 +362,7 @@ export function registerDbInitCommand(program: Command): void {
279
362
  const genSpinner = ora("Running prisma generate...").start();
280
363
  try {
281
364
  genSpinner.stop();
282
- execSync("npx prisma generate", execOpts);
365
+ runDbInitCommand("npx prisma generate", execOpts);
283
366
  logger.success("Prisma client generated.");
284
367
  } catch {
285
368
  logger.warn("prisma generate failed. You may need to set DATABASE_URL first.");
@@ -289,7 +372,7 @@ export function registerDbInitCommand(program: Command): void {
289
372
  const migSpinner = ora("Running prisma migrate dev...").start();
290
373
  try {
291
374
  migSpinner.stop();
292
- execSync("npx prisma migrate dev --name actuate-cms-init", execOpts);
375
+ runDbInitCommand("npx prisma migrate dev --name actuate-cms-init", execOpts);
293
376
  logger.success("Migration created and applied.");
294
377
  } catch {
295
378
  logger.warn("prisma migrate dev failed. Run it manually after setting DATABASE_URL.");
@@ -5,18 +5,7 @@ import { resolve } from "node:path";
5
5
  import ora from "ora";
6
6
  import chalk from "chalk";
7
7
  import { logger } from "../utils/logger.js";
8
-
9
- const CMS_EXPECTED_MODELS = [
10
- "User",
11
- "Session",
12
- "Document",
13
- "Media",
14
- "Version",
15
- "Folder",
16
- "Redirect",
17
- "FormSubmission",
18
- "AuditLog",
19
- ];
8
+ import { REQUIRED_CMS_MODELS } from "../deployment/diagnostics.js";
20
9
 
21
10
  const CMS_SCHEMA_MARKER = "// ── Actuate CMS models";
22
11
 
@@ -74,7 +63,7 @@ export function registerDbStatusCommand(program: Command): void {
74
63
  let present = 0;
75
64
  let missing = 0;
76
65
 
77
- for (const model of CMS_EXPECTED_MODELS) {
66
+ for (const model of REQUIRED_CMS_MODELS) {
78
67
  if (schemaModels.has(model)) {
79
68
  console.log(` ${chalk.green("✓")} ${model}`);
80
69
  present++;
@@ -0,0 +1,169 @@
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 readOptionalFile(path: string): Promise<string> {
39
+ if (!(await fileExists(path))) return "";
40
+ return readFile(path, "utf-8");
41
+ }
42
+
43
+ async function detectProjectPackageManager(): Promise<string> {
44
+ const entries = new Set(await readdir(process.cwd()));
45
+ return detectPackageManager(entries);
46
+ }
47
+
48
+ function printReport(title: string, report: DiagnosticReport): void {
49
+ console.log();
50
+ console.log(chalk.bold(title));
51
+ console.log(chalk.dim("─────────────────────────────────"));
52
+ for (const check of report.checks) {
53
+ const icon = check.status === "pass" ? chalk.green("PASS") : check.status === "warn" ? chalk.yellow("WARN") : chalk.red("FAIL");
54
+ console.log(`${icon} ${check.label}: ${check.message}`);
55
+ if (check.fix) console.log(chalk.dim(` Fix: ${check.fix}`));
56
+ if (check.docs) console.log(chalk.dim(` Docs: ${check.docs}`));
57
+ }
58
+ console.log(chalk.dim("─────────────────────────────────"));
59
+ console.log(`Status: ${report.status.toUpperCase()}`);
60
+ console.log();
61
+ }
62
+
63
+ async function buildReport(schemaPath: string, configPath: string, mode: "doctor" | "deploy" = "doctor") {
64
+ const resolvedSchema = resolve(process.cwd(), schemaPath);
65
+ const resolvedConfig = resolve(process.cwd(), configPath);
66
+ const [schemaModels, schemaContent, configContent, packageManager] = await Promise.all([
67
+ readSchemaModels(resolvedSchema),
68
+ readSchemaContent(resolvedSchema),
69
+ readOptionalFile(resolvedConfig),
70
+ detectProjectPackageManager(),
71
+ ]);
72
+
73
+ return createDiagnosticReport({
74
+ schemaModels,
75
+ schemaContent,
76
+ configContent,
77
+ env: process.env,
78
+ packageManager,
79
+ schemaPath,
80
+ mode,
81
+ });
82
+ }
83
+
84
+ export function registerDoctorCommand(program: Command): void {
85
+ program
86
+ .command("doctor")
87
+ .description("Run AI-friendly preflight checks for an Actuate CMS project")
88
+ .option("--schema <path>", "Path to schema.prisma", "prisma/schema.prisma")
89
+ .option("--config <path>", "Path to actuate.config.ts", "actuate.config.ts")
90
+ .option("--json", "Print machine-readable JSON")
91
+ .action(async (opts: { schema: string; config: string; json?: boolean }) => {
92
+ const report = await buildReport(opts.schema, opts.config, "doctor");
93
+ if (opts.json) {
94
+ console.log(JSON.stringify(report, null, 2));
95
+ } else {
96
+ printReport("Actuate Doctor", report);
97
+ }
98
+ if (report.status === "fail") process.exitCode = 1;
99
+ });
100
+ }
101
+
102
+ export function registerDeployCheckCommand(program: Command): void {
103
+ program
104
+ .command("deploy:check")
105
+ .description("Check production deployment readiness for Actuate CMS")
106
+ .option("--schema <path>", "Path to schema.prisma", "prisma/schema.prisma")
107
+ .option("--config <path>", "Path to actuate.config.ts", "actuate.config.ts")
108
+ .option("--json", "Print machine-readable JSON")
109
+ .action(async (opts: { schema: string; config: string; json?: boolean }) => {
110
+ const report = await buildReport(opts.schema, opts.config, "deploy");
111
+ if (opts.json) {
112
+ console.log(JSON.stringify({ ...report, manifest: buildDeploymentManifest() }, null, 2));
113
+ } else {
114
+ printReport("Actuate Deploy Check", report);
115
+ console.log(chalk.dim("Run `actuate verify --full` after deployment succeeds."));
116
+ }
117
+ if (report.status === "fail") process.exitCode = 1;
118
+ });
119
+ }
120
+
121
+ export function registerVerifyCommand(program: Command): void {
122
+ program
123
+ .command("verify")
124
+ .description("Verify an installed or deployed Actuate CMS project")
125
+ .option("--full", "Run the full verification checklist")
126
+ .option("--url <origin>", "Deployed site origin to verify, for example https://example.com")
127
+ .option("--json", "Print the deployment manifest as JSON")
128
+ .action(async (opts: { full?: boolean; url?: string; json?: boolean }) => {
129
+ const manifest = buildDeploymentManifest();
130
+ if (opts.json) {
131
+ console.log(JSON.stringify(manifest, null, 2));
132
+ return;
133
+ }
134
+
135
+ console.log();
136
+ console.log(chalk.bold(opts.full ? "Actuate Full Verification" : "Actuate Verification"));
137
+ console.log(chalk.dim("─────────────────────────────────"));
138
+ console.log(`PASS Manifest generated for ${manifest.packageScope} packages.`);
139
+ if (!opts.url) {
140
+ console.log("WARN No --url provided, so remote health/admin endpoints were not checked.");
141
+ console.log(chalk.dim("Run `actuate verify --full --url https://your-site.com` after deployment."));
142
+ console.log();
143
+ return;
144
+ }
145
+
146
+ const origin = opts.url.replace(/\/$/, "");
147
+ const healthUrl = `${origin}${manifest.routes.apiHealth}`;
148
+ const adminUrl = `${origin}${manifest.routes.adminDefault}`;
149
+ let failed = false;
150
+
151
+ for (const [label, url] of [['Health', healthUrl], ['Admin', adminUrl]] as const) {
152
+ try {
153
+ const response = await fetch(url, { method: 'GET' });
154
+ if (response.ok || response.status === 302 || response.status === 401) {
155
+ console.log(`PASS ${label} route responded at ${url}.`);
156
+ } else {
157
+ failed = true;
158
+ console.log(`FAIL ${label} route returned HTTP ${response.status} at ${url}.`);
159
+ }
160
+ } catch (error) {
161
+ failed = true;
162
+ const message = error instanceof Error ? error.message : String(error);
163
+ console.log(`FAIL ${label} route could not be reached at ${url}: ${message}`);
164
+ }
165
+ }
166
+ console.log();
167
+ if (failed) process.exitCode = 1;
168
+ });
169
+ }
@@ -298,7 +298,9 @@ async function runSeed(options: SeedOptions): Promise<void> {
298
298
 
299
299
  const resetSpinner = ora("Clearing existing data…").start();
300
300
  await db.version.deleteMany({});
301
- await db.mediaUsage.deleteMany({});
301
+ if (db.mediaUsage?.deleteMany) {
302
+ await db.mediaUsage.deleteMany({});
303
+ }
302
304
  await db.document.deleteMany({});
303
305
  resetSpinner.succeed("Existing data cleared.");
304
306
  }
@@ -0,0 +1,238 @@
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
+ configContent?: string;
45
+ env: Record<string, string | undefined>;
46
+ packageManager: string;
47
+ schemaPath: string;
48
+ mode?: 'doctor' | 'deploy';
49
+ }
50
+
51
+ export function missingModels(schemaModels: Set<string>): string[] {
52
+ return REQUIRED_CMS_MODELS.filter((model) => !schemaModels.has(model));
53
+ }
54
+
55
+ export function missingEnvVars(env: Record<string, string | undefined>): string[] {
56
+ return REQUIRED_ENV_VARS.filter((name) => !env[name]);
57
+ }
58
+
59
+ export function detectPackageManager(lockfiles: Set<string>): string {
60
+ if (lockfiles.has('pnpm-lock.yaml')) return 'pnpm';
61
+ if (lockfiles.has('yarn.lock')) return 'yarn';
62
+ if (lockfiles.has('package-lock.json')) return 'npm';
63
+ return 'npm';
64
+ }
65
+
66
+ export function createDiagnosticReport(input: DiagnosticInput): DiagnosticReport {
67
+ const checks: DiagnosticCheck[] = [];
68
+ const models = missingModels(input.schemaModels);
69
+ const envVars = input.mode === 'deploy'
70
+ ? [...missingEnvVars(input.env), ...(!input.env.DIRECT_DATABASE_URL ? ['DIRECT_DATABASE_URL'] : [])]
71
+ : missingEnvVars(input.env);
72
+ const fieldProblems = input.schemaContent ? missingCriticalFields(input.schemaContent) : [];
73
+ const pageBuilderRisk = input.configContent ? detectsFlatMarketingPageModel(input.configContent) : false;
74
+
75
+ checks.push({
76
+ id: 'schema-models',
77
+ label: 'Prisma schema models',
78
+ status: models.length === 0 ? 'pass' : 'fail',
79
+ message: models.length === 0
80
+ ? 'All deploy-critical Actuate models are present.'
81
+ : `Missing deploy-critical Actuate models: ${models.join(', ')}.`,
82
+ fix: models.length === 0
83
+ ? undefined
84
+ : `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.`,
85
+ docs: 'https://actuatecms.dev/docs/database-setup',
86
+ });
87
+
88
+ checks.push({
89
+ id: 'schema-fields',
90
+ label: 'Prisma schema fields',
91
+ status: fieldProblems.length === 0 ? 'pass' : 'fail',
92
+ message: fieldProblems.length === 0
93
+ ? 'Deploy-critical model fields are present.'
94
+ : `Missing deploy-critical model fields: ${fieldProblems.join(', ')}.`,
95
+ fix: fieldProblems.length === 0
96
+ ? undefined
97
+ : 'Update the Actuate Prisma models from the database setup docs, then create and apply a Prisma migration.',
98
+ docs: 'https://actuatecms.dev/docs/database-setup',
99
+ });
100
+
101
+ checks.push({
102
+ id: 'environment',
103
+ label: 'Required environment variables',
104
+ status: envVars.length === 0 ? 'pass' : 'fail',
105
+ message: envVars.length === 0
106
+ ? 'Required deployment environment variables are set.'
107
+ : `Missing required environment variables: ${envVars.join(', ')}.`,
108
+ fix: envVars.length === 0
109
+ ? undefined
110
+ : `Set missing environment variables before deploying: ${envVars.join(', ')}.`,
111
+ docs: 'https://actuatecms.dev/docs/environment-variables',
112
+ });
113
+
114
+ checks.push({
115
+ id: 'package-manager',
116
+ label: 'Package manager',
117
+ status: 'pass',
118
+ message: `Detected ${input.packageManager}. Use the same package manager for install, build, and deploy checks.`,
119
+ });
120
+
121
+ checks.push({
122
+ id: 'design-first-page-builder',
123
+ label: 'Design-first page builder fit',
124
+ status: pageBuilderRisk ? 'warn' : 'pass',
125
+ message: pageBuilderRisk
126
+ ? 'A pages collection appears to model a designed marketing page as flat hero/body/CTA fields. For marketing, service, location, landing, and homepage routes, prefer a page-builder template with blocks, saved sections, forms, media, and seeded layout content unless field-mapped mode was explicitly approved.'
127
+ : 'No obvious flat marketing-page model detected in the Actuate config.',
128
+ fix: pageBuilderRisk
129
+ ? 'Create a design inventory and block coverage plan, then model section-based pages with page-builder content or document why field-mapped mode is intentional.'
130
+ : undefined,
131
+ docs: pageBuilderRisk ? 'https://actuatecms.dev/docs/design-first-page-builder' : undefined,
132
+ });
133
+
134
+ const status: DiagnosticStatus = checks.some((check) => check.status === 'fail')
135
+ ? 'fail'
136
+ : checks.some((check) => check.status === 'warn')
137
+ ? 'warn'
138
+ : 'pass';
139
+
140
+ return { status, checks };
141
+ }
142
+
143
+ export function buildDeploymentManifest() {
144
+ return {
145
+ schemaVersion: 1,
146
+ packageScope: '@actuate-media',
147
+ registry: 'https://registry.npmjs.org',
148
+ requiredModels: [...REQUIRED_CMS_MODELS],
149
+ requiredEnv: [...REQUIRED_ENV_VARS],
150
+ optionalEnv: [
151
+ 'DIRECT_DATABASE_URL',
152
+ 'CMS_ADMIN_EMAIL',
153
+ 'CMS_ADMIN_PASSWORD',
154
+ 'BLOB_READ_WRITE_TOKEN',
155
+ 'UPSTASH_REDIS_REST_URL',
156
+ 'UPSTASH_REDIS_REST_TOKEN',
157
+ 'RESEND_API_KEY',
158
+ ],
159
+ routes: {
160
+ apiHealth: '/api/cms/health',
161
+ apiBase: '/api/cms',
162
+ adminDefault: '/admin',
163
+ },
164
+ crons: {
165
+ scheduledPublish: '/api/cms/cron/publish',
166
+ cleanup: '/api/cms/cron/cleanup',
167
+ seoScan: '/api/cms/cron/seo-scan',
168
+ },
169
+ verification: {
170
+ preflight: 'actuate doctor',
171
+ production: 'actuate deploy:check',
172
+ postDeploy: 'actuate verify --full',
173
+ },
174
+ docs: {
175
+ deployment: 'https://actuatecms.dev/docs/deployment',
176
+ databaseSetup: 'https://actuatecms.dev/docs/database-setup',
177
+ environmentVariables: 'https://actuatecms.dev/docs/environment-variables',
178
+ aiRunbook: 'https://actuatecms.dev/docs/ai-runbook',
179
+ },
180
+ };
181
+ }
182
+
183
+ export function missingCriticalFields(schemaContent: string): string[] {
184
+ const requiredFields: Record<string, string[]> = {
185
+ ScriptTag: ['targetPaths', 'priority', 'enabled'],
186
+ PageTemplate: ['tree', 'builtIn', 'category'],
187
+ SavedSection: ['tree', 'usageCount', 'category'],
188
+ PasswordResetToken: ['tokenHash', 'expiresAt', 'usedAt'],
189
+ MediaUsage: ['mediaId', 'documentId', 'fieldPath'],
190
+ };
191
+ const missing: string[] = [];
192
+
193
+ for (const [model, fields] of Object.entries(requiredFields)) {
194
+ const body = schemaContent.match(new RegExp(`model\\s+${model}\\s+\\{([\\s\\S]*?)\\n\\}`))?.[1] ?? '';
195
+ if (!body) continue;
196
+ for (const field of fields) {
197
+ if (!new RegExp(`^\\s*${field}\\s+`, 'm').test(body)) {
198
+ missing.push(`${model}.${field}`);
199
+ }
200
+ }
201
+ }
202
+
203
+ return missing;
204
+ }
205
+
206
+ export function detectsFlatMarketingPageModel(configContent: string): boolean {
207
+ const normalized = configContent.toLowerCase();
208
+ const hasPagesCollection = /\bpages\b/.test(normalized) || /slug\s*:\s*['"]pages['"]/.test(normalized);
209
+ if (!hasPagesCollection) return false;
210
+
211
+ const hasPageBuilderSignals = [
212
+ 'pagebuilder',
213
+ 'page-builder',
214
+ "type: 'blocks'",
215
+ 'type: "blocks"',
216
+ 'layout:',
217
+ 'savedsections',
218
+ 'pagetemplate',
219
+ ].some((signal) => normalized.includes(signal.toLowerCase()));
220
+
221
+ if (hasPageBuilderSignals) return false;
222
+
223
+ const flatMarketingSignals = [
224
+ 'heroheadline',
225
+ 'herosubheadline',
226
+ 'herotitle',
227
+ 'heroimage',
228
+ 'bodycontent',
229
+ 'ctabuttontext',
230
+ 'ctabuttonurl',
231
+ 'servicecards',
232
+ 'testimonial',
233
+ 'reviews',
234
+ ];
235
+
236
+ const signalCount = flatMarketingSignals.filter((signal) => normalized.includes(signal)).length;
237
+ return signalCount >= 3;
238
+ }
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();