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.
Files changed (174) hide show
  1. package/README.md +148 -7
  2. package/cheatsheet.md +440 -0
  3. package/dist/cli/commands/db.d.ts +10 -0
  4. package/dist/cli/commands/db.d.ts.map +1 -0
  5. package/dist/cli/commands/db.js +240 -0
  6. package/dist/cli/commands/db.js.map +1 -0
  7. package/dist/cli/commands/doctor.d.ts +18 -0
  8. package/dist/cli/commands/doctor.d.ts.map +1 -0
  9. package/dist/cli/commands/doctor.js +255 -0
  10. package/dist/cli/commands/doctor.js.map +1 -0
  11. package/dist/cli/commands/index.d.ts +3 -0
  12. package/dist/cli/commands/index.d.ts.map +1 -1
  13. package/dist/cli/commands/index.js +3 -0
  14. package/dist/cli/commands/index.js.map +1 -1
  15. package/dist/cli/commands/review.d.ts +31 -0
  16. package/dist/cli/commands/review.d.ts.map +1 -0
  17. package/dist/cli/commands/review.js +156 -0
  18. package/dist/cli/commands/review.js.map +1 -0
  19. package/dist/cli/index.d.ts.map +1 -1
  20. package/dist/cli/index.js +4 -1
  21. package/dist/cli/index.js.map +1 -1
  22. package/dist/cli/interactive.d.ts.map +1 -1
  23. package/dist/cli/interactive.js +218 -61
  24. package/dist/cli/interactive.js.map +1 -1
  25. package/dist/generators/admin-wizard.d.ts +25 -0
  26. package/dist/generators/admin-wizard.d.ts.map +1 -0
  27. package/dist/generators/admin-wizard.js +123 -0
  28. package/dist/generators/admin-wizard.js.map +1 -0
  29. package/dist/generators/all.d.ts.map +1 -1
  30. package/dist/generators/all.js +10 -3
  31. package/dist/generators/all.js.map +1 -1
  32. package/dist/generators/database.d.ts +58 -0
  33. package/dist/generators/database.d.ts.map +1 -0
  34. package/dist/generators/database.js +229 -0
  35. package/dist/generators/database.js.map +1 -0
  36. package/dist/generators/fullstack.d.ts.map +1 -1
  37. package/dist/generators/fullstack.js +23 -7
  38. package/dist/generators/fullstack.js.map +1 -1
  39. package/dist/generators/index.d.ts +2 -0
  40. package/dist/generators/index.d.ts.map +1 -1
  41. package/dist/generators/index.js +2 -0
  42. package/dist/generators/index.js.map +1 -1
  43. package/dist/generators/templates/admin-wizard-python.d.ts +32 -0
  44. package/dist/generators/templates/admin-wizard-python.d.ts.map +1 -0
  45. package/dist/generators/templates/admin-wizard-python.js +425 -0
  46. package/dist/generators/templates/admin-wizard-python.js.map +1 -0
  47. package/dist/generators/templates/admin-wizard-react.d.ts +48 -0
  48. package/dist/generators/templates/admin-wizard-react.d.ts.map +1 -0
  49. package/dist/generators/templates/admin-wizard-react.js +554 -0
  50. package/dist/generators/templates/admin-wizard-react.js.map +1 -0
  51. package/dist/generators/templates/database-docker.d.ts +23 -0
  52. package/dist/generators/templates/database-docker.d.ts.map +1 -0
  53. package/dist/generators/templates/database-docker.js +221 -0
  54. package/dist/generators/templates/database-docker.js.map +1 -0
  55. package/dist/generators/templates/database-python.d.ts +54 -0
  56. package/dist/generators/templates/database-python.d.ts.map +1 -0
  57. package/dist/generators/templates/database-python.js +723 -0
  58. package/dist/generators/templates/database-python.js.map +1 -0
  59. package/dist/generators/templates/database-typescript.d.ts +34 -0
  60. package/dist/generators/templates/database-typescript.d.ts.map +1 -0
  61. package/dist/generators/templates/database-typescript.js +232 -0
  62. package/dist/generators/templates/database-typescript.js.map +1 -0
  63. package/dist/generators/templates/fullstack.d.ts.map +1 -1
  64. package/dist/generators/templates/fullstack.js +29 -0
  65. package/dist/generators/templates/fullstack.js.map +1 -1
  66. package/dist/generators/templates/index.d.ts +5 -0
  67. package/dist/generators/templates/index.d.ts.map +1 -1
  68. package/dist/generators/templates/index.js +5 -0
  69. package/dist/generators/templates/index.js.map +1 -1
  70. package/dist/state/index.d.ts +10 -0
  71. package/dist/state/index.d.ts.map +1 -1
  72. package/dist/state/index.js +21 -0
  73. package/dist/state/index.js.map +1 -1
  74. package/dist/types/audit.d.ts +623 -0
  75. package/dist/types/audit.d.ts.map +1 -0
  76. package/dist/types/audit.js +240 -0
  77. package/dist/types/audit.js.map +1 -0
  78. package/dist/types/database-runtime.d.ts +86 -0
  79. package/dist/types/database-runtime.d.ts.map +1 -0
  80. package/dist/types/database-runtime.js +61 -0
  81. package/dist/types/database-runtime.js.map +1 -0
  82. package/dist/types/database.d.ts +85 -0
  83. package/dist/types/database.d.ts.map +1 -0
  84. package/dist/types/database.js +71 -0
  85. package/dist/types/database.js.map +1 -0
  86. package/dist/types/index.d.ts +2 -0
  87. package/dist/types/index.d.ts.map +1 -1
  88. package/dist/types/index.js +4 -0
  89. package/dist/types/index.js.map +1 -1
  90. package/dist/types/workflow.d.ts +36 -0
  91. package/dist/types/workflow.d.ts.map +1 -1
  92. package/dist/types/workflow.js +7 -0
  93. package/dist/types/workflow.js.map +1 -1
  94. package/dist/workflow/audit-analyzer.d.ts +58 -0
  95. package/dist/workflow/audit-analyzer.d.ts.map +1 -0
  96. package/dist/workflow/audit-analyzer.js +420 -0
  97. package/dist/workflow/audit-analyzer.js.map +1 -0
  98. package/dist/workflow/audit-mode.d.ts +28 -0
  99. package/dist/workflow/audit-mode.d.ts.map +1 -0
  100. package/dist/workflow/audit-mode.js +169 -0
  101. package/dist/workflow/audit-mode.js.map +1 -0
  102. package/dist/workflow/audit-recovery.d.ts +61 -0
  103. package/dist/workflow/audit-recovery.d.ts.map +1 -0
  104. package/dist/workflow/audit-recovery.js +242 -0
  105. package/dist/workflow/audit-recovery.js.map +1 -0
  106. package/dist/workflow/audit-reporter.d.ts +65 -0
  107. package/dist/workflow/audit-reporter.d.ts.map +1 -0
  108. package/dist/workflow/audit-reporter.js +301 -0
  109. package/dist/workflow/audit-reporter.js.map +1 -0
  110. package/dist/workflow/audit-scanner.d.ts +87 -0
  111. package/dist/workflow/audit-scanner.d.ts.map +1 -0
  112. package/dist/workflow/audit-scanner.js +768 -0
  113. package/dist/workflow/audit-scanner.js.map +1 -0
  114. package/dist/workflow/db-setup-runner.d.ts +63 -0
  115. package/dist/workflow/db-setup-runner.d.ts.map +1 -0
  116. package/dist/workflow/db-setup-runner.js +336 -0
  117. package/dist/workflow/db-setup-runner.js.map +1 -0
  118. package/dist/workflow/db-state-machine.d.ts +30 -0
  119. package/dist/workflow/db-state-machine.d.ts.map +1 -0
  120. package/dist/workflow/db-state-machine.js +51 -0
  121. package/dist/workflow/db-state-machine.js.map +1 -0
  122. package/dist/workflow/index.d.ts +7 -0
  123. package/dist/workflow/index.d.ts.map +1 -1
  124. package/dist/workflow/index.js +7 -0
  125. package/dist/workflow/index.js.map +1 -1
  126. package/package.json +1 -1
  127. package/src/cli/commands/db.ts +281 -0
  128. package/src/cli/commands/doctor.ts +273 -0
  129. package/src/cli/commands/index.ts +3 -0
  130. package/src/cli/commands/review.ts +187 -0
  131. package/src/cli/index.ts +6 -0
  132. package/src/cli/interactive.ts +174 -4
  133. package/src/generators/admin-wizard.ts +146 -0
  134. package/src/generators/all.ts +10 -3
  135. package/src/generators/database.ts +286 -0
  136. package/src/generators/fullstack.ts +26 -9
  137. package/src/generators/index.ts +12 -0
  138. package/src/generators/templates/admin-wizard-python.ts +431 -0
  139. package/src/generators/templates/admin-wizard-react.ts +560 -0
  140. package/src/generators/templates/database-docker.ts +227 -0
  141. package/src/generators/templates/database-python.ts +734 -0
  142. package/src/generators/templates/database-typescript.ts +238 -0
  143. package/src/generators/templates/fullstack.ts +29 -0
  144. package/src/generators/templates/index.ts +5 -0
  145. package/src/state/index.ts +28 -0
  146. package/src/types/audit.ts +294 -0
  147. package/src/types/database-runtime.ts +69 -0
  148. package/src/types/database.ts +84 -0
  149. package/src/types/index.ts +29 -0
  150. package/src/types/workflow.ts +20 -0
  151. package/src/workflow/audit-analyzer.ts +491 -0
  152. package/src/workflow/audit-mode.ts +240 -0
  153. package/src/workflow/audit-recovery.ts +284 -0
  154. package/src/workflow/audit-reporter.ts +370 -0
  155. package/src/workflow/audit-scanner.ts +873 -0
  156. package/src/workflow/db-setup-runner.ts +391 -0
  157. package/src/workflow/db-state-machine.ts +58 -0
  158. package/src/workflow/index.ts +7 -0
  159. package/tests/cli/commands/review.test.ts +52 -0
  160. package/tests/generators/admin-wizard-orchestrator.test.ts +64 -0
  161. package/tests/generators/admin-wizard-templates.test.ts +366 -0
  162. package/tests/generators/cross-phase-integration.test.ts +383 -0
  163. package/tests/generators/database.test.ts +456 -0
  164. package/tests/generators/fe-be-db-integration.test.ts +613 -0
  165. package/tests/types/audit.test.ts +250 -0
  166. package/tests/types/database-runtime.test.ts +158 -0
  167. package/tests/types/database.test.ts +187 -0
  168. package/tests/workflow/audit-analyzer.test.ts +281 -0
  169. package/tests/workflow/audit-mode.test.ts +114 -0
  170. package/tests/workflow/audit-recovery.test.ts +237 -0
  171. package/tests/workflow/audit-reporter.test.ts +254 -0
  172. package/tests/workflow/audit-scanner.test.ts +270 -0
  173. package/tests/workflow/db-setup-runner.test.ts +211 -0
  174. 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
+ }
@@ -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
+ });