@actuate-media/cli 0.2.5 → 0.3.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.
@@ -0,0 +1,36 @@
1
+ import { spawn } from 'node:child_process';
2
+ import type { Command } from 'commander';
3
+ import { logger } from '../utils/logger.js';
4
+
5
+ export function buildCreateActuateArgs(projectName?: string): string[] {
6
+ const args = ['create', 'actuate-cms@latest'];
7
+ if (projectName) args.push(projectName);
8
+ return args;
9
+ }
10
+
11
+ function npmCommand(): string {
12
+ return process.platform === 'win32' ? 'npm.cmd' : 'npm';
13
+ }
14
+
15
+ async function runInit(projectName?: string): Promise<void> {
16
+ const child = spawn(npmCommand(), buildCreateActuateArgs(projectName), {
17
+ stdio: 'inherit',
18
+ });
19
+
20
+ const exitCode = await new Promise<number | null>((resolve, reject) => {
21
+ child.once('error', reject);
22
+ child.once('exit', resolve);
23
+ });
24
+
25
+ if (exitCode && exitCode !== 0) {
26
+ logger.error(`Project initialization failed with exit code ${exitCode}.`);
27
+ process.exit(exitCode);
28
+ }
29
+ }
30
+
31
+ export function registerInitCommand(program: Command): void {
32
+ program
33
+ .command('init [project-name]')
34
+ .description('Scaffold a new Actuate CMS project')
35
+ .action(runInit);
36
+ }
@@ -1,7 +1,10 @@
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";
5
+ import path from "node:path";
4
6
  import { createInterface } from "node:readline/promises";
7
+ import { pathToFileURL } from "node:url";
5
8
  import ora from "ora";
6
9
  import { logger } from "../utils/logger.js";
7
10
 
@@ -173,15 +176,116 @@ interface SeedOptions {
173
176
  reset?: boolean;
174
177
  }
175
178
 
179
+ export interface NormalizedSeedDocument {
180
+ collection: string;
181
+ data: Record<string, unknown>;
182
+ status: string;
183
+ }
184
+
185
+ export interface NormalizedSeedGlobal {
186
+ slug: string;
187
+ data: Record<string, unknown>;
188
+ }
189
+
190
+ export interface NormalizedSeedPayload {
191
+ documents: NormalizedSeedDocument[];
192
+ globals: NormalizedSeedGlobal[];
193
+ }
194
+
195
+ const SEED_FILE_CANDIDATES = [
196
+ "actuate.seed.json",
197
+ "actuate.seed.ts",
198
+ "actuate.seed.js",
199
+ "actuate.seed.mjs",
200
+ "cms.seed.json",
201
+ ];
202
+
203
+ function asRecord(value: unknown): Record<string, unknown> {
204
+ return value && typeof value === "object" && !Array.isArray(value)
205
+ ? value as Record<string, unknown>
206
+ : {};
207
+ }
208
+
209
+ function normalizeDocument(collection: string, doc: unknown): NormalizedSeedDocument {
210
+ const record = asRecord(doc);
211
+ return {
212
+ collection,
213
+ data: asRecord(record.data ?? record),
214
+ status: typeof record.status === "string" ? record.status : "DRAFT",
215
+ };
216
+ }
217
+
218
+ export function normalizeSeedPayload(seedData: unknown): NormalizedSeedPayload {
219
+ const documents: NormalizedSeedDocument[] = [];
220
+ const globals: NormalizedSeedGlobal[] = [];
221
+
222
+ if (Array.isArray(seedData)) {
223
+ for (const doc of seedData) {
224
+ const record = asRecord(doc);
225
+ documents.push(normalizeDocument(
226
+ typeof record.collection === "string" ? record.collection : "imported",
227
+ record.data ? record : { data: record },
228
+ ));
229
+ }
230
+ return { documents, globals };
231
+ }
232
+
233
+ const root = asRecord(seedData);
234
+
235
+ const globalEntries = asRecord(root.globals);
236
+ for (const [slug, value] of Object.entries(globalEntries)) {
237
+ globals.push({ slug, data: asRecord(value) });
238
+ }
239
+
240
+ const collections = root.collections ? asRecord(root.collections) : root;
241
+ for (const [collection, docs] of Object.entries(collections)) {
242
+ if (collection === "globals" || collection === "collections") continue;
243
+ if (!Array.isArray(docs)) continue;
244
+ for (const doc of docs) {
245
+ documents.push(normalizeDocument(collection, doc));
246
+ }
247
+ }
248
+
249
+ return { documents, globals };
250
+ }
251
+
252
+ function findConventionSeedFile(): string | null {
253
+ for (const candidate of SEED_FILE_CANDIDATES) {
254
+ if (existsSync(candidate)) return candidate;
255
+ }
256
+ return null;
257
+ }
258
+
259
+ async function loadSeedFile(filePath: string): Promise<unknown> {
260
+ const extension = path.extname(filePath);
261
+ if (extension === ".json" || extension === "") {
262
+ const raw = await readFile(filePath, "utf-8");
263
+ return JSON.parse(raw);
264
+ }
265
+
266
+ const fileUrl = pathToFileURL(path.resolve(filePath)).href;
267
+ const mod = extension === ".ts"
268
+ ? await import("tsx/esm/api").then(({ tsImport }) => tsImport(fileUrl, import.meta.url))
269
+ : await import(fileUrl);
270
+
271
+ return (mod as { default?: unknown; seed?: unknown }).default
272
+ ?? (mod as { seed?: unknown }).seed;
273
+ }
274
+
176
275
  async function runSeed(options: SeedOptions): Promise<void> {
177
- if (!options.demo && !options.file) {
178
- logger.error("Specify --demo to seed demo content or --file <path> to seed from a JSON file.");
276
+ const conventionFile = !options.demo && !options.file ? findConventionSeedFile() : null;
277
+ const file = options.file ?? conventionFile ?? undefined;
278
+
279
+ if (!options.demo && !file) {
280
+ logger.error("Specify --demo, --file <path>, or add actuate.seed.json in the project root.");
179
281
  process.exit(1);
180
282
  }
181
283
 
284
+ let seededDb: { db: any; disconnect: () => Promise<void> } | null = null;
285
+
182
286
  try {
183
- const { getDB } = await import("@actuate-media/cms-core");
184
- const db = getDB<any>();
287
+ seededDb = await getSeedDatabase();
288
+ const db = seededDb.db;
185
289
 
186
290
  if (options.reset) {
187
291
  const yes = await confirm(
@@ -203,78 +307,178 @@ async function runSeed(options: SeedOptions): Promise<void> {
203
307
  await seedDemoData(db);
204
308
  }
205
309
 
206
- if (options.file) {
207
- await seedFromFile(db, options.file);
310
+ if (file) {
311
+ await seedFromFile(db, file);
208
312
  }
209
313
  } catch (err) {
210
314
  const message = err instanceof Error ? err.message : String(err);
211
315
  logger.error(`Seed failed: ${message}`);
212
316
  process.exit(1);
317
+ } finally {
318
+ await seededDb?.disconnect();
213
319
  }
214
320
  }
215
321
 
216
- async function seedDemoData(db: any): Promise<void> {
217
- const spinner = ora("Seeding demo data…").start();
322
+ async function getSeedDatabase(): Promise<{ db: any; disconnect: () => Promise<void> }> {
323
+ const { getDB, initDB, isDBInitialized } = await import("@actuate-media/cms-core");
324
+
325
+ if (isDBInitialized()) {
326
+ return { db: getDB<any>(), disconnect: async () => {} };
327
+ }
328
+
329
+ const db = await createProjectPrismaClient();
330
+ initDB(db);
331
+ return {
332
+ db,
333
+ disconnect: async () => {
334
+ if (typeof db.$disconnect === "function") {
335
+ await db.$disconnect();
336
+ }
337
+ },
338
+ };
339
+ }
340
+
341
+ async function createProjectPrismaClient(): Promise<any> {
342
+ if (!process.env.DATABASE_URL) {
343
+ throw new Error("DATABASE_URL is required to run seed/populate.");
344
+ }
345
+
346
+ const requireFromProject = createRequire(path.join(process.cwd(), "package.json"));
347
+ const generatedClient = path.resolve("generated", "prisma", "client.ts");
348
+
349
+ if (existsSync(generatedClient)) {
350
+ const [{ tsImport }, adapterModule, pgModule] = await Promise.all([
351
+ import("tsx/esm/api"),
352
+ import(pathToFileURL(requireFromProject.resolve("@prisma/adapter-pg")).href),
353
+ import(pathToFileURL(requireFromProject.resolve("pg")).href),
354
+ ]);
355
+ const { PrismaClient } = await tsImport(pathToFileURL(generatedClient).href, import.meta.url) as {
356
+ PrismaClient: new (options?: unknown) => any;
357
+ };
358
+ const { PrismaPg } = adapterModule as { PrismaPg: new (pool: unknown) => unknown };
359
+ const pg = (pgModule as { default?: typeof pgModule }).default ?? pgModule;
360
+ const pool = new (pg as any).Pool({ connectionString: process.env.DATABASE_URL });
361
+ const adapter = new PrismaPg(pool);
362
+ return new PrismaClient({ adapter } as any);
363
+ }
364
+
365
+ const clientModule = await import(pathToFileURL(requireFromProject.resolve("@prisma/client")).href) as {
366
+ PrismaClient: new () => any;
367
+ };
368
+ return new clientModule.PrismaClient();
369
+ }
370
+
371
+ export async function ensureSeedAdmin(db: any): Promise<{ id: string }> {
372
+ const existing = await db.user.findFirst({ where: { role: "ADMIN" } });
373
+ if (existing) return existing;
374
+
375
+ const email = process.env.CMS_ADMIN_EMAIL;
376
+ const password = process.env.CMS_ADMIN_PASSWORD;
377
+ const name = process.env.CMS_ADMIN_NAME ?? "Admin";
378
+
379
+ if (!email || !password) {
380
+ throw new Error(
381
+ "No admin user exists. Set CMS_ADMIN_EMAIL and CMS_ADMIN_PASSWORD before running seed, or complete the setup wizard first.",
382
+ );
383
+ }
384
+
385
+ const { createInitialAdmin } = await import("@actuate-media/cms-core");
386
+ const result = await createInitialAdmin(db, { email, password, name });
387
+ if (!result.success || !result.userId) {
388
+ throw new Error(result.error ?? "Failed to create initial admin user");
389
+ }
390
+
391
+ return { id: result.userId };
392
+ }
393
+
394
+ function sanitizeSeedData(value: unknown, sanitizeHtml: (html: string) => string): unknown {
395
+ if (Array.isArray(value)) {
396
+ return value.map((item) => sanitizeSeedData(item, sanitizeHtml));
397
+ }
398
+ if (value && typeof value === "object") {
399
+ return Object.fromEntries(
400
+ Object.entries(value as Record<string, unknown>).map(([key, item]) => [
401
+ key,
402
+ sanitizeSeedData(item, sanitizeHtml),
403
+ ]),
404
+ );
405
+ }
406
+ if (typeof value === "string" && /<[a-z][\s\S]*>/i.test(value)) {
407
+ return sanitizeHtml(value);
408
+ }
409
+ return value;
410
+ }
411
+
412
+ export async function createSeedDocument(
413
+ db: any,
414
+ userId: string,
415
+ doc: NormalizedSeedDocument,
416
+ ): Promise<void> {
417
+ const { extractPlainText, hashContent, sanitizeHtml } = await import("@actuate-media/cms-core");
418
+ const data = sanitizeSeedData(doc.data, sanitizeHtml) as Record<string, unknown>;
419
+ const serialized = JSON.stringify(data);
420
+ const plainText = extractPlainText(serialized);
421
+ const contentHash = await hashContent(serialized);
218
422
 
219
- let adminUser = await db.user.findFirst({ where: { role: "ADMIN" } });
220
- if (!adminUser) {
221
- adminUser = await db.user.create({
423
+ await db.$transaction(async (tx: any) => {
424
+ const created = await tx.document.create({
222
425
  data: {
223
- email: "admin@actuatecms.dev",
224
- name: "Admin",
225
- role: "ADMIN",
226
- isActive: true,
227
- isApproved: true,
228
- emailVerified: true,
426
+ collection: doc.collection,
427
+ title: typeof data.title === "string" ? data.title : null,
428
+ slug: typeof data.slug === "string" ? data.slug : null,
429
+ data,
430
+ status: doc.status,
431
+ publishedAt: doc.status === "PUBLISHED" ? new Date() : null,
432
+ createdById: userId,
433
+ updatedById: userId,
434
+ plainText,
435
+ contentHash,
229
436
  },
230
437
  });
231
- }
438
+
439
+ await tx.version.create({
440
+ data: {
441
+ documentId: created.id,
442
+ data,
443
+ changedById: userId,
444
+ changeType: "CREATE",
445
+ },
446
+ });
447
+ });
448
+ }
449
+
450
+ async function seedDemoData(db: any): Promise<void> {
451
+ const spinner = ora("Seeding demo data…").start();
452
+
453
+ const adminUser = await ensureSeedAdmin(db);
232
454
  const userId = adminUser.id;
233
455
 
234
456
  let pagesCreated = 0;
235
457
  for (const page of DEMO_PAGES) {
236
- await db.document.create({
237
- data: {
238
- collection: "pages",
239
- data: page,
240
- status: "PUBLISHED",
241
- publishedAt: new Date(),
242
- createdById: userId,
243
- updatedById: userId,
244
- plainText: `${page.title} ${page.slug}`,
245
- },
458
+ await createSeedDocument(db, userId, {
459
+ collection: "pages",
460
+ data: page,
461
+ status: "PUBLISHED",
246
462
  });
247
463
  pagesCreated++;
248
464
  }
249
465
 
250
466
  let postsCreated = 0;
251
467
  for (const post of DEMO_POSTS) {
252
- await db.document.create({
253
- data: {
254
- collection: "posts",
255
- data: post,
256
- status: post.status,
257
- publishedAt: post.status === "PUBLISHED" ? new Date() : null,
258
- createdById: userId,
259
- updatedById: userId,
260
- plainText: `${post.title} ${post.excerpt}`,
261
- },
468
+ await createSeedDocument(db, userId, {
469
+ collection: "posts",
470
+ data: post,
471
+ status: post.status,
262
472
  });
263
473
  postsCreated++;
264
474
  }
265
475
 
266
476
  let formsCreated = 0;
267
477
  for (const form of DEMO_FORMS) {
268
- await db.document.create({
269
- data: {
270
- collection: "forms",
271
- data: form,
272
- status: "PUBLISHED",
273
- publishedAt: new Date(),
274
- createdById: userId,
275
- updatedById: userId,
276
- plainText: `${form.title} ${form.slug}`,
277
- },
478
+ await createSeedDocument(db, userId, {
479
+ collection: "forms",
480
+ data: form,
481
+ status: "PUBLISHED",
278
482
  });
279
483
  formsCreated++;
280
484
  }
@@ -312,12 +516,11 @@ async function seedFromFile(db: any, filePath: string): Promise<void> {
312
516
 
313
517
  const spinner = ora(`Seeding from ${filePath}…`).start();
314
518
 
315
- const raw = await readFile(filePath, "utf-8");
316
519
  let seedData: any;
317
520
  try {
318
- seedData = JSON.parse(raw);
521
+ seedData = await loadSeedFile(filePath);
319
522
  } catch {
320
- spinner.fail("Invalid JSON file.");
523
+ spinner.fail("Invalid seed file.");
321
524
  process.exit(1);
322
525
  }
323
526
 
@@ -326,55 +529,26 @@ async function seedFromFile(db: any, filePath: string): Promise<void> {
326
529
  process.exit(1);
327
530
  }
328
531
 
329
- let adminUser = await db.user.findFirst({ where: { role: "ADMIN" } });
330
- if (!adminUser) {
331
- adminUser = await db.user.create({
332
- data: {
333
- email: "admin@actuatecms.dev",
334
- name: "Admin",
335
- role: "ADMIN",
336
- isActive: true,
337
- isApproved: true,
338
- emailVerified: true,
339
- },
340
- });
341
- }
532
+ const adminUser = await ensureSeedAdmin(db);
342
533
  const userId = adminUser.id;
343
534
 
344
- let count = 0;
535
+ const normalized = normalizeSeedPayload(seedData);
536
+ const { updateGlobal } = await import("@actuate-media/cms-core");
537
+ const ctx = { userId, role: "ADMIN", db };
345
538
 
346
- if (Array.isArray(seedData)) {
347
- for (const doc of seedData) {
348
- await db.document.create({
349
- data: {
350
- collection: doc.collection ?? "imported",
351
- data: doc.data ?? doc,
352
- status: doc.status ?? "DRAFT",
353
- createdById: userId,
354
- updatedById: userId,
355
- },
356
- });
357
- count++;
358
- }
359
- } else {
360
- for (const [collection, docs] of Object.entries(seedData)) {
361
- if (!Array.isArray(docs)) continue;
362
- for (const doc of docs as any[]) {
363
- await db.document.create({
364
- data: {
365
- collection,
366
- data: doc.data ?? doc,
367
- status: doc.status ?? "DRAFT",
368
- createdById: userId,
369
- updatedById: userId,
370
- },
371
- });
372
- count++;
373
- }
374
- }
539
+ let documentCount = 0;
540
+ for (const doc of normalized.documents) {
541
+ await createSeedDocument(db, userId, doc);
542
+ documentCount++;
375
543
  }
376
544
 
377
- spinner.succeed(`Seeded ${count} documents from ${filePath}.`);
545
+ let globalCount = 0;
546
+ for (const global of normalized.globals) {
547
+ await updateGlobal(global.slug, global.data, ctx);
548
+ globalCount++;
549
+ }
550
+
551
+ spinner.succeed(`Seeded ${documentCount} documents and ${globalCount} globals from ${filePath}.`);
378
552
  }
379
553
 
380
554
  export function registerSeedCommand(program: Command): void {
@@ -382,7 +556,14 @@ export function registerSeedCommand(program: Command): void {
382
556
  .command("seed")
383
557
  .description("Seed the database with demo or custom data")
384
558
  .option("--demo", "Seed demo content (pages, posts, forms, users)")
385
- .option("--file <path>", "Seed from a JSON file")
559
+ .option("--file <path>", "Seed from a JSON, JavaScript, or TypeScript file")
560
+ .option("--reset", "Clear existing data before seeding")
561
+ .action(runSeed);
562
+
563
+ program
564
+ .command("populate")
565
+ .description("Populate the database from actuate.seed.json or a custom seed file")
566
+ .option("--file <path>", "Seed from a JSON, JavaScript, or TypeScript file")
386
567
  .option("--reset", "Clear existing data before seeding")
387
568
  .action(runSeed);
388
569
  }
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import { registerUpgradeCommand } from "./commands/upgrade.js";
9
9
  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
+ import { registerInitCommand } from "./commands/init.js";
12
13
 
13
14
  const program = new Command();
14
15
 
@@ -26,5 +27,6 @@ registerUpgradeCommand(program);
26
27
  registerUpdateCheckCommand(program);
27
28
  registerDbInitCommand(program);
28
29
  registerDbStatusCommand(program);
30
+ registerInitCommand(program);
29
31
 
30
32
  program.parse();