@qwickapps/server 1.7.2 → 1.8.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 (120) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/src/core/control-panel.js +5 -5
  3. package/dist/src/core/control-panel.js.map +1 -1
  4. package/dist/src/core/gateway.d.ts.map +1 -1
  5. package/dist/src/core/gateway.js +117 -15
  6. package/dist/src/core/gateway.js.map +1 -1
  7. package/dist/src/core/plugin-registry.d.ts +70 -0
  8. package/dist/src/core/plugin-registry.d.ts.map +1 -1
  9. package/dist/src/core/plugin-registry.js +94 -0
  10. package/dist/src/core/plugin-registry.js.map +1 -1
  11. package/dist/src/index.d.ts +1 -1
  12. package/dist/src/index.d.ts.map +1 -1
  13. package/dist/src/plugins/api-keys/api-keys-plugin.d.ts.map +1 -1
  14. package/dist/src/plugins/api-keys/api-keys-plugin.js +53 -1
  15. package/dist/src/plugins/api-keys/api-keys-plugin.js.map +1 -1
  16. package/dist/src/plugins/api-keys/index.d.ts +1 -1
  17. package/dist/src/plugins/api-keys/index.d.ts.map +1 -1
  18. package/dist/src/plugins/api-keys/index.js.map +1 -1
  19. package/dist/src/plugins/api-keys/stores/postgres-store.d.ts.map +1 -1
  20. package/dist/src/plugins/api-keys/stores/postgres-store.js +83 -65
  21. package/dist/src/plugins/api-keys/stores/postgres-store.js.map +1 -1
  22. package/dist/src/plugins/api-keys/types.d.ts +13 -1
  23. package/dist/src/plugins/api-keys/types.d.ts.map +1 -1
  24. package/dist/src/plugins/api-keys/types.js.map +1 -1
  25. package/dist/src/plugins/diagnostics-plugin.d.ts.map +1 -1
  26. package/dist/src/plugins/diagnostics-plugin.js +73 -0
  27. package/dist/src/plugins/diagnostics-plugin.js.map +1 -1
  28. package/dist/src/plugins/index.d.ts +1 -1
  29. package/dist/src/plugins/index.d.ts.map +1 -1
  30. package/dist/src/plugins/maintenance/SeedExecutor.d.ts +2 -0
  31. package/dist/src/plugins/maintenance/SeedExecutor.d.ts.map +1 -1
  32. package/dist/src/plugins/maintenance/SeedExecutor.js +6 -2
  33. package/dist/src/plugins/maintenance/SeedExecutor.js.map +1 -1
  34. package/dist/src/plugins/maintenance/SeedList.d.ts +2 -2
  35. package/dist/src/plugins/maintenance/SeedList.d.ts.map +1 -1
  36. package/dist/src/plugins/maintenance/SeedList.js +39 -14
  37. package/dist/src/plugins/maintenance/SeedList.js.map +1 -1
  38. package/dist/src/plugins/maintenance/SeedManagementPage.d.ts +1 -1
  39. package/dist/src/plugins/maintenance/SeedManagementPage.d.ts.map +1 -1
  40. package/dist/src/plugins/maintenance/SeedManagementPage.js +9 -5
  41. package/dist/src/plugins/maintenance/SeedManagementPage.js.map +1 -1
  42. package/dist/src/plugins/maintenance/seed-executor.d.ts +6 -4
  43. package/dist/src/plugins/maintenance/seed-executor.d.ts.map +1 -1
  44. package/dist/src/plugins/maintenance/seed-executor.js +53 -17
  45. package/dist/src/plugins/maintenance/seed-executor.js.map +1 -1
  46. package/dist/src/plugins/maintenance-plugin.d.ts +24 -0
  47. package/dist/src/plugins/maintenance-plugin.d.ts.map +1 -1
  48. package/dist/src/plugins/maintenance-plugin.js +222 -34
  49. package/dist/src/plugins/maintenance-plugin.js.map +1 -1
  50. package/dist/src/plugins/postgres-plugin.d.ts +12 -0
  51. package/dist/src/plugins/postgres-plugin.d.ts.map +1 -1
  52. package/dist/src/plugins/postgres-plugin.js +319 -5
  53. package/dist/src/plugins/postgres-plugin.js.map +1 -1
  54. package/dist/ui/src/components/ControlPanelApp.d.ts.map +1 -1
  55. package/dist/ui/src/components/ControlPanelApp.js +4 -3
  56. package/dist/ui/src/components/ControlPanelApp.js.map +1 -1
  57. package/dist/ui/src/dashboard/builtInWidgets.d.ts.map +1 -1
  58. package/dist/ui/src/dashboard/builtInWidgets.js +3 -1
  59. package/dist/ui/src/dashboard/builtInWidgets.js.map +1 -1
  60. package/dist/ui/src/dashboard/widgets/CMSMaintenanceWidget.d.ts.map +1 -1
  61. package/dist/ui/src/dashboard/widgets/CMSMaintenanceWidget.js +17 -4
  62. package/dist/ui/src/dashboard/widgets/CMSMaintenanceWidget.js.map +1 -1
  63. package/dist/ui/src/dashboard/widgets/CMSStatusWidget.d.ts.map +1 -1
  64. package/dist/ui/src/dashboard/widgets/CMSStatusWidget.js +5 -1
  65. package/dist/ui/src/dashboard/widgets/CMSStatusWidget.js.map +1 -1
  66. package/dist/ui/src/dashboard/widgets/CacheMaintenanceWidget.d.ts.map +1 -1
  67. package/dist/ui/src/dashboard/widgets/CacheMaintenanceWidget.js +4 -2
  68. package/dist/ui/src/dashboard/widgets/CacheMaintenanceWidget.js.map +1 -1
  69. package/dist/ui/src/dashboard/widgets/DatabaseOperationsWidget.d.ts +12 -0
  70. package/dist/ui/src/dashboard/widgets/DatabaseOperationsWidget.d.ts.map +1 -0
  71. package/dist/ui/src/dashboard/widgets/DatabaseOperationsWidget.js +174 -0
  72. package/dist/ui/src/dashboard/widgets/DatabaseOperationsWidget.js.map +1 -0
  73. package/dist/ui/src/dashboard/widgets/LogsMaintenanceWidget.d.ts.map +1 -1
  74. package/dist/ui/src/dashboard/widgets/LogsMaintenanceWidget.js +6 -3
  75. package/dist/ui/src/dashboard/widgets/LogsMaintenanceWidget.js.map +1 -1
  76. package/dist/ui/src/dashboard/widgets/SeedManagementWidget.d.ts +1 -1
  77. package/dist/ui/src/dashboard/widgets/SeedManagementWidget.d.ts.map +1 -1
  78. package/dist/ui/src/dashboard/widgets/SeedManagementWidget.js +256 -16
  79. package/dist/ui/src/dashboard/widgets/SeedManagementWidget.js.map +1 -1
  80. package/dist/ui/src/dashboard/widgets/index.d.ts +1 -0
  81. package/dist/ui/src/dashboard/widgets/index.d.ts.map +1 -1
  82. package/dist/ui/src/dashboard/widgets/index.js +1 -0
  83. package/dist/ui/src/dashboard/widgets/index.js.map +1 -1
  84. package/dist-ui/assets/index-BkGp7ZKd.js +529 -0
  85. package/dist-ui/assets/index-BkGp7ZKd.js.map +1 -0
  86. package/dist-ui/index.html +1 -1
  87. package/dist-ui-lib/index.js +3735 -3187
  88. package/dist-ui-lib/index.js.map +1 -1
  89. package/dist-ui-lib/src/dashboard/widgets/DatabaseOperationsWidget.d.ts +11 -0
  90. package/dist-ui-lib/src/dashboard/widgets/SeedManagementWidget.d.ts +1 -1
  91. package/dist-ui-lib/src/dashboard/widgets/index.d.ts +1 -0
  92. package/package.json +2 -2
  93. package/src/core/control-panel.ts +5 -5
  94. package/src/core/gateway.ts +135 -15
  95. package/src/core/plugin-registry.ts +171 -0
  96. package/src/index.ts +2 -0
  97. package/src/plugins/api-keys/api-keys-plugin.ts +58 -1
  98. package/src/plugins/api-keys/index.ts +1 -0
  99. package/src/plugins/api-keys/stores/postgres-store.ts +90 -67
  100. package/src/plugins/api-keys/types.ts +14 -1
  101. package/src/plugins/diagnostics-plugin.ts +77 -0
  102. package/src/plugins/index.ts +1 -1
  103. package/src/plugins/maintenance/SeedExecutor.tsx +9 -1
  104. package/src/plugins/maintenance/SeedList.tsx +85 -38
  105. package/src/plugins/maintenance/SeedManagementPage.tsx +10 -4
  106. package/src/plugins/maintenance/seed-executor.ts +56 -17
  107. package/src/plugins/maintenance-plugin.ts +267 -36
  108. package/src/plugins/postgres-plugin.ts +410 -5
  109. package/ui/src/App.tsx +3 -3
  110. package/ui/src/components/ControlPanelApp.tsx +4 -3
  111. package/ui/src/dashboard/builtInWidgets.tsx +3 -0
  112. package/ui/src/dashboard/widgets/CMSMaintenanceWidget.tsx +17 -4
  113. package/ui/src/dashboard/widgets/CMSStatusWidget.tsx +5 -1
  114. package/ui/src/dashboard/widgets/CacheMaintenanceWidget.tsx +4 -2
  115. package/ui/src/dashboard/widgets/DatabaseOperationsWidget.tsx +410 -0
  116. package/ui/src/dashboard/widgets/LogsMaintenanceWidget.tsx +6 -3
  117. package/ui/src/dashboard/widgets/SeedManagementWidget.tsx +533 -49
  118. package/ui/src/dashboard/widgets/index.ts +1 -0
  119. package/dist-ui/assets/index-0gzisPdy.js +0 -528
  120. package/dist-ui/assets/index-0gzisPdy.js.map +0 -1
@@ -40,33 +40,33 @@ const MAX_OUTPUT_SIZE = 100 * 1024;
40
40
  /**
41
41
  * Validate script path to prevent path traversal attacks
42
42
  *
43
- * @param scriptName - Name of the script (e.g., "seed-products.mjs")
43
+ * @param scriptPath - Relative path to the script (e.g., "database/001.init.mjs" or "01-Setup/seed-data.mjs")
44
44
  * @param scriptsPath - Base path for scripts directory
45
- * @returns Resolved path if valid, null if invalid
45
+ * @returns Resolved absolute path if valid, null if invalid
46
46
  */
47
- export function validateScriptPath(scriptName: string, scriptsPath: string): string | null {
48
- // Only allow seed-*.mjs pattern
49
- if (!/^seed-[a-z0-9-]+\.mjs$/.test(scriptName)) {
47
+ export function validateScriptPath(scriptPath: string, scriptsPath: string): string | null {
48
+ // Only allow .mjs files
49
+ if (!scriptPath.endsWith('.mjs')) {
50
50
  return null;
51
51
  }
52
52
 
53
53
  // Resolve paths
54
54
  const basePath = resolve(scriptsPath);
55
- const scriptPath = resolve(basePath, scriptName);
55
+ const resolvedScriptPath = resolve(basePath, scriptPath);
56
56
 
57
57
  // Ensure resolved path is within scriptsPath (prevent path traversal)
58
58
  // Use relative() for platform-agnostic check (works on Windows and Unix)
59
- const relativePath = relative(basePath, scriptPath);
59
+ const relativePath = relative(basePath, resolvedScriptPath);
60
60
  if (relativePath.startsWith('..') || relativePath.includes('../') || relativePath.includes('..\\')) {
61
61
  return null;
62
62
  }
63
63
 
64
64
  // Ensure file exists
65
- if (!existsSync(scriptPath)) {
65
+ if (!existsSync(resolvedScriptPath)) {
66
66
  return null;
67
67
  }
68
68
 
69
- return scriptPath;
69
+ return resolvedScriptPath;
70
70
  }
71
71
 
72
72
  /**
@@ -95,30 +95,64 @@ export class SeedExecutor {
95
95
  *
96
96
  * @param scriptPath - Absolute path to the script
97
97
  * @param res - Express response object (for SSE streaming)
98
+ * @param databaseUrl - Optional database URL to pass to the script
99
+ * @param projectRoot - Optional project root directory (defaults to script's directory)
98
100
  * @returns Promise resolving to execution result
99
101
  */
100
- async execute(scriptPath: string, res: Response): Promise<SeedExecutionResult> {
102
+ async execute(scriptPath: string, res: Response, databaseUrl?: string, projectRoot?: string): Promise<SeedExecutionResult> {
101
103
  this.startTime = Date.now();
102
104
  this.outputBuffer = '';
103
105
  this.errorBuffer = '';
104
106
  this.outputSize = 0;
105
107
 
106
108
  return new Promise((resolvePromise, rejectPromise) => {
107
- // Spawn Node.js process with minimal environment
108
- // Use process.execPath to ensure we use the same node binary as the parent process
109
- this.child = spawn(process.execPath, [scriptPath], {
109
+ // Determine if we need tsx (for .ts/.mts files that import TS modules)
110
+ // Use tsx for .mjs files too since they might import from TS source
111
+ const needsTsx = scriptPath.match(/\.(mjs|ts|mts)$/);
112
+ const execCommand = needsTsx ? 'tsx' : process.execPath;
113
+ const execArgs = needsTsx ? [scriptPath] : [scriptPath];
114
+
115
+ // Spawn process with TypeScript support if needed
116
+ this.child = spawn(execCommand, execArgs, {
110
117
  env: {
118
+ ...process.env, // Inherit all env vars for tsx to work properly
111
119
  NODE_ENV: process.env.NODE_ENV || 'development',
112
- DATABASE_URI: process.env.DATABASE_URI,
113
- DATABASE_URL: process.env.DATABASE_URL,
120
+ DATABASE_URI: databaseUrl || process.env.DATABASE_URI,
121
+ DATABASE_URL: databaseUrl || process.env.DATABASE_URL,
114
122
  PAYLOAD_PUBLIC_SERVER_URL: process.env.PAYLOAD_PUBLIC_SERVER_URL,
115
123
  NEXT_PUBLIC_SERVER_URL: process.env.NEXT_PUBLIC_SERVER_URL,
116
124
  API_URL: process.env.API_URL,
125
+ PAYLOAD_SECRET: process.env.PAYLOAD_SECRET || 'dev-secret-key-change-in-production',
117
126
  },
118
- stdio: ['ignore', 'pipe', 'pipe'], // stdin: ignore, stdout: pipe, stderr: pipe
119
- cwd: resolve(scriptPath, '..'), // Run from scripts directory
127
+ stdio: ['pipe', 'pipe', 'pipe'], // stdin: pipe, stdout: pipe, stderr: pipe
128
+ cwd: projectRoot || resolve(scriptPath, '..'), // Run from project root (or scripts directory if not provided)
120
129
  });
121
130
 
131
+ // Auto-answer interactive prompts (like Drizzle's schema push confirmation)
132
+ // Send 'y\n' every 500ms to handle any prompts that appear
133
+ const stdinInterval = setInterval(() => {
134
+ if (this.child && this.child.stdin && !this.child.stdin.destroyed) {
135
+ try {
136
+ this.child.stdin.write('y\n');
137
+ } catch (err) {
138
+ // Ignore errors - stdin might be closed
139
+ }
140
+ }
141
+ }, 500);
142
+
143
+ // Clear interval when process exits
144
+ const cleanupInterval = () => {
145
+ clearInterval(stdinInterval);
146
+ // Close stdin to signal EOF
147
+ if (this.child && this.child.stdin && !this.child.stdin.destroyed) {
148
+ try {
149
+ this.child.stdin.end();
150
+ } catch (err) {
151
+ // Ignore errors
152
+ }
153
+ }
154
+ };
155
+
122
156
  // Handle stdout
123
157
  this.child.stdout?.on('data', (data: Buffer) => {
124
158
  const text = data.toString();
@@ -165,6 +199,8 @@ export class SeedExecutor {
165
199
 
166
200
  // Handle process exit
167
201
  this.child.on('exit', (code, signal) => {
202
+ cleanupInterval(); // Clean up stdin interval
203
+
168
204
  const exitCode = code ?? (signal ? 1 : 0);
169
205
  const duration = Date.now() - this.startTime;
170
206
 
@@ -188,6 +224,8 @@ export class SeedExecutor {
188
224
 
189
225
  // Handle spawn errors
190
226
  this.child.on('error', (err: Error) => {
227
+ cleanupInterval(); // Clean up stdin interval
228
+
191
229
  sendSSEEvent(res, {
192
230
  type: 'error',
193
231
  data: err.message,
@@ -199,6 +237,7 @@ export class SeedExecutor {
199
237
 
200
238
  // Handle SSE connection close - terminate child process
201
239
  res.on('close', () => {
240
+ cleanupInterval(); // Clean up stdin interval
202
241
  if (this.child && !this.child.killed) {
203
242
  this.child.kill('SIGTERM');
204
243
  }
@@ -10,13 +10,103 @@
10
10
  * Copyright (c) 2025 QwickApps.com. All rights reserved.
11
11
  */
12
12
 
13
- import { readdirSync, statSync } from 'fs';
14
- import { resolve } from 'path';
13
+ import { readdirSync, statSync, readFileSync } from 'fs';
14
+ import { resolve, join, relative } from 'path';
15
15
  import type { Request, Response } from 'express';
16
16
  import type { Plugin, PluginConfig, PluginRegistry } from '../core/plugin-registry.js';
17
17
  import { SeedExecutor, validateScriptPath } from './maintenance/seed-executor.js';
18
18
  import { getPostgres, hasPostgres } from './postgres-plugin.js';
19
19
 
20
+ /**
21
+ * Extract description from JSDoc comment at top of file
22
+ */
23
+ function extractDescription(filePath: string): string | undefined {
24
+ try {
25
+ const content = readFileSync(filePath, 'utf-8');
26
+ // Match JSDoc comment at start of file (after any whitespace)
27
+ const jsdocMatch = content.match(/^\s*\/\*\*\s*\n([\s\S]*?)\*\//);
28
+
29
+ if (jsdocMatch) {
30
+ // Extract lines and clean up asterisks and whitespace
31
+ const lines = jsdocMatch[1]
32
+ .split('\n')
33
+ .map(line => line.replace(/^\s*\*\s?/, '').trim())
34
+ .filter(line => line.length > 0);
35
+
36
+ // Skip the first line if it's just the title (will be shown separately)
37
+ // Return subsequent lines as the description
38
+ return lines.slice(1).join(' ').trim() || undefined;
39
+ }
40
+ } catch (err) {
41
+ // Failed to read file or parse description
42
+ }
43
+
44
+ return undefined;
45
+ }
46
+
47
+ /**
48
+ * Recursively scan directory for .mjs files
49
+ */
50
+ function scanSeedScripts(dir: string, basePath: string = dir): any[] {
51
+ const results: any[] = [];
52
+
53
+ try {
54
+ const entries = readdirSync(dir, { withFileTypes: true });
55
+
56
+ for (const entry of entries) {
57
+ const fullPath = join(dir, entry.name);
58
+
59
+ if (entry.isDirectory()) {
60
+ // Recursively scan subdirectories
61
+ results.push(...scanSeedScripts(fullPath, basePath));
62
+ } else if (entry.isFile() && entry.name.endsWith('.mjs')) {
63
+ // Found a .mjs file
64
+ const stats = statSync(fullPath);
65
+ const relativePath = relative(basePath, fullPath);
66
+ const description = extractDescription(fullPath);
67
+
68
+ results.push({
69
+ type: 'file',
70
+ name: entry.name,
71
+ path: relativePath, // Relative path from scripts directory
72
+ fullPath: fullPath, // Absolute path
73
+ size: stats.size,
74
+ createdAt: stats.birthtime,
75
+ modifiedAt: stats.mtime,
76
+ description, // Add description from JSDoc
77
+ });
78
+ }
79
+ }
80
+ } catch (err) {
81
+ // Directory not accessible, return empty array
82
+ }
83
+
84
+ return results;
85
+ }
86
+
87
+ /**
88
+ * Custom seed task handler
89
+ */
90
+ export interface SeedTaskHandler {
91
+ (options?: Record<string, any>, res?: Response): Promise<void>;
92
+ }
93
+
94
+ /**
95
+ * Custom seed task definition
96
+ */
97
+ export interface SeedTask {
98
+ /** Unique task identifier */
99
+ id: string;
100
+ /** Display name */
101
+ name: string;
102
+ /** Description of what this task does */
103
+ description: string;
104
+ /** Task handler function */
105
+ handler: SeedTaskHandler;
106
+ /** Optional task options/parameters */
107
+ options?: Record<string, any>;
108
+ }
109
+
20
110
  export interface MaintenancePluginConfig {
21
111
  /** Path to scripts directory (default: './scripts') */
22
112
  scriptsPath?: string;
@@ -41,6 +131,9 @@ export interface MaintenancePluginConfig {
41
131
 
42
132
  /** Enable database operations (default: true) */
43
133
  enableDatabaseOps?: boolean;
134
+
135
+ /** Custom seed tasks */
136
+ customTasks?: SeedTask[];
44
137
  }
45
138
 
46
139
  /**
@@ -60,6 +153,38 @@ export function createMaintenancePlugin(config: MaintenancePluginConfig = {}): P
60
153
  const logger = registry.getLogger('maintenance');
61
154
  logger.info('Maintenance plugin starting...');
62
155
 
156
+ // Initialize seed_executions table if PostgreSQL is available
157
+ if (hasPostgres()) {
158
+ try {
159
+ const db = getPostgres();
160
+ await db.queryRaw(`
161
+ CREATE TABLE IF NOT EXISTS seed_executions (
162
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
163
+ name TEXT NOT NULL,
164
+ status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed')),
165
+ started_at TIMESTAMPTZ NOT NULL,
166
+ completed_at TIMESTAMPTZ,
167
+ exit_code INTEGER,
168
+ output TEXT,
169
+ error TEXT,
170
+ duration_ms INTEGER,
171
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
172
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
173
+ )
174
+ `);
175
+
176
+ // Create index on status for faster queries
177
+ await db.queryRaw(`
178
+ CREATE INDEX IF NOT EXISTS idx_seed_executions_status
179
+ ON seed_executions(status)
180
+ `);
181
+
182
+ logger.debug('Seed executions table initialized');
183
+ } catch (error) {
184
+ logger.error('Failed to initialize seed_executions table', { error });
185
+ }
186
+ }
187
+
63
188
  // Clean up orphaned executions from previous crashes
64
189
  if (hasPostgres()) {
65
190
  try {
@@ -112,7 +237,7 @@ export function createMaintenancePlugin(config: MaintenancePluginConfig = {}): P
112
237
  logger.warn('Seed management requires PostgreSQL plugin for execution history');
113
238
  }
114
239
 
115
- // GET /seeds/discover - List available seed scripts
240
+ // GET /seeds/discover - List available seed scripts and custom tasks
116
241
  registry.addRoute({
117
242
  method: 'get',
118
243
  path: '/seeds/discover',
@@ -120,25 +245,30 @@ export function createMaintenancePlugin(config: MaintenancePluginConfig = {}): P
120
245
  handler: (_req: Request, res: Response) => {
121
246
  try {
122
247
  const resolvedPath = resolve(scriptsPath);
123
- const files = readdirSync(resolvedPath);
124
-
125
- // Filter for seed-*.mjs files
126
- const seedFiles = files
127
- .filter((file) => /^seed-[a-z0-9-]+\.mjs$/.test(file))
128
- .map((file) => {
129
- const filePath = resolve(resolvedPath, file);
130
- const stats = statSync(filePath);
131
- return {
132
- name: file,
133
- path: filePath,
134
- size: stats.size,
135
- createdAt: stats.birthtime,
136
- modifiedAt: stats.mtime,
137
- };
138
- })
139
- .sort((a, b) => a.name.localeCompare(b.name));
140
-
141
- res.json({ seeds: seedFiles });
248
+ let seedFiles: any[] = [];
249
+
250
+ try {
251
+ // Recursively scan for .mjs files
252
+ seedFiles = scanSeedScripts(resolvedPath);
253
+
254
+ // Sort by relative path (natural ordering respects numbered prefixes)
255
+ // Example: "01-Setup/001.init.mjs" comes before "02-Production/001.seed.mjs"
256
+ seedFiles.sort((a, b) => a.path.localeCompare(b.path, undefined, { numeric: true }));
257
+ } catch (err) {
258
+ // Scripts directory may not exist, which is fine if we have custom tasks
259
+ logger.debug('Scripts directory not found or not readable');
260
+ }
261
+
262
+ // Add custom tasks
263
+ const customTasks = (config.customTasks || []).map((task) => ({
264
+ type: 'task',
265
+ id: task.id,
266
+ name: task.name,
267
+ description: task.description,
268
+ options: task.options,
269
+ }));
270
+
271
+ res.json({ seeds: [...seedFiles, ...customTasks] });
142
272
  } catch (error) {
143
273
  logger.error('Failed to discover seed scripts', { error });
144
274
  res.status(500).json({
@@ -149,23 +279,45 @@ export function createMaintenancePlugin(config: MaintenancePluginConfig = {}): P
149
279
  },
150
280
  });
151
281
 
152
- // POST /seeds/execute - Execute a seed script
282
+ // POST /seeds/execute - Execute a seed script or custom task
153
283
  registry.addRoute({
154
284
  method: 'post',
155
285
  path: '/seeds/execute',
156
286
  pluginId: 'maintenance',
157
287
  handler: async (req: Request, res: Response) => {
158
288
  try {
159
- const { name } = req.body;
289
+ const { name, type, options } = req.body;
160
290
 
161
291
  if (!name || typeof name !== 'string') {
162
292
  return res.status(400).json({ error: 'Script name is required' });
163
293
  }
164
294
 
165
- // Validate script path
166
- const scriptPath = validateScriptPath(name, scriptsPath);
167
- if (!scriptPath) {
168
- return res.status(400).json({ error: 'Invalid script name or file not found' });
295
+ // Ensure seed_executions table exists (lazy initialization)
296
+ if (hasPostgres()) {
297
+ const db = getPostgres();
298
+ try {
299
+ await db.queryRaw(`
300
+ CREATE TABLE IF NOT EXISTS seed_executions (
301
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
302
+ name TEXT NOT NULL,
303
+ status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed')),
304
+ started_at TIMESTAMPTZ NOT NULL,
305
+ completed_at TIMESTAMPTZ,
306
+ exit_code INTEGER,
307
+ output TEXT,
308
+ error TEXT,
309
+ duration_ms INTEGER,
310
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
311
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
312
+ )
313
+ `);
314
+ await db.queryRaw(`
315
+ CREATE INDEX IF NOT EXISTS idx_seed_executions_status
316
+ ON seed_executions(status)
317
+ `);
318
+ } catch (err) {
319
+ logger.debug('Table initialization check', { err });
320
+ }
169
321
  }
170
322
 
171
323
  // Check for concurrent execution
@@ -204,10 +356,39 @@ export function createMaintenancePlugin(config: MaintenancePluginConfig = {}): P
204
356
  executionId = result?.id || null;
205
357
  }
206
358
 
207
- // Execute seed script
208
- const executor = new SeedExecutor();
359
+ const startTime = Date.now();
360
+ let exitCode = 0;
361
+ let output = '';
362
+ let error = '';
363
+
209
364
  try {
210
- const result = await executor.execute(scriptPath, res);
365
+ // Execute based on type
366
+ if (type === 'task') {
367
+ // Find custom task
368
+ const task = (config.customTasks || []).find((t) => t.id === name);
369
+ if (!task) {
370
+ throw new Error(`Custom task not found: ${name}`);
371
+ }
372
+
373
+ // Execute custom task handler with SSE streaming
374
+ await task.handler(options || {}, res);
375
+ } else {
376
+ // Execute file-based seed script
377
+ const scriptPath = validateScriptPath(name, scriptsPath);
378
+ if (!scriptPath) {
379
+ throw new Error('Invalid script name or file not found');
380
+ }
381
+
382
+ const executor = new SeedExecutor();
383
+ // Project root is one level up from scripts directory
384
+ const projectRoot = resolve(scriptsPath, '..');
385
+ const result = await executor.execute(scriptPath, res, config.databaseUrl, projectRoot);
386
+ exitCode = result.exitCode;
387
+ output = result.output;
388
+ error = result.error;
389
+ }
390
+
391
+ const duration = Date.now() - startTime;
211
392
 
212
393
  // Update execution record
213
394
  if (hasPostgres() && executionId) {
@@ -218,11 +399,11 @@ export function createMaintenancePlugin(config: MaintenancePluginConfig = {}): P
218
399
  output = $3, error = $4, duration_ms = $5, updated_at = NOW()
219
400
  WHERE id = $6`,
220
401
  [
221
- result.exitCode === 0 ? 'completed' : 'failed',
222
- result.exitCode,
223
- result.output,
224
- result.error,
225
- result.duration,
402
+ exitCode === 0 ? 'completed' : 'failed',
403
+ exitCode,
404
+ output,
405
+ error,
406
+ duration,
226
407
  executionId,
227
408
  ]
228
409
  );
@@ -262,6 +443,56 @@ export function createMaintenancePlugin(config: MaintenancePluginConfig = {}): P
262
443
  },
263
444
  });
264
445
 
446
+ // POST /database/reset - Drop and recreate database schema (local/dev only)
447
+ registry.addRoute({
448
+ method: 'post',
449
+ path: '/database/reset',
450
+ pluginId: 'maintenance',
451
+ handler: async (req: Request, res: Response) => {
452
+ try {
453
+ // Security: Only allow in local or development environments
454
+ const nodeEnv = process.env.NODE_ENV?.toLowerCase();
455
+ if (nodeEnv !== 'local' && nodeEnv !== 'development') {
456
+ return res.status(403).json({
457
+ error: 'Database reset is only available in local or development environments',
458
+ currentEnv: nodeEnv || 'production',
459
+ });
460
+ }
461
+
462
+ if (!hasPostgres()) {
463
+ return res.status(503).json({
464
+ error: 'PostgreSQL plugin required for database reset',
465
+ });
466
+ }
467
+
468
+ const db = getPostgres();
469
+
470
+ // Drop and recreate public schema (removes all tables, data, etc.)
471
+ await db.queryRaw('DROP SCHEMA IF EXISTS public CASCADE');
472
+ await db.queryRaw('CREATE SCHEMA public');
473
+ await db.queryRaw('GRANT ALL ON SCHEMA public TO public');
474
+ await db.queryRaw('GRANT ALL ON SCHEMA public TO postgres');
475
+ await db.queryRaw('GRANT ALL ON SCHEMA public TO qwickapps');
476
+
477
+ // Grant default privileges for future tables and sequences
478
+ await db.queryRaw('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO qwickapps');
479
+ await db.queryRaw('ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO qwickapps');
480
+
481
+ res.json({
482
+ success: true,
483
+ message: 'Database schema has been reset. All tables and data have been deleted.',
484
+ timestamp: new Date().toISOString(),
485
+ });
486
+ } catch (error) {
487
+ logger.error('Database reset failed', { error });
488
+ res.status(500).json({
489
+ error: 'Failed to reset database',
490
+ message: error instanceof Error ? error.message : String(error),
491
+ });
492
+ }
493
+ },
494
+ });
495
+
265
496
  // GET /seeds/history - List execution history
266
497
  registry.addRoute({
267
498
  method: 'get',