@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +27 -25
- package/CHANGELOG.md +10 -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/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 +2 -0
- package/dist/index.js.map +1 -1
- 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/vercel-blob-link.ts +115 -0
- package/src/deployment/diagnostics.ts +70 -0
- package/src/index.ts +2 -0
- package/src/vercel/client.ts +112 -0
- package/src/vercel/env-matrix.ts +101 -0
|
@@ -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()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal Vercel REST client for the CLI. Scope is deliberately small: read a
|
|
6
|
+
* project's environment variables so we can validate Blob wiring and print a
|
|
7
|
+
* per-environment readiness matrix. No write operations.
|
|
8
|
+
*
|
|
9
|
+
* Auth: a Vercel access token via `--token`, `VERCEL_TOKEN`, or
|
|
10
|
+
* `VERCEL_API_TOKEN`. Project/team identity from `.vercel/project.json` (written
|
|
11
|
+
* by `vercel link`) or explicit overrides. The client is fetch-injectable so the
|
|
12
|
+
* evaluation logic can be unit-tested without network access.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const DEFAULT_BASE_URL = 'https://api.vercel.com'
|
|
16
|
+
|
|
17
|
+
export const VERCEL_TARGETS = ['production', 'preview', 'development'] as const
|
|
18
|
+
export type VercelTarget = (typeof VERCEL_TARGETS)[number]
|
|
19
|
+
|
|
20
|
+
export interface VercelLink {
|
|
21
|
+
projectId: string
|
|
22
|
+
orgId?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface VercelEnvVar {
|
|
26
|
+
key: string
|
|
27
|
+
/** Targets this var applies to, e.g. ['production', 'preview']. */
|
|
28
|
+
target: VercelTarget[]
|
|
29
|
+
type?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class VercelApiError extends Error {
|
|
33
|
+
readonly status: number
|
|
34
|
+
|
|
35
|
+
constructor(message: string, status: number) {
|
|
36
|
+
super(message)
|
|
37
|
+
this.name = 'VercelApiError'
|
|
38
|
+
this.status = status
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read `.vercel/project.json` (written by `vercel link`). Returns null if absent. */
|
|
43
|
+
export async function readVercelLink(cwd: string): Promise<VercelLink | null> {
|
|
44
|
+
try {
|
|
45
|
+
const raw = await readFile(join(cwd, '.vercel', 'project.json'), 'utf-8')
|
|
46
|
+
const parsed = JSON.parse(raw) as { projectId?: string; orgId?: string }
|
|
47
|
+
if (!parsed.projectId) return null
|
|
48
|
+
return { projectId: parsed.projectId, orgId: parsed.orgId }
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Resolve a Vercel token from an explicit flag or the standard env vars. */
|
|
55
|
+
export function resolveVercelToken(explicit?: string): string | undefined {
|
|
56
|
+
return explicit ?? process.env.VERCEL_TOKEN ?? process.env.VERCEL_API_TOKEN ?? undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface VercelClientOptions {
|
|
60
|
+
token: string
|
|
61
|
+
/** Team / org id, appended as `teamId` when the project belongs to a team. */
|
|
62
|
+
teamId?: string
|
|
63
|
+
fetchImpl?: typeof fetch
|
|
64
|
+
baseUrl?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface VercelClient {
|
|
68
|
+
listProjectEnv(projectId: string): Promise<VercelEnvVar[]>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createVercelClient(options: VercelClientOptions): VercelClient {
|
|
72
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
73
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
|
|
74
|
+
|
|
75
|
+
async function request<T>(path: string): Promise<T> {
|
|
76
|
+
const url = new URL(path, baseUrl)
|
|
77
|
+
if (options.teamId) url.searchParams.set('teamId', options.teamId)
|
|
78
|
+
|
|
79
|
+
const response = await fetchImpl(url.toString(), {
|
|
80
|
+
headers: { Authorization: `Bearer ${options.token}` },
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
let detail = ''
|
|
85
|
+
try {
|
|
86
|
+
const body = (await response.json()) as { error?: { message?: string } }
|
|
87
|
+
detail = body?.error?.message ? `: ${body.error.message}` : ''
|
|
88
|
+
} catch {
|
|
89
|
+
// non-JSON error body — status alone is enough context
|
|
90
|
+
}
|
|
91
|
+
throw new VercelApiError(
|
|
92
|
+
`Vercel API request failed (HTTP ${response.status})${detail}`,
|
|
93
|
+
response.status,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return (await response.json()) as T
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
async listProjectEnv(projectId: string): Promise<VercelEnvVar[]> {
|
|
102
|
+
const data = await request<{ envs?: VercelEnvVar[] }>(
|
|
103
|
+
`/v9/projects/${encodeURIComponent(projectId)}/env`,
|
|
104
|
+
)
|
|
105
|
+
return (data.envs ?? []).map((env) => ({
|
|
106
|
+
key: env.key,
|
|
107
|
+
target: Array.isArray(env.target) ? env.target : [],
|
|
108
|
+
type: env.type,
|
|
109
|
+
}))
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { VERCEL_TARGETS, type VercelEnvVar, type VercelTarget } from './client.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure evaluation helpers over a project's Vercel env vars. Kept free of I/O so
|
|
5
|
+
* the matrix / blob logic is unit-testable without hitting the Vercel API.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type TargetPresence = Record<VercelTarget, boolean>
|
|
9
|
+
|
|
10
|
+
function emptyPresence(): TargetPresence {
|
|
11
|
+
return { production: false, preview: false, development: false }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Which targets a given env var key is defined for. */
|
|
15
|
+
export function presenceForKey(envVars: VercelEnvVar[], key: string): TargetPresence {
|
|
16
|
+
const presence = emptyPresence()
|
|
17
|
+
for (const env of envVars) {
|
|
18
|
+
if (env.key !== key) continue
|
|
19
|
+
for (const target of env.target) {
|
|
20
|
+
if ((VERCEL_TARGETS as readonly string[]).includes(target)) {
|
|
21
|
+
presence[target as VercelTarget] = true
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return presence
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EnvMatrixRow {
|
|
29
|
+
key: string
|
|
30
|
+
required: boolean
|
|
31
|
+
presence: TargetPresence
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface EnvMatrix {
|
|
35
|
+
rows: EnvMatrixRow[]
|
|
36
|
+
/** Required vars missing on production or preview (deploy-blocking). */
|
|
37
|
+
missingCritical: { key: string; targets: VercelTarget[] }[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Production and preview are the deploy-blocking targets; development is local-only. */
|
|
41
|
+
const DEPLOY_TARGETS: VercelTarget[] = ['production', 'preview']
|
|
42
|
+
|
|
43
|
+
export function buildEnvMatrix(
|
|
44
|
+
envVars: VercelEnvVar[],
|
|
45
|
+
requiredKeys: readonly string[],
|
|
46
|
+
optionalKeys: readonly string[] = [],
|
|
47
|
+
): EnvMatrix {
|
|
48
|
+
const rows: EnvMatrixRow[] = []
|
|
49
|
+
const seen = new Set<string>()
|
|
50
|
+
const missingCritical: { key: string; targets: VercelTarget[] }[] = []
|
|
51
|
+
|
|
52
|
+
for (const key of requiredKeys) {
|
|
53
|
+
seen.add(key)
|
|
54
|
+
const presence = presenceForKey(envVars, key)
|
|
55
|
+
rows.push({ key, required: true, presence })
|
|
56
|
+
const missing = DEPLOY_TARGETS.filter((t) => !presence[t])
|
|
57
|
+
if (missing.length > 0) missingCritical.push({ key, targets: missing })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const key of optionalKeys) {
|
|
61
|
+
if (seen.has(key)) continue
|
|
62
|
+
seen.add(key)
|
|
63
|
+
rows.push({ key, required: false, presence: presenceForKey(envVars, key) })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { rows, missingCritical }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type BlobLinkStatus = 'ok' | 'partial' | 'none'
|
|
70
|
+
|
|
71
|
+
export interface BlobLinkReport {
|
|
72
|
+
/** True when any BLOB_* var indicates a store is connected to the project. */
|
|
73
|
+
linked: boolean
|
|
74
|
+
tokenByTarget: TargetPresence
|
|
75
|
+
/** Targets (any) where BLOB_READ_WRITE_TOKEN is absent while linked. */
|
|
76
|
+
missingTargets: VercelTarget[]
|
|
77
|
+
/** 'partial' when production/preview lack the token (deploy-blocking). */
|
|
78
|
+
status: BlobLinkStatus
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const BLOB_LINK_SIGNALS = ['BLOB_STORE_ID', 'BLOB_WEBHOOK_PUBLIC_KEY', 'BLOB_READ_WRITE_TOKEN']
|
|
82
|
+
|
|
83
|
+
export function evaluateBlobLink(envVars: VercelEnvVar[]): BlobLinkReport {
|
|
84
|
+
const linked = envVars.some(
|
|
85
|
+
(env) => BLOB_LINK_SIGNALS.includes(env.key) || env.key.startsWith('BLOB_'),
|
|
86
|
+
)
|
|
87
|
+
const tokenByTarget = presenceForKey(envVars, 'BLOB_READ_WRITE_TOKEN')
|
|
88
|
+
|
|
89
|
+
if (!linked) {
|
|
90
|
+
return { linked: false, tokenByTarget, missingTargets: [], status: 'none' }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const missingTargets = VERCEL_TARGETS.filter((t) => !tokenByTarget[t])
|
|
94
|
+
const criticalMissing = DEPLOY_TARGETS.some((t) => !tokenByTarget[t])
|
|
95
|
+
return {
|
|
96
|
+
linked: true,
|
|
97
|
+
tokenByTarget,
|
|
98
|
+
missingTargets,
|
|
99
|
+
status: criticalMissing ? 'partial' : 'ok',
|
|
100
|
+
}
|
|
101
|
+
}
|