@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +32 -28
- package/CHANGELOG.md +28 -0
- package/README.md +30 -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__/form-seed.test.d.ts +2 -0
- package/dist/__tests__/form-seed.test.d.ts.map +1 -0
- package/dist/__tests__/form-seed.test.js +79 -0
- package/dist/__tests__/form-seed.test.js.map +1 -0
- package/dist/__tests__/seed.test.js +73 -0
- package/dist/__tests__/seed.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/seed.d.ts +30 -1
- package/dist/commands/seed.d.ts.map +1 -1
- package/dist/commands/seed.js +146 -21
- package/dist/commands/seed.js.map +1 -1
- package/dist/commands/vercel-blob-link.d.ts +3 -0
- package/dist/commands/vercel-blob-link.d.ts.map +1 -0
- package/dist/commands/vercel-blob-link.js +82 -0
- package/dist/commands/vercel-blob-link.js.map +1 -0
- package/dist/deployment/diagnostics.d.ts +13 -0
- package/dist/deployment/diagnostics.d.ts.map +1 -1
- package/dist/deployment/diagnostics.js +55 -0
- package/dist/deployment/diagnostics.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/form-seed.d.ts +15 -0
- package/dist/utils/form-seed.d.ts.map +1 -0
- package/dist/utils/form-seed.js +97 -0
- package/dist/utils/form-seed.js.map +1 -0
- package/dist/vercel/client.d.ts +32 -0
- package/dist/vercel/client.d.ts.map +1 -0
- package/dist/vercel/client.js +74 -0
- package/dist/vercel/client.js.map +1 -0
- package/dist/vercel/env-matrix.d.ts +34 -0
- package/dist/vercel/env-matrix.d.ts.map +1 -0
- package/dist/vercel/env-matrix.js +57 -0
- package/dist/vercel/env-matrix.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/db-sync.test.ts +55 -1
- package/src/__tests__/deployment-diagnostics.test.ts +51 -0
- package/src/__tests__/form-seed.test.ts +91 -0
- package/src/__tests__/seed.test.ts +97 -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/seed.ts +167 -21
- package/src/commands/vercel-blob-link.ts +115 -0
- package/src/deployment/diagnostics.ts +70 -0
- package/src/index.ts +2 -0
- package/src/utils/form-seed.ts +137 -0
- package/src/vercel/client.ts +112 -0
- package/src/vercel/env-matrix.ts +101 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation for `forms`-collection seed entries.
|
|
3
|
+
*
|
|
4
|
+
* The form definition lives in `document.data` and has its own lifecycle
|
|
5
|
+
* `data.status` (`active` | `draft` | `archived`) that gates the public
|
|
6
|
+
* endpoints — a separate axis from the *document envelope* status
|
|
7
|
+
* (`DRAFT` | `PUBLISHED`). Seeding a form with the wrong shape used to fail
|
|
8
|
+
* silently: the document row was created, but `/api/cms/public/forms/:slug`
|
|
9
|
+
* 404'd (not `active`) or rendered nothing (fields missing `key`/`label`).
|
|
10
|
+
* This validator turns those mistakes into actionable errors at seed time.
|
|
11
|
+
*
|
|
12
|
+
* Type-only imports keep cms-core a runtime peer dependency (the CLI loads it
|
|
13
|
+
* lazily); the literal lists below are typed against the cms-core contracts so
|
|
14
|
+
* a typo here fails the build.
|
|
15
|
+
*/
|
|
16
|
+
import type { FormFieldType, FormStatus } from '@actuate-media/cms-core'
|
|
17
|
+
|
|
18
|
+
const VALID_FIELD_TYPES: readonly FormFieldType[] = [
|
|
19
|
+
'text',
|
|
20
|
+
'email',
|
|
21
|
+
'phone',
|
|
22
|
+
'textarea',
|
|
23
|
+
'select',
|
|
24
|
+
'multiselect',
|
|
25
|
+
'radio',
|
|
26
|
+
'checkbox',
|
|
27
|
+
'date',
|
|
28
|
+
'number',
|
|
29
|
+
'url',
|
|
30
|
+
'hidden',
|
|
31
|
+
'file',
|
|
32
|
+
'consent',
|
|
33
|
+
'richtext',
|
|
34
|
+
'divider',
|
|
35
|
+
'honeypot',
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const VALID_FORM_STATUSES: readonly FormStatus[] = ['active', 'draft', 'archived']
|
|
39
|
+
|
|
40
|
+
/** Document-envelope statuses people mistakenly put in `data.status`. */
|
|
41
|
+
const ENVELOPE_STATUSES = new Set(['DRAFT', 'PUBLISHED', 'SCHEDULED', 'ARCHIVED'])
|
|
42
|
+
|
|
43
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
44
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validate one form's `data` payload. Returns human-readable errors —
|
|
49
|
+
* empty array means the form will work with the public endpoints
|
|
50
|
+
* (`GET /api/cms/public/forms/:slug` + `/submit`) and `<ActuateForm>`.
|
|
51
|
+
*/
|
|
52
|
+
export function validateFormSeedData(data: unknown, label = 'form'): string[] {
|
|
53
|
+
const errors: string[] = []
|
|
54
|
+
if (!isRecord(data)) {
|
|
55
|
+
return [`${label}: form data must be an object.`]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (typeof data.slug !== 'string' || data.slug.trim() === '') {
|
|
59
|
+
errors.push(
|
|
60
|
+
`${label}: missing "slug" (string). The public endpoints look forms up by slug — e.g. /api/cms/public/forms/contact.`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const name = data.name ?? data.title
|
|
65
|
+
if (typeof name !== 'string' || name.trim() === '') {
|
|
66
|
+
errors.push(`${label}: missing "name" (string) — shown in the admin Forms list and inbox.`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const status = data.status
|
|
70
|
+
if (status === undefined || status === null) {
|
|
71
|
+
errors.push(
|
|
72
|
+
`${label}: missing "status". Set data.status to "active" to enable the public form endpoints. ` +
|
|
73
|
+
`Note: this is the FORM's lifecycle status inside data — separate from the document envelope status ` +
|
|
74
|
+
`(DRAFT/PUBLISHED) set next to data.`,
|
|
75
|
+
)
|
|
76
|
+
} else if (typeof status === 'string' && ENVELOPE_STATUSES.has(status)) {
|
|
77
|
+
errors.push(
|
|
78
|
+
`${label}: data.status "${status}" looks like a document envelope status. The form definition's ` +
|
|
79
|
+
`data.status must be one of ${VALID_FORM_STATUSES.map((s) => `"${s}"`).join(' | ')} — use "active" ` +
|
|
80
|
+
`to enable the public endpoints. Put DRAFT/PUBLISHED on the document's top-level "status" key instead.`,
|
|
81
|
+
)
|
|
82
|
+
} else if (!VALID_FORM_STATUSES.includes(status as FormStatus)) {
|
|
83
|
+
errors.push(
|
|
84
|
+
`${label}: invalid data.status ${JSON.stringify(status)} — must be one of ` +
|
|
85
|
+
`${VALID_FORM_STATUSES.map((s) => `"${s}"`).join(' | ')}.`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const fields = data.fields
|
|
90
|
+
if (!Array.isArray(fields) || fields.length === 0) {
|
|
91
|
+
errors.push(
|
|
92
|
+
`${label}: missing "fields" (non-empty array). Each field needs at least { key, label, type }.`,
|
|
93
|
+
)
|
|
94
|
+
return errors
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fields.forEach((field, i) => {
|
|
98
|
+
const fieldLabel = `${label}: fields[${i}]`
|
|
99
|
+
if (!isRecord(field)) {
|
|
100
|
+
errors.push(`${fieldLabel}: must be an object with at least { key, label, type }.`)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (typeof field.key !== 'string' || field.key.trim() === '') {
|
|
104
|
+
errors.push(
|
|
105
|
+
typeof field.name === 'string'
|
|
106
|
+
? `${fieldLabel}: uses "name" — the form schema calls this "key". Rename name → key (and add a human-readable "label").`
|
|
107
|
+
: `${fieldLabel}: missing "key" (string) — the submission payload is keyed by it.`,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
if (typeof field.label !== 'string' || field.label.trim() === '') {
|
|
111
|
+
errors.push(`${fieldLabel}: missing "label" (string) — shown to visitors and in the inbox.`)
|
|
112
|
+
}
|
|
113
|
+
if (!VALID_FIELD_TYPES.includes(field.type as FormFieldType)) {
|
|
114
|
+
errors.push(
|
|
115
|
+
`${fieldLabel}: invalid type ${JSON.stringify(field.type)} — must be one of ${VALID_FIELD_TYPES.join(', ')}.`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
return errors
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Validate every `forms`-collection document in a normalized seed payload.
|
|
125
|
+
* Returns all errors across all forms (so one run surfaces everything).
|
|
126
|
+
*/
|
|
127
|
+
export function validateFormSeeds(
|
|
128
|
+
documents: ReadonlyArray<{ collection: string; data: Record<string, unknown> }>,
|
|
129
|
+
): string[] {
|
|
130
|
+
const errors: string[] = []
|
|
131
|
+
documents.forEach((doc, i) => {
|
|
132
|
+
if (doc.collection !== 'forms') return
|
|
133
|
+
const slug = typeof doc.data.slug === 'string' ? doc.data.slug : `#${i + 1}`
|
|
134
|
+
errors.push(...validateFormSeedData(doc.data, `forms "${slug}"`))
|
|
135
|
+
})
|
|
136
|
+
return errors
|
|
137
|
+
}
|
|
@@ -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
|
+
}
|