@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
|
@@ -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.
|
|
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.
|
|
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 {
|
|
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
|
+
})
|
package/src/commands/db-sync.ts
CHANGED
|
@@ -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
|
}
|
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 {
|
|
@@ -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
|
+
}
|