@actuate-media/cli 0.4.2 → 0.5.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 +62 -16
- package/CHANGELOG.md +30 -0
- package/dist/__tests__/db-init.test.d.ts +2 -0
- package/dist/__tests__/db-init.test.d.ts.map +1 -0
- package/dist/__tests__/db-init.test.js +127 -0
- package/dist/__tests__/db-init.test.js.map +1 -0
- package/dist/__tests__/db-sync.test.d.ts +2 -0
- package/dist/__tests__/db-sync.test.d.ts.map +1 -0
- package/dist/__tests__/db-sync.test.js +136 -0
- package/dist/__tests__/db-sync.test.js.map +1 -0
- package/dist/commands/db-init.d.ts +17 -0
- package/dist/commands/db-init.d.ts.map +1 -1
- package/dist/commands/db-init.js +100 -278
- package/dist/commands/db-init.js.map +1 -1
- package/dist/commands/db-sync.d.ts +31 -0
- package/dist/commands/db-sync.d.ts.map +1 -0
- package/dist/commands/db-sync.js +195 -0
- package/dist/commands/db-sync.js.map +1 -0
- package/dist/commands/upgrade.d.ts.map +1 -1
- package/dist/commands/upgrade.js +5 -0
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/db-init.test.ts +155 -0
- package/src/__tests__/db-sync.test.ts +167 -0
- package/src/commands/db-init.ts +93 -266
- package/src/commands/db-sync.ts +227 -0
- package/src/commands/upgrade.ts +8 -0
- package/src/index.ts +2 -0
package/src/commands/db-init.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
64
|
+
export function setCanonicalSchemaReader(reader: typeof readCanonicalCmsSchema): void {
|
|
65
|
+
readCanonicalCmsSchema = reader
|
|
206
66
|
}
|
|
207
67
|
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
274
|
-
@@index([placement])
|
|
102
|
+
return kept.join('\n').trim()
|
|
275
103
|
}
|
|
276
104
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
289
|
-
|
|
112
|
+
${models}
|
|
113
|
+
`
|
|
290
114
|
}
|
|
291
115
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
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
|
|
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' +
|
|
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
|
+
}
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -167,6 +167,14 @@ async function runUpgrade(options: UpgradeOptions): Promise<void> {
|
|
|
167
167
|
|
|
168
168
|
logger.success(`Updated ${upgrades.length} package(s) in package.json.`)
|
|
169
169
|
logger.info('Run "pnpm install" to apply the upgrade.')
|
|
170
|
+
|
|
171
|
+
// A cms-core bump can introduce new models/migrations. Bumping the version
|
|
172
|
+
// alone leaves the consumer's Prisma schema stale, so point them at db:sync.
|
|
173
|
+
if (upgrades.some((u) => u.name === '@actuate-media/cms-core')) {
|
|
174
|
+
logger.info(
|
|
175
|
+
'cms-core changed — after installing, run "actuate db:sync" then "npx prisma migrate deploy" to update your Prisma schema.',
|
|
176
|
+
)
|
|
177
|
+
}
|
|
170
178
|
}
|
|
171
179
|
|
|
172
180
|
export function registerUpgradeCommand(program: Command): void {
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { registerExportCommand } from './commands/export.js'
|
|
|
8
8
|
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
|
+
import { registerDbSyncCommand } from './commands/db-sync.js'
|
|
11
12
|
import { registerDbStatusCommand } from './commands/db-status.js'
|
|
12
13
|
import { registerInitCommand } from './commands/init.js'
|
|
13
14
|
import {
|
|
@@ -31,6 +32,7 @@ registerExportCommand(program)
|
|
|
31
32
|
registerUpgradeCommand(program)
|
|
32
33
|
registerUpdateCheckCommand(program)
|
|
33
34
|
registerDbInitCommand(program)
|
|
35
|
+
registerDbSyncCommand(program)
|
|
34
36
|
registerDbStatusCommand(program)
|
|
35
37
|
registerInitCommand(program)
|
|
36
38
|
registerDoctorCommand(program)
|