@actuate-media/cli 0.7.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 (47) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +27 -25
  3. package/CHANGELOG.md +10 -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/vercel-blob-link.d.ts +3 -0
  20. package/dist/commands/vercel-blob-link.d.ts.map +1 -0
  21. package/dist/commands/vercel-blob-link.js +82 -0
  22. package/dist/commands/vercel-blob-link.js.map +1 -0
  23. package/dist/deployment/diagnostics.d.ts +13 -0
  24. package/dist/deployment/diagnostics.d.ts.map +1 -1
  25. package/dist/deployment/diagnostics.js +55 -0
  26. package/dist/deployment/diagnostics.js.map +1 -1
  27. package/dist/index.js +2 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/vercel/client.d.ts +32 -0
  30. package/dist/vercel/client.d.ts.map +1 -0
  31. package/dist/vercel/client.js +74 -0
  32. package/dist/vercel/client.js.map +1 -0
  33. package/dist/vercel/env-matrix.d.ts +34 -0
  34. package/dist/vercel/env-matrix.d.ts.map +1 -0
  35. package/dist/vercel/env-matrix.js +57 -0
  36. package/dist/vercel/env-matrix.js.map +1 -0
  37. package/package.json +2 -2
  38. package/src/__tests__/db-sync.test.ts +55 -1
  39. package/src/__tests__/deployment-diagnostics.test.ts +51 -0
  40. package/src/__tests__/vercel-env-matrix.test.ts +56 -0
  41. package/src/commands/db-sync.ts +116 -0
  42. package/src/commands/doctor.ts +118 -10
  43. package/src/commands/vercel-blob-link.ts +115 -0
  44. package/src/deployment/diagnostics.ts +70 -0
  45. package/src/index.ts +2 -0
  46. package/src/vercel/client.ts +112 -0
  47. package/src/vercel/env-matrix.ts +101 -0
@@ -0,0 +1,57 @@
1
+ import { VERCEL_TARGETS } from './client.js';
2
+ function emptyPresence() {
3
+ return { production: false, preview: false, development: false };
4
+ }
5
+ /** Which targets a given env var key is defined for. */
6
+ export function presenceForKey(envVars, key) {
7
+ const presence = emptyPresence();
8
+ for (const env of envVars) {
9
+ if (env.key !== key)
10
+ continue;
11
+ for (const target of env.target) {
12
+ if (VERCEL_TARGETS.includes(target)) {
13
+ presence[target] = true;
14
+ }
15
+ }
16
+ }
17
+ return presence;
18
+ }
19
+ /** Production and preview are the deploy-blocking targets; development is local-only. */
20
+ const DEPLOY_TARGETS = ['production', 'preview'];
21
+ export function buildEnvMatrix(envVars, requiredKeys, optionalKeys = []) {
22
+ const rows = [];
23
+ const seen = new Set();
24
+ const missingCritical = [];
25
+ for (const key of requiredKeys) {
26
+ seen.add(key);
27
+ const presence = presenceForKey(envVars, key);
28
+ rows.push({ key, required: true, presence });
29
+ const missing = DEPLOY_TARGETS.filter((t) => !presence[t]);
30
+ if (missing.length > 0)
31
+ missingCritical.push({ key, targets: missing });
32
+ }
33
+ for (const key of optionalKeys) {
34
+ if (seen.has(key))
35
+ continue;
36
+ seen.add(key);
37
+ rows.push({ key, required: false, presence: presenceForKey(envVars, key) });
38
+ }
39
+ return { rows, missingCritical };
40
+ }
41
+ const BLOB_LINK_SIGNALS = ['BLOB_STORE_ID', 'BLOB_WEBHOOK_PUBLIC_KEY', 'BLOB_READ_WRITE_TOKEN'];
42
+ export function evaluateBlobLink(envVars) {
43
+ const linked = envVars.some((env) => BLOB_LINK_SIGNALS.includes(env.key) || env.key.startsWith('BLOB_'));
44
+ const tokenByTarget = presenceForKey(envVars, 'BLOB_READ_WRITE_TOKEN');
45
+ if (!linked) {
46
+ return { linked: false, tokenByTarget, missingTargets: [], status: 'none' };
47
+ }
48
+ const missingTargets = VERCEL_TARGETS.filter((t) => !tokenByTarget[t]);
49
+ const criticalMissing = DEPLOY_TARGETS.some((t) => !tokenByTarget[t]);
50
+ return {
51
+ linked: true,
52
+ tokenByTarget,
53
+ missingTargets,
54
+ status: criticalMissing ? 'partial' : 'ok',
55
+ };
56
+ }
57
+ //# sourceMappingURL=env-matrix.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env-matrix.js","sourceRoot":"","sources":["../../src/vercel/env-matrix.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "CLI for Actuate CMS — migrations, codegen, imports, exports, and upgrades",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,7 +28,7 @@
28
28
  "devDependencies": {
29
29
  "typescript": "^5.7.0",
30
30
  "vitest": "^4.1.0",
31
- "@actuate-media/cms-core": "0.53.0"
31
+ "@actuate-media/cms-core": "0.55.0"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsc",
@@ -3,7 +3,13 @@ import { tmpdir } from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { afterEach, describe, expect, it } from 'vitest'
5
5
 
6
- import { buildConsumerSchema, syncPrismaAssets } from '../commands/db-sync.js'
6
+ import {
7
+ buildConsumerSchema,
8
+ checkSchemaDrift,
9
+ driftGuidance,
10
+ syncPrismaAssets,
11
+ type DriftRunner,
12
+ } from '../commands/db-sync.js'
7
13
 
8
14
  const CORE_SCHEMA = `generator client {
9
15
  provider = "prisma-client"
@@ -164,4 +170,52 @@ describe('db:sync (WS-D D2 — post-upgrade schema sync)', () => {
164
170
  expect(second.migrationsAdded).toEqual([])
165
171
  })
166
172
  })
173
+
174
+ describe('checkSchemaDrift', () => {
175
+ const okRunner: DriftRunner = async () => ({ exitCode: 0, stdout: '', stderr: '' })
176
+ const driftRunner: DriftRunner = async () => ({
177
+ exitCode: 2,
178
+ stdout: 'ALTER TABLE "actuate_media_usages" RENAME COLUMN "fieldName" TO "fieldPath";',
179
+ stderr: '',
180
+ })
181
+
182
+ it('skips when no database URL is configured', async () => {
183
+ const result = await checkSchemaDrift('prisma/schema.prisma', {}, okRunner)
184
+ expect(result.status).toBe('skipped')
185
+ })
186
+
187
+ it('reports no-drift on exit code 0', async () => {
188
+ const result = await checkSchemaDrift(
189
+ 'prisma/schema.prisma',
190
+ { DATABASE_URL: 'postgres://x' },
191
+ okRunner,
192
+ )
193
+ expect(result.status).toBe('no-drift')
194
+ })
195
+
196
+ it('reports drift (with reconciling SQL) on exit code 2', async () => {
197
+ const result = await checkSchemaDrift(
198
+ 'prisma/schema.prisma',
199
+ { DATABASE_URL: 'postgres://x' },
200
+ driftRunner,
201
+ )
202
+ expect(result.status).toBe('drift')
203
+ expect(result.detail).toContain('RENAME COLUMN')
204
+ expect(driftGuidance(result).join('\n')).toContain('prisma migrate dev --name')
205
+ })
206
+
207
+ it('prefers DIRECT_DATABASE_URL for the diff connection', async () => {
208
+ let usedUrl = ''
209
+ const spyRunner: DriftRunner = async ({ url }) => {
210
+ usedUrl = url
211
+ return { exitCode: 0, stdout: '', stderr: '' }
212
+ }
213
+ await checkSchemaDrift(
214
+ 'prisma/schema.prisma',
215
+ { DATABASE_URL: 'postgres://pooled', DIRECT_DATABASE_URL: 'postgres://direct' },
216
+ spyRunner,
217
+ )
218
+ expect(usedUrl).toBe('postgres://direct')
219
+ })
220
+ })
167
221
  })
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
3
3
  import {
4
4
  buildDeploymentManifest,
5
5
  createDiagnosticReport,
6
+ detectBlobLinkState,
6
7
  REQUIRED_CMS_MODELS,
7
8
  REQUIRED_ENV_VARS,
8
9
  } from '../deployment/diagnostics.js'
@@ -127,6 +128,56 @@ describe('deployment diagnostics', () => {
127
128
  )
128
129
  })
129
130
 
131
+ it('fails (not warns) when a Blob store is linked but the read-write token is missing', () => {
132
+ const report = createDiagnosticReport({
133
+ schemaModels: new Set(REQUIRED_CMS_MODELS),
134
+ schemaContent: '',
135
+ env: {
136
+ DATABASE_URL: 'postgresql://example',
137
+ CMS_SECRET: 'secret',
138
+ CMS_ENCRYPTION_KEY: 'a'.repeat(64),
139
+ NEXT_PUBLIC_SITE_URL: 'https://example.com',
140
+ BLOB_STORE_ID: 'store_abc123',
141
+ BLOB_WEBHOOK_PUBLIC_KEY: 'pk_abc',
142
+ // BLOB_READ_WRITE_TOKEN deliberately absent — the partial-link failure.
143
+ },
144
+ packageManager: 'pnpm',
145
+ schemaPath: 'prisma/schema.prisma',
146
+ })
147
+
148
+ expect(report.status).toBe('fail')
149
+ expect(report.checks).toEqual(
150
+ expect.arrayContaining([expect.objectContaining({ id: 'blob-storage', status: 'fail' })]),
151
+ )
152
+ })
153
+
154
+ it('passes the blob check when no Blob storage is configured', () => {
155
+ const report = createDiagnosticReport({
156
+ schemaModels: new Set(REQUIRED_CMS_MODELS),
157
+ schemaContent: '',
158
+ env: {
159
+ DATABASE_URL: 'postgresql://example',
160
+ CMS_SECRET: 'secret',
161
+ CMS_ENCRYPTION_KEY: 'a'.repeat(64),
162
+ NEXT_PUBLIC_SITE_URL: 'https://example.com',
163
+ },
164
+ packageManager: 'pnpm',
165
+ schemaPath: 'prisma/schema.prisma',
166
+ })
167
+
168
+ expect(report.checks).toEqual(
169
+ expect.arrayContaining([expect.objectContaining({ id: 'blob-storage', status: 'pass' })]),
170
+ )
171
+ })
172
+
173
+ it('classifies blob link state from env and config signals', () => {
174
+ expect(detectBlobLinkState({ BLOB_READ_WRITE_TOKEN: 'vercel_blob_rw_x' })).toBe('ok')
175
+ expect(detectBlobLinkState({ BLOB_READ_WRITE_TOKEN: 'not-a-real-token' })).toBe('token-format')
176
+ expect(detectBlobLinkState({ BLOB_STORE_ID: 'store_x' })).toBe('partial')
177
+ expect(detectBlobLinkState({}, "storage: { provider: 'vercel-blob' }")).toBe('partial')
178
+ expect(detectBlobLinkState({})).toBe('none')
179
+ })
180
+
130
181
  it('exposes machine-readable deployment metadata', () => {
131
182
  const manifest = buildDeploymentManifest()
132
183
 
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { VercelEnvVar } from '../vercel/client.js'
4
+ import { buildEnvMatrix, evaluateBlobLink, presenceForKey } from '../vercel/env-matrix.js'
5
+
6
+ function env(key: string, target: VercelEnvVar['target']): VercelEnvVar {
7
+ return { key, target }
8
+ }
9
+
10
+ describe('presenceForKey', () => {
11
+ it('aggregates targets across multiple entries for the same key', () => {
12
+ const vars = [env('DATABASE_URL', ['production']), env('DATABASE_URL', ['preview'])]
13
+ expect(presenceForKey(vars, 'DATABASE_URL')).toEqual({
14
+ production: true,
15
+ preview: true,
16
+ development: false,
17
+ })
18
+ })
19
+ })
20
+
21
+ describe('buildEnvMatrix', () => {
22
+ it('flags required vars missing on production or preview as critical', () => {
23
+ const vars = [
24
+ env('DATABASE_URL', ['production', 'preview', 'development']),
25
+ env('CMS_SECRET', ['production']),
26
+ ]
27
+ const matrix = buildEnvMatrix(vars, ['DATABASE_URL', 'CMS_SECRET'], ['CRON_SECRET'])
28
+
29
+ expect(matrix.missingCritical).toEqual([{ key: 'CMS_SECRET', targets: ['preview'] }])
30
+ expect(matrix.rows.find((r) => r.key === 'CRON_SECRET')).toMatchObject({ required: false })
31
+ })
32
+ })
33
+
34
+ describe('evaluateBlobLink', () => {
35
+ it('reports none when no blob vars exist', () => {
36
+ expect(evaluateBlobLink([env('DATABASE_URL', ['production'])]).status).toBe('none')
37
+ })
38
+
39
+ it('reports partial when linked but token missing on production/preview', () => {
40
+ const report = evaluateBlobLink([
41
+ env('BLOB_STORE_ID', ['production', 'preview', 'development']),
42
+ ])
43
+ expect(report.linked).toBe(true)
44
+ expect(report.status).toBe('partial')
45
+ expect(report.missingTargets).toContain('production')
46
+ })
47
+
48
+ it('reports ok when token covers production and preview (dev optional)', () => {
49
+ const report = evaluateBlobLink([
50
+ env('BLOB_STORE_ID', ['production', 'preview']),
51
+ env('BLOB_READ_WRITE_TOKEN', ['production', 'preview']),
52
+ ])
53
+ expect(report.status).toBe('ok')
54
+ expect(report.missingTargets).toEqual(['development'])
55
+ })
56
+ })
@@ -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,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
+ }