popeye-cli 1.7.0 → 1.9.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/README.md +148 -7
- package/cheatsheet.md +440 -0
- package/dist/cli/commands/db.d.ts +10 -0
- package/dist/cli/commands/db.d.ts.map +1 -0
- package/dist/cli/commands/db.js +240 -0
- package/dist/cli/commands/db.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +18 -0
- package/dist/cli/commands/doctor.d.ts.map +1 -0
- package/dist/cli/commands/doctor.js +255 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/index.d.ts +3 -0
- package/dist/cli/commands/index.d.ts.map +1 -1
- package/dist/cli/commands/index.js +3 -0
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/cli/commands/review.d.ts +31 -0
- package/dist/cli/commands/review.d.ts.map +1 -0
- package/dist/cli/commands/review.js +156 -0
- package/dist/cli/commands/review.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +4 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/interactive.d.ts.map +1 -1
- package/dist/cli/interactive.js +218 -61
- package/dist/cli/interactive.js.map +1 -1
- package/dist/generators/admin-wizard.d.ts +25 -0
- package/dist/generators/admin-wizard.d.ts.map +1 -0
- package/dist/generators/admin-wizard.js +123 -0
- package/dist/generators/admin-wizard.js.map +1 -0
- package/dist/generators/all.d.ts.map +1 -1
- package/dist/generators/all.js +10 -3
- package/dist/generators/all.js.map +1 -1
- package/dist/generators/database.d.ts +58 -0
- package/dist/generators/database.d.ts.map +1 -0
- package/dist/generators/database.js +229 -0
- package/dist/generators/database.js.map +1 -0
- package/dist/generators/fullstack.d.ts.map +1 -1
- package/dist/generators/fullstack.js +23 -7
- package/dist/generators/fullstack.js.map +1 -1
- package/dist/generators/index.d.ts +2 -0
- package/dist/generators/index.d.ts.map +1 -1
- package/dist/generators/index.js +2 -0
- package/dist/generators/index.js.map +1 -1
- package/dist/generators/templates/admin-wizard-python.d.ts +32 -0
- package/dist/generators/templates/admin-wizard-python.d.ts.map +1 -0
- package/dist/generators/templates/admin-wizard-python.js +425 -0
- package/dist/generators/templates/admin-wizard-python.js.map +1 -0
- package/dist/generators/templates/admin-wizard-react.d.ts +48 -0
- package/dist/generators/templates/admin-wizard-react.d.ts.map +1 -0
- package/dist/generators/templates/admin-wizard-react.js +554 -0
- package/dist/generators/templates/admin-wizard-react.js.map +1 -0
- package/dist/generators/templates/database-docker.d.ts +23 -0
- package/dist/generators/templates/database-docker.d.ts.map +1 -0
- package/dist/generators/templates/database-docker.js +221 -0
- package/dist/generators/templates/database-docker.js.map +1 -0
- package/dist/generators/templates/database-python.d.ts +54 -0
- package/dist/generators/templates/database-python.d.ts.map +1 -0
- package/dist/generators/templates/database-python.js +723 -0
- package/dist/generators/templates/database-python.js.map +1 -0
- package/dist/generators/templates/database-typescript.d.ts +34 -0
- package/dist/generators/templates/database-typescript.d.ts.map +1 -0
- package/dist/generators/templates/database-typescript.js +232 -0
- package/dist/generators/templates/database-typescript.js.map +1 -0
- package/dist/generators/templates/fullstack.d.ts.map +1 -1
- package/dist/generators/templates/fullstack.js +29 -0
- package/dist/generators/templates/fullstack.js.map +1 -1
- package/dist/generators/templates/index.d.ts +5 -0
- package/dist/generators/templates/index.d.ts.map +1 -1
- package/dist/generators/templates/index.js +5 -0
- package/dist/generators/templates/index.js.map +1 -1
- package/dist/state/index.d.ts +10 -0
- package/dist/state/index.d.ts.map +1 -1
- package/dist/state/index.js +21 -0
- package/dist/state/index.js.map +1 -1
- package/dist/types/audit.d.ts +623 -0
- package/dist/types/audit.d.ts.map +1 -0
- package/dist/types/audit.js +240 -0
- package/dist/types/audit.js.map +1 -0
- package/dist/types/database-runtime.d.ts +86 -0
- package/dist/types/database-runtime.d.ts.map +1 -0
- package/dist/types/database-runtime.js +61 -0
- package/dist/types/database-runtime.js.map +1 -0
- package/dist/types/database.d.ts +85 -0
- package/dist/types/database.d.ts.map +1 -0
- package/dist/types/database.js +71 -0
- package/dist/types/database.js.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +4 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/workflow.d.ts +36 -0
- package/dist/types/workflow.d.ts.map +1 -1
- package/dist/types/workflow.js +7 -0
- package/dist/types/workflow.js.map +1 -1
- package/dist/workflow/audit-analyzer.d.ts +58 -0
- package/dist/workflow/audit-analyzer.d.ts.map +1 -0
- package/dist/workflow/audit-analyzer.js +420 -0
- package/dist/workflow/audit-analyzer.js.map +1 -0
- package/dist/workflow/audit-mode.d.ts +28 -0
- package/dist/workflow/audit-mode.d.ts.map +1 -0
- package/dist/workflow/audit-mode.js +169 -0
- package/dist/workflow/audit-mode.js.map +1 -0
- package/dist/workflow/audit-recovery.d.ts +61 -0
- package/dist/workflow/audit-recovery.d.ts.map +1 -0
- package/dist/workflow/audit-recovery.js +242 -0
- package/dist/workflow/audit-recovery.js.map +1 -0
- package/dist/workflow/audit-reporter.d.ts +65 -0
- package/dist/workflow/audit-reporter.d.ts.map +1 -0
- package/dist/workflow/audit-reporter.js +301 -0
- package/dist/workflow/audit-reporter.js.map +1 -0
- package/dist/workflow/audit-scanner.d.ts +87 -0
- package/dist/workflow/audit-scanner.d.ts.map +1 -0
- package/dist/workflow/audit-scanner.js +768 -0
- package/dist/workflow/audit-scanner.js.map +1 -0
- package/dist/workflow/db-setup-runner.d.ts +63 -0
- package/dist/workflow/db-setup-runner.d.ts.map +1 -0
- package/dist/workflow/db-setup-runner.js +336 -0
- package/dist/workflow/db-setup-runner.js.map +1 -0
- package/dist/workflow/db-state-machine.d.ts +30 -0
- package/dist/workflow/db-state-machine.d.ts.map +1 -0
- package/dist/workflow/db-state-machine.js +51 -0
- package/dist/workflow/db-state-machine.js.map +1 -0
- package/dist/workflow/index.d.ts +7 -0
- package/dist/workflow/index.d.ts.map +1 -1
- package/dist/workflow/index.js +7 -0
- package/dist/workflow/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/db.ts +281 -0
- package/src/cli/commands/doctor.ts +273 -0
- package/src/cli/commands/index.ts +3 -0
- package/src/cli/commands/review.ts +187 -0
- package/src/cli/index.ts +6 -0
- package/src/cli/interactive.ts +174 -4
- package/src/generators/admin-wizard.ts +146 -0
- package/src/generators/all.ts +10 -3
- package/src/generators/database.ts +286 -0
- package/src/generators/fullstack.ts +26 -9
- package/src/generators/index.ts +12 -0
- package/src/generators/templates/admin-wizard-python.ts +431 -0
- package/src/generators/templates/admin-wizard-react.ts +560 -0
- package/src/generators/templates/database-docker.ts +227 -0
- package/src/generators/templates/database-python.ts +734 -0
- package/src/generators/templates/database-typescript.ts +238 -0
- package/src/generators/templates/fullstack.ts +29 -0
- package/src/generators/templates/index.ts +5 -0
- package/src/state/index.ts +28 -0
- package/src/types/audit.ts +294 -0
- package/src/types/database-runtime.ts +69 -0
- package/src/types/database.ts +84 -0
- package/src/types/index.ts +29 -0
- package/src/types/workflow.ts +20 -0
- package/src/workflow/audit-analyzer.ts +491 -0
- package/src/workflow/audit-mode.ts +240 -0
- package/src/workflow/audit-recovery.ts +284 -0
- package/src/workflow/audit-reporter.ts +370 -0
- package/src/workflow/audit-scanner.ts +873 -0
- package/src/workflow/db-setup-runner.ts +391 -0
- package/src/workflow/db-state-machine.ts +58 -0
- package/src/workflow/index.ts +7 -0
- package/tests/cli/commands/review.test.ts +52 -0
- package/tests/generators/admin-wizard-orchestrator.test.ts +64 -0
- package/tests/generators/admin-wizard-templates.test.ts +366 -0
- package/tests/generators/cross-phase-integration.test.ts +383 -0
- package/tests/generators/database.test.ts +456 -0
- package/tests/generators/fe-be-db-integration.test.ts +613 -0
- package/tests/types/audit.test.ts +250 -0
- package/tests/types/database-runtime.test.ts +158 -0
- package/tests/types/database.test.ts +187 -0
- package/tests/workflow/audit-analyzer.test.ts +281 -0
- package/tests/workflow/audit-mode.test.ts +114 -0
- package/tests/workflow/audit-recovery.test.ts +237 -0
- package/tests/workflow/audit-reporter.test.ts +254 -0
- package/tests/workflow/audit-scanner.test.ts +270 -0
- package/tests/workflow/db-setup-runner.test.ts +211 -0
- package/tests/workflow/db-state-machine.test.ts +117 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database setup pipeline runner
|
|
3
|
+
* Executes sequential steps to configure, migrate, and verify a database
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { promises as fs } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { exec } from 'node:child_process';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
import type { DbStatus, DbSetupStep } from '../types/database.js';
|
|
11
|
+
import type { SetupStepResult, SetupResult } from '../types/database-runtime.js';
|
|
12
|
+
import { transitionDbStatus } from './db-state-machine.js';
|
|
13
|
+
|
|
14
|
+
const execAsync = promisify(exec);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Options for the setup pipeline
|
|
18
|
+
*/
|
|
19
|
+
export interface SetupPipelineOptions {
|
|
20
|
+
/** Skip seed step */
|
|
21
|
+
skipSeed?: boolean;
|
|
22
|
+
/** Callback for step progress */
|
|
23
|
+
onStep?: (step: DbSetupStep, status: 'start' | 'success' | 'fail', message: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read and parse a .env file for key=value pairs
|
|
28
|
+
*
|
|
29
|
+
* @param envPath - Path to .env file
|
|
30
|
+
* @returns Map of environment variable key-value pairs
|
|
31
|
+
*/
|
|
32
|
+
export async function readEnvFile(envPath: string): Promise<Record<string, string>> {
|
|
33
|
+
const result: Record<string, string> = {};
|
|
34
|
+
try {
|
|
35
|
+
const content = await fs.readFile(envPath, 'utf-8');
|
|
36
|
+
for (const line of content.split('\n')) {
|
|
37
|
+
const trimmed = line.trim();
|
|
38
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
39
|
+
const eqIndex = trimmed.indexOf('=');
|
|
40
|
+
if (eqIndex === -1) continue;
|
|
41
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
42
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
43
|
+
// Strip surrounding quotes
|
|
44
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
45
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
46
|
+
value = value.slice(1, -1);
|
|
47
|
+
}
|
|
48
|
+
result[key] = value;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// File doesn't exist or isn't readable
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Scan migration files for prerequisite extension comments
|
|
58
|
+
* Looks for lines like: # popeye:requires_extension=vector
|
|
59
|
+
*
|
|
60
|
+
* @param migrationsDir - Path to migrations/versions/ directory
|
|
61
|
+
* @returns Array of required extension names
|
|
62
|
+
*/
|
|
63
|
+
export async function parseMigrationPrereqs(migrationsDir: string): Promise<string[]> {
|
|
64
|
+
const extensions: string[] = [];
|
|
65
|
+
const versionsDir = path.join(migrationsDir, 'versions');
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const files = await fs.readdir(versionsDir);
|
|
69
|
+
for (const file of files) {
|
|
70
|
+
if (!file.endsWith('.py')) continue;
|
|
71
|
+
const content = await fs.readFile(path.join(versionsDir, file), 'utf-8');
|
|
72
|
+
const matches = content.matchAll(/# popeye:requires_extension=(\w+)/g);
|
|
73
|
+
for (const match of matches) {
|
|
74
|
+
if (!extensions.includes(match[1])) {
|
|
75
|
+
extensions.push(match[1]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Directory doesn't exist or isn't readable
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return extensions;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Derive the snake_case package name from the project state
|
|
88
|
+
*
|
|
89
|
+
* @param projectDir - Project root directory
|
|
90
|
+
* @returns Python package name
|
|
91
|
+
*/
|
|
92
|
+
export async function getPackageName(projectDir: string): Promise<string> {
|
|
93
|
+
try {
|
|
94
|
+
const statePath = path.join(projectDir, '.popeye', 'state.json');
|
|
95
|
+
const content = await fs.readFile(statePath, 'utf-8');
|
|
96
|
+
const state = JSON.parse(content);
|
|
97
|
+
const name: string = state.name || 'project';
|
|
98
|
+
return name.toLowerCase().replace(/-/g, '_').replace(/[^a-z0-9_]/g, '');
|
|
99
|
+
} catch {
|
|
100
|
+
return 'backend';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the backend directory path
|
|
106
|
+
*
|
|
107
|
+
* @param projectDir - Project root directory
|
|
108
|
+
* @returns Absolute path to apps/backend
|
|
109
|
+
*/
|
|
110
|
+
export function resolveBackendDir(projectDir: string): string {
|
|
111
|
+
return path.join(projectDir, 'apps', 'backend');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Execute a single pipeline step and track timing
|
|
116
|
+
*/
|
|
117
|
+
async function executeStep(
|
|
118
|
+
step: DbSetupStep,
|
|
119
|
+
fn: () => Promise<string>,
|
|
120
|
+
options?: SetupPipelineOptions
|
|
121
|
+
): Promise<SetupStepResult> {
|
|
122
|
+
options?.onStep?.(step, 'start', `Starting ${step}...`);
|
|
123
|
+
const start = Date.now();
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const message = await fn();
|
|
127
|
+
const durationMs = Date.now() - start;
|
|
128
|
+
options?.onStep?.(step, 'success', message);
|
|
129
|
+
return { step, success: true, message, durationMs };
|
|
130
|
+
} catch (err) {
|
|
131
|
+
const durationMs = Date.now() - start;
|
|
132
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
133
|
+
options?.onStep?.(step, 'fail', error);
|
|
134
|
+
return { step, success: false, message: `Step failed: ${step}`, durationMs, error };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Step 1: Check database connection
|
|
140
|
+
*/
|
|
141
|
+
async function checkConnection(backendDir: string): Promise<string> {
|
|
142
|
+
const env = await readEnvFile(path.join(backendDir, '.env'));
|
|
143
|
+
const dbUrl = env['DATABASE_URL'] || '';
|
|
144
|
+
|
|
145
|
+
if (!dbUrl) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
'DATABASE_URL not found in apps/backend/.env. ' +
|
|
148
|
+
'Run "popeye db configure" to set it up.'
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Test connectivity using Python asyncpg
|
|
153
|
+
const cmd = `cd "${backendDir}" && python3 -c "
|
|
154
|
+
import asyncio, asyncpg, os
|
|
155
|
+
async def check():
|
|
156
|
+
conn = await asyncpg.connect('${dbUrl.replace(/'/g, "\\'")}')
|
|
157
|
+
await conn.execute('SELECT 1')
|
|
158
|
+
await conn.close()
|
|
159
|
+
asyncio.run(check())
|
|
160
|
+
"`;
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
await execAsync(cmd, { timeout: 15000 });
|
|
164
|
+
return 'Database connection verified successfully';
|
|
165
|
+
} catch (err) {
|
|
166
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
167
|
+
throw new Error(`Database connection failed: ${msg}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Step 2: Ensure required extensions
|
|
173
|
+
*/
|
|
174
|
+
async function ensureExtensions(backendDir: string): Promise<string> {
|
|
175
|
+
const migrationsDir = path.join(backendDir, 'migrations');
|
|
176
|
+
const extensions = await parseMigrationPrereqs(migrationsDir);
|
|
177
|
+
|
|
178
|
+
if (extensions.length === 0) {
|
|
179
|
+
return 'No prerequisite extensions required';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const env = await readEnvFile(path.join(backendDir, '.env'));
|
|
183
|
+
const dbUrl = env['DATABASE_URL'] || '';
|
|
184
|
+
|
|
185
|
+
for (const ext of extensions) {
|
|
186
|
+
const cmd = `cd "${backendDir}" && python3 -c "
|
|
187
|
+
import asyncio, asyncpg
|
|
188
|
+
async def ensure():
|
|
189
|
+
conn = await asyncpg.connect('${dbUrl.replace(/'/g, "\\'")}')
|
|
190
|
+
await conn.execute('CREATE EXTENSION IF NOT EXISTS ${ext}')
|
|
191
|
+
await conn.close()
|
|
192
|
+
asyncio.run(ensure())
|
|
193
|
+
"`;
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
await execAsync(cmd, { timeout: 15000 });
|
|
197
|
+
} catch (err) {
|
|
198
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
199
|
+
throw new Error(`Failed to create extension '${ext}': ${msg}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return `Extensions verified: ${extensions.join(', ')}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Step 3: Apply Alembic migrations
|
|
208
|
+
*/
|
|
209
|
+
async function applyMigrations(backendDir: string): Promise<string> {
|
|
210
|
+
const cmd = `cd "${backendDir}" && alembic upgrade head 2>&1`;
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const { stdout } = await execAsync(cmd, { timeout: 60000 });
|
|
214
|
+
// Count applied migrations from output
|
|
215
|
+
const appliedMatches = stdout.match(/Running upgrade/g);
|
|
216
|
+
const count = appliedMatches ? appliedMatches.length : 0;
|
|
217
|
+
return count > 0
|
|
218
|
+
? `Applied ${count} migration(s) successfully`
|
|
219
|
+
: 'All migrations already up to date';
|
|
220
|
+
} catch (err) {
|
|
221
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
222
|
+
throw new Error(`Migration failed: ${msg}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Step 4: Run seed script (if exists)
|
|
228
|
+
*/
|
|
229
|
+
async function seedMinimal(backendDir: string): Promise<string> {
|
|
230
|
+
const seedPaths = [
|
|
231
|
+
path.join(backendDir, 'scripts', 'seed.py'),
|
|
232
|
+
path.join(backendDir, 'seed.py'),
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
let seedPath: string | null = null;
|
|
236
|
+
for (const p of seedPaths) {
|
|
237
|
+
try {
|
|
238
|
+
await fs.access(p);
|
|
239
|
+
seedPath = p;
|
|
240
|
+
break;
|
|
241
|
+
} catch {
|
|
242
|
+
// Try next
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!seedPath) {
|
|
247
|
+
return 'No seed script found (skipped)';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const cmd = `cd "${backendDir}" && python3 "${seedPath}" 2>&1`;
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
await execAsync(cmd, { timeout: 30000 });
|
|
254
|
+
return 'Seed script executed successfully';
|
|
255
|
+
} catch (err) {
|
|
256
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
257
|
+
throw new Error(`Seed script failed: ${msg}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Step 5: Run readiness tests
|
|
263
|
+
*/
|
|
264
|
+
async function runReadinessTests(backendDir: string): Promise<string> {
|
|
265
|
+
const env = await readEnvFile(path.join(backendDir, '.env'));
|
|
266
|
+
const dbUrl = env['DATABASE_URL'] || '';
|
|
267
|
+
|
|
268
|
+
const cmd = `cd "${backendDir}" && python3 -c "
|
|
269
|
+
import asyncio, asyncpg
|
|
270
|
+
async def check():
|
|
271
|
+
conn = await asyncpg.connect('${dbUrl.replace(/'/g, "\\'")}')
|
|
272
|
+
# Verify connectivity
|
|
273
|
+
await conn.execute('SELECT 1')
|
|
274
|
+
# Verify alembic_version table exists and has a version
|
|
275
|
+
row = await conn.fetchrow('SELECT version_num FROM alembic_version LIMIT 1')
|
|
276
|
+
if row is None:
|
|
277
|
+
raise Exception('No migration version found in alembic_version table')
|
|
278
|
+
await conn.close()
|
|
279
|
+
return row['version_num']
|
|
280
|
+
asyncio.run(check())
|
|
281
|
+
"`;
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
await execAsync(cmd, { timeout: 15000 });
|
|
285
|
+
return 'Readiness tests passed: connectivity and migration version verified';
|
|
286
|
+
} catch (err) {
|
|
287
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
288
|
+
throw new Error(`Readiness tests failed: ${msg}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Run the complete database setup pipeline
|
|
294
|
+
*
|
|
295
|
+
* Steps execute sequentially. Pipeline stops on first failure.
|
|
296
|
+
*
|
|
297
|
+
* @param projectDir - Project root directory
|
|
298
|
+
* @param options - Pipeline options
|
|
299
|
+
* @returns Full pipeline result
|
|
300
|
+
*/
|
|
301
|
+
export async function runDbSetupPipeline(
|
|
302
|
+
projectDir: string,
|
|
303
|
+
options: SetupPipelineOptions = {}
|
|
304
|
+
): Promise<SetupResult> {
|
|
305
|
+
const backendDir = resolveBackendDir(projectDir);
|
|
306
|
+
const steps: SetupStepResult[] = [];
|
|
307
|
+
const pipelineStart = Date.now();
|
|
308
|
+
|
|
309
|
+
// Step 1: Check connection
|
|
310
|
+
const connResult = await executeStep('check_connection', () => checkConnection(backendDir), options);
|
|
311
|
+
steps.push(connResult);
|
|
312
|
+
if (!connResult.success) {
|
|
313
|
+
return buildResult(steps, pipelineStart, 'error');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Step 2: Ensure extensions
|
|
317
|
+
const extResult = await executeStep('ensure_extensions', () => ensureExtensions(backendDir), options);
|
|
318
|
+
steps.push(extResult);
|
|
319
|
+
if (!extResult.success) {
|
|
320
|
+
return buildResult(steps, pipelineStart, 'error');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Step 3: Apply migrations
|
|
324
|
+
const migResult = await executeStep('apply_migrations', () => applyMigrations(backendDir), options);
|
|
325
|
+
steps.push(migResult);
|
|
326
|
+
if (!migResult.success) {
|
|
327
|
+
return buildResult(steps, pipelineStart, 'error');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Step 4: Seed (optional)
|
|
331
|
+
if (!options.skipSeed) {
|
|
332
|
+
const seedResult = await executeStep('seed_minimal', () => seedMinimal(backendDir), options);
|
|
333
|
+
steps.push(seedResult);
|
|
334
|
+
if (!seedResult.success) {
|
|
335
|
+
return buildResult(steps, pipelineStart, 'error');
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Step 5: Readiness tests
|
|
340
|
+
const readyResult = await executeStep('readiness_tests', () => runReadinessTests(backendDir), options);
|
|
341
|
+
steps.push(readyResult);
|
|
342
|
+
if (!readyResult.success) {
|
|
343
|
+
return buildResult(steps, pipelineStart, 'error');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Step 6: Mark ready (always succeeds if we got here)
|
|
347
|
+
const markResult = await executeStep('mark_ready', async () => {
|
|
348
|
+
return 'Database marked as ready';
|
|
349
|
+
}, options);
|
|
350
|
+
steps.push(markResult);
|
|
351
|
+
|
|
352
|
+
return buildResult(steps, pipelineStart, 'ready');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Build a SetupResult from accumulated steps
|
|
357
|
+
*/
|
|
358
|
+
function buildResult(
|
|
359
|
+
steps: SetupStepResult[],
|
|
360
|
+
pipelineStart: number,
|
|
361
|
+
finalStatus: DbStatus
|
|
362
|
+
): SetupResult {
|
|
363
|
+
const totalDurationMs = Date.now() - pipelineStart;
|
|
364
|
+
const success = finalStatus === 'ready';
|
|
365
|
+
const failedStep = steps.find((s) => !s.success);
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
success,
|
|
369
|
+
steps,
|
|
370
|
+
totalDurationMs,
|
|
371
|
+
finalStatus,
|
|
372
|
+
error: failedStep?.error,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Compute the new DB status after a pipeline run and validate the transition
|
|
378
|
+
*
|
|
379
|
+
* @param currentStatus - Current DB status from state
|
|
380
|
+
* @param pipelineResult - Result of the pipeline run
|
|
381
|
+
* @returns New DB status after validated transition
|
|
382
|
+
*/
|
|
383
|
+
export function computePostPipelineStatus(
|
|
384
|
+
currentStatus: DbStatus,
|
|
385
|
+
pipelineResult: SetupResult
|
|
386
|
+
): DbStatus {
|
|
387
|
+
// Transition to 'applying' first
|
|
388
|
+
const applying = transitionDbStatus(currentStatus, 'applying');
|
|
389
|
+
// Then transition to final status
|
|
390
|
+
return transitionDbStatus(applying, pipelineResult.finalStatus);
|
|
391
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database lifecycle state machine
|
|
3
|
+
* Enforces valid transitions between DB status states
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { DbStatus } from '../types/database.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Valid state transitions map
|
|
10
|
+
* Each key maps to an array of valid target states
|
|
11
|
+
*/
|
|
12
|
+
const VALID_TRANSITIONS: Record<DbStatus, DbStatus[]> = {
|
|
13
|
+
unconfigured: ['configured'],
|
|
14
|
+
configured: ['applying', 'unconfigured'],
|
|
15
|
+
applying: ['ready', 'error'],
|
|
16
|
+
ready: ['configured', 'unconfigured'],
|
|
17
|
+
error: ['configured', 'unconfigured'],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check if a transition from current to target is valid
|
|
22
|
+
*
|
|
23
|
+
* @param current - Current DB status
|
|
24
|
+
* @param target - Target DB status
|
|
25
|
+
* @returns True if the transition is allowed
|
|
26
|
+
*/
|
|
27
|
+
export function canTransition(current: DbStatus, target: DbStatus): boolean {
|
|
28
|
+
const allowed = VALID_TRANSITIONS[current];
|
|
29
|
+
return allowed !== undefined && allowed.includes(target);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate and execute a state transition
|
|
34
|
+
*
|
|
35
|
+
* @param current - Current DB status
|
|
36
|
+
* @param target - Desired target status
|
|
37
|
+
* @returns The new status
|
|
38
|
+
* @throws Error if the transition is invalid
|
|
39
|
+
*/
|
|
40
|
+
export function transitionDbStatus(current: DbStatus, target: DbStatus): DbStatus {
|
|
41
|
+
if (!canTransition(current, target)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Invalid DB status transition: '${current}' -> '${target}'. ` +
|
|
44
|
+
`Allowed transitions from '${current}': [${getAvailableTransitions(current).join(', ')}]`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return target;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get the list of valid next states from the current status
|
|
52
|
+
*
|
|
53
|
+
* @param current - Current DB status
|
|
54
|
+
* @returns Array of valid target states
|
|
55
|
+
*/
|
|
56
|
+
export function getAvailableTransitions(current: DbStatus): DbStatus[] {
|
|
57
|
+
return VALID_TRANSITIONS[current] || [];
|
|
58
|
+
}
|
package/src/workflow/index.ts
CHANGED
|
@@ -55,6 +55,13 @@ export * from './workspace-manager.js';
|
|
|
55
55
|
export * from './website-updater.js';
|
|
56
56
|
export * from './website-strategy.js';
|
|
57
57
|
export * from './overview.js';
|
|
58
|
+
export * from './db-state-machine.js';
|
|
59
|
+
export * from './db-setup-runner.js';
|
|
60
|
+
export * from './audit-scanner.js';
|
|
61
|
+
export * from './audit-analyzer.js';
|
|
62
|
+
export * from './audit-reporter.js';
|
|
63
|
+
export * from './audit-recovery.js';
|
|
64
|
+
export * from './audit-mode.js';
|
|
58
65
|
|
|
59
66
|
/**
|
|
60
67
|
* Workflow options
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the review CLI command.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import { createReviewCommand } from '../../../src/cli/commands/review.js';
|
|
6
|
+
|
|
7
|
+
describe('createReviewCommand', () => {
|
|
8
|
+
it('should create a command named review', () => {
|
|
9
|
+
const cmd = createReviewCommand();
|
|
10
|
+
expect(cmd.name()).toBe('review');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should have audit as an alias', () => {
|
|
14
|
+
const cmd = createReviewCommand();
|
|
15
|
+
expect(cmd.aliases()).toContain('audit');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should accept the depth option', () => {
|
|
19
|
+
const cmd = createReviewCommand();
|
|
20
|
+
const depthOpt = cmd.options.find((o) => o.long === '--depth');
|
|
21
|
+
expect(depthOpt).toBeDefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should accept the strict option', () => {
|
|
25
|
+
const cmd = createReviewCommand();
|
|
26
|
+
const strictOpt = cmd.options.find((o) => o.long === '--strict');
|
|
27
|
+
expect(strictOpt).toBeDefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should accept the format option', () => {
|
|
31
|
+
const cmd = createReviewCommand();
|
|
32
|
+
const formatOpt = cmd.options.find((o) => o.long === '--format');
|
|
33
|
+
expect(formatOpt).toBeDefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should accept the no-recover option', () => {
|
|
37
|
+
const cmd = createReviewCommand();
|
|
38
|
+
const recoverOpt = cmd.options.find((o) => o.long === '--no-recover');
|
|
39
|
+
expect(recoverOpt).toBeDefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should accept the target option', () => {
|
|
43
|
+
const cmd = createReviewCommand();
|
|
44
|
+
const targetOpt = cmd.options.find((o) => o.long === '--target');
|
|
45
|
+
expect(targetOpt).toBeDefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should have a description', () => {
|
|
49
|
+
const cmd = createReviewCommand();
|
|
50
|
+
expect(cmd.description()).toBeTruthy();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for admin wizard orchestrator (file list and dependencies)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
getAdminWizardFiles,
|
|
9
|
+
ADMIN_WIZARD_PYTHON_DEPS,
|
|
10
|
+
} from '../../src/generators/admin-wizard.js';
|
|
11
|
+
|
|
12
|
+
const TEST_PACKAGE = 'my_project';
|
|
13
|
+
|
|
14
|
+
describe('getAdminWizardFiles', () => {
|
|
15
|
+
const files = getAdminWizardFiles(TEST_PACKAGE);
|
|
16
|
+
|
|
17
|
+
it('should return backend middleware files', () => {
|
|
18
|
+
expect(files).toContain(`apps/backend/src/${TEST_PACKAGE}/middleware/__init__.py`);
|
|
19
|
+
expect(files).toContain(`apps/backend/src/${TEST_PACKAGE}/middleware/admin_auth.py`);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should return backend admin route file', () => {
|
|
23
|
+
expect(files).toContain(`apps/backend/src/${TEST_PACKAGE}/routes/admin_db.py`);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should return frontend admin component files', () => {
|
|
27
|
+
expect(files).toContain('apps/frontend/src/admin/useAdminApi.ts');
|
|
28
|
+
expect(files).toContain('apps/frontend/src/admin/DbStatusBanner.tsx');
|
|
29
|
+
expect(files).toContain('apps/frontend/src/admin/ConnectionForm.tsx');
|
|
30
|
+
expect(files).toContain('apps/frontend/src/admin/MigrationProgress.tsx');
|
|
31
|
+
expect(files).toContain('apps/frontend/src/admin/DbSetupStepper.tsx');
|
|
32
|
+
expect(files).toContain('apps/frontend/src/admin/index.ts');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should have correct number of files', () => {
|
|
36
|
+
expect(files).toHaveLength(9);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should use correct package name prefix for backend files', () => {
|
|
40
|
+
const customFiles = getAdminWizardFiles('acme_app');
|
|
41
|
+
const backendFiles = customFiles.filter((f) => f.startsWith('apps/backend/'));
|
|
42
|
+
for (const f of backendFiles) {
|
|
43
|
+
expect(f).toContain('acme_app');
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should use apps/frontend prefix for frontend files', () => {
|
|
48
|
+
const frontendFiles = files.filter((f) => f.startsWith('apps/frontend/'));
|
|
49
|
+
expect(frontendFiles).toHaveLength(6);
|
|
50
|
+
for (const f of frontendFiles) {
|
|
51
|
+
expect(f).toContain('src/admin/');
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('ADMIN_WIZARD_PYTHON_DEPS', () => {
|
|
57
|
+
it('should include python-multipart', () => {
|
|
58
|
+
expect(ADMIN_WIZARD_PYTHON_DEPS).toContain('python-multipart>=0.0.7');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should be a non-empty array', () => {
|
|
62
|
+
expect(ADMIN_WIZARD_PYTHON_DEPS.length).toBeGreaterThan(0);
|
|
63
|
+
});
|
|
64
|
+
});
|