@actuate-media/cli 0.6.0 → 0.8.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 +27 -25
- package/CHANGELOG.md +19 -0
- package/dist/__tests__/db-sync.test.js +32 -1
- package/dist/__tests__/db-sync.test.js.map +1 -1
- package/dist/__tests__/deployment-diagnostics.test.js +42 -1
- package/dist/__tests__/deployment-diagnostics.test.js.map +1 -1
- package/dist/__tests__/vercel-env-matrix.test.d.ts +2 -0
- package/dist/__tests__/vercel-env-matrix.test.d.ts.map +1 -0
- package/dist/__tests__/vercel-env-matrix.test.js +48 -0
- package/dist/__tests__/vercel-env-matrix.test.js.map +1 -0
- package/dist/commands/db-sync.d.ts +22 -0
- package/dist/commands/db-sync.d.ts.map +1 -1
- package/dist/commands/db-sync.js +94 -0
- package/dist/commands/db-sync.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +70 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/migrate-sections.d.ts +3 -0
- package/dist/commands/migrate-sections.d.ts.map +1 -0
- package/dist/commands/migrate-sections.js +56 -0
- package/dist/commands/migrate-sections.js.map +1 -0
- package/dist/commands/seed.d.ts.map +1 -1
- package/dist/commands/seed.js +2 -40
- package/dist/commands/seed.js.map +1 -1
- package/dist/commands/vercel-blob-link.d.ts +3 -0
- package/dist/commands/vercel-blob-link.d.ts.map +1 -0
- package/dist/commands/vercel-blob-link.js +82 -0
- package/dist/commands/vercel-blob-link.js.map +1 -0
- package/dist/deployment/diagnostics.d.ts +13 -0
- package/dist/deployment/diagnostics.d.ts.map +1 -1
- package/dist/deployment/diagnostics.js +55 -0
- package/dist/deployment/diagnostics.js.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/database.d.ts +19 -0
- package/dist/utils/database.d.ts.map +1 -0
- package/dist/utils/database.js +58 -0
- package/dist/utils/database.js.map +1 -0
- package/dist/vercel/client.d.ts +32 -0
- package/dist/vercel/client.d.ts.map +1 -0
- package/dist/vercel/client.js +74 -0
- package/dist/vercel/client.js.map +1 -0
- package/dist/vercel/env-matrix.d.ts +34 -0
- package/dist/vercel/env-matrix.d.ts.map +1 -0
- package/dist/vercel/env-matrix.js +57 -0
- package/dist/vercel/env-matrix.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/db-sync.test.ts +55 -1
- package/src/__tests__/deployment-diagnostics.test.ts +51 -0
- package/src/__tests__/vercel-env-matrix.test.ts +56 -0
- package/src/commands/db-sync.ts +116 -0
- package/src/commands/doctor.ts +118 -10
- package/src/commands/migrate-sections.ts +73 -0
- package/src/commands/seed.ts +2 -56
- package/src/commands/vercel-blob-link.ts +115 -0
- package/src/deployment/diagnostics.ts +70 -0
- package/src/index.ts +4 -0
- package/src/utils/database.ts +77 -0
- package/src/vercel/client.ts +112 -0
- package/src/vercel/env-matrix.ts +101 -0
package/src/commands/db-sync.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander'
|
|
2
2
|
import { access, cp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { existsSync } from 'node:fs'
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
4
5
|
import { dirname, join, resolve } from 'node:path'
|
|
5
6
|
import { createRequire } from 'node:module'
|
|
6
7
|
import ora from 'ora'
|
|
@@ -96,6 +97,99 @@ async function listMigrationDirs(dir: string): Promise<string[]> {
|
|
|
96
97
|
interface DbSyncOptions {
|
|
97
98
|
schema: string
|
|
98
99
|
force?: boolean
|
|
100
|
+
checkDrift?: boolean
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface DriftDiffResult {
|
|
104
|
+
exitCode: number
|
|
105
|
+
stdout: string
|
|
106
|
+
stderr: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type DriftRunner = (args: { schemaPath: string; url: string }) => Promise<DriftDiffResult>
|
|
110
|
+
|
|
111
|
+
export interface DriftCheckResult {
|
|
112
|
+
status: 'no-drift' | 'drift' | 'skipped' | 'error'
|
|
113
|
+
detail?: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const defaultDriftRunner: DriftRunner = ({ schemaPath, url }) =>
|
|
117
|
+
new Promise((resolvePromise) => {
|
|
118
|
+
// `--exit-code` makes prisma return 2 when the database differs from the
|
|
119
|
+
// datamodel, 0 when in sync. `--script` prints the reconciling SQL.
|
|
120
|
+
const child = spawn(
|
|
121
|
+
'npx',
|
|
122
|
+
[
|
|
123
|
+
'prisma',
|
|
124
|
+
'migrate',
|
|
125
|
+
'diff',
|
|
126
|
+
'--from-url',
|
|
127
|
+
url,
|
|
128
|
+
'--to-schema-datamodel',
|
|
129
|
+
schemaPath,
|
|
130
|
+
'--script',
|
|
131
|
+
'--exit-code',
|
|
132
|
+
],
|
|
133
|
+
{ shell: process.platform === 'win32' },
|
|
134
|
+
)
|
|
135
|
+
let stdout = ''
|
|
136
|
+
let stderr = ''
|
|
137
|
+
child.stdout?.on('data', (chunk) => {
|
|
138
|
+
stdout += String(chunk)
|
|
139
|
+
})
|
|
140
|
+
child.stderr?.on('data', (chunk) => {
|
|
141
|
+
stderr += String(chunk)
|
|
142
|
+
})
|
|
143
|
+
child.on('error', (err) => {
|
|
144
|
+
resolvePromise({ exitCode: 1, stdout, stderr: stderr || String(err) })
|
|
145
|
+
})
|
|
146
|
+
child.on('close', (code) => {
|
|
147
|
+
resolvePromise({ exitCode: code ?? 1, stdout, stderr })
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Detect whether the live database differs from the canonical schema. Pure of
|
|
153
|
+
* I/O via the injectable `runner` so the decision logic is unit-testable. A
|
|
154
|
+
* non-pooled `DIRECT_DATABASE_URL` is preferred (migrations use it); falls back
|
|
155
|
+
* to `DATABASE_URL`. Skips gracefully when neither is set.
|
|
156
|
+
*/
|
|
157
|
+
export async function checkSchemaDrift(
|
|
158
|
+
schemaPath: string,
|
|
159
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
160
|
+
runner: DriftRunner = defaultDriftRunner,
|
|
161
|
+
): Promise<DriftCheckResult> {
|
|
162
|
+
const url = env.DIRECT_DATABASE_URL || env.DATABASE_URL
|
|
163
|
+
if (!url) {
|
|
164
|
+
return {
|
|
165
|
+
status: 'skipped',
|
|
166
|
+
detail: 'Set DATABASE_URL (or DIRECT_DATABASE_URL) to let db:sync detect schema drift.',
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const result = await runner({ schemaPath, url })
|
|
170
|
+
if (result.exitCode === 0) return { status: 'no-drift' }
|
|
171
|
+
if (result.exitCode === 2) return { status: 'drift', detail: result.stdout.trim() }
|
|
172
|
+
return { status: 'error', detail: (result.stderr || result.stdout).trim() }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Human-readable next steps for a drift verdict (pure, for testing + reuse). */
|
|
176
|
+
export function driftGuidance(result: DriftCheckResult): string[] {
|
|
177
|
+
switch (result.status) {
|
|
178
|
+
case 'drift':
|
|
179
|
+
return [
|
|
180
|
+
'Database schema differs from the canonical Actuate schema.',
|
|
181
|
+
'Apply the migrations shipped by db:sync (includes column renames):',
|
|
182
|
+
' npx prisma migrate deploy',
|
|
183
|
+
'If drift remains afterward, generate a reconciling migration:',
|
|
184
|
+
' npx prisma migrate dev --name actuate-schema-sync',
|
|
185
|
+
]
|
|
186
|
+
case 'error':
|
|
187
|
+
return ['Could not check schema drift (is the database reachable?).']
|
|
188
|
+
case 'skipped':
|
|
189
|
+
return [result.detail ?? 'Drift check skipped.']
|
|
190
|
+
case 'no-drift':
|
|
191
|
+
return ['Database schema matches the canonical Actuate schema.']
|
|
192
|
+
}
|
|
99
193
|
}
|
|
100
194
|
|
|
101
195
|
export interface DbSyncResult {
|
|
@@ -215,6 +309,27 @@ async function runDbSync(options: DbSyncOptions): Promise<void> {
|
|
|
215
309
|
if (result.schemaWritten || result.migrationsAdded.length > 0) {
|
|
216
310
|
logger.info('Next: run `npx prisma migrate deploy` then `npx prisma generate`.')
|
|
217
311
|
}
|
|
312
|
+
|
|
313
|
+
// Drift detection: compare the live DB against the canonical schema so a
|
|
314
|
+
// mismatch (e.g. a renamed column not yet migrated) surfaces an exact command
|
|
315
|
+
// rather than a silent runtime failure. Opt-out with --no-check-drift.
|
|
316
|
+
if (options.checkDrift !== false && !result.skippedReason) {
|
|
317
|
+
const driftSpinner = ora('Checking database for schema drift…').start()
|
|
318
|
+
const drift = await checkSchemaDrift(consumerSchemaPath)
|
|
319
|
+
if (drift.status === 'drift') {
|
|
320
|
+
driftSpinner.warn('Schema drift detected.')
|
|
321
|
+
if (drift.detail) {
|
|
322
|
+
logger.info('Reconciling SQL (review before applying):')
|
|
323
|
+
console.log(drift.detail)
|
|
324
|
+
}
|
|
325
|
+
for (const line of driftGuidance(drift)) logger.info(line)
|
|
326
|
+
process.exitCode = 1
|
|
327
|
+
} else if (drift.status === 'no-drift') {
|
|
328
|
+
driftSpinner.succeed('No schema drift detected.')
|
|
329
|
+
} else {
|
|
330
|
+
driftSpinner.info(driftGuidance(drift)[0] ?? 'Drift check skipped.')
|
|
331
|
+
}
|
|
332
|
+
}
|
|
218
333
|
}
|
|
219
334
|
|
|
220
335
|
export function registerDbSyncCommand(program: Command): void {
|
|
@@ -223,5 +338,6 @@ export function registerDbSyncCommand(program: Command): void {
|
|
|
223
338
|
.description('Sync the canonical Prisma schema + migrations from the installed cms-core')
|
|
224
339
|
.option('--schema <path>', 'Path to schema.prisma', 'prisma/schema.prisma')
|
|
225
340
|
.option('--force', 'Overwrite schema.prisma even if it lacks the AUTO-SYNCED marker')
|
|
341
|
+
.option('--no-check-drift', 'Skip the post-sync database drift check')
|
|
226
342
|
.action(runDbSync)
|
|
227
343
|
}
|
package/src/commands/doctor.ts
CHANGED
|
@@ -6,8 +6,19 @@ import {
|
|
|
6
6
|
buildDeploymentManifest,
|
|
7
7
|
createDiagnosticReport,
|
|
8
8
|
detectPackageManager,
|
|
9
|
+
VERCEL_MATRIX_OPTIONAL,
|
|
10
|
+
VERCEL_MATRIX_REQUIRED,
|
|
9
11
|
type DiagnosticReport,
|
|
10
12
|
} from '../deployment/diagnostics.js'
|
|
13
|
+
import {
|
|
14
|
+
createVercelClient,
|
|
15
|
+
readVercelLink,
|
|
16
|
+
resolveVercelToken,
|
|
17
|
+
VercelApiError,
|
|
18
|
+
VERCEL_TARGETS,
|
|
19
|
+
} from '../vercel/client.js'
|
|
20
|
+
import { buildEnvMatrix, type EnvMatrix } from '../vercel/env-matrix.js'
|
|
21
|
+
import { logger } from '../utils/logger.js'
|
|
11
22
|
|
|
12
23
|
async function fileExists(path: string): Promise<boolean> {
|
|
13
24
|
try {
|
|
@@ -108,23 +119,120 @@ export function registerDoctorCommand(program: Command): void {
|
|
|
108
119
|
})
|
|
109
120
|
}
|
|
110
121
|
|
|
122
|
+
function printEnvMatrix(matrix: EnvMatrix): void {
|
|
123
|
+
const cell = (present: boolean): string => (present ? chalk.green(' Y ') : chalk.red(' . '))
|
|
124
|
+
const keyWidth = Math.max(24, ...matrix.rows.map((r) => `${r.key} (optional)`.length + 1))
|
|
125
|
+
|
|
126
|
+
console.log()
|
|
127
|
+
console.log(chalk.bold('Environment variable matrix (Vercel)'))
|
|
128
|
+
console.log(chalk.dim('─────────────────────────────────'))
|
|
129
|
+
console.log(`${'Variable'.padEnd(keyWidth)} Prod Prev Dev`)
|
|
130
|
+
for (const row of matrix.rows) {
|
|
131
|
+
const labelPlain = row.required ? row.key : `${row.key} (optional)`
|
|
132
|
+
const label = row.required ? row.key : chalk.dim(labelPlain)
|
|
133
|
+
console.log(
|
|
134
|
+
`${label}${' '.repeat(Math.max(1, keyWidth - labelPlain.length))}` +
|
|
135
|
+
`${cell(row.presence.production)} ${cell(row.presence.preview)} ${cell(row.presence.development)}`,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
console.log(chalk.dim('─────────────────────────────────'))
|
|
139
|
+
console.log(chalk.dim('Y = set for that environment, . = missing'))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function runVercelMatrix(opts: {
|
|
143
|
+
token?: string
|
|
144
|
+
project?: string
|
|
145
|
+
org?: string
|
|
146
|
+
json?: boolean
|
|
147
|
+
}): Promise<{ ok: boolean; matrix?: EnvMatrix }> {
|
|
148
|
+
const token = resolveVercelToken(opts.token)
|
|
149
|
+
if (!token) {
|
|
150
|
+
logger.error('No Vercel token found. Pass --token or set VERCEL_TOKEN to use --vercel.')
|
|
151
|
+
return { ok: false }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const link = opts.project
|
|
155
|
+
? { projectId: opts.project, orgId: opts.org }
|
|
156
|
+
: await readVercelLink(process.cwd())
|
|
157
|
+
if (!link?.projectId) {
|
|
158
|
+
logger.error('No linked Vercel project. Run `vercel link` or pass --project <id>.')
|
|
159
|
+
return { ok: false }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const client = createVercelClient({ token, teamId: link.orgId ?? opts.org })
|
|
163
|
+
try {
|
|
164
|
+
const envVars = await client.listProjectEnv(link.projectId)
|
|
165
|
+
const matrix = buildEnvMatrix(envVars, VERCEL_MATRIX_REQUIRED, VERCEL_MATRIX_OPTIONAL)
|
|
166
|
+
if (!opts.json) {
|
|
167
|
+
printEnvMatrix(matrix)
|
|
168
|
+
if (matrix.missingCritical.length > 0) {
|
|
169
|
+
for (const m of matrix.missingCritical) {
|
|
170
|
+
logger.error(`${m.key} missing on: ${m.targets.join(', ')}`)
|
|
171
|
+
}
|
|
172
|
+
logger.info(
|
|
173
|
+
`Add the missing variables in the Vercel dashboard (include Development for local \`vercel env pull\`). Targets checked: ${VERCEL_TARGETS.join(', ')}.`,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { ok: matrix.missingCritical.length === 0, matrix }
|
|
178
|
+
} catch (error) {
|
|
179
|
+
const message =
|
|
180
|
+
error instanceof VercelApiError
|
|
181
|
+
? error.message
|
|
182
|
+
: error instanceof Error
|
|
183
|
+
? error.message
|
|
184
|
+
: String(error)
|
|
185
|
+
logger.error(`Could not read project environment from Vercel: ${message}`)
|
|
186
|
+
return { ok: false }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
111
190
|
export function registerDeployCheckCommand(program: Command): void {
|
|
112
191
|
program
|
|
113
192
|
.command('deploy:check')
|
|
114
193
|
.description('Check production deployment readiness for Actuate CMS')
|
|
115
194
|
.option('--schema <path>', 'Path to schema.prisma', 'prisma/schema.prisma')
|
|
116
195
|
.option('--config <path>', 'Path to actuate.config.ts', 'actuate.config.ts')
|
|
196
|
+
.option('--vercel', 'Read the linked Vercel project and print a per-environment matrix')
|
|
197
|
+
.option('--token <token>', 'Vercel access token (with --vercel; defaults to $VERCEL_TOKEN)')
|
|
198
|
+
.option('--project <id>', 'Vercel project id (with --vercel; defaults to .vercel/project.json)')
|
|
199
|
+
.option('--org <id>', 'Vercel team/org id (with --vercel)')
|
|
117
200
|
.option('--json', 'Print machine-readable JSON')
|
|
118
|
-
.action(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
201
|
+
.action(
|
|
202
|
+
async (opts: {
|
|
203
|
+
schema: string
|
|
204
|
+
config: string
|
|
205
|
+
vercel?: boolean
|
|
206
|
+
token?: string
|
|
207
|
+
project?: string
|
|
208
|
+
org?: string
|
|
209
|
+
json?: boolean
|
|
210
|
+
}) => {
|
|
211
|
+
const report = await buildReport(opts.schema, opts.config, 'deploy')
|
|
212
|
+
const vercelResult = opts.vercel ? await runVercelMatrix(opts) : null
|
|
213
|
+
|
|
214
|
+
if (opts.json) {
|
|
215
|
+
console.log(
|
|
216
|
+
JSON.stringify(
|
|
217
|
+
{
|
|
218
|
+
...report,
|
|
219
|
+
manifest: buildDeploymentManifest(),
|
|
220
|
+
...(vercelResult ? { vercelMatrix: vercelResult.matrix ?? null } : {}),
|
|
221
|
+
},
|
|
222
|
+
null,
|
|
223
|
+
2,
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
} else {
|
|
227
|
+
printReport('Actuate Deploy Check', report)
|
|
228
|
+
console.log(chalk.dim('Run `actuate verify --full` after deployment succeeds.'))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (report.status === 'fail' || (vercelResult && !vercelResult.ok)) {
|
|
232
|
+
process.exitCode = 1
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
)
|
|
128
236
|
}
|
|
129
237
|
|
|
130
238
|
export function registerVerifyCommand(program: Command): void {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import ora from 'ora'
|
|
3
|
+
import { logger } from '../utils/logger.js'
|
|
4
|
+
import { connectProjectDatabase } from '../utils/database.js'
|
|
5
|
+
|
|
6
|
+
interface MigrateSectionsOptions {
|
|
7
|
+
dryRun?: boolean
|
|
8
|
+
batchSize?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `actuate migrate:sections` — backfill canonical `data.sections` for legacy
|
|
13
|
+
* page-builder documents (ADR 0002). Idempotent: safe to re-run.
|
|
14
|
+
*/
|
|
15
|
+
async function runMigrateSections(options: MigrateSectionsOptions): Promise<void> {
|
|
16
|
+
const dryRun = options.dryRun ?? false
|
|
17
|
+
const batchSize = options.batchSize ? Number.parseInt(options.batchSize, 10) : undefined
|
|
18
|
+
if (batchSize !== undefined && (!Number.isFinite(batchSize) || batchSize <= 0)) {
|
|
19
|
+
logger.error('--batch-size must be a positive integer.')
|
|
20
|
+
process.exit(1)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let connection: { db: any; disconnect: () => Promise<void> } | null = null
|
|
24
|
+
const spinner = ora(
|
|
25
|
+
dryRun ? 'Scanning page-builder documents (dry run)…' : 'Backfilling page sections…',
|
|
26
|
+
).start()
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
connection = await connectProjectDatabase()
|
|
30
|
+
const db = connection.db
|
|
31
|
+
|
|
32
|
+
const { migratePageBuilderSections } = await import('@actuate-media/cms-core')
|
|
33
|
+
|
|
34
|
+
// Run as the system admin so per-collection update access is satisfied.
|
|
35
|
+
const admin = await db.user.findFirst({ where: { role: 'ADMIN' } })
|
|
36
|
+
if (!admin) {
|
|
37
|
+
spinner.fail('No ADMIN user found. Create an admin (setup wizard or seed) first.')
|
|
38
|
+
process.exit(1)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ctx = { userId: admin.id, role: 'ADMIN', db }
|
|
42
|
+
const result = await migratePageBuilderSections(ctx, { dryRun, batchSize })
|
|
43
|
+
|
|
44
|
+
spinner.succeed(
|
|
45
|
+
dryRun
|
|
46
|
+
? `Dry run complete — ${result.migrated} document(s) would be migrated.`
|
|
47
|
+
: `Migrated ${result.migrated} document(s).`,
|
|
48
|
+
)
|
|
49
|
+
logger.info(` Scanned: ${result.scanned}`)
|
|
50
|
+
logger.info(` Migrated: ${result.migrated}`)
|
|
51
|
+
logger.info(` Skipped: ${result.skipped}`)
|
|
52
|
+
if (result.migratedIds.length > 0) {
|
|
53
|
+
logger.info(` IDs: ${result.migratedIds.join(', ')}`)
|
|
54
|
+
}
|
|
55
|
+
} catch (err) {
|
|
56
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
57
|
+
spinner.fail(`Section migration failed: ${message}`)
|
|
58
|
+
process.exit(1)
|
|
59
|
+
} finally {
|
|
60
|
+
await connection?.disconnect()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function registerMigrateSectionsCommand(program: Command): void {
|
|
65
|
+
program
|
|
66
|
+
.command('migrate:sections')
|
|
67
|
+
.description(
|
|
68
|
+
'Backfill canonical page sections for legacy page-builder documents (ADR 0002). Idempotent.',
|
|
69
|
+
)
|
|
70
|
+
.option('--dry-run', 'Report what would change without writing')
|
|
71
|
+
.option('--batch-size <n>', 'Documents to scan per DB page (default 100)')
|
|
72
|
+
.action(runMigrateSections)
|
|
73
|
+
}
|
package/src/commands/seed.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
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
4
|
import path from 'node:path'
|
|
6
5
|
import { createInterface } from 'node:readline/promises'
|
|
7
6
|
import { pathToFileURL } from 'node:url'
|
|
8
7
|
import ora from 'ora'
|
|
9
8
|
import { logger } from '../utils/logger.js'
|
|
9
|
+
import { connectProjectDatabase } from '../utils/database.js'
|
|
10
10
|
|
|
11
11
|
async function confirm(question: string): Promise<boolean> {
|
|
12
12
|
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
@@ -300,7 +300,7 @@ async function runSeed(options: SeedOptions): Promise<void> {
|
|
|
300
300
|
let seededDb: { db: any; disconnect: () => Promise<void> } | null = null
|
|
301
301
|
|
|
302
302
|
try {
|
|
303
|
-
seededDb = await
|
|
303
|
+
seededDb = await connectProjectDatabase()
|
|
304
304
|
const db = seededDb.db
|
|
305
305
|
|
|
306
306
|
if (options.reset) {
|
|
@@ -335,60 +335,6 @@ async function runSeed(options: SeedOptions): Promise<void> {
|
|
|
335
335
|
}
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
-
async function getSeedDatabase(): Promise<{ db: any; disconnect: () => Promise<void> }> {
|
|
339
|
-
const { getDB, initDB, isDBInitialized } = await import('@actuate-media/cms-core')
|
|
340
|
-
|
|
341
|
-
if (isDBInitialized()) {
|
|
342
|
-
return { db: getDB<any>(), disconnect: async () => {} }
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
const db = await createProjectPrismaClient()
|
|
346
|
-
initDB(db)
|
|
347
|
-
return {
|
|
348
|
-
db,
|
|
349
|
-
disconnect: async () => {
|
|
350
|
-
if (typeof db.$disconnect === 'function') {
|
|
351
|
-
await db.$disconnect()
|
|
352
|
-
}
|
|
353
|
-
},
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
async function createProjectPrismaClient(): Promise<any> {
|
|
358
|
-
if (!process.env.DATABASE_URL) {
|
|
359
|
-
throw new Error('DATABASE_URL is required to run seed/populate.')
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const requireFromProject = createRequire(path.join(process.cwd(), 'package.json'))
|
|
363
|
-
const generatedClient = path.resolve('generated', 'prisma', 'client.ts')
|
|
364
|
-
|
|
365
|
-
if (existsSync(generatedClient)) {
|
|
366
|
-
const [{ tsImport }, adapterModule, pgModule] = await Promise.all([
|
|
367
|
-
import('tsx/esm/api'),
|
|
368
|
-
import(pathToFileURL(requireFromProject.resolve('@prisma/adapter-pg')).href),
|
|
369
|
-
import(pathToFileURL(requireFromProject.resolve('pg')).href),
|
|
370
|
-
])
|
|
371
|
-
const { PrismaClient } = (await tsImport(
|
|
372
|
-
pathToFileURL(generatedClient).href,
|
|
373
|
-
import.meta.url,
|
|
374
|
-
)) as {
|
|
375
|
-
PrismaClient: new (options?: unknown) => any
|
|
376
|
-
}
|
|
377
|
-
const { PrismaPg } = adapterModule as { PrismaPg: new (pool: unknown) => unknown }
|
|
378
|
-
const pg = (pgModule as { default?: typeof pgModule }).default ?? pgModule
|
|
379
|
-
const pool = new (pg as any).Pool({ connectionString: process.env.DATABASE_URL })
|
|
380
|
-
const adapter = new PrismaPg(pool)
|
|
381
|
-
return new PrismaClient({ adapter } as any)
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
const clientModule = (await import(
|
|
385
|
-
pathToFileURL(requireFromProject.resolve('@prisma/client')).href
|
|
386
|
-
)) as {
|
|
387
|
-
PrismaClient: new () => any
|
|
388
|
-
}
|
|
389
|
-
return new clientModule.PrismaClient()
|
|
390
|
-
}
|
|
391
|
-
|
|
392
338
|
export async function ensureSeedAdmin(db: any): Promise<{ id: string }> {
|
|
393
339
|
const existing = await db.user.findFirst({ where: { role: 'ADMIN' } })
|
|
394
340
|
if (existing) return existing
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Command } from 'commander'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import {
|
|
4
|
+
createVercelClient,
|
|
5
|
+
readVercelLink,
|
|
6
|
+
resolveVercelToken,
|
|
7
|
+
VercelApiError,
|
|
8
|
+
VERCEL_TARGETS,
|
|
9
|
+
type VercelTarget,
|
|
10
|
+
} from '../vercel/client.js'
|
|
11
|
+
import { evaluateBlobLink } from '../vercel/env-matrix.js'
|
|
12
|
+
import { logger } from '../utils/logger.js'
|
|
13
|
+
|
|
14
|
+
interface BlobLinkOptions {
|
|
15
|
+
token?: string
|
|
16
|
+
project?: string
|
|
17
|
+
org?: string
|
|
18
|
+
json?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function mark(present: boolean): string {
|
|
22
|
+
return present ? chalk.green('PASS') : chalk.red('FAIL')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function registerVercelBlobLinkCommand(program: Command): void {
|
|
26
|
+
program
|
|
27
|
+
.command('vercel:blob-link')
|
|
28
|
+
.description('Validate the Vercel Blob store connection for the linked project')
|
|
29
|
+
.option('--token <token>', 'Vercel access token (defaults to $VERCEL_TOKEN)')
|
|
30
|
+
.option('--project <id>', 'Vercel project id (defaults to .vercel/project.json)')
|
|
31
|
+
.option('--org <id>', 'Vercel team/org id (defaults to .vercel/project.json)')
|
|
32
|
+
.option('--json', 'Print machine-readable JSON')
|
|
33
|
+
.action(async (opts: BlobLinkOptions) => {
|
|
34
|
+
const token = resolveVercelToken(opts.token)
|
|
35
|
+
if (!token) {
|
|
36
|
+
logger.error(
|
|
37
|
+
'No Vercel token found. Pass --token or set VERCEL_TOKEN (create one at https://vercel.com/account/tokens).',
|
|
38
|
+
)
|
|
39
|
+
process.exitCode = 1
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const link = opts.project
|
|
44
|
+
? { projectId: opts.project, orgId: opts.org }
|
|
45
|
+
: await readVercelLink(process.cwd())
|
|
46
|
+
if (!link?.projectId) {
|
|
47
|
+
logger.error(
|
|
48
|
+
'No linked Vercel project found. Run `vercel link` first, or pass --project <id> (and --org <id> for team projects).',
|
|
49
|
+
)
|
|
50
|
+
process.exitCode = 1
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const client = createVercelClient({ token, teamId: link.orgId ?? opts.org })
|
|
55
|
+
|
|
56
|
+
let envVars
|
|
57
|
+
try {
|
|
58
|
+
envVars = await client.listProjectEnv(link.projectId)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const message =
|
|
61
|
+
error instanceof VercelApiError
|
|
62
|
+
? error.message
|
|
63
|
+
: error instanceof Error
|
|
64
|
+
? error.message
|
|
65
|
+
: String(error)
|
|
66
|
+
logger.error(`Could not read project environment variables from Vercel: ${message}`)
|
|
67
|
+
process.exitCode = 1
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const report = evaluateBlobLink(envVars)
|
|
72
|
+
|
|
73
|
+
if (opts.json) {
|
|
74
|
+
console.log(JSON.stringify({ projectId: link.projectId, ...report }, null, 2))
|
|
75
|
+
} else {
|
|
76
|
+
console.log()
|
|
77
|
+
console.log(chalk.bold('Vercel Blob connection'))
|
|
78
|
+
console.log(chalk.dim('─────────────────────────────────'))
|
|
79
|
+
if (!report.linked) {
|
|
80
|
+
console.log('No Vercel Blob store is connected to this project.')
|
|
81
|
+
} else {
|
|
82
|
+
console.log('BLOB_READ_WRITE_TOKEN by environment:')
|
|
83
|
+
for (const target of VERCEL_TARGETS) {
|
|
84
|
+
console.log(` ${mark(report.tokenByTarget[target])} ${target}`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
console.log(chalk.dim('─────────────────────────────────'))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (report.status === 'partial') {
|
|
91
|
+
if (!opts.json) {
|
|
92
|
+
logger.error(
|
|
93
|
+
'A Blob store is connected but BLOB_READ_WRITE_TOKEN is missing for Production/Preview. Uploads will fail.',
|
|
94
|
+
)
|
|
95
|
+
logger.info(
|
|
96
|
+
'Reconnect the Blob store and provision the token to all environments (include Development for local `vercel env pull`), then re-run this command.',
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
process.exitCode = 1
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const devOnlyMissing =
|
|
104
|
+
report.linked && report.missingTargets.includes('development' as VercelTarget)
|
|
105
|
+
if (devOnlyMissing && !opts.json) {
|
|
106
|
+
logger.warn(
|
|
107
|
+
'BLOB_READ_WRITE_TOKEN is not set for Development — `vercel env pull` will not provide a local token. Include Development when connecting the store.',
|
|
108
|
+
)
|
|
109
|
+
} else if (report.status === 'ok' && !opts.json) {
|
|
110
|
+
logger.success(
|
|
111
|
+
'Blob store is connected and BLOB_READ_WRITE_TOKEN is provisioned for Production and Preview.',
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
}
|
|
@@ -38,6 +38,24 @@ export const REQUIRED_ENV_VARS = [
|
|
|
38
38
|
'NEXT_PUBLIC_SITE_URL',
|
|
39
39
|
] as const
|
|
40
40
|
|
|
41
|
+
/** Vars the per-environment Vercel matrix treats as deploy-blocking. */
|
|
42
|
+
export const VERCEL_MATRIX_REQUIRED = [
|
|
43
|
+
'DATABASE_URL',
|
|
44
|
+
'DIRECT_DATABASE_URL',
|
|
45
|
+
'CMS_SECRET',
|
|
46
|
+
'CMS_ENCRYPTION_KEY',
|
|
47
|
+
'NEXT_PUBLIC_SITE_URL',
|
|
48
|
+
'CRON_SECRET',
|
|
49
|
+
] as const
|
|
50
|
+
|
|
51
|
+
/** Vars the matrix surfaces as advisory (feature-gated, not deploy-blocking). */
|
|
52
|
+
export const VERCEL_MATRIX_OPTIONAL = [
|
|
53
|
+
'BLOB_READ_WRITE_TOKEN',
|
|
54
|
+
'UPSTASH_REDIS_REST_URL',
|
|
55
|
+
'UPSTASH_REDIS_REST_TOKEN',
|
|
56
|
+
'RESEND_API_KEY',
|
|
57
|
+
] as const
|
|
58
|
+
|
|
41
59
|
export interface DiagnosticInput {
|
|
42
60
|
schemaModels: Set<string>
|
|
43
61
|
schemaContent?: string
|
|
@@ -56,6 +74,32 @@ export function missingEnvVars(env: Record<string, string | undefined>): string[
|
|
|
56
74
|
return REQUIRED_ENV_VARS.filter((name) => !env[name])
|
|
57
75
|
}
|
|
58
76
|
|
|
77
|
+
export type BlobLinkState = 'ok' | 'partial' | 'token-format' | 'none'
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Classify the local Vercel Blob wiring. A "partial" link is the failure mode
|
|
81
|
+
* Opal hit: a Blob store is connected (so `BLOB_STORE_ID` / the webhook key are
|
|
82
|
+
* present, or the config selects vercel-blob storage) but `BLOB_READ_WRITE_TOKEN`
|
|
83
|
+
* was never provisioned — uploads then fail at runtime even though the store is
|
|
84
|
+
* "connected" in the dashboard. We treat that as a hard failure, not a warning.
|
|
85
|
+
*/
|
|
86
|
+
export function detectBlobLinkState(
|
|
87
|
+
env: Record<string, string | undefined>,
|
|
88
|
+
configContent?: string,
|
|
89
|
+
): BlobLinkState {
|
|
90
|
+
const token = env.BLOB_READ_WRITE_TOKEN
|
|
91
|
+
if (token) {
|
|
92
|
+
return token.startsWith('vercel_blob_') ? 'ok' : 'token-format'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const hasPartialSignals =
|
|
96
|
+
Boolean(env.BLOB_STORE_ID) ||
|
|
97
|
+
Boolean(env.BLOB_WEBHOOK_PUBLIC_KEY) ||
|
|
98
|
+
(configContent ? /vercel-blob|platform-vercel|vercelBlob/i.test(configContent) : false)
|
|
99
|
+
|
|
100
|
+
return hasPartialSignals ? 'partial' : 'none'
|
|
101
|
+
}
|
|
102
|
+
|
|
59
103
|
export function detectPackageManager(lockfiles: Set<string>): string {
|
|
60
104
|
if (lockfiles.has('pnpm-lock.yaml')) return 'pnpm'
|
|
61
105
|
if (lockfiles.has('yarn.lock')) return 'yarn'
|
|
@@ -123,6 +167,31 @@ export function createDiagnosticReport(input: DiagnosticInput): DiagnosticReport
|
|
|
123
167
|
docs: 'https://actuatecms.dev/docs/environment-variables',
|
|
124
168
|
})
|
|
125
169
|
|
|
170
|
+
const blobState = detectBlobLinkState(input.env, input.configContent)
|
|
171
|
+
checks.push({
|
|
172
|
+
id: 'blob-storage',
|
|
173
|
+
label: 'Vercel Blob storage',
|
|
174
|
+
status: blobState === 'partial' ? 'fail' : blobState === 'token-format' ? 'warn' : 'pass',
|
|
175
|
+
message:
|
|
176
|
+
blobState === 'partial'
|
|
177
|
+
? 'A Vercel Blob store is linked (BLOB_STORE_ID / webhook key or vercel-blob storage configured) but BLOB_READ_WRITE_TOKEN is missing. Media uploads will fail.'
|
|
178
|
+
: blobState === 'token-format'
|
|
179
|
+
? 'BLOB_READ_WRITE_TOKEN does not start with `vercel_blob_` — verify it is a real Vercel Blob read-write token.'
|
|
180
|
+
: blobState === 'ok'
|
|
181
|
+
? 'BLOB_READ_WRITE_TOKEN is configured.'
|
|
182
|
+
: 'No Vercel Blob storage configured (skipped).',
|
|
183
|
+
fix:
|
|
184
|
+
blobState === 'partial'
|
|
185
|
+
? 'Finish the Blob connection so BLOB_READ_WRITE_TOKEN is provisioned to every environment (including Development for local `vercel env pull`), then `vercel env pull`. Validate with `actuate vercel:blob-link`.'
|
|
186
|
+
: blobState === 'token-format'
|
|
187
|
+
? 'Re-copy the read-write token from the Vercel Blob store (Storage tab) and re-pull your environment.'
|
|
188
|
+
: undefined,
|
|
189
|
+
docs:
|
|
190
|
+
blobState === 'partial' || blobState === 'token-format'
|
|
191
|
+
? 'https://actuatecms.dev/docs/deployment'
|
|
192
|
+
: undefined,
|
|
193
|
+
})
|
|
194
|
+
|
|
126
195
|
checks.push({
|
|
127
196
|
id: 'package-manager',
|
|
128
197
|
label: 'Package manager',
|
|
@@ -163,6 +232,7 @@ export function buildDeploymentManifest() {
|
|
|
163
232
|
'DIRECT_DATABASE_URL',
|
|
164
233
|
'CMS_ADMIN_EMAIL',
|
|
165
234
|
'CMS_ADMIN_PASSWORD',
|
|
235
|
+
'CRON_SECRET',
|
|
166
236
|
'BLOB_READ_WRITE_TOKEN',
|
|
167
237
|
'UPSTASH_REDIS_REST_URL',
|
|
168
238
|
'UPSTASH_REDIS_REST_TOKEN',
|