@actuate-media/cli 0.3.1 → 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 +6 -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/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.map +1 -1
- package/dist/commands/seed.js +3 -1
- 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/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 +3 -1
- 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
|
@@ -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
|
-
|
|
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,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();
|