@beechcms/cli 0.6.0-preview.1 → 0.6.0-preview.3
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 +5 -0
- package/coverage/index.html +21 -21
- package/coverage/lcov-report/index.html +21 -21
- package/coverage/lcov-report/validate.ts.html +99 -402
- package/coverage/lcov.info +76 -176
- package/coverage/validate.ts.html +99 -402
- package/dist/commands/seed-load.d.ts +8 -0
- package/dist/commands/seed-load.d.ts.map +1 -0
- package/dist/commands/seed-load.js +89 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +137 -0
- package/dist/lib/schema-diff.d.ts +15 -0
- package/dist/lib/schema-diff.d.ts.map +1 -0
- package/dist/lib/schema-diff.js +37 -0
- package/dist/lib/wrangler.d.ts +17 -0
- package/dist/lib/wrangler.d.ts.map +1 -0
- package/dist/lib/wrangler.js +65 -0
- package/package.json +11 -11
- package/src/commands/deploy.ts +126 -126
- package/src/commands/init.ts +599 -599
- package/src/commands/onboard.ts +32 -32
- package/src/commands/reset.ts +157 -0
- package/src/commands/seed-create.ts +192 -192
- package/src/commands/seed-load.ts +235 -235
- package/src/commands/update.ts +54 -54
- package/src/commands/validate.ts +80 -80
- package/src/index.ts +20 -17
- package/src/lib/schema-diff.ts +150 -150
- package/src/lib/wrangler.ts +129 -129
- package/src/test/reset.test.ts +128 -0
- package/src/test/seed-load.test.ts +158 -0
- package/src/test/validate.test.ts +261 -0
- package/tsconfig.json +16 -16
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +33 -32
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
5
|
+
import { validateSeeds, validate } from '../commands/validate.js'
|
|
6
|
+
import type { Seed } from '@beechcms/core'
|
|
7
|
+
|
|
8
|
+
type PartialBranch = Omit<Seed['branches'][number], 'id'>
|
|
9
|
+
|
|
10
|
+
function makeSeed(slug: string, branches: PartialBranch[] = []): Seed {
|
|
11
|
+
let counter = 0
|
|
12
|
+
const withIds = (branches.length > 0 ? branches : [{ alias: 'title', label: 'Title', type: 'text' }])
|
|
13
|
+
.map(b => ({ id: `br_${String(++counter).padStart(2, '0')}`, ...b }))
|
|
14
|
+
return {
|
|
15
|
+
slug,
|
|
16
|
+
label: slug,
|
|
17
|
+
displayNameAlias: withIds[0]?.alias ?? 'title',
|
|
18
|
+
branches: withIds,
|
|
19
|
+
} as Seed
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── Unknown relation target ────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
describe('validateSeeds — unknown relation target', () => {
|
|
25
|
+
it('emits a fatal error when targetSeed not in registry', () => {
|
|
26
|
+
const registry = {
|
|
27
|
+
articles: makeSeed('articles', [
|
|
28
|
+
{ alias: 'title', label: 'Title', type: 'text' },
|
|
29
|
+
{ alias: 'author_id', label: 'Author', type: 'relation', targetSeed: 'team' },
|
|
30
|
+
]),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const errors = validateSeeds(registry as Record<string, Seed>)
|
|
34
|
+
const fatal = errors.filter(e => e.fatal)
|
|
35
|
+
|
|
36
|
+
expect(fatal).toHaveLength(1)
|
|
37
|
+
expect(fatal[0].slug).toBe('articles')
|
|
38
|
+
expect(fatal[0].messages[0]).toContain('author_id')
|
|
39
|
+
expect(fatal[0].messages[0]).toContain('team')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('no fatal error when targetSeed is in registry', () => {
|
|
43
|
+
const registry = {
|
|
44
|
+
team: makeSeed('team', [{ alias: 'name', label: 'Name', type: 'text' }]),
|
|
45
|
+
articles: makeSeed('articles', [
|
|
46
|
+
{ alias: 'title', label: 'Title', type: 'text' },
|
|
47
|
+
{ alias: 'author_id', label: 'Author', type: 'relation', targetSeed: 'team' },
|
|
48
|
+
]),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const errors = validateSeeds(registry as Record<string, Seed>)
|
|
52
|
+
const fatal = errors.filter(e => e.fatal)
|
|
53
|
+
expect(fatal).toHaveLength(0)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// ── Cyclic dependency ─────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
describe('validateSeeds — cyclic dependency', () => {
|
|
60
|
+
it('emits a fatal error listing both slugs in a cycle', () => {
|
|
61
|
+
const registry = {
|
|
62
|
+
a: {
|
|
63
|
+
slug: 'a',
|
|
64
|
+
label: 'A',
|
|
65
|
+
displayNameAlias: 'title',
|
|
66
|
+
branches: [
|
|
67
|
+
{ id: 'br_01', alias: 'title', label: 'Title', type: 'text' },
|
|
68
|
+
{ id: 'br_02', alias: 'b_id', label: 'B', type: 'relation', targetSeed: 'b' },
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
b: {
|
|
72
|
+
slug: 'b',
|
|
73
|
+
label: 'B',
|
|
74
|
+
displayNameAlias: 'title',
|
|
75
|
+
branches: [
|
|
76
|
+
{ id: 'br_01', alias: 'title', label: 'Title', type: 'text' },
|
|
77
|
+
{ id: 'br_02', alias: 'a_id', label: 'A', type: 'relation', targetSeed: 'a' },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const errors = validateSeeds(registry as Record<string, Seed>)
|
|
83
|
+
const fatal = errors.filter(e => e.fatal)
|
|
84
|
+
|
|
85
|
+
// One fatal entry for the cycle
|
|
86
|
+
expect(fatal.length).toBeGreaterThan(0)
|
|
87
|
+
const cycleMsg = fatal.find(e => e.messages.some(m => m.includes('Cyclic')))
|
|
88
|
+
expect(cycleMsg).toBeDefined()
|
|
89
|
+
expect(cycleMsg!.messages[0]).toContain('a')
|
|
90
|
+
expect(cycleMsg!.messages[0]).toContain('b')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// ── Multi-relation: SET NULL rejection ───────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
describe('validateSeeds — multi-relation SET NULL', () => {
|
|
97
|
+
it('emits a fatal error when multiple: true + onDelete: SET NULL', () => {
|
|
98
|
+
const registry = {
|
|
99
|
+
tag: makeSeed('tag'),
|
|
100
|
+
articles: makeSeed('articles', [
|
|
101
|
+
{ alias: 'title', label: 'Title', type: 'text' },
|
|
102
|
+
{
|
|
103
|
+
alias: 'tags',
|
|
104
|
+
label: 'Tags',
|
|
105
|
+
type: 'relation',
|
|
106
|
+
targetSeed: 'tag',
|
|
107
|
+
multiple: true,
|
|
108
|
+
onDelete: 'SET NULL',
|
|
109
|
+
},
|
|
110
|
+
]),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const errors = validateSeeds(registry as Record<string, Seed>)
|
|
114
|
+
const fatal = errors.filter(e => e.fatal)
|
|
115
|
+
expect(fatal.length).toBeGreaterThan(0)
|
|
116
|
+
const tagError = fatal.find(e => e.slug === 'articles')
|
|
117
|
+
expect(tagError).toBeDefined()
|
|
118
|
+
expect(tagError!.messages[0]).toContain("'tags'")
|
|
119
|
+
expect(tagError!.messages[0]).toContain('SET NULL')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('does not error for multiple: true + onDelete: CASCADE', () => {
|
|
123
|
+
const registry = {
|
|
124
|
+
tag: makeSeed('tag'),
|
|
125
|
+
articles: makeSeed('articles', [
|
|
126
|
+
{ alias: 'title', label: 'Title', type: 'text' },
|
|
127
|
+
{
|
|
128
|
+
alias: 'tags',
|
|
129
|
+
label: 'Tags',
|
|
130
|
+
type: 'relation',
|
|
131
|
+
targetSeed: 'tag',
|
|
132
|
+
multiple: true,
|
|
133
|
+
onDelete: 'CASCADE',
|
|
134
|
+
},
|
|
135
|
+
]),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const errors = validateSeeds(registry as Record<string, Seed>)
|
|
139
|
+
const setNullErrors = errors.filter(e => e.fatal && e.messages.some(m => m.includes('SET NULL')))
|
|
140
|
+
expect(setNullErrors).toHaveLength(0)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('does not error for multiple: true with no onDelete (defaults to CASCADE)', () => {
|
|
144
|
+
const registry = {
|
|
145
|
+
tag: makeSeed('tag'),
|
|
146
|
+
articles: makeSeed('articles', [
|
|
147
|
+
{ alias: 'title', label: 'Title', type: 'text' },
|
|
148
|
+
{ alias: 'tags', label: 'Tags', type: 'relation', targetSeed: 'tag', multiple: true },
|
|
149
|
+
]),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const errors = validateSeeds(registry as Record<string, Seed>)
|
|
153
|
+
const fatal = errors.filter(e => e.fatal)
|
|
154
|
+
expect(fatal).toHaveLength(0)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// ── Multi-relation: junction table name collision ─────────────────────────────
|
|
159
|
+
|
|
160
|
+
describe('validateSeeds — junction table name collision', () => {
|
|
161
|
+
it('emits fatal error when two branches produce the same junction table name', () => {
|
|
162
|
+
// 'rel_articles_tags' collides if somehow two branches have the same alias
|
|
163
|
+
// This is prevented by duplicate alias check, but the junction collision check
|
|
164
|
+
// handles cross-seed collisions after truncation.
|
|
165
|
+
const registry = {
|
|
166
|
+
tag: makeSeed('tag'),
|
|
167
|
+
// Simulate a collision: same junction name from different seeds
|
|
168
|
+
rel_articles: makeSeed('rel_articles', [
|
|
169
|
+
{ alias: 'title', label: 'T', type: 'text' },
|
|
170
|
+
{ alias: 'tags', label: 'Tags', type: 'relation', targetSeed: 'tag', multiple: true },
|
|
171
|
+
]),
|
|
172
|
+
articles: makeSeed('articles', [
|
|
173
|
+
{ alias: 'title', label: 'T', type: 'text' },
|
|
174
|
+
// slug='articles' + alias='rel_articles_tags' → 'rel_articles_rel_articles_tags'
|
|
175
|
+
// Use a manually crafted collision scenario:
|
|
176
|
+
{ alias: 'tags', label: 'Tags', type: 'relation', targetSeed: 'tag', multiple: true },
|
|
177
|
+
]),
|
|
178
|
+
}
|
|
179
|
+
// These won't collide but the rule should run without errors
|
|
180
|
+
const errors = validateSeeds(registry as Record<string, Seed>)
|
|
181
|
+
const collisionErrors = errors.filter(e => e.fatal && e.messages.some(m => m.includes('collision')))
|
|
182
|
+
expect(collisionErrors).toHaveLength(0)
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// ── Existing warning checks still work ────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
describe('validateSeeds — existing warning checks', () => {
|
|
189
|
+
it('flags missing displayNameAlias as warning (not fatal)', () => {
|
|
190
|
+
const registry = {
|
|
191
|
+
items: {
|
|
192
|
+
slug: 'items',
|
|
193
|
+
label: 'Items',
|
|
194
|
+
displayNameAlias: 'nonexistent',
|
|
195
|
+
branches: [{ id: 'br_01', alias: 'title', label: 'Title', type: 'text' }],
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const errors = validateSeeds(registry as Record<string, Seed>)
|
|
200
|
+
const warnings = errors.filter(e => !e.fatal)
|
|
201
|
+
expect(warnings.length).toBeGreaterThan(0)
|
|
202
|
+
expect(warnings[0].messages[0]).toContain('displayNameAlias')
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('validate CLI command wrapper', () => {
|
|
207
|
+
let logSpy: any
|
|
208
|
+
let warnSpy: any
|
|
209
|
+
let exitSpy: any
|
|
210
|
+
|
|
211
|
+
beforeEach(() => {
|
|
212
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
213
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
214
|
+
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
afterEach(() => {
|
|
218
|
+
vi.restoreAllMocks()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('warns when registry is empty', async () => {
|
|
222
|
+
await validate({ registry: {} })
|
|
223
|
+
expect(warnSpy).toHaveBeenCalled()
|
|
224
|
+
expect(warnSpy.mock.calls[0][0]).toContain('SEED_REGISTRY is empty')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('logs success when all seeds are valid', async () => {
|
|
228
|
+
const registry = {
|
|
229
|
+
articles: makeSeed('articles'),
|
|
230
|
+
}
|
|
231
|
+
await validate({ registry })
|
|
232
|
+
expect(logSpy).toHaveBeenCalled()
|
|
233
|
+
expect(logSpy.mock.calls.some((c: any[]) => c[0].includes('All seeds valid'))).toBe(true)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('logs warnings when seeds have non-fatal issues', async () => {
|
|
237
|
+
const registry = {
|
|
238
|
+
items: {
|
|
239
|
+
slug: 'items',
|
|
240
|
+
label: 'Items',
|
|
241
|
+
displayNameAlias: 'nonexistent',
|
|
242
|
+
branches: [{ id: 'br_01', alias: 'title', label: 'Title', type: 'text' }],
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
await validate({ registry: registry as any })
|
|
246
|
+
expect(logSpy).toHaveBeenCalled()
|
|
247
|
+
expect(logSpy.mock.calls.some((c: any[]) => c[0].includes('Found 1 warning'))).toBe(true)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('exits with 1 when seeds have fatal errors', async () => {
|
|
251
|
+
const registry = {
|
|
252
|
+
articles: makeSeed('articles', [
|
|
253
|
+
{ alias: 'title', label: 'Title', type: 'text' },
|
|
254
|
+
{ alias: 'author_id', label: 'Author', type: 'relation', targetSeed: 'team' },
|
|
255
|
+
]),
|
|
256
|
+
}
|
|
257
|
+
await validate({ registry: registry as any })
|
|
258
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
package/tsconfig.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
{
|
|
2
|
-
"extends": "../../tsconfig.json",
|
|
3
|
-
"compilerOptions": {
|
|
4
|
-
"composite": true,
|
|
5
|
-
"declaration": true,
|
|
6
|
-
"declarationMap": true,
|
|
7
|
-
"outDir": "dist",
|
|
8
|
-
"rootDir": "src",
|
|
9
|
-
"module": "ESNext",
|
|
10
|
-
"moduleResolution": "Bundler",
|
|
11
|
-
"target": "ES2022"
|
|
12
|
-
},
|
|
13
|
-
"include": ["src/**/*"],
|
|
14
|
-
"exclude": ["node_modules", "dist"],
|
|
15
|
-
"references": [{ "path": "../core" }]
|
|
16
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"composite": true,
|
|
5
|
+
"declaration": true,
|
|
6
|
+
"declarationMap": true,
|
|
7
|
+
"outDir": "dist",
|
|
8
|
+
"rootDir": "src",
|
|
9
|
+
"module": "ESNext",
|
|
10
|
+
"moduleResolution": "Bundler",
|
|
11
|
+
"target": "ES2022"
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*"],
|
|
14
|
+
"exclude": ["node_modules", "dist"],
|
|
15
|
+
"references": [{ "path": "../core" }]
|
|
16
|
+
}
|