@beechcms/cli 0.6.0-preview.2 → 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/dist/index.js +137 -0
- package/package.json +11 -11
- package/src/commands/reset.ts +157 -0
- package/src/index.ts +3 -0
- 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.tsbuildinfo +1 -1
package/dist/index.js
CHANGED
|
@@ -1248,10 +1248,147 @@ async function update(_args) {
|
|
|
1248
1248
|
console.log(pc7.cyan(" 3. npx beech seed:load"));
|
|
1249
1249
|
console.log(pc7.dim(" \u2192 sync remote schema\n"));
|
|
1250
1250
|
}
|
|
1251
|
+
|
|
1252
|
+
// src/commands/reset.ts
|
|
1253
|
+
import pc8 from "picocolors";
|
|
1254
|
+
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
1255
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5, rmSync as rmSync2 } from "node:fs";
|
|
1256
|
+
import { resolve as resolve4 } from "node:path";
|
|
1257
|
+
import { createInterface as createInterface3 } from "node:readline/promises";
|
|
1258
|
+
function isDockerInstalled() {
|
|
1259
|
+
try {
|
|
1260
|
+
const result = spawnSync5("docker", ["--version"], { stdio: "ignore", shell: true });
|
|
1261
|
+
return result.status === 0;
|
|
1262
|
+
} catch {
|
|
1263
|
+
return false;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
function isDockerRunning() {
|
|
1267
|
+
try {
|
|
1268
|
+
const result = spawnSync5("docker", ["info"], { stdio: "ignore", shell: true });
|
|
1269
|
+
return result.status === 0;
|
|
1270
|
+
} catch {
|
|
1271
|
+
return false;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
async function reset(args) {
|
|
1275
|
+
console.log(pc8.cyan("\n beech reset \u2014 cleanup environments\n"));
|
|
1276
|
+
let resetDb = args.db || args.all;
|
|
1277
|
+
let resetDocker = args.docker || args.all;
|
|
1278
|
+
if (!args.db && !args.docker && !args.all) {
|
|
1279
|
+
if (process.stdin.isTTY) {
|
|
1280
|
+
const rl = createInterface3({ input: process.stdin, output: process.stdout });
|
|
1281
|
+
try {
|
|
1282
|
+
const answer = (await rl.question(
|
|
1283
|
+
pc8.cyan(" \u2192 No options provided. Would you like to reset everything (DB & Docker)? (y/N): ")
|
|
1284
|
+
)).trim().toLowerCase();
|
|
1285
|
+
if (answer === "y" || answer === "yes") {
|
|
1286
|
+
resetDb = true;
|
|
1287
|
+
resetDocker = true;
|
|
1288
|
+
} else {
|
|
1289
|
+
console.log(pc8.dim("\n Reset cancelled. Use --db, --docker, or --all.\n"));
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
} finally {
|
|
1293
|
+
rl.close();
|
|
1294
|
+
}
|
|
1295
|
+
} else {
|
|
1296
|
+
console.log(pc8.red("\n \u2717 Error: Please specify what to reset using --db, --docker, or --all.\n"));
|
|
1297
|
+
process.exit(1);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
const cwd = process.cwd();
|
|
1301
|
+
if (resetDocker) {
|
|
1302
|
+
if (!isDockerInstalled()) {
|
|
1303
|
+
console.log(pc8.red(" \u2717 Docker is not installed or not found in your PATH."));
|
|
1304
|
+
console.log(pc8.dim(" Please install Docker to reset Docker containers and volumes.\n"));
|
|
1305
|
+
if (!args.all) {
|
|
1306
|
+
process.exit(1);
|
|
1307
|
+
}
|
|
1308
|
+
} else if (!isDockerRunning()) {
|
|
1309
|
+
console.log(pc8.yellow(" \u26A0 Docker is installed, but the Docker daemon is NOT running."));
|
|
1310
|
+
console.log(pc8.dim(" Please start Docker Desktop or your Docker daemon to reset containers.\n"));
|
|
1311
|
+
if (!args.all) {
|
|
1312
|
+
process.exit(1);
|
|
1313
|
+
}
|
|
1314
|
+
} else {
|
|
1315
|
+
console.log(pc8.dim(" Resetting Docker containers and volumes\u2026\n"));
|
|
1316
|
+
const result = spawnSync5("docker", ["compose", "down", "-v"], {
|
|
1317
|
+
stdio: "inherit",
|
|
1318
|
+
cwd,
|
|
1319
|
+
shell: true
|
|
1320
|
+
});
|
|
1321
|
+
if (result.status === 0) {
|
|
1322
|
+
console.log(pc8.green("\n \u2713 Docker containers stopped and volumes removed."));
|
|
1323
|
+
} else {
|
|
1324
|
+
console.log(pc8.red("\n \u2717 Docker reset failed."));
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
if (resetDb) {
|
|
1329
|
+
console.log(pc8.dim("\n Resetting local database\u2026\n"));
|
|
1330
|
+
let dbResetSuccess = false;
|
|
1331
|
+
const apiDir = resolve4(cwd, "apps", "api");
|
|
1332
|
+
if (existsSync4(resolve4(apiDir, "package.json"))) {
|
|
1333
|
+
const result = spawnSync5("npm", ["run", "db:reset:local"], {
|
|
1334
|
+
stdio: "inherit",
|
|
1335
|
+
cwd: apiDir,
|
|
1336
|
+
shell: true
|
|
1337
|
+
});
|
|
1338
|
+
dbResetSuccess = result.status === 0;
|
|
1339
|
+
} else if (existsSync4(resolve4(cwd, "package.json"))) {
|
|
1340
|
+
const pkg = JSON.parse(readFileSync5(resolve4(cwd, "package.json"), "utf-8"));
|
|
1341
|
+
if (pkg.scripts?.["db:reset:local"]) {
|
|
1342
|
+
const result = spawnSync5("npm", ["run", "db:reset:local"], {
|
|
1343
|
+
stdio: "inherit",
|
|
1344
|
+
cwd,
|
|
1345
|
+
shell: true
|
|
1346
|
+
});
|
|
1347
|
+
dbResetSuccess = result.status === 0;
|
|
1348
|
+
} else {
|
|
1349
|
+
const wranglerStateDir = resolve4(cwd, ".wrangler/state");
|
|
1350
|
+
if (existsSync4(wranglerStateDir)) {
|
|
1351
|
+
console.log(pc8.dim(" Removing .wrangler/state\u2026"));
|
|
1352
|
+
rmSync2(wranglerStateDir, { recursive: true, force: true });
|
|
1353
|
+
}
|
|
1354
|
+
if (existsSync4(resolve4(cwd, "scripts", "bootstrap-d1.mjs"))) {
|
|
1355
|
+
const result = spawnSync5("node", ["scripts/bootstrap-d1.mjs"], {
|
|
1356
|
+
stdio: "inherit",
|
|
1357
|
+
cwd,
|
|
1358
|
+
shell: true
|
|
1359
|
+
});
|
|
1360
|
+
dbResetSuccess = result.status === 0;
|
|
1361
|
+
} else {
|
|
1362
|
+
console.log(pc8.yellow(" \u26A0 Could not find database reset script."));
|
|
1363
|
+
const initResult = spawnSync5("npx", ["beech", "init", "--db", "--local"], {
|
|
1364
|
+
stdio: "inherit",
|
|
1365
|
+
cwd,
|
|
1366
|
+
shell: true
|
|
1367
|
+
});
|
|
1368
|
+
dbResetSuccess = initResult.status === 0;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
} else {
|
|
1372
|
+
const wranglerStateDir = resolve4(cwd, ".wrangler/state");
|
|
1373
|
+
if (existsSync4(wranglerStateDir)) {
|
|
1374
|
+
console.log(pc8.dim(" Removing .wrangler/state\u2026"));
|
|
1375
|
+
rmSync2(wranglerStateDir, { recursive: true, force: true });
|
|
1376
|
+
}
|
|
1377
|
+
dbResetSuccess = true;
|
|
1378
|
+
}
|
|
1379
|
+
if (dbResetSuccess) {
|
|
1380
|
+
console.log(pc8.green("\n \u2713 Local database reset completed."));
|
|
1381
|
+
} else {
|
|
1382
|
+
console.log(pc8.red("\n \u2717 Database reset failed."));
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
console.log(pc8.dim("\n Reset process finished.\n"));
|
|
1386
|
+
}
|
|
1251
1387
|
export {
|
|
1252
1388
|
deploy,
|
|
1253
1389
|
init,
|
|
1254
1390
|
onboard,
|
|
1391
|
+
reset,
|
|
1255
1392
|
seedCreate,
|
|
1256
1393
|
seedLoad,
|
|
1257
1394
|
update,
|
package/package.json
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beechcms/cli",
|
|
3
|
-
"version": "0.6.0-preview.
|
|
3
|
+
"version": "0.6.0-preview.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./dist/index.js"
|
|
8
8
|
},
|
|
9
|
-
"scripts": {
|
|
10
|
-
"build": "tsc --noEmit && esbuild src/index.ts --bundle --packages=external --platform=node --format=esm --outfile=dist/index.js",
|
|
11
|
-
"dev": "esbuild src/index.ts --bundle --packages=external --platform=node --format=esm --outfile=dist/index.js --watch",
|
|
12
|
-
"test": "vitest run",
|
|
13
|
-
"test:coverage": "vitest run --coverage"
|
|
14
|
-
},
|
|
15
9
|
"dependencies": {
|
|
16
|
-
"@beechcms/core": "^0.6.0-preview.
|
|
10
|
+
"@beechcms/core": "^0.6.0-preview.3",
|
|
17
11
|
"picocolors": "^1.1.1"
|
|
18
12
|
},
|
|
19
13
|
"devDependencies": {
|
|
20
|
-
"esbuild": "^0.
|
|
14
|
+
"esbuild": "^0.28.1",
|
|
21
15
|
"typescript": "^5.9.3",
|
|
22
16
|
"vitest": "^4.1.0"
|
|
23
17
|
},
|
|
24
|
-
"license": "MIT"
|
|
25
|
-
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc --noEmit && esbuild src/index.ts --bundle --packages=external --platform=node --format=esm --outfile=dist/index.js",
|
|
21
|
+
"dev": "esbuild src/index.ts --bundle --packages=external --platform=node --format=esm --outfile=dist/index.js --watch",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"test:coverage": "vitest run --coverage"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
|
|
4
|
+
import pc from 'picocolors'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
7
|
+
import { resolve } from 'node:path'
|
|
8
|
+
import { createInterface } from 'node:readline/promises'
|
|
9
|
+
|
|
10
|
+
export interface ResetOptions {
|
|
11
|
+
db?: boolean
|
|
12
|
+
docker?: boolean
|
|
13
|
+
all?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isDockerInstalled(): boolean {
|
|
17
|
+
try {
|
|
18
|
+
const result = spawnSync('docker', ['--version'], { stdio: 'ignore', shell: true })
|
|
19
|
+
return result.status === 0
|
|
20
|
+
} catch {
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isDockerRunning(): boolean {
|
|
26
|
+
try {
|
|
27
|
+
const result = spawnSync('docker', ['info'], { stdio: 'ignore', shell: true })
|
|
28
|
+
return result.status === 0
|
|
29
|
+
} catch {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function reset(args: ResetOptions): Promise<void> {
|
|
35
|
+
console.log(pc.cyan('\n beech reset — cleanup environments\n'))
|
|
36
|
+
|
|
37
|
+
let resetDb = args.db || args.all
|
|
38
|
+
let resetDocker = args.docker || args.all
|
|
39
|
+
|
|
40
|
+
if (!args.db && !args.docker && !args.all) {
|
|
41
|
+
if (process.stdin.isTTY) {
|
|
42
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
43
|
+
try {
|
|
44
|
+
const answer = (await rl.question(
|
|
45
|
+
pc.cyan(' → No options provided. Would you like to reset everything (DB & Docker)? (y/N): ')
|
|
46
|
+
)).trim().toLowerCase()
|
|
47
|
+
if (answer === 'y' || answer === 'yes') {
|
|
48
|
+
resetDb = true
|
|
49
|
+
resetDocker = true
|
|
50
|
+
} else {
|
|
51
|
+
console.log(pc.dim('\n Reset cancelled. Use --db, --docker, or --all.\n'))
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
} finally {
|
|
55
|
+
rl.close()
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
console.log(pc.red('\n ✗ Error: Please specify what to reset using --db, --docker, or --all.\n'))
|
|
59
|
+
process.exit(1)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const cwd = process.cwd()
|
|
64
|
+
|
|
65
|
+
// Reset Docker
|
|
66
|
+
if (resetDocker) {
|
|
67
|
+
if (!isDockerInstalled()) {
|
|
68
|
+
console.log(pc.red(' ✗ Docker is not installed or not found in your PATH.'))
|
|
69
|
+
console.log(pc.dim(' Please install Docker to reset Docker containers and volumes.\n'))
|
|
70
|
+
if (!args.all) {
|
|
71
|
+
process.exit(1)
|
|
72
|
+
}
|
|
73
|
+
} else if (!isDockerRunning()) {
|
|
74
|
+
console.log(pc.yellow(' ⚠ Docker is installed, but the Docker daemon is NOT running.'))
|
|
75
|
+
console.log(pc.dim(' Please start Docker Desktop or your Docker daemon to reset containers.\n'))
|
|
76
|
+
if (!args.all) {
|
|
77
|
+
process.exit(1)
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
console.log(pc.dim(' Resetting Docker containers and volumes…\n'))
|
|
81
|
+
const result = spawnSync('docker', ['compose', 'down', '-v'], {
|
|
82
|
+
stdio: 'inherit',
|
|
83
|
+
cwd,
|
|
84
|
+
shell: true,
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
if (result.status === 0) {
|
|
88
|
+
console.log(pc.green('\n ✓ Docker containers stopped and volumes removed.'))
|
|
89
|
+
} else {
|
|
90
|
+
console.log(pc.red('\n ✗ Docker reset failed.'))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Reset DB
|
|
96
|
+
if (resetDb) {
|
|
97
|
+
console.log(pc.dim('\n Resetting local database…\n'))
|
|
98
|
+
let dbResetSuccess = false
|
|
99
|
+
const apiDir = resolve(cwd, 'apps', 'api')
|
|
100
|
+
|
|
101
|
+
if (existsSync(resolve(apiDir, 'package.json'))) {
|
|
102
|
+
const result = spawnSync('npm', ['run', 'db:reset:local'], {
|
|
103
|
+
stdio: 'inherit',
|
|
104
|
+
cwd: apiDir,
|
|
105
|
+
shell: true,
|
|
106
|
+
})
|
|
107
|
+
dbResetSuccess = result.status === 0
|
|
108
|
+
} else if (existsSync(resolve(cwd, 'package.json'))) {
|
|
109
|
+
const pkg = JSON.parse(readFileSync(resolve(cwd, 'package.json'), 'utf-8'))
|
|
110
|
+
if (pkg.scripts?.['db:reset:local']) {
|
|
111
|
+
const result = spawnSync('npm', ['run', 'db:reset:local'], {
|
|
112
|
+
stdio: 'inherit',
|
|
113
|
+
cwd,
|
|
114
|
+
shell: true,
|
|
115
|
+
})
|
|
116
|
+
dbResetSuccess = result.status === 0
|
|
117
|
+
} else {
|
|
118
|
+
const wranglerStateDir = resolve(cwd, '.wrangler/state')
|
|
119
|
+
if (existsSync(wranglerStateDir)) {
|
|
120
|
+
console.log(pc.dim(' Removing .wrangler/state…'))
|
|
121
|
+
rmSync(wranglerStateDir, { recursive: true, force: true })
|
|
122
|
+
}
|
|
123
|
+
if (existsSync(resolve(cwd, 'scripts', 'bootstrap-d1.mjs'))) {
|
|
124
|
+
const result = spawnSync('node', ['scripts/bootstrap-d1.mjs'], {
|
|
125
|
+
stdio: 'inherit',
|
|
126
|
+
cwd,
|
|
127
|
+
shell: true,
|
|
128
|
+
})
|
|
129
|
+
dbResetSuccess = result.status === 0
|
|
130
|
+
} else {
|
|
131
|
+
console.log(pc.yellow(' ⚠ Could not find database reset script.'))
|
|
132
|
+
const initResult = spawnSync('npx', ['beech', 'init', '--db', '--local'], {
|
|
133
|
+
stdio: 'inherit',
|
|
134
|
+
cwd,
|
|
135
|
+
shell: true,
|
|
136
|
+
})
|
|
137
|
+
dbResetSuccess = initResult.status === 0
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
const wranglerStateDir = resolve(cwd, '.wrangler/state')
|
|
142
|
+
if (existsSync(wranglerStateDir)) {
|
|
143
|
+
console.log(pc.dim(' Removing .wrangler/state…'))
|
|
144
|
+
rmSync(wranglerStateDir, { recursive: true, force: true })
|
|
145
|
+
}
|
|
146
|
+
dbResetSuccess = true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (dbResetSuccess) {
|
|
150
|
+
console.log(pc.green('\n ✓ Local database reset completed.'))
|
|
151
|
+
} else {
|
|
152
|
+
console.log(pc.red('\n ✗ Database reset failed.'))
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
console.log(pc.dim('\n Reset process finished.\n'))
|
|
157
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -15,3 +15,6 @@ export { onboard } from './commands/onboard.js'
|
|
|
15
15
|
export type { OnboardOptions } from './commands/onboard.js'
|
|
16
16
|
export { update } from './commands/update.js'
|
|
17
17
|
export type { UpdateOptions } from './commands/update.js'
|
|
18
|
+
export { reset } from './commands/reset.js'
|
|
19
|
+
export type { ResetOptions } from './commands/reset.js'
|
|
20
|
+
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
5
|
+
import { reset } from '../commands/reset.js'
|
|
6
|
+
import { spawnSync } from 'node:child_process'
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
8
|
+
|
|
9
|
+
vi.mock('node:child_process', () => ({
|
|
10
|
+
spawnSync: vi.fn(() => ({ status: 0 })),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
vi.mock('node:fs', () => ({
|
|
14
|
+
existsSync: vi.fn(),
|
|
15
|
+
rmSync: vi.fn(),
|
|
16
|
+
readFileSync: vi.fn(() => '{}'),
|
|
17
|
+
}))
|
|
18
|
+
|
|
19
|
+
vi.mock('picocolors', () => ({
|
|
20
|
+
default: {
|
|
21
|
+
cyan: (s: string) => s,
|
|
22
|
+
dim: (s: string) => s,
|
|
23
|
+
green: (s: string) => s,
|
|
24
|
+
red: (s: string) => s,
|
|
25
|
+
yellow: (s: string) => s,
|
|
26
|
+
},
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
describe('reset command', () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
vi.clearAllMocks()
|
|
32
|
+
vi.mocked(spawnSync).mockImplementation(() => ({ status: 0 } as any))
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('runs docker compose down when --docker is passed and docker is running', async () => {
|
|
36
|
+
await reset({ docker: true })
|
|
37
|
+
expect(spawnSync).toHaveBeenCalledWith(
|
|
38
|
+
'docker',
|
|
39
|
+
['compose', 'down', '-v'],
|
|
40
|
+
expect.any(Object)
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('does not run docker compose down when docker is not installed', async () => {
|
|
45
|
+
// Mock docker --version to fail
|
|
46
|
+
vi.mocked(spawnSync).mockImplementation((cmd: string, args?: any) => {
|
|
47
|
+
if (cmd === 'docker' && args && args[0] === '--version') {
|
|
48
|
+
return { status: 1 } as any
|
|
49
|
+
}
|
|
50
|
+
return { status: 0 } as any
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never)
|
|
54
|
+
try {
|
|
55
|
+
await reset({ docker: true })
|
|
56
|
+
expect(spawnSync).not.toHaveBeenCalledWith(
|
|
57
|
+
'docker',
|
|
58
|
+
['compose', 'down', '-v'],
|
|
59
|
+
expect.any(Object)
|
|
60
|
+
)
|
|
61
|
+
expect(mockExit).toHaveBeenCalledWith(1)
|
|
62
|
+
} finally {
|
|
63
|
+
mockExit.mockRestore()
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('does not run docker compose down when docker daemon is not running', async () => {
|
|
68
|
+
// Mock docker info to fail
|
|
69
|
+
vi.mocked(spawnSync).mockImplementation((cmd: string, args?: any) => {
|
|
70
|
+
if (cmd === 'docker' && args && args[0] === 'info') {
|
|
71
|
+
return { status: 1 } as any
|
|
72
|
+
}
|
|
73
|
+
return { status: 0 } as any
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never)
|
|
77
|
+
try {
|
|
78
|
+
await reset({ docker: true })
|
|
79
|
+
expect(spawnSync).not.toHaveBeenCalledWith(
|
|
80
|
+
'docker',
|
|
81
|
+
['compose', 'down', '-v'],
|
|
82
|
+
expect.any(Object)
|
|
83
|
+
)
|
|
84
|
+
expect(mockExit).toHaveBeenCalledWith(1)
|
|
85
|
+
} finally {
|
|
86
|
+
mockExit.mockRestore()
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('runs db reset using npm script when apps/api has package.json', async () => {
|
|
91
|
+
vi.mocked(existsSync).mockImplementation((path: any) => {
|
|
92
|
+
if (typeof path === 'string' && path.includes('apps') && path.includes('package.json')) {
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
return false
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
await reset({ db: true })
|
|
99
|
+
expect(spawnSync).toHaveBeenCalledWith(
|
|
100
|
+
'npm',
|
|
101
|
+
['run', 'db:reset:local'],
|
|
102
|
+
expect.objectContaining({
|
|
103
|
+
cwd: expect.stringMatching(/apps[/\\]api/),
|
|
104
|
+
})
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('runs both docker and db when --all is passed', async () => {
|
|
109
|
+
vi.mocked(existsSync).mockImplementation((path: any) => {
|
|
110
|
+
if (typeof path === 'string' && path.includes('apps') && path.includes('package.json')) {
|
|
111
|
+
return true
|
|
112
|
+
}
|
|
113
|
+
return false
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
await reset({ all: true })
|
|
117
|
+
expect(spawnSync).toHaveBeenCalledWith(
|
|
118
|
+
'docker',
|
|
119
|
+
['compose', 'down', '-v'],
|
|
120
|
+
expect.any(Object)
|
|
121
|
+
)
|
|
122
|
+
expect(spawnSync).toHaveBeenCalledWith(
|
|
123
|
+
'npm',
|
|
124
|
+
['run', 'db:reset:local'],
|
|
125
|
+
expect.any(Object)
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
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 { sortSeedsByDependencies } from '@beechcms/core'
|
|
6
|
+
import type { Seed } from '@beechcms/core'
|
|
7
|
+
import { sqlQuote } from '../lib/wrangler.js'
|
|
8
|
+
import { buildSeedRegistrationSql } from '../commands/seed-load.js'
|
|
9
|
+
|
|
10
|
+
// These seeds declare articles BEFORE team (arbitrary user order in seed.ts)
|
|
11
|
+
const TEAM_SEED: Seed = {
|
|
12
|
+
slug: 'team',
|
|
13
|
+
label: 'Team',
|
|
14
|
+
displayNameAlias: 'name',
|
|
15
|
+
branches: [{ alias: 'name', label: 'Name', type: 'text' }],
|
|
16
|
+
} as Seed
|
|
17
|
+
|
|
18
|
+
const ARTICLES_SEED: Seed = {
|
|
19
|
+
slug: 'articles',
|
|
20
|
+
label: 'Articles',
|
|
21
|
+
displayNameAlias: 'title',
|
|
22
|
+
branches: [
|
|
23
|
+
{ alias: 'title', label: 'Title', type: 'text' },
|
|
24
|
+
{ alias: 'author_id', label: 'Author', type: 'relation', targetSeed: 'team' },
|
|
25
|
+
],
|
|
26
|
+
} as Seed
|
|
27
|
+
|
|
28
|
+
describe('sortSeedsByDependencies — topological ordering', () => {
|
|
29
|
+
it('puts team before articles even when articles is declared first', () => {
|
|
30
|
+
// articles declared first — arbitrary insertion order
|
|
31
|
+
const sorted = sortSeedsByDependencies([ARTICLES_SEED, TEAM_SEED])
|
|
32
|
+
const slugs = sorted.map(s => s.slug)
|
|
33
|
+
expect(slugs.indexOf('team')).toBeLessThan(slugs.indexOf('articles'))
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('preserves order for seeds with no relations', () => {
|
|
37
|
+
const a = { slug: 'a', label: 'A', displayNameAlias: 'x', branches: [{ alias: 'x', label: 'X', type: 'text' }] } as Seed
|
|
38
|
+
const b = { slug: 'b', label: 'B', displayNameAlias: 'x', branches: [{ alias: 'x', label: 'X', type: 'text' }] } as Seed
|
|
39
|
+
const sorted = sortSeedsByDependencies([a, b])
|
|
40
|
+
expect(sorted).toHaveLength(2)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('throws on unknown targetSeed', () => {
|
|
44
|
+
const bad: Seed = {
|
|
45
|
+
slug: 'bad',
|
|
46
|
+
label: 'Bad',
|
|
47
|
+
displayNameAlias: 'title',
|
|
48
|
+
branches: [
|
|
49
|
+
{ alias: 'title', label: 'Title', type: 'text' },
|
|
50
|
+
{ alias: 'ref_id', label: 'Ref', type: 'relation', targetSeed: 'ghost' },
|
|
51
|
+
],
|
|
52
|
+
} as Seed
|
|
53
|
+
expect(() => sortSeedsByDependencies([bad])).toThrow(/unknown target|ghost/)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('throws on cyclic graph', () => {
|
|
57
|
+
const a = {
|
|
58
|
+
slug: 'a', label: 'A', displayNameAlias: 'x',
|
|
59
|
+
branches: [
|
|
60
|
+
{ alias: 'x', label: 'X', type: 'text' },
|
|
61
|
+
{ alias: 'b_id', label: 'B', type: 'relation', targetSeed: 'b' },
|
|
62
|
+
],
|
|
63
|
+
} as Seed
|
|
64
|
+
const b = {
|
|
65
|
+
slug: 'b', label: 'B', displayNameAlias: 'x',
|
|
66
|
+
branches: [
|
|
67
|
+
{ alias: 'x', label: 'X', type: 'text' },
|
|
68
|
+
{ alias: 'a_id', label: 'A', type: 'relation', targetSeed: 'a' },
|
|
69
|
+
],
|
|
70
|
+
} as Seed
|
|
71
|
+
expect(() => sortSeedsByDependencies([a, b])).toThrow(/[Cc]ycl/)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// ── sqlQuote ─────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
describe('sqlQuote', () => {
|
|
78
|
+
it('wraps value in single quotes', () => {
|
|
79
|
+
expect(sqlQuote('hello')).toBe("'hello'")
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("escapes internal single quotes by doubling them", () => {
|
|
83
|
+
expect(sqlQuote("it's")).toBe("'it''s'")
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('handles multiple single quotes', () => {
|
|
87
|
+
expect(sqlQuote("a'b'c")).toBe("'a''b''c'")
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('handles empty string', () => {
|
|
91
|
+
expect(sqlQuote('')).toBe("''")
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// ── buildSeedRegistrationSql ─────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
describe('buildSeedRegistrationSql', () => {
|
|
98
|
+
const SIMPLE_SEED: Seed = {
|
|
99
|
+
slug: 'posts',
|
|
100
|
+
label: 'Posts',
|
|
101
|
+
displayNameAlias: 'title',
|
|
102
|
+
branches: [{ id: 'br_01', alias: 'title', label: 'Title', type: 'text' }],
|
|
103
|
+
} as Seed
|
|
104
|
+
|
|
105
|
+
it('produces INSERT … ON CONFLICT for the correct slug', () => {
|
|
106
|
+
const sql = buildSeedRegistrationSql(SIMPLE_SEED)
|
|
107
|
+
expect(sql).toContain("INSERT INTO seeds")
|
|
108
|
+
expect(sql).toContain("ON CONFLICT(slug) DO UPDATE SET")
|
|
109
|
+
expect(sql).toContain("'posts'")
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("sets source to 'code'", () => {
|
|
113
|
+
const sql = buildSeedRegistrationSql(SIMPLE_SEED)
|
|
114
|
+
expect(sql).toContain("'code'")
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('escapes single quotes in slug and JSON literal', () => {
|
|
118
|
+
const seedWithApostrophe: Seed = {
|
|
119
|
+
...SIMPLE_SEED,
|
|
120
|
+
slug: "it's",
|
|
121
|
+
label: "It's",
|
|
122
|
+
}
|
|
123
|
+
const sql = buildSeedRegistrationSql(seedWithApostrophe)
|
|
124
|
+
// Slug value must have its single quote doubled
|
|
125
|
+
expect(sql).toContain("'it''s'")
|
|
126
|
+
// JSON label must also have its single quote doubled
|
|
127
|
+
expect(sql).toContain("It''s")
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('does not contain unescaped single quotes inside the JSON literal', () => {
|
|
131
|
+
const sql = buildSeedRegistrationSql(SIMPLE_SEED)
|
|
132
|
+
const jsonStart = sql.indexOf("VALUES (")
|
|
133
|
+
const jsonPart = sql.slice(jsonStart)
|
|
134
|
+
// Extract the JSON literal between the second pair of outer quotes
|
|
135
|
+
// Verify it round-trips back to the original seed
|
|
136
|
+
const inner = JSON.stringify(SIMPLE_SEED).replace(/'/g, "''")
|
|
137
|
+
expect(sql).toContain(inner)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// ── Dry-run output ordering ───────────────────────────────────────────────
|
|
142
|
+
// Verify that seed-load uses sortSeedsByDependencies (not Object.values order)
|
|
143
|
+
// by testing the pure function behavior that underpins it.
|
|
144
|
+
|
|
145
|
+
describe('seed-load dry-run ordering contract', () => {
|
|
146
|
+
it('content_team CREATE TABLE appears before content_articles when articles declared first', () => {
|
|
147
|
+
// The dry-run loops over sortSeedsByDependencies(Object.values(registry)).
|
|
148
|
+
// We verify the sort result here — the integration is in seed-load.ts.
|
|
149
|
+
const registry = {
|
|
150
|
+
articles: ARTICLES_SEED, // declared first
|
|
151
|
+
team: TEAM_SEED,
|
|
152
|
+
}
|
|
153
|
+
const sorted = sortSeedsByDependencies(Object.values(registry))
|
|
154
|
+
const slugs = sorted.map(s => s.slug)
|
|
155
|
+
// team must come first so its CREATE TABLE is emitted before articles'
|
|
156
|
+
expect(slugs.indexOf('team')).toBeLessThan(slugs.indexOf('articles'))
|
|
157
|
+
})
|
|
158
|
+
})
|