@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.
Files changed (61) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +27 -25
  3. package/CHANGELOG.md +19 -0
  4. package/dist/__tests__/db-sync.test.js +32 -1
  5. package/dist/__tests__/db-sync.test.js.map +1 -1
  6. package/dist/__tests__/deployment-diagnostics.test.js +42 -1
  7. package/dist/__tests__/deployment-diagnostics.test.js.map +1 -1
  8. package/dist/__tests__/vercel-env-matrix.test.d.ts +2 -0
  9. package/dist/__tests__/vercel-env-matrix.test.d.ts.map +1 -0
  10. package/dist/__tests__/vercel-env-matrix.test.js +48 -0
  11. package/dist/__tests__/vercel-env-matrix.test.js.map +1 -0
  12. package/dist/commands/db-sync.d.ts +22 -0
  13. package/dist/commands/db-sync.d.ts.map +1 -1
  14. package/dist/commands/db-sync.js +94 -0
  15. package/dist/commands/db-sync.js.map +1 -1
  16. package/dist/commands/doctor.d.ts.map +1 -1
  17. package/dist/commands/doctor.js +70 -3
  18. package/dist/commands/doctor.js.map +1 -1
  19. package/dist/commands/migrate-sections.d.ts +3 -0
  20. package/dist/commands/migrate-sections.d.ts.map +1 -0
  21. package/dist/commands/migrate-sections.js +56 -0
  22. package/dist/commands/migrate-sections.js.map +1 -0
  23. package/dist/commands/seed.d.ts.map +1 -1
  24. package/dist/commands/seed.js +2 -40
  25. package/dist/commands/seed.js.map +1 -1
  26. package/dist/commands/vercel-blob-link.d.ts +3 -0
  27. package/dist/commands/vercel-blob-link.d.ts.map +1 -0
  28. package/dist/commands/vercel-blob-link.js +82 -0
  29. package/dist/commands/vercel-blob-link.js.map +1 -0
  30. package/dist/deployment/diagnostics.d.ts +13 -0
  31. package/dist/deployment/diagnostics.d.ts.map +1 -1
  32. package/dist/deployment/diagnostics.js +55 -0
  33. package/dist/deployment/diagnostics.js.map +1 -1
  34. package/dist/index.js +4 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/utils/database.d.ts +19 -0
  37. package/dist/utils/database.d.ts.map +1 -0
  38. package/dist/utils/database.js +58 -0
  39. package/dist/utils/database.js.map +1 -0
  40. package/dist/vercel/client.d.ts +32 -0
  41. package/dist/vercel/client.d.ts.map +1 -0
  42. package/dist/vercel/client.js +74 -0
  43. package/dist/vercel/client.js.map +1 -0
  44. package/dist/vercel/env-matrix.d.ts +34 -0
  45. package/dist/vercel/env-matrix.d.ts.map +1 -0
  46. package/dist/vercel/env-matrix.js +57 -0
  47. package/dist/vercel/env-matrix.js.map +1 -0
  48. package/package.json +2 -2
  49. package/src/__tests__/db-sync.test.ts +55 -1
  50. package/src/__tests__/deployment-diagnostics.test.ts +51 -0
  51. package/src/__tests__/vercel-env-matrix.test.ts +56 -0
  52. package/src/commands/db-sync.ts +116 -0
  53. package/src/commands/doctor.ts +118 -10
  54. package/src/commands/migrate-sections.ts +73 -0
  55. package/src/commands/seed.ts +2 -56
  56. package/src/commands/vercel-blob-link.ts +115 -0
  57. package/src/deployment/diagnostics.ts +70 -0
  58. package/src/index.ts +4 -0
  59. package/src/utils/database.ts +77 -0
  60. package/src/vercel/client.ts +112 -0
  61. package/src/vercel/env-matrix.ts +101 -0
@@ -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
  }
@@ -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(async (opts: { schema: string; config: string; json?: boolean }) => {
119
- const report = await buildReport(opts.schema, opts.config, 'deploy')
120
- if (opts.json) {
121
- console.log(JSON.stringify({ ...report, manifest: buildDeploymentManifest() }, null, 2))
122
- } else {
123
- printReport('Actuate Deploy Check', report)
124
- console.log(chalk.dim('Run `actuate verify --full` after deployment succeeds.'))
125
- }
126
- if (report.status === 'fail') process.exitCode = 1
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
+ }
@@ -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 getSeedDatabase()
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',