@actuate-media/cli 0.7.0 → 0.10.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 (66) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +32 -28
  3. package/CHANGELOG.md +28 -0
  4. package/README.md +30 -0
  5. package/dist/__tests__/db-sync.test.js +32 -1
  6. package/dist/__tests__/db-sync.test.js.map +1 -1
  7. package/dist/__tests__/deployment-diagnostics.test.js +42 -1
  8. package/dist/__tests__/deployment-diagnostics.test.js.map +1 -1
  9. package/dist/__tests__/form-seed.test.d.ts +2 -0
  10. package/dist/__tests__/form-seed.test.d.ts.map +1 -0
  11. package/dist/__tests__/form-seed.test.js +79 -0
  12. package/dist/__tests__/form-seed.test.js.map +1 -0
  13. package/dist/__tests__/seed.test.js +73 -0
  14. package/dist/__tests__/seed.test.js.map +1 -1
  15. package/dist/__tests__/vercel-env-matrix.test.d.ts +2 -0
  16. package/dist/__tests__/vercel-env-matrix.test.d.ts.map +1 -0
  17. package/dist/__tests__/vercel-env-matrix.test.js +48 -0
  18. package/dist/__tests__/vercel-env-matrix.test.js.map +1 -0
  19. package/dist/commands/db-sync.d.ts +22 -0
  20. package/dist/commands/db-sync.d.ts.map +1 -1
  21. package/dist/commands/db-sync.js +94 -0
  22. package/dist/commands/db-sync.js.map +1 -1
  23. package/dist/commands/doctor.d.ts.map +1 -1
  24. package/dist/commands/doctor.js +70 -3
  25. package/dist/commands/doctor.js.map +1 -1
  26. package/dist/commands/seed.d.ts +30 -1
  27. package/dist/commands/seed.d.ts.map +1 -1
  28. package/dist/commands/seed.js +146 -21
  29. package/dist/commands/seed.js.map +1 -1
  30. package/dist/commands/vercel-blob-link.d.ts +3 -0
  31. package/dist/commands/vercel-blob-link.d.ts.map +1 -0
  32. package/dist/commands/vercel-blob-link.js +82 -0
  33. package/dist/commands/vercel-blob-link.js.map +1 -0
  34. package/dist/deployment/diagnostics.d.ts +13 -0
  35. package/dist/deployment/diagnostics.d.ts.map +1 -1
  36. package/dist/deployment/diagnostics.js +55 -0
  37. package/dist/deployment/diagnostics.js.map +1 -1
  38. package/dist/index.js +2 -0
  39. package/dist/index.js.map +1 -1
  40. package/dist/utils/form-seed.d.ts +15 -0
  41. package/dist/utils/form-seed.d.ts.map +1 -0
  42. package/dist/utils/form-seed.js +97 -0
  43. package/dist/utils/form-seed.js.map +1 -0
  44. package/dist/vercel/client.d.ts +32 -0
  45. package/dist/vercel/client.d.ts.map +1 -0
  46. package/dist/vercel/client.js +74 -0
  47. package/dist/vercel/client.js.map +1 -0
  48. package/dist/vercel/env-matrix.d.ts +34 -0
  49. package/dist/vercel/env-matrix.d.ts.map +1 -0
  50. package/dist/vercel/env-matrix.js +57 -0
  51. package/dist/vercel/env-matrix.js.map +1 -0
  52. package/package.json +2 -2
  53. package/src/__tests__/db-sync.test.ts +55 -1
  54. package/src/__tests__/deployment-diagnostics.test.ts +51 -0
  55. package/src/__tests__/form-seed.test.ts +91 -0
  56. package/src/__tests__/seed.test.ts +97 -0
  57. package/src/__tests__/vercel-env-matrix.test.ts +56 -0
  58. package/src/commands/db-sync.ts +116 -0
  59. package/src/commands/doctor.ts +118 -10
  60. package/src/commands/seed.ts +167 -21
  61. package/src/commands/vercel-blob-link.ts +115 -0
  62. package/src/deployment/diagnostics.ts +70 -0
  63. package/src/index.ts +2 -0
  64. package/src/utils/form-seed.ts +137 -0
  65. package/src/vercel/client.ts +112 -0
  66. package/src/vercel/env-matrix.ts +101 -0
@@ -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 {
@@ -7,6 +7,7 @@ import { pathToFileURL } from 'node:url'
7
7
  import ora from 'ora'
8
8
  import { logger } from '../utils/logger.js'
9
9
  import { connectProjectDatabase } from '../utils/database.js'
10
+ import { validateFormSeeds } from '../utils/form-seed.js'
10
11
 
11
12
  async function confirm(question: string): Promise<boolean> {
12
13
  const rl = createInterface({ input: process.stdin, output: process.stdout })
@@ -119,37 +120,101 @@ const DEMO_POSTS = [
119
120
  },
120
121
  ]
121
122
 
122
- const DEMO_FORMS = [
123
+ // Canonical form seed shape: the form definition lives in document `data`
124
+ // with its own lifecycle `status: 'active'` (gates the public endpoints) and
125
+ // `fields` keyed by { id, key, label, type, required, sortOrder }. See
126
+ // `validateFormSeeds` — these are validated by the same rules as user seeds.
127
+ export const DEMO_FORMS = [
123
128
  {
129
+ name: 'Contact Form',
124
130
  title: 'Contact Form',
125
131
  slug: 'contact-form',
132
+ status: 'active',
126
133
  fields: [
127
- { name: 'name', type: 'text', required: true },
128
- { name: 'email', type: 'email', required: true },
129
- { name: 'message', type: 'textarea', required: true },
134
+ {
135
+ id: 'contact-name',
136
+ key: 'name',
137
+ label: 'Name',
138
+ type: 'text',
139
+ required: true,
140
+ sortOrder: 0,
141
+ },
142
+ {
143
+ id: 'contact-email',
144
+ key: 'email',
145
+ label: 'Email',
146
+ type: 'email',
147
+ required: true,
148
+ sortOrder: 1,
149
+ },
150
+ {
151
+ id: 'contact-message',
152
+ key: 'message',
153
+ label: 'Message',
154
+ type: 'textarea',
155
+ required: true,
156
+ sortOrder: 2,
157
+ },
130
158
  ],
131
- submitLabel: 'Send Message',
132
159
  successMessage: "Thanks for reaching out! We'll get back to you soon.",
133
160
  },
134
161
  {
162
+ name: 'Newsletter Signup',
135
163
  title: 'Newsletter Signup',
136
164
  slug: 'newsletter',
165
+ status: 'active',
137
166
  fields: [
138
- { name: 'email', type: 'email', required: true },
139
- { name: 'firstName', type: 'text', required: false },
167
+ {
168
+ id: 'newsletter-email',
169
+ key: 'email',
170
+ label: 'Email',
171
+ type: 'email',
172
+ required: true,
173
+ sortOrder: 0,
174
+ },
175
+ {
176
+ id: 'newsletter-first-name',
177
+ key: 'firstName',
178
+ label: 'First name',
179
+ type: 'text',
180
+ required: false,
181
+ sortOrder: 1,
182
+ },
140
183
  ],
141
- submitLabel: 'Subscribe',
142
184
  successMessage: "You're subscribed! Check your inbox for confirmation.",
143
185
  },
144
186
  {
187
+ name: 'Feedback Form',
145
188
  title: 'Feedback Form',
146
189
  slug: 'feedback',
190
+ status: 'active',
147
191
  fields: [
148
- { name: 'name', type: 'text', required: false },
149
- { name: 'rating', type: 'select', options: ['1', '2', '3', '4', '5'], required: true },
150
- { name: 'comments', type: 'textarea', required: false },
192
+ {
193
+ id: 'feedback-name',
194
+ key: 'name',
195
+ label: 'Name',
196
+ type: 'text',
197
+ required: false,
198
+ sortOrder: 0,
199
+ },
200
+ {
201
+ id: 'feedback-rating',
202
+ key: 'rating',
203
+ label: 'Rating',
204
+ type: 'select',
205
+ required: true,
206
+ sortOrder: 1,
207
+ options: ['1', '2', '3', '4', '5'].map((v) => ({ label: v, value: v })),
208
+ },
209
+ {
210
+ id: 'feedback-comments',
211
+ key: 'comments',
212
+ label: 'Comments',
213
+ type: 'textarea',
214
+ required: false,
215
+ sortOrder: 2,
216
+ },
151
217
  ],
152
- submitLabel: 'Submit Feedback',
153
218
  successMessage: 'Thank you for your feedback!',
154
219
  },
155
220
  ]
@@ -188,6 +253,7 @@ interface SeedOptions {
188
253
  demo?: boolean
189
254
  file?: string
190
255
  reset?: boolean
256
+ upsert?: boolean
191
257
  }
192
258
 
193
259
  export interface NormalizedSeedDocument {
@@ -324,7 +390,7 @@ async function runSeed(options: SeedOptions): Promise<void> {
324
390
  }
325
391
 
326
392
  if (file) {
327
- await seedFromFile(db, file)
393
+ await seedFromFile(db, file, { upsert: options.upsert })
328
394
  }
329
395
  } catch (err) {
330
396
  const message = err instanceof Error ? err.message : String(err)
@@ -380,19 +446,63 @@ export async function createSeedDocument(
380
446
  db: any,
381
447
  userId: string,
382
448
  doc: NormalizedSeedDocument,
383
- ): Promise<void> {
449
+ options: { upsert?: boolean } = {},
450
+ ): Promise<'created' | 'updated'> {
384
451
  const { extractPlainText, hashContent, sanitizeHtml } = await import('@actuate-media/cms-core')
385
452
  const data = sanitizeSeedData(doc.data, sanitizeHtml) as Record<string, unknown>
386
453
  const serialized = JSON.stringify(data)
387
454
  const plainText = extractPlainText(serialized)
388
455
  const contentHash = await hashContent(serialized)
456
+ const slug = typeof data.slug === 'string' ? data.slug : null
457
+ const title = typeof data.title === 'string' ? data.title : null
458
+
459
+ // Upsert key: collection + slug. Slugless documents can't be re-identified,
460
+ // so they always insert (same as a plain seed run).
461
+ const existing =
462
+ options.upsert && slug
463
+ ? await db.document.findFirst({
464
+ where: { collection: doc.collection, slug, deletedAt: null },
465
+ select: { id: true, status: true, publishedAt: true },
466
+ })
467
+ : null
468
+
469
+ if (existing) {
470
+ await db.$transaction(async (tx: any) => {
471
+ await tx.document.update({
472
+ where: { id: existing.id },
473
+ data: {
474
+ title,
475
+ data,
476
+ status: doc.status,
477
+ // Stamp publishedAt only on the transition into PUBLISHED.
478
+ publishedAt:
479
+ doc.status === 'PUBLISHED'
480
+ ? (existing.publishedAt ?? new Date())
481
+ : existing.publishedAt,
482
+ updatedById: userId,
483
+ plainText,
484
+ contentHash,
485
+ },
486
+ })
487
+
488
+ await tx.version.create({
489
+ data: {
490
+ documentId: existing.id,
491
+ data,
492
+ changedById: userId,
493
+ changeType: 'UPDATE',
494
+ },
495
+ })
496
+ })
497
+ return 'updated'
498
+ }
389
499
 
390
500
  await db.$transaction(async (tx: any) => {
391
501
  const created = await tx.document.create({
392
502
  data: {
393
503
  collection: doc.collection,
394
- title: typeof data.title === 'string' ? data.title : null,
395
- slug: typeof data.slug === 'string' ? data.slug : null,
504
+ title,
505
+ slug,
396
506
  data,
397
507
  status: doc.status,
398
508
  publishedAt: doc.status === 'PUBLISHED' ? new Date() : null,
@@ -412,6 +522,7 @@ export async function createSeedDocument(
412
522
  },
413
523
  })
414
524
  })
525
+ return 'created'
415
526
  }
416
527
 
417
528
  async function seedDemoData(db: any): Promise<void> {
@@ -486,7 +597,11 @@ async function seedDemoData(db: any): Promise<void> {
486
597
  logger.info(` Users: ${usersCreated} (+ existing admin)`)
487
598
  }
488
599
 
489
- async function seedFromFile(db: any, filePath: string): Promise<void> {
600
+ async function seedFromFile(
601
+ db: any,
602
+ filePath: string,
603
+ options: { upsert?: boolean } = {},
604
+ ): Promise<void> {
490
605
  if (!existsSync(filePath)) {
491
606
  logger.error(`File not found: ${filePath}`)
492
607
  process.exit(1)
@@ -511,13 +626,32 @@ async function seedFromFile(db: any, filePath: string): Promise<void> {
511
626
  const userId = adminUser.id
512
627
 
513
628
  const normalized = normalizeSeedPayload(seedData)
629
+
630
+ // Forms have a stricter shape than generic documents (data.status gates the
631
+ // public endpoints; fields need key/label/type). Fail loudly BEFORE writing
632
+ // anything — a malformed form seed used to insert fine and then 404 on
633
+ // /api/cms/public/forms/:slug with no hint why.
634
+ const formErrors = validateFormSeeds(normalized.documents)
635
+ if (formErrors.length > 0) {
636
+ spinner.fail('Form seed validation failed — nothing was written:')
637
+ for (const error of formErrors) {
638
+ logger.error(` ${error}`)
639
+ }
640
+ logger.info(
641
+ ' Expected shape: { "forms": [{ "status": "PUBLISHED", "data": { "name", "slug", "status": "active", "fields": [{ "key", "label", "type", … }] } }] }',
642
+ )
643
+ process.exit(1)
644
+ }
645
+
514
646
  const { updateGlobal } = await import('@actuate-media/cms-core')
515
647
  const ctx = { userId, role: 'ADMIN', db }
516
648
 
517
- let documentCount = 0
649
+ let createdCount = 0
650
+ let updatedCount = 0
518
651
  for (const doc of normalized.documents) {
519
- await createSeedDocument(db, userId, doc)
520
- documentCount++
652
+ const result = await createSeedDocument(db, userId, doc, options)
653
+ if (result === 'updated') updatedCount++
654
+ else createdCount++
521
655
  }
522
656
 
523
657
  let globalCount = 0
@@ -526,7 +660,11 @@ async function seedFromFile(db: any, filePath: string): Promise<void> {
526
660
  globalCount++
527
661
  }
528
662
 
529
- spinner.succeed(`Seeded ${documentCount} documents and ${globalCount} globals from ${filePath}.`)
663
+ spinner.succeed(
664
+ options.upsert
665
+ ? `Seeded from ${filePath}: ${createdCount} created, ${updatedCount} updated, ${globalCount} globals.`
666
+ : `Seeded ${createdCount} documents and ${globalCount} globals from ${filePath}.`,
667
+ )
530
668
  }
531
669
 
532
670
  export function registerSeedCommand(program: Command): void {
@@ -536,6 +674,10 @@ export function registerSeedCommand(program: Command): void {
536
674
  .option('--demo', 'Seed demo content (pages, posts, forms, users)')
537
675
  .option('--file <path>', 'Seed from a JSON, JavaScript, or TypeScript file')
538
676
  .option('--reset', 'Clear existing data before seeding')
677
+ .option(
678
+ '--upsert',
679
+ 'Update existing documents matched by collection + slug instead of inserting duplicates',
680
+ )
539
681
  .action(runSeed)
540
682
 
541
683
  program
@@ -543,5 +685,9 @@ export function registerSeedCommand(program: Command): void {
543
685
  .description('Populate the database from actuate.seed.json or a custom seed file')
544
686
  .option('--file <path>', 'Seed from a JSON, JavaScript, or TypeScript file')
545
687
  .option('--reset', 'Clear existing data before seeding')
688
+ .option(
689
+ '--upsert',
690
+ 'Update existing documents matched by collection + slug instead of inserting duplicates',
691
+ )
546
692
  .action(runSeed)
547
693
  }
@@ -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',
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  registerDoctorCommand,
18
18
  registerVerifyCommand,
19
19
  } from './commands/doctor.js'
20
+ import { registerVercelBlobLinkCommand } from './commands/vercel-blob-link.js'
20
21
 
21
22
  const program = new Command()
22
23
 
@@ -40,5 +41,6 @@ registerInitCommand(program)
40
41
  registerDoctorCommand(program)
41
42
  registerDeployCheckCommand(program)
42
43
  registerVerifyCommand(program)
44
+ registerVercelBlobLinkCommand(program)
43
45
 
44
46
  program.parse()