@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
package/src/commands/doctor.ts
CHANGED
|
@@ -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(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 {
|
package/src/commands/seed.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
{
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
{
|
|
139
|
-
|
|
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
|
-
{
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
395
|
-
slug
|
|
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(
|
|
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
|
|
649
|
+
let createdCount = 0
|
|
650
|
+
let updatedCount = 0
|
|
518
651
|
for (const doc of normalized.documents) {
|
|
519
|
-
await createSeedDocument(db, userId, doc)
|
|
520
|
-
|
|
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(
|
|
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()
|