@actuate-media/cli 0.4.2 → 0.6.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 (39) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +64 -18
  3. package/CHANGELOG.md +46 -0
  4. package/dist/__tests__/db-init.test.d.ts +2 -0
  5. package/dist/__tests__/db-init.test.d.ts.map +1 -0
  6. package/dist/__tests__/db-init.test.js +127 -0
  7. package/dist/__tests__/db-init.test.js.map +1 -0
  8. package/dist/__tests__/db-sync.test.d.ts +2 -0
  9. package/dist/__tests__/db-sync.test.d.ts.map +1 -0
  10. package/dist/__tests__/db-sync.test.js +136 -0
  11. package/dist/__tests__/db-sync.test.js.map +1 -0
  12. package/dist/__tests__/seed.test.js +20 -1
  13. package/dist/__tests__/seed.test.js.map +1 -1
  14. package/dist/commands/db-init.d.ts +17 -0
  15. package/dist/commands/db-init.d.ts.map +1 -1
  16. package/dist/commands/db-init.js +100 -278
  17. package/dist/commands/db-init.js.map +1 -1
  18. package/dist/commands/db-sync.d.ts +31 -0
  19. package/dist/commands/db-sync.d.ts.map +1 -0
  20. package/dist/commands/db-sync.js +195 -0
  21. package/dist/commands/db-sync.js.map +1 -0
  22. package/dist/commands/seed.d.ts +10 -0
  23. package/dist/commands/seed.d.ts.map +1 -1
  24. package/dist/commands/seed.js +38 -4
  25. package/dist/commands/seed.js.map +1 -1
  26. package/dist/commands/upgrade.d.ts.map +1 -1
  27. package/dist/commands/upgrade.js +5 -0
  28. package/dist/commands/upgrade.js.map +1 -1
  29. package/dist/index.js +2 -0
  30. package/dist/index.js.map +1 -1
  31. package/package.json +2 -2
  32. package/src/__tests__/db-init.test.ts +155 -0
  33. package/src/__tests__/db-sync.test.ts +167 -0
  34. package/src/__tests__/seed.test.ts +27 -1
  35. package/src/commands/db-init.ts +93 -266
  36. package/src/commands/db-sync.ts +227 -0
  37. package/src/commands/seed.ts +40 -4
  38. package/src/commands/upgrade.ts +8 -0
  39. package/src/index.ts +2 -0
@@ -1,12 +1,15 @@
1
1
  import { Command } from 'commander'
2
2
  import { readFile, writeFile, access } from 'node:fs/promises'
3
- import { resolve, join } from 'node:path'
3
+ import { resolve, join, dirname } from 'node:path'
4
+ import { createRequire } from 'node:module'
4
5
  import { execSync, type ExecSyncOptions } from 'node:child_process'
5
6
  import ora from 'ora'
6
7
  import { logger } from '../utils/logger.js'
7
8
 
8
9
  const CMS_SCHEMA_MARKER = '// ── Actuate CMS models'
9
10
 
11
+ const require = createRequire(import.meta.url)
12
+
10
13
  export let runDbInitCommand = (command: string, options: ExecSyncOptions): void => {
11
14
  execSync(command, options)
12
15
  }
@@ -21,294 +24,108 @@ export function resetDbInitCommandRunner(): void {
21
24
  runDbInitCommand = defaultDbInitCommandRunner
22
25
  }
23
26
 
24
- async function fileExists(filePath: string): Promise<boolean> {
27
+ /**
28
+ * Reads the canonical Prisma schema shipped by the installed
29
+ * `@actuate-media/cms-core` package. This is the single source of truth — it has
30
+ * the correct `@@map("actuate_*")` table names and every model the API handlers
31
+ * expect. Previously `db:init` injected a hand-maintained copy that drifted
32
+ * (missing `@@map`, missing models), producing a database the runtime couldn't
33
+ * query. Overridable for tests.
34
+ */
35
+ export let readCanonicalCmsSchema = async (): Promise<string | null> => {
36
+ const candidates: string[] = []
25
37
  try {
26
- await access(filePath)
27
- return true
38
+ // `./prisma/schema` is an exported subpath, so this resolves inside the
39
+ // package's `prisma/` directory without depending on the build output.
40
+ const exported = require.resolve('@actuate-media/cms-core/prisma/schema')
41
+ candidates.push(join(dirname(exported), 'schema.prisma'))
28
42
  } catch {
29
- return false
43
+ /* exports map missing the subpath — fall through to the main entry */
44
+ }
45
+ try {
46
+ const mainEntry = require.resolve('@actuate-media/cms-core')
47
+ candidates.push(join(dirname(mainEntry), '..', 'prisma', 'schema.prisma'))
48
+ } catch {
49
+ /* package not installed */
30
50
  }
31
- }
32
-
33
- function getCmsSchemaFragment(): string {
34
- return `
35
- // ── Actuate CMS models ─────────────────────────────────────────────────────
36
- // Auto-injected by \`actuate db:init\`. Do not remove this marker comment.
37
- // Schema version: 1
38
-
39
- enum DocumentStatus {
40
- DRAFT
41
- PUBLISHED
42
- ARCHIVED
43
- SCHEDULED
44
- }
45
-
46
- model User {
47
- id String @id @default(cuid())
48
- email String @unique
49
- name String @default("")
50
- role String @default("EDITOR")
51
- passwordHash String?
52
- isActive Boolean @default(true)
53
- isApproved Boolean @default(false)
54
- emailVerified Boolean @default(false)
55
- totpEnabled Boolean @default(false)
56
- totpSecret String?
57
- backupCodes Json?
58
- oauthProvider String?
59
- oauthId String?
60
- createdAt DateTime @default(now())
61
- updatedAt DateTime @updatedAt
62
-
63
- sessions Session[]
64
- documentsCreated Document[] @relation("DocumentCreatedBy")
65
- documentsUpdated Document[] @relation("DocumentUpdatedBy")
66
- documentsReviewed Document[] @relation("DocumentReviewer")
67
- versions Version[]
68
- mediaUploaded Media[] @relation("MediaUploadedBy")
69
- auditLogs AuditLog[]
70
-
71
- @@index([role])
72
- @@index([isActive])
73
- }
74
-
75
- model Session {
76
- id String @id @default(cuid())
77
- userId String
78
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
79
- token String? @unique
80
- expiresAt DateTime
81
- revokedAt DateTime?
82
- ipAddress String?
83
- userAgent String?
84
- createdAt DateTime @default(now())
85
-
86
- @@index([userId])
87
- @@index([expiresAt])
88
- }
89
-
90
- model Document {
91
- id String @id @default(cuid())
92
- collection String
93
- slug String?
94
- title String?
95
- data Json
96
- status DocumentStatus @default(DRAFT)
97
- plainText String? @db.Text
98
- locale String?
99
- folderId String?
100
- structuredData Json?
101
- workflowStage String?
102
- reviewerId String?
103
- reviewNote String? @db.Text
104
- publishedAt DateTime?
105
- scheduledAt DateTime?
106
- scheduledUnpublishAt DateTime?
107
- deletedAt DateTime?
108
- contentHash String?
109
- siteId String?
110
- templateId String?
111
- createdById String
112
- updatedById String
113
- createdAt DateTime @default(now())
114
- updatedAt DateTime @updatedAt
115
-
116
- createdBy User @relation("DocumentCreatedBy", fields: [createdById], references: [id], onDelete: Restrict)
117
- updatedBy User @relation("DocumentUpdatedBy", fields: [updatedById], references: [id], onDelete: Restrict)
118
- reviewer User? @relation("DocumentReviewer", fields: [reviewerId], references: [id], onDelete: SetNull)
119
- folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
120
- versions Version[]
121
- formSubmissions FormSubmission[]
122
-
123
- @@unique([collection, slug], name: "collection_slug")
124
- @@index([collection])
125
- @@index([status])
126
- @@index([deletedAt])
127
- @@index([publishedAt])
128
- @@index([folderId])
129
- @@index([locale])
130
- @@index([scheduledAt])
131
- @@index([scheduledUnpublishAt])
132
- @@index([createdById])
133
- @@index([updatedById])
134
- }
135
-
136
- model Media {
137
- id String @id @default(cuid())
138
- filename String
139
- storageKey String @unique
140
- mimeType String
141
- fileSize Int
142
- width Int?
143
- height Int?
144
- altText String?
145
- title String?
146
- blurHash String?
147
- focalPointX Float?
148
- focalPointY Float?
149
- folderId String?
150
- uploadedById String
151
- createdAt DateTime @default(now())
152
- updatedAt DateTime @updatedAt
153
-
154
- uploadedBy User @relation("MediaUploadedBy", fields: [uploadedById], references: [id], onDelete: Restrict)
155
- folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
156
-
157
- @@index([folderId])
158
- @@index([mimeType])
159
- @@index([createdAt])
160
- }
161
-
162
- model Version {
163
- id String @id @default(cuid())
164
- documentId String
165
- document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
166
- data Json
167
- changedById String
168
- changedBy User @relation(fields: [changedById], references: [id], onDelete: Restrict)
169
- changeType String @default("UPDATE")
170
- createdAt DateTime @default(now())
171
-
172
- @@index([documentId])
173
- @@index([createdAt])
174
- }
175
-
176
- model Folder {
177
- id String @id @default(cuid())
178
- name String
179
- scope String
180
- parentId String?
181
- position Int @default(0)
182
- createdAt DateTime @default(now())
183
-
184
- parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: Cascade)
185
- children Folder[] @relation("FolderTree")
186
- documents Document[]
187
- media Media[]
188
51
 
189
- @@index([scope])
190
- @@index([parentId])
191
- @@index([scope, parentId, position])
52
+ for (const candidate of candidates) {
53
+ try {
54
+ return await readFile(candidate, 'utf-8')
55
+ } catch {
56
+ /* try the next candidate */
57
+ }
58
+ }
59
+ return null
192
60
  }
193
61
 
194
- model Redirect {
195
- id String @id @default(cuid())
196
- source String
197
- destination String
198
- statusCode Int @default(301)
199
- isRegex Boolean @default(false)
200
- notes String?
201
- createdAt DateTime @default(now())
202
- updatedAt DateTime @updatedAt
62
+ const defaultSchemaReader = readCanonicalCmsSchema
203
63
 
204
- @@unique([source])
205
- @@index([createdAt])
64
+ export function setCanonicalSchemaReader(reader: typeof readCanonicalCmsSchema): void {
65
+ readCanonicalCmsSchema = reader
206
66
  }
207
67
 
208
- model FormSubmission {
209
- id String @id @default(cuid())
210
- formId String
211
- form Document @relation(fields: [formId], references: [id], onDelete: Cascade)
212
- data Json
213
- attribution Json?
214
- submittedAt DateTime @default(now())
215
- createdAt DateTime @default(now())
216
-
217
- @@index([formId])
218
- @@index([submittedAt])
219
- @@index([createdAt])
68
+ export function resetCanonicalSchemaReader(): void {
69
+ readCanonicalCmsSchema = defaultSchemaReader
220
70
  }
221
71
 
222
- model AuditLog {
223
- id String @id @default(cuid())
224
- event String
225
- userId String?
226
- user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
227
- details Json?
228
- ipAddress String?
229
- userAgent String?
230
- createdAt DateTime @default(now())
231
-
232
- @@index([event])
233
- @@index([userId])
234
- @@index([createdAt])
72
+ function netBraces(line: string): number {
73
+ return (line.match(/\{/g)?.length ?? 0) - (line.match(/\}/g)?.length ?? 0)
235
74
  }
236
75
 
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
- }
76
+ /**
77
+ * Strips the top-level `generator` and `datasource` blocks from a full Prisma
78
+ * schema, leaving only the `enum` and `model` definitions. The consumer keeps
79
+ * their own datasource/generator; we only contribute models.
80
+ */
81
+ export function extractModelsFragment(fullSchema: string): string {
82
+ const lines = fullSchema.split(/\r?\n/)
83
+ const kept: string[] = []
84
+ let skipping = false
85
+ let depth = 0
248
86
 
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
87
+ for (const line of lines) {
88
+ if (!skipping) {
89
+ if (/^\s*(generator|datasource)\s+[A-Za-z0-9_]+\s*\{/.test(line)) {
90
+ skipping = true
91
+ depth = netBraces(line)
92
+ if (depth <= 0) skipping = false
93
+ continue
94
+ }
95
+ kept.push(line)
96
+ } else {
97
+ depth += netBraces(line)
98
+ if (depth <= 0) skipping = false
99
+ }
100
+ }
272
101
 
273
- @@index([enabled])
274
- @@index([placement])
102
+ return kept.join('\n').trim()
275
103
  }
276
104
 
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
105
+ function wrapFragment(models: string): string {
106
+ return `
107
+ ${CMS_SCHEMA_MARKER} ─────────────────────────────────────────────────────────
108
+ // Auto-injected by \`actuate db:init\` from the installed @actuate-media/cms-core
109
+ // package's canonical prisma/schema.prisma (the single source of truth). Do not
110
+ // edit by hand — re-run \`actuate db:init --force\` after upgrading cms-core.
287
111
 
288
- @@index([category])
289
- @@index([builtIn])
112
+ ${models}
113
+ `
290
114
  }
291
115
 
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
- }
305
- `
116
+ async function fileExists(filePath: string): Promise<boolean> {
117
+ try {
118
+ await access(filePath)
119
+ return true
120
+ } catch {
121
+ return false
122
+ }
306
123
  }
307
124
 
308
125
  export function registerDbInitCommand(program: Command): void {
309
126
  program
310
127
  .command('db:init')
311
- .description('Add Actuate CMS models to your Prisma schema and generate the client')
128
+ .description('Add Actuate CMS models (from the installed cms-core) to your Prisma schema')
312
129
  .option('--schema <path>', 'Path to schema.prisma', 'prisma/schema.prisma')
313
130
  .option('--migrate', 'Run prisma migrate dev after adding models')
314
131
  .option('--force', 'Overwrite existing CMS models if present')
@@ -322,8 +139,18 @@ export function registerDbInitCommand(program: Command): void {
322
139
  return
323
140
  }
324
141
 
325
- const spinner = ora('Reading Prisma schema...').start()
142
+ const spinner = ora('Reading canonical Actuate schema...').start()
143
+
144
+ const canonical = await readCanonicalCmsSchema()
145
+ if (!canonical) {
146
+ spinner.fail('Could not locate @actuate-media/cms-core.')
147
+ logger.info('Install it first: `npm install @actuate-media/cms-core`.')
148
+ process.exitCode = 1
149
+ return
150
+ }
151
+ const fragment = wrapFragment(extractModelsFragment(canonical))
326
152
 
153
+ spinner.text = 'Reading Prisma schema...'
327
154
  let content: string
328
155
  try {
329
156
  content = await readFile(schemaPath, 'utf-8')
@@ -345,7 +172,7 @@ export function registerDbInitCommand(program: Command): void {
345
172
  }
346
173
 
347
174
  spinner.text = 'Adding Actuate CMS models...'
348
- const updatedContent = content.trimEnd() + '\n' + getCmsSchemaFragment()
175
+ const updatedContent = content.trimEnd() + '\n' + fragment
349
176
 
350
177
  try {
351
178
  await writeFile(schemaPath, updatedContent)
@@ -0,0 +1,227 @@
1
+ import { Command } from 'commander'
2
+ import { access, cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
3
+ import { existsSync } from 'node:fs'
4
+ import { dirname, join, resolve } from 'node:path'
5
+ import { createRequire } from 'node:module'
6
+ import ora from 'ora'
7
+ import { logger } from '../utils/logger.js'
8
+
9
+ const require = createRequire(import.meta.url)
10
+
11
+ // Marker that identifies a schema this CLI/scaffolder owns (auto-synced from
12
+ // cms-core). We refuse to overwrite a schema lacking it unless --force, so a
13
+ // hand-customized schema is never silently clobbered.
14
+ const AUTO_SYNCED_MARKER = 'AUTO-SYNCED from @actuate-media/cms-core'
15
+
16
+ // Must match create-actuate-cms/scripts/sync-prisma-assets.ts `SCAFFOLD_SCHEMA_HEADER`
17
+ // so re-syncing a scaffolded project is idempotent (no spurious diffs).
18
+ const SCHEMA_HEADER = `// ─────────────────────────────────────────────────────────────────────────────
19
+ // Actuate CMS — Prisma schema
20
+ //
21
+ // AUTO-SYNCED from @actuate-media/cms-core. Do NOT edit the model definitions
22
+ // by hand — they must match the bundled migrations and cms-core's API layer.
23
+ // (Generated by \`actuate db:sync\`)
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+
26
+ generator client {
27
+ provider = "prisma-client"
28
+ output = "../generated/prisma"
29
+ previewFeatures = ["fullTextSearchPostgres", "relationJoins"]
30
+ }
31
+
32
+ datasource db {
33
+ provider = "postgresql"
34
+ }`
35
+
36
+ /**
37
+ * Build the consumer's `schema.prisma` from cms-core's canonical schema: strip
38
+ * cms-core's own generator/datasource blocks and prepend the consumer header
39
+ * (client output `../generated/prisma`, no datasource `url` — supplied by
40
+ * `prisma.config.ts`). Pure for testing. Mirrors the scaffolder's builder.
41
+ */
42
+ export function buildConsumerSchema(coreSchemaSource: string): string {
43
+ const datasourceMatch = coreSchemaSource.match(/datasource\s+\w+\s*\{[\s\S]*?\}/)
44
+ if (!datasourceMatch || datasourceMatch.index === undefined) {
45
+ throw new Error('Could not locate the `datasource` block in cms-core schema.prisma')
46
+ }
47
+ const body = coreSchemaSource
48
+ .slice(datasourceMatch.index + datasourceMatch[0].length)
49
+ .replace(/^\s+/, '')
50
+
51
+ if (!/@@map\("actuate_users"\)/.test(body)) {
52
+ throw new Error('cms-core schema body is missing `@@map("actuate_users")` — aborting sync')
53
+ }
54
+
55
+ return `${SCHEMA_HEADER}\n\n${body.trimEnd()}\n`
56
+ }
57
+
58
+ /**
59
+ * Resolve the installed `@actuate-media/cms-core` package's `prisma/` directory
60
+ * (the source of truth for schema + migrations). Overridable for tests.
61
+ */
62
+ export let resolveCmsCorePrismaDir = (): string | null => {
63
+ try {
64
+ const exported = require.resolve('@actuate-media/cms-core/prisma/schema')
65
+ return dirname(exported)
66
+ } catch {
67
+ /* exports map missing the subpath — try the main entry */
68
+ }
69
+ try {
70
+ const mainEntry = require.resolve('@actuate-media/cms-core')
71
+ return join(dirname(mainEntry), '..', 'prisma')
72
+ } catch {
73
+ return null
74
+ }
75
+ }
76
+
77
+ const defaultPrismaDirResolver = resolveCmsCorePrismaDir
78
+
79
+ export function setCmsCorePrismaDirResolver(resolver: typeof resolveCmsCorePrismaDir): void {
80
+ resolveCmsCorePrismaDir = resolver
81
+ }
82
+
83
+ export function resetCmsCorePrismaDirResolver(): void {
84
+ resolveCmsCorePrismaDir = defaultPrismaDirResolver
85
+ }
86
+
87
+ async function listMigrationDirs(dir: string): Promise<string[]> {
88
+ try {
89
+ const entries = await readdir(dir, { withFileTypes: true })
90
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name)
91
+ } catch {
92
+ return []
93
+ }
94
+ }
95
+
96
+ interface DbSyncOptions {
97
+ schema: string
98
+ force?: boolean
99
+ }
100
+
101
+ export interface DbSyncResult {
102
+ schemaWritten: boolean
103
+ migrationsAdded: string[]
104
+ skippedReason?: string
105
+ }
106
+
107
+ /**
108
+ * Core sync logic, separated from CLI plumbing for testing. Additive for
109
+ * migrations (never deletes existing ones — they are immutable history), and
110
+ * guarded for the schema (won't overwrite a non-auto-synced schema without
111
+ * `force`).
112
+ */
113
+ export async function syncPrismaAssets(
114
+ consumerSchemaPath: string,
115
+ corePrismaDir: string,
116
+ opts: { force?: boolean } = {},
117
+ ): Promise<DbSyncResult> {
118
+ const coreSchemaPath = join(corePrismaDir, 'schema.prisma')
119
+ const coreSchema = await readFile(coreSchemaPath, 'utf-8')
120
+ const nextSchema = buildConsumerSchema(coreSchema)
121
+
122
+ const consumerDir = dirname(consumerSchemaPath)
123
+ const consumerMigrationsDir = join(consumerDir, 'migrations')
124
+ const coreMigrationsDir = join(corePrismaDir, 'migrations')
125
+
126
+ // Guard the schema overwrite.
127
+ let schemaWritten = false
128
+ let skippedReason: string | undefined
129
+ const existing = existsSync(consumerSchemaPath)
130
+ ? await readFile(consumerSchemaPath, 'utf-8')
131
+ : null
132
+
133
+ if (existing && !existing.includes(AUTO_SYNCED_MARKER) && !opts.force) {
134
+ skippedReason =
135
+ 'schema.prisma is not an auto-synced Actuate schema (no AUTO-SYNCED marker). Re-run with --force to overwrite it.'
136
+ } else {
137
+ await mkdir(consumerDir, { recursive: true })
138
+ if (existing !== nextSchema) {
139
+ await writeFile(consumerSchemaPath, nextSchema)
140
+ schemaWritten = true
141
+ }
142
+ }
143
+
144
+ // Additively copy any cms-core migrations the consumer doesn't already have.
145
+ const coreMigrations = await listMigrationDirs(coreMigrationsDir)
146
+ const existingMigrations = new Set(await listMigrationDirs(consumerMigrationsDir))
147
+ const migrationsAdded: string[] = []
148
+
149
+ if (coreMigrations.length > 0) {
150
+ await mkdir(consumerMigrationsDir, { recursive: true })
151
+ const lockSrc = join(coreMigrationsDir, 'migration_lock.toml')
152
+ const lockDest = join(consumerMigrationsDir, 'migration_lock.toml')
153
+ if (existsSync(lockSrc) && !existsSync(lockDest)) {
154
+ await cp(lockSrc, lockDest)
155
+ }
156
+ for (const name of coreMigrations) {
157
+ if (existingMigrations.has(name)) continue
158
+ await cp(join(coreMigrationsDir, name), join(consumerMigrationsDir, name), {
159
+ recursive: true,
160
+ })
161
+ migrationsAdded.push(name)
162
+ }
163
+ }
164
+
165
+ return { schemaWritten, migrationsAdded, skippedReason }
166
+ }
167
+
168
+ async function runDbSync(options: DbSyncOptions): Promise<void> {
169
+ const consumerSchemaPath = resolve(process.cwd(), options.schema)
170
+ const spinner = ora('Locating @actuate-media/cms-core…').start()
171
+
172
+ const corePrismaDir = resolveCmsCorePrismaDir()
173
+ if (!corePrismaDir) {
174
+ spinner.fail('Could not locate @actuate-media/cms-core.')
175
+ logger.info('Install it first: `npm install @actuate-media/cms-core`.')
176
+ process.exitCode = 1
177
+ return
178
+ }
179
+
180
+ try {
181
+ await access(join(corePrismaDir, 'schema.prisma'))
182
+ } catch {
183
+ spinner.fail(`cms-core does not ship a Prisma schema at ${corePrismaDir}.`)
184
+ process.exitCode = 1
185
+ return
186
+ }
187
+
188
+ spinner.text = 'Syncing schema + migrations…'
189
+ let result: DbSyncResult
190
+ try {
191
+ result = await syncPrismaAssets(consumerSchemaPath, corePrismaDir, { force: options.force })
192
+ } catch (err) {
193
+ spinner.fail('Failed to sync Prisma assets.')
194
+ logger.error(err instanceof Error ? err.message : String(err))
195
+ process.exitCode = 1
196
+ return
197
+ }
198
+
199
+ spinner.succeed('Prisma assets synced from cms-core.')
200
+
201
+ if (result.skippedReason) {
202
+ logger.warn(`Schema not updated: ${result.skippedReason}`)
203
+ } else if (result.schemaWritten) {
204
+ logger.success('schema.prisma refreshed.')
205
+ } else {
206
+ logger.info('schema.prisma already up to date.')
207
+ }
208
+
209
+ if (result.migrationsAdded.length > 0) {
210
+ logger.success(`Added ${result.migrationsAdded.length} new migration(s).`)
211
+ } else {
212
+ logger.info('No new migrations to add.')
213
+ }
214
+
215
+ if (result.schemaWritten || result.migrationsAdded.length > 0) {
216
+ logger.info('Next: run `npx prisma migrate deploy` then `npx prisma generate`.')
217
+ }
218
+ }
219
+
220
+ export function registerDbSyncCommand(program: Command): void {
221
+ program
222
+ .command('db:sync')
223
+ .description('Sync the canonical Prisma schema + migrations from the installed cms-core')
224
+ .option('--schema <path>', 'Path to schema.prisma', 'prisma/schema.prisma')
225
+ .option('--force', 'Overwrite schema.prisma even if it lacks the AUTO-SYNCED marker')
226
+ .action(runDbSync)
227
+ }