@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,34 @@
|
|
|
1
|
+
import { type VercelEnvVar, type VercelTarget } from './client.js';
|
|
2
|
+
/**
|
|
3
|
+
* Pure evaluation helpers over a project's Vercel env vars. Kept free of I/O so
|
|
4
|
+
* the matrix / blob logic is unit-testable without hitting the Vercel API.
|
|
5
|
+
*/
|
|
6
|
+
export type TargetPresence = Record<VercelTarget, boolean>;
|
|
7
|
+
/** Which targets a given env var key is defined for. */
|
|
8
|
+
export declare function presenceForKey(envVars: VercelEnvVar[], key: string): TargetPresence;
|
|
9
|
+
export interface EnvMatrixRow {
|
|
10
|
+
key: string;
|
|
11
|
+
required: boolean;
|
|
12
|
+
presence: TargetPresence;
|
|
13
|
+
}
|
|
14
|
+
export interface EnvMatrix {
|
|
15
|
+
rows: EnvMatrixRow[];
|
|
16
|
+
/** Required vars missing on production or preview (deploy-blocking). */
|
|
17
|
+
missingCritical: {
|
|
18
|
+
key: string;
|
|
19
|
+
targets: VercelTarget[];
|
|
20
|
+
}[];
|
|
21
|
+
}
|
|
22
|
+
export declare function buildEnvMatrix(envVars: VercelEnvVar[], requiredKeys: readonly string[], optionalKeys?: readonly string[]): EnvMatrix;
|
|
23
|
+
export type BlobLinkStatus = 'ok' | 'partial' | 'none';
|
|
24
|
+
export interface BlobLinkReport {
|
|
25
|
+
/** True when any BLOB_* var indicates a store is connected to the project. */
|
|
26
|
+
linked: boolean;
|
|
27
|
+
tokenByTarget: TargetPresence;
|
|
28
|
+
/** Targets (any) where BLOB_READ_WRITE_TOKEN is absent while linked. */
|
|
29
|
+
missingTargets: VercelTarget[];
|
|
30
|
+
/** 'partial' when production/preview lack the token (deploy-blocking). */
|
|
31
|
+
status: BlobLinkStatus;
|
|
32
|
+
}
|
|
33
|
+
export declare function evaluateBlobLink(envVars: VercelEnvVar[]): BlobLinkReport;
|
|
34
|
+
//# sourceMappingURL=env-matrix.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"env-matrix.d.ts","sourceRoot":"","sources":["../../src/vercel/env-matrix.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,MAAM,aAAa,CAAA;AAElF;;;GAGG;AAEH,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,YAAY,EAAE,OAAO,CAAC,CAAA;AAM1D,wDAAwD;AACxD,wBAAgB,cAAc,CAAC,OAAO,EAAE,YAAY,EAAE,EAAE,GAAG,EAAE,MAAM,GAAG,cAAc,CAWnF;AAED,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAA;IACX,QAAQ,EAAE,OAAO,CAAA;IACjB,QAAQ,EAAE,cAAc,CAAA;CACzB;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,YAAY,EAAE,CAAA;IACpB,wEAAwE;IACxE,eAAe,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,YAAY,EAAE,CAAA;KAAE,EAAE,CAAA;CAC5D;AAKD,wBAAgB,cAAc,CAC5B,OAAO,EAAE,YAAY,EAAE,EACvB,YAAY,EAAE,SAAS,MAAM,EAAE,EAC/B,YAAY,GAAE,SAAS,MAAM,EAAO,GACnC,SAAS,CAoBX;AAED,MAAM,MAAM,cAAc,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,CAAA;AAEtD,MAAM,WAAW,cAAc;IAC7B,8EAA8E;IAC9E,MAAM,EAAE,OAAO,CAAA;IACf,aAAa,EAAE,cAAc,CAAA;IAC7B,wEAAwE;IACxE,cAAc,EAAE,YAAY,EAAE,CAAA;IAC9B,0EAA0E;IAC1E,MAAM,EAAE,cAAc,CAAA;CACvB;AAID,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,YAAY,EAAE,GAAG,cAAc,CAkBxE"}
|
|
@@ -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.10.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.61.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,91 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// seed.js transitively imports cms-core helpers at module scope via mocks
|
|
4
|
+
// elsewhere; keep the same stub so importing DEMO_FORMS stays side-effect-free.
|
|
5
|
+
vi.mock('@actuate-media/cms-core', () => ({
|
|
6
|
+
extractPlainText: vi.fn(),
|
|
7
|
+
hashContent: vi.fn(),
|
|
8
|
+
sanitizeHtml: vi.fn(),
|
|
9
|
+
createInitialAdmin: vi.fn(),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
import { validateFormSeedData, validateFormSeeds } from '../utils/form-seed.js'
|
|
13
|
+
import { DEMO_FORMS } from '../commands/seed.js'
|
|
14
|
+
|
|
15
|
+
const validForm = () => ({
|
|
16
|
+
name: 'Contact',
|
|
17
|
+
slug: 'contact',
|
|
18
|
+
status: 'active',
|
|
19
|
+
fields: [{ id: 'f1', key: 'email', label: 'Email', type: 'email', required: true, sortOrder: 0 }],
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('validateFormSeedData', () => {
|
|
23
|
+
it('accepts the canonical form shape', () => {
|
|
24
|
+
expect(validateFormSeedData(validForm())).toEqual([])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('every DEMO_FORMS entry passes its own validation', () => {
|
|
28
|
+
for (const form of DEMO_FORMS) {
|
|
29
|
+
expect(validateFormSeedData(form, form.slug)).toEqual([])
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('explains the data.status vs document status split when status is missing', () => {
|
|
34
|
+
const { status: _status, ...form } = validForm()
|
|
35
|
+
const errors = validateFormSeedData(form)
|
|
36
|
+
expect(errors).toHaveLength(1)
|
|
37
|
+
expect(errors[0]).toContain('data.status to "active"')
|
|
38
|
+
expect(errors[0]).toContain('document envelope status')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('catches a document-envelope status (PUBLISHED) used as the form status', () => {
|
|
42
|
+
const errors = validateFormSeedData({ ...validForm(), status: 'PUBLISHED' })
|
|
43
|
+
expect(errors).toHaveLength(1)
|
|
44
|
+
expect(errors[0]).toContain('looks like a document envelope status')
|
|
45
|
+
expect(errors[0]).toContain('"active" | "draft" | "archived"')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('rejects unknown statuses with the allowed list', () => {
|
|
49
|
+
const errors = validateFormSeedData({ ...validForm(), status: 'live' })
|
|
50
|
+
expect(errors[0]).toContain('invalid data.status "live"')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('requires slug, name, and a non-empty fields array', () => {
|
|
54
|
+
const errors = validateFormSeedData({ status: 'active' })
|
|
55
|
+
expect(errors.some((e) => e.includes('"slug"'))).toBe(true)
|
|
56
|
+
expect(errors.some((e) => e.includes('"name"'))).toBe(true)
|
|
57
|
+
expect(errors.some((e) => e.includes('"fields"'))).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('points "name"-keyed fields (the old demo shape) at key/label', () => {
|
|
61
|
+
const errors = validateFormSeedData({
|
|
62
|
+
...validForm(),
|
|
63
|
+
fields: [{ name: 'email', type: 'email' }],
|
|
64
|
+
})
|
|
65
|
+
expect(errors.some((e) => e.includes('Rename name → key'))).toBe(true)
|
|
66
|
+
expect(errors.some((e) => e.includes('"label"'))).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('rejects unknown field types', () => {
|
|
70
|
+
const errors = validateFormSeedData({
|
|
71
|
+
...validForm(),
|
|
72
|
+
fields: [{ key: 'x', label: 'X', type: 'dropdown' }],
|
|
73
|
+
})
|
|
74
|
+
expect(errors[0]).toContain('invalid type "dropdown"')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('validateFormSeeds', () => {
|
|
79
|
+
it('only validates forms-collection documents and labels errors by slug', () => {
|
|
80
|
+
const errors = validateFormSeeds([
|
|
81
|
+
{ collection: 'pages', data: { title: 'Home' } },
|
|
82
|
+
{ collection: 'forms', data: { ...validForm(), slug: 'contact', status: 'PUBLISHED' } },
|
|
83
|
+
])
|
|
84
|
+
expect(errors).toHaveLength(1)
|
|
85
|
+
expect(errors[0]).toContain('forms "contact"')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('returns no errors for a payload without forms', () => {
|
|
89
|
+
expect(validateFormSeeds([{ collection: 'pages', data: {} }])).toEqual([])
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -130,6 +130,103 @@ describe('seed document creation', () => {
|
|
|
130
130
|
})
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
+
describe('seed document upsert', () => {
|
|
134
|
+
function makeDb(existing: { id: string; status: string; publishedAt: Date | null } | null) {
|
|
135
|
+
const tx = {
|
|
136
|
+
document: {
|
|
137
|
+
create: vi.fn().mockResolvedValue({ id: 'doc-new' }),
|
|
138
|
+
update: vi.fn().mockResolvedValue({ id: existing?.id ?? 'doc-new' }),
|
|
139
|
+
},
|
|
140
|
+
version: { create: vi.fn() },
|
|
141
|
+
}
|
|
142
|
+
const db = {
|
|
143
|
+
document: { findFirst: vi.fn().mockResolvedValue(existing) },
|
|
144
|
+
$transaction: vi.fn(async (fn: any) => fn(tx)),
|
|
145
|
+
}
|
|
146
|
+
return { db, tx }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const doc = {
|
|
150
|
+
collection: 'pages',
|
|
151
|
+
status: 'PUBLISHED',
|
|
152
|
+
data: { title: 'Home v2', slug: 'home' },
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
it('updates an existing document matched by collection + slug', async () => {
|
|
156
|
+
const publishedAt = new Date('2026-01-01T00:00:00Z')
|
|
157
|
+
const { db, tx } = makeDb({ id: 'doc-1', status: 'PUBLISHED', publishedAt })
|
|
158
|
+
|
|
159
|
+
const result = await createSeedDocument(db, 'admin-1', doc, { upsert: true })
|
|
160
|
+
|
|
161
|
+
expect(result).toBe('updated')
|
|
162
|
+
expect(db.document.findFirst).toHaveBeenCalledWith({
|
|
163
|
+
where: { collection: 'pages', slug: 'home', deletedAt: null },
|
|
164
|
+
select: { id: true, status: true, publishedAt: true },
|
|
165
|
+
})
|
|
166
|
+
expect(tx.document.create).not.toHaveBeenCalled()
|
|
167
|
+
expect(tx.document.update).toHaveBeenCalledWith({
|
|
168
|
+
where: { id: 'doc-1' },
|
|
169
|
+
data: expect.objectContaining({
|
|
170
|
+
title: 'Home v2',
|
|
171
|
+
status: 'PUBLISHED',
|
|
172
|
+
// Re-publishing an already-published doc keeps the original timestamp.
|
|
173
|
+
publishedAt,
|
|
174
|
+
updatedById: 'admin-1',
|
|
175
|
+
}),
|
|
176
|
+
})
|
|
177
|
+
expect(tx.version.create).toHaveBeenCalledWith({
|
|
178
|
+
data: expect.objectContaining({ documentId: 'doc-1', changeType: 'UPDATE' }),
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('stamps publishedAt on the transition into PUBLISHED', async () => {
|
|
183
|
+
const { db, tx } = makeDb({ id: 'doc-1', status: 'DRAFT', publishedAt: null })
|
|
184
|
+
|
|
185
|
+
await createSeedDocument(db, 'admin-1', doc, { upsert: true })
|
|
186
|
+
|
|
187
|
+
expect(tx.document.update).toHaveBeenCalledWith(
|
|
188
|
+
expect.objectContaining({
|
|
189
|
+
data: expect.objectContaining({ publishedAt: expect.any(Date) }),
|
|
190
|
+
}),
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('creates when no document matches', async () => {
|
|
195
|
+
const { db, tx } = makeDb(null)
|
|
196
|
+
|
|
197
|
+
const result = await createSeedDocument(db, 'admin-1', doc, { upsert: true })
|
|
198
|
+
|
|
199
|
+
expect(result).toBe('created')
|
|
200
|
+
expect(tx.document.create).toHaveBeenCalled()
|
|
201
|
+
expect(tx.document.update).not.toHaveBeenCalled()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('always creates slugless documents (no upsert key)', async () => {
|
|
205
|
+
const { db, tx } = makeDb({ id: 'doc-1', status: 'DRAFT', publishedAt: null })
|
|
206
|
+
|
|
207
|
+
const result = await createSeedDocument(
|
|
208
|
+
db,
|
|
209
|
+
'admin-1',
|
|
210
|
+
{ collection: 'pages', status: 'DRAFT', data: { title: 'No slug' } },
|
|
211
|
+
{ upsert: true },
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
expect(result).toBe('created')
|
|
215
|
+
expect(db.document.findFirst).not.toHaveBeenCalled()
|
|
216
|
+
expect(tx.document.create).toHaveBeenCalled()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('does not look up existing documents without the upsert flag', async () => {
|
|
220
|
+
const { db, tx } = makeDb({ id: 'doc-1', status: 'DRAFT', publishedAt: null })
|
|
221
|
+
|
|
222
|
+
const result = await createSeedDocument(db, 'admin-1', doc)
|
|
223
|
+
|
|
224
|
+
expect(result).toBe('created')
|
|
225
|
+
expect(db.document.findFirst).not.toHaveBeenCalled()
|
|
226
|
+
expect(tx.document.create).toHaveBeenCalled()
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
133
230
|
describe('demo navigations', () => {
|
|
134
231
|
// The scaffold site layout fetches menus by the `main` and `footer` slugs, so
|
|
135
232
|
// the demo seed must provide exactly those — guard against slug drift.
|
|
@@ -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
|
}
|