chadstart 1.0.4 → 1.0.6
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/Dockerfile +1 -1
- package/TODO.md +739 -0
- package/admin/index.html +27 -1
- package/{chadstart.example.yml → chadstart.example.yaml} +23 -1
- package/chadstart.schema.json +83 -0
- package/cli/cli.js +124 -35
- package/core/auth.js +160 -3
- package/core/backup.js +191 -0
- package/core/config-loader.js +266 -0
- package/core/db.js +4 -0
- package/core/email.js +170 -0
- package/core/entity-engine.js +4 -0
- package/core/logs.js +179 -0
- package/core/migrations.js +23 -18
- package/core/openapi.js +6 -2
- package/core/yaml-loader.js +8 -53
- package/docs/llm-rules.md +1 -1
- package/package.json +3 -1
- package/server/express-server.js +149 -18
- package/test/backup.test.js +146 -0
- package/test/config-loader.test.js +257 -0
- package/test/email.test.js +362 -0
- package/test/logs.test.js +239 -0
- package/test/verification.test.js +439 -0
package/server/express-server.js
CHANGED
|
@@ -8,7 +8,7 @@ const express = require('express');
|
|
|
8
8
|
const swaggerUi = require('swagger-ui-express');
|
|
9
9
|
const rateLimit = require('express-rate-limit');
|
|
10
10
|
|
|
11
|
-
const {
|
|
11
|
+
const { loadConfig, saveConfig, detectFormat, isWritableFormat } = require('../core/config-loader');
|
|
12
12
|
const { validateSchema } = require('../core/schema-validator');
|
|
13
13
|
const { buildCore } = require('../core/entity-engine');
|
|
14
14
|
const { initDb, findAll, findAllSimple, create: dbCreate } = require('../core/db');
|
|
@@ -24,6 +24,9 @@ const { initErrorReporter, getRequestHandler, attachErrorHandler } = require('..
|
|
|
24
24
|
const { getTelemetryConfig, initTelemetry } = require('../core/telemetry');
|
|
25
25
|
const { setupFunctions, cleanup: cleanupFunctions } = require('../core/functions-engine');
|
|
26
26
|
const { registerOAuthRoutes } = require('../core/oauth');
|
|
27
|
+
const { initEmail, sendEmail, verifyConnection, getEmailStatus } = require('../core/email');
|
|
28
|
+
const { initLogs, requestLoggerMiddleware, queryLogs, cleanupOldLogs } = require('../core/logs');
|
|
29
|
+
const { createBackup, restoreBackup, listBackups } = require('../core/backup');
|
|
27
30
|
const logger = require('../utils/logger');
|
|
28
31
|
|
|
29
32
|
function limiter(windowMs, max) {
|
|
@@ -81,29 +84,43 @@ function buildApiLimiters(core) {
|
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
/**
|
|
84
|
-
* Build an Express application for the given
|
|
87
|
+
* Build an Express application for the given config file.
|
|
85
88
|
*
|
|
86
|
-
* @param {string}
|
|
87
|
-
* @param {Function|null} reloadFn
|
|
88
|
-
*
|
|
89
|
-
*
|
|
89
|
+
* @param {string} configPath Path to the config file (any supported format).
|
|
90
|
+
* @param {Function|null} reloadFn When provided, the PUT /admin/config route will
|
|
91
|
+
* trigger this callback after saving so the running
|
|
92
|
+
* server picks up the new config without a restart.
|
|
90
93
|
* @returns {{ app: import('express').Application, core: object }}
|
|
91
94
|
*/
|
|
92
|
-
async function buildApp(
|
|
93
|
-
const config =
|
|
95
|
+
async function buildApp(configPath, reloadFn) {
|
|
96
|
+
const config = loadConfig(configPath);
|
|
94
97
|
validateSchema(config);
|
|
95
98
|
const core = buildCore(config);
|
|
99
|
+
const configFormat = detectFormat(configPath);
|
|
96
100
|
logger.info(`Loading "${core.name}"...`);
|
|
97
101
|
|
|
98
102
|
// Initialize OpenTelemetry (singleton — no-op on hot reload)
|
|
99
103
|
const telConfig = getTelemetryConfig(core.telemetry);
|
|
100
104
|
await initTelemetry(telConfig);
|
|
101
105
|
|
|
106
|
+
// Initialize email/SMTP service
|
|
107
|
+
initEmail(core.email);
|
|
108
|
+
|
|
102
109
|
const dbPath = core.database
|
|
103
|
-
? path.resolve(path.dirname(
|
|
110
|
+
? path.resolve(path.dirname(configPath), core.database)
|
|
104
111
|
: undefined;
|
|
105
112
|
await initDb(core, dbPath);
|
|
106
113
|
await initApiKeys();
|
|
114
|
+
await initLogs();
|
|
115
|
+
|
|
116
|
+
// Schedule periodic log cleanup
|
|
117
|
+
const logsCfg = core.logs || {};
|
|
118
|
+
const retentionDays = logsCfg.retention !== undefined ? logsCfg.retention : 30;
|
|
119
|
+
if (retentionDays > 0) {
|
|
120
|
+
// Run cleanup once at startup and then every 24 hours
|
|
121
|
+
cleanupOldLogs(retentionDays).catch(() => {});
|
|
122
|
+
setInterval(() => cleanupOldLogs(retentionDays).catch(() => {}), 24 * 60 * 60 * 1000).unref();
|
|
123
|
+
}
|
|
107
124
|
|
|
108
125
|
initErrorReporter(core);
|
|
109
126
|
|
|
@@ -116,6 +133,10 @@ async function buildApp(yamlPath, reloadFn) {
|
|
|
116
133
|
const sentryRequestHandler = getRequestHandler();
|
|
117
134
|
if (sentryRequestHandler) app.use(sentryRequestHandler);
|
|
118
135
|
|
|
136
|
+
// Request logging middleware — placed after Sentry but before routes
|
|
137
|
+
const logExclude = (logsCfg.exclude || ['/health', '/admin/vendor']);
|
|
138
|
+
app.use(requestLoggerMiddleware({ exclude: logExclude }));
|
|
139
|
+
|
|
119
140
|
// Public static files
|
|
120
141
|
if (core.public && core.public.folder) {
|
|
121
142
|
const publicDir = path.resolve(core.public.folder);
|
|
@@ -211,7 +232,7 @@ async function buildApp(yamlPath, reloadFn) {
|
|
|
211
232
|
});
|
|
212
233
|
|
|
213
234
|
// ── Admin config endpoints ────────────────────────────────────────────
|
|
214
|
-
// GET /admin/config — return the current
|
|
235
|
+
// GET /admin/config — return the current config as JSON (auth required)
|
|
215
236
|
app.get('/admin/config', adminRateLimiter, (req, res) => {
|
|
216
237
|
const header = req.headers.authorization;
|
|
217
238
|
if (!header || !header.startsWith('Bearer ')) {
|
|
@@ -221,13 +242,30 @@ async function buildApp(yamlPath, reloadFn) {
|
|
|
221
242
|
return res.status(401).json({ error: 'Invalid token' });
|
|
222
243
|
}
|
|
223
244
|
try {
|
|
224
|
-
res.
|
|
245
|
+
res.set('X-Config-Format', configFormat);
|
|
246
|
+
res.json(loadConfig(configPath));
|
|
225
247
|
} catch (e) {
|
|
226
248
|
res.status(500).json({ error: e.message });
|
|
227
249
|
}
|
|
228
250
|
});
|
|
229
251
|
|
|
230
|
-
//
|
|
252
|
+
// GET /admin/config-info — return metadata about the config file
|
|
253
|
+
app.get('/admin/config-info', adminRateLimiter, (req, res) => {
|
|
254
|
+
const header = req.headers.authorization;
|
|
255
|
+
if (!header || !header.startsWith('Bearer ')) {
|
|
256
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
257
|
+
}
|
|
258
|
+
try { verifyToken(header.slice(7)); } catch {
|
|
259
|
+
return res.status(401).json({ error: 'Invalid token' });
|
|
260
|
+
}
|
|
261
|
+
res.json({
|
|
262
|
+
format: configFormat,
|
|
263
|
+
file: path.basename(configPath),
|
|
264
|
+
writable: isWritableFormat(configFormat),
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// PUT /admin/config — receive JSON config, validate, save in original format, then hot-reload
|
|
231
269
|
app.put('/admin/config', adminRateLimiter, (req, res) => {
|
|
232
270
|
const header = req.headers.authorization;
|
|
233
271
|
if (!header || !header.startsWith('Bearer ')) {
|
|
@@ -246,7 +284,7 @@ async function buildApp(yamlPath, reloadFn) {
|
|
|
246
284
|
return res.status(400).json({ error: e.message });
|
|
247
285
|
}
|
|
248
286
|
try {
|
|
249
|
-
|
|
287
|
+
saveConfig(configPath, newConfig);
|
|
250
288
|
if (reloadFn) {
|
|
251
289
|
// Schedule hot reload after the response has been fully flushed
|
|
252
290
|
res.on('finish', () => {
|
|
@@ -258,7 +296,44 @@ async function buildApp(yamlPath, reloadFn) {
|
|
|
258
296
|
}
|
|
259
297
|
} catch (e) {
|
|
260
298
|
logger.error('Failed to save config:', e.message);
|
|
261
|
-
res.status(500).json({ error:
|
|
299
|
+
res.status(500).json({ error: e.message });
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ── Admin email endpoints ──────────────────────────────────────────────
|
|
304
|
+
// GET /admin/email/status — check if SMTP is configured
|
|
305
|
+
app.get('/admin/email/status', adminRateLimiter, (req, res) => {
|
|
306
|
+
const header = req.headers.authorization;
|
|
307
|
+
if (!header || !header.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
|
|
308
|
+
try { verifyToken(header.slice(7)); } catch { return res.status(401).json({ error: 'Invalid token' }); }
|
|
309
|
+
res.json(getEmailStatus());
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// POST /admin/test-email — send a test email to verify SMTP configuration (auth required)
|
|
313
|
+
app.post('/admin/test-email', adminRateLimiter, async (req, res) => {
|
|
314
|
+
const header = req.headers.authorization;
|
|
315
|
+
if (!header || !header.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
|
|
316
|
+
try { verifyToken(header.slice(7)); } catch { return res.status(401).json({ error: 'Invalid token' }); }
|
|
317
|
+
|
|
318
|
+
const { to } = req.body || {};
|
|
319
|
+
if (!to || typeof to !== 'string') return res.status(400).json({ error: 'to (email address) is required' });
|
|
320
|
+
|
|
321
|
+
// First verify the SMTP connection
|
|
322
|
+
const verification = await verifyConnection();
|
|
323
|
+
if (!verification.success) return res.status(503).json(verification);
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
const safeName = escAdminHtml(core.name);
|
|
327
|
+
await sendEmail({
|
|
328
|
+
to,
|
|
329
|
+
subject: 'ChadStart Test Email',
|
|
330
|
+
text: `This is a test email from your ChadStart application "${core.name}".\n\nIf you received this, your SMTP configuration is working correctly.`,
|
|
331
|
+
html: `<h2>ChadStart Test Email</h2><p>This is a test email from your ChadStart application <strong>"${safeName}"</strong>.</p><p>If you received this, your SMTP configuration is working correctly. ✅</p>`,
|
|
332
|
+
});
|
|
333
|
+
res.json({ success: true, message: `Test email sent to ${to}` });
|
|
334
|
+
} catch (e) {
|
|
335
|
+
logger.error('Test email failed:', e.message);
|
|
336
|
+
res.status(502).json({ success: false, message: `Failed to send test email: ${e.message}` });
|
|
262
337
|
}
|
|
263
338
|
});
|
|
264
339
|
|
|
@@ -369,6 +444,62 @@ async function buildApp(yamlPath, reloadFn) {
|
|
|
369
444
|
}
|
|
370
445
|
});
|
|
371
446
|
|
|
447
|
+
// ── Admin logs endpoint ─────────────────────────────────────────────
|
|
448
|
+
app.get('/admin/logs', adminRateLimiter, async (req, res) => {
|
|
449
|
+
const header = req.headers.authorization;
|
|
450
|
+
if (!header || !header.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
|
|
451
|
+
try { verifyToken(header.slice(7)); } catch { return res.status(401).json({ error: 'Invalid token' }); }
|
|
452
|
+
try {
|
|
453
|
+
const { method, statusCode, path: filterPath, from, to, page, perPage, order } = req.query;
|
|
454
|
+
const result = await queryLogs(
|
|
455
|
+
{ method, statusCode, path: filterPath, from, to },
|
|
456
|
+
{ page, perPage, order }
|
|
457
|
+
);
|
|
458
|
+
res.json(result);
|
|
459
|
+
} catch (err) {
|
|
460
|
+
res.status(500).json({ error: err.message });
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// ── Admin backup endpoints ──────────────────────────────────────────
|
|
465
|
+
app.post('/admin/backup', adminRateLimiter, async (req, res) => {
|
|
466
|
+
const header = req.headers.authorization;
|
|
467
|
+
if (!header || !header.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
|
|
468
|
+
try { verifyToken(header.slice(7)); } catch { return res.status(401).json({ error: 'Invalid token' }); }
|
|
469
|
+
try {
|
|
470
|
+
const result = await createBackup(core.backup);
|
|
471
|
+
res.json(result);
|
|
472
|
+
} catch (err) {
|
|
473
|
+
res.status(500).json({ error: err.message });
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
app.post('/admin/restore', adminRateLimiter, async (req, res) => {
|
|
478
|
+
const header = req.headers.authorization;
|
|
479
|
+
if (!header || !header.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
|
|
480
|
+
try { verifyToken(header.slice(7)); } catch { return res.status(401).json({ error: 'Invalid token' }); }
|
|
481
|
+
const { file } = req.body || {};
|
|
482
|
+
if (!file || typeof file !== 'string') return res.status(400).json({ error: 'file (backup filename) is required' });
|
|
483
|
+
try {
|
|
484
|
+
const result = await restoreBackup(file, core.backup);
|
|
485
|
+
if (!result.success) return res.status(404).json(result);
|
|
486
|
+
res.json(result);
|
|
487
|
+
} catch (err) {
|
|
488
|
+
res.status(500).json({ error: err.message });
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
app.get('/admin/backups', adminRateLimiter, (req, res) => {
|
|
493
|
+
const header = req.headers.authorization;
|
|
494
|
+
if (!header || !header.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
|
|
495
|
+
try { verifyToken(header.slice(7)); } catch { return res.status(401).json({ error: 'Invalid token' }); }
|
|
496
|
+
try {
|
|
497
|
+
res.json(listBackups(core.backup));
|
|
498
|
+
} catch (err) {
|
|
499
|
+
res.status(500).json({ error: err.message });
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
372
503
|
// ── Admin seed endpoint ─────────────────────────────────────────────
|
|
373
504
|
app.post('/admin/seed', adminRateLimiter, async (req, res) => {
|
|
374
505
|
const header = req.headers.authorization;
|
|
@@ -503,14 +634,14 @@ async function buildApp(yamlPath, reloadFn) {
|
|
|
503
634
|
return { app, core };
|
|
504
635
|
}
|
|
505
636
|
|
|
506
|
-
async function createServer(
|
|
507
|
-
const { app, core } = await buildApp(
|
|
637
|
+
async function createServer(configPath) {
|
|
638
|
+
const { app, core } = await buildApp(configPath, null);
|
|
508
639
|
const server = http.createServer(app);
|
|
509
640
|
initRealtime(server);
|
|
510
641
|
return { app, server, core };
|
|
511
642
|
}
|
|
512
643
|
|
|
513
|
-
async function startServer(
|
|
644
|
+
async function startServer(configPath) {
|
|
514
645
|
// ── Dispatcher pattern ───────────────────────────────────────────────
|
|
515
646
|
// The HTTP server and WebSocket server are created once and never replaced.
|
|
516
647
|
// Hot reload works by rebuilding the Express app and swapping the handler
|
|
@@ -523,7 +654,7 @@ async function startServer(yamlPath) {
|
|
|
523
654
|
|
|
524
655
|
async function reload() {
|
|
525
656
|
logger.info('Reloading config…');
|
|
526
|
-
const result = await buildApp(
|
|
657
|
+
const result = await buildApp(configPath, reload);
|
|
527
658
|
currentApp = result.app;
|
|
528
659
|
logger.info(`Config loaded: "${result.core.name}"`);
|
|
529
660
|
return result;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { buildCore } = require('../core/entity-engine');
|
|
8
|
+
const dbModule = require('../core/db');
|
|
9
|
+
const { getBackupDir, createBackup, restoreBackup, listBackups } = require('../core/backup');
|
|
10
|
+
const { validateSchema } = require('../core/schema-validator');
|
|
11
|
+
|
|
12
|
+
// ── Backup module ──────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
describe('backup module', () => {
|
|
15
|
+
let tmpDb;
|
|
16
|
+
let tmpBackupDir;
|
|
17
|
+
const core = buildCore({
|
|
18
|
+
name: 'BackupTest',
|
|
19
|
+
entities: { Widget: { properties: ['name'] } },
|
|
20
|
+
backup: {},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
before(async () => {
|
|
24
|
+
tmpDb = path.join(os.tmpdir(), `chadstart-backup-test-${Date.now()}.db`);
|
|
25
|
+
tmpBackupDir = path.join(os.tmpdir(), `chadstart-backups-${Date.now()}`);
|
|
26
|
+
await dbModule.initDb(core, tmpDb);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
after(() => {
|
|
30
|
+
try { fs.unlinkSync(tmpDb); } catch { /* noop */ }
|
|
31
|
+
// Clean up backup dir
|
|
32
|
+
try {
|
|
33
|
+
if (fs.existsSync(tmpBackupDir)) {
|
|
34
|
+
for (const f of fs.readdirSync(tmpBackupDir)) {
|
|
35
|
+
fs.unlinkSync(path.join(tmpBackupDir, f));
|
|
36
|
+
}
|
|
37
|
+
fs.rmdirSync(tmpBackupDir);
|
|
38
|
+
}
|
|
39
|
+
} catch { /* noop */ }
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('getBackupDir creates directory', () => {
|
|
43
|
+
const dir = getBackupDir({ dir: tmpBackupDir });
|
|
44
|
+
assert.ok(fs.existsSync(dir));
|
|
45
|
+
assert.strictEqual(dir, tmpBackupDir);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('getBackupDir uses default when no config', () => {
|
|
49
|
+
const dir = getBackupDir(null);
|
|
50
|
+
assert.ok(typeof dir === 'string');
|
|
51
|
+
assert.ok(dir.includes('backups'));
|
|
52
|
+
// Clean up the auto-created default dir
|
|
53
|
+
try { fs.rmdirSync(path.resolve('backups')); } catch { /* may not exist or not empty */ }
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('createBackup creates a backup file (SQLite)', async () => {
|
|
57
|
+
// Insert some data first
|
|
58
|
+
await dbModule.create('widget', { name: 'Backup Test Item' });
|
|
59
|
+
|
|
60
|
+
const result = await createBackup({ dir: tmpBackupDir });
|
|
61
|
+
assert.ok(result.file);
|
|
62
|
+
assert.ok(result.file.startsWith('backup-'));
|
|
63
|
+
assert.ok(result.file.endsWith('.db'));
|
|
64
|
+
assert.ok(result.size > 0);
|
|
65
|
+
assert.strictEqual(result.engine, 'sqlite');
|
|
66
|
+
assert.ok(fs.existsSync(result.path));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('listBackups returns the backup we just created', () => {
|
|
70
|
+
const backups = listBackups({ dir: tmpBackupDir });
|
|
71
|
+
assert.ok(backups.length >= 1);
|
|
72
|
+
assert.ok(backups[0].file.startsWith('backup-'));
|
|
73
|
+
assert.ok(backups[0].size > 0);
|
|
74
|
+
assert.ok(backups[0].createdAt);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('listBackups sorts newest first', async () => {
|
|
78
|
+
// Create a second backup
|
|
79
|
+
await createBackup({ dir: tmpBackupDir });
|
|
80
|
+
const backups = listBackups({ dir: tmpBackupDir });
|
|
81
|
+
assert.ok(backups.length >= 2);
|
|
82
|
+
// Newest first
|
|
83
|
+
assert.ok(backups[0].createdAt >= backups[1].createdAt);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('listBackups returns empty for non-existent dir', () => {
|
|
87
|
+
const nonExistDir = path.join(os.tmpdir(), `nonexistent-${Date.now()}`);
|
|
88
|
+
const backups = listBackups({ dir: nonExistDir });
|
|
89
|
+
// getBackupDir creates the dir, so it returns empty array
|
|
90
|
+
assert.ok(Array.isArray(backups));
|
|
91
|
+
assert.strictEqual(backups.length, 0);
|
|
92
|
+
// Clean up
|
|
93
|
+
try { fs.rmdirSync(nonExistDir); } catch { /* noop */ }
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('restoreBackup fails for non-existent file', async () => {
|
|
97
|
+
const result = await restoreBackup('nonexistent.db', { dir: tmpBackupDir });
|
|
98
|
+
assert.strictEqual(result.success, false);
|
|
99
|
+
assert.ok(result.message.includes('not found'));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('restoreBackup prevents path traversal', async () => {
|
|
103
|
+
const result = await restoreBackup('../../etc/passwd', { dir: tmpBackupDir });
|
|
104
|
+
assert.strictEqual(result.success, false);
|
|
105
|
+
// basename('../../etc/passwd') = 'passwd', which won't exist in backup dir
|
|
106
|
+
assert.ok(result.message.includes('not found'));
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── Schema validation ───────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
describe('schema: backup', () => {
|
|
113
|
+
it('accepts config without backup section', () => {
|
|
114
|
+
assert.strictEqual(validateSchema({ name: 'App' }), true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('accepts backup with dir', () => {
|
|
118
|
+
assert.strictEqual(validateSchema({ name: 'App', backup: { dir: 'my-backups' } }), true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('accepts empty backup object', () => {
|
|
122
|
+
assert.strictEqual(validateSchema({ name: 'App', backup: {} }), true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('rejects unknown backup key', () => {
|
|
126
|
+
assert.throws(() => validateSchema({
|
|
127
|
+
name: 'App',
|
|
128
|
+
backup: { schedule: '0 3 * * *' },
|
|
129
|
+
}));
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ── buildCore: backup passthrough ───────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
describe('buildCore: backup passthrough', () => {
|
|
136
|
+
it('exposes backup config when provided', () => {
|
|
137
|
+
const core = buildCore({ name: 'App', backup: { dir: 'my-backups' } });
|
|
138
|
+
assert.ok(core.backup);
|
|
139
|
+
assert.strictEqual(core.backup.dir, 'my-backups');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('sets backup to null when not provided', () => {
|
|
143
|
+
const core = buildCore({ name: 'App' });
|
|
144
|
+
assert.strictEqual(core.backup, null);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const {
|
|
8
|
+
CONFIG_FILENAMES,
|
|
9
|
+
detectFormat,
|
|
10
|
+
isWritableFormat,
|
|
11
|
+
discoverConfigFile,
|
|
12
|
+
loadConfig,
|
|
13
|
+
saveConfig,
|
|
14
|
+
parseRaw,
|
|
15
|
+
} = require('../core/config-loader');
|
|
16
|
+
|
|
17
|
+
function tmpPath(ext) {
|
|
18
|
+
return path.join(os.tmpdir(), `chadstart-cl-${Date.now()}-${Math.random().toString(36).slice(2)}${ext}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('config-loader', () => {
|
|
22
|
+
// ── detectFormat ─────────────────────────────────────────────────────────
|
|
23
|
+
describe('detectFormat', () => {
|
|
24
|
+
it('detects .yaml as yaml', () => assert.strictEqual(detectFormat('app.yaml'), 'yaml'));
|
|
25
|
+
it('detects .yml as yaml', () => assert.strictEqual(detectFormat('app.yml'), 'yaml'));
|
|
26
|
+
it('detects .json as json', () => assert.strictEqual(detectFormat('app.json'), 'json'));
|
|
27
|
+
it('detects .json5 as json5', () => assert.strictEqual(detectFormat('app.json5'), 'json5'));
|
|
28
|
+
it('detects .jsonnet as jsonnet', () => assert.strictEqual(detectFormat('app.jsonnet'), 'jsonnet'));
|
|
29
|
+
it('detects .config.js as js', () => assert.strictEqual(detectFormat('chadstart.config.js'), 'js'));
|
|
30
|
+
it('detects .config.cjs as js', () => assert.strictEqual(detectFormat('chadstart.config.cjs'), 'js'));
|
|
31
|
+
it('defaults to yaml for unknown extensions', () => assert.strictEqual(detectFormat('app.txt'), 'yaml'));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// ── isWritableFormat ─────────────────────────────────────────────────────
|
|
35
|
+
describe('isWritableFormat', () => {
|
|
36
|
+
it('yaml is writable', () => assert.ok(isWritableFormat('yaml')));
|
|
37
|
+
it('json is writable', () => assert.ok(isWritableFormat('json')));
|
|
38
|
+
it('json5 is writable', () => assert.ok(isWritableFormat('json5')));
|
|
39
|
+
it('jsonnet is not writable', () => assert.ok(!isWritableFormat('jsonnet')));
|
|
40
|
+
it('js is not writable', () => assert.ok(!isWritableFormat('js')));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ── discoverConfigFile ───────────────────────────────────────────────────
|
|
44
|
+
describe('discoverConfigFile', () => {
|
|
45
|
+
let tmpDir;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cs-disc-'));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns null when no config file exists', () => {
|
|
56
|
+
assert.strictEqual(discoverConfigFile(tmpDir), null);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('discovers chadstart.yaml', () => {
|
|
60
|
+
fs.writeFileSync(path.join(tmpDir, 'chadstart.yaml'), 'name: Test\n');
|
|
61
|
+
assert.strictEqual(discoverConfigFile(tmpDir), path.join(tmpDir, 'chadstart.yaml'));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('discovers chadstart.json', () => {
|
|
65
|
+
fs.writeFileSync(path.join(tmpDir, 'chadstart.json'), '{"name":"Test"}');
|
|
66
|
+
assert.strictEqual(discoverConfigFile(tmpDir), path.join(tmpDir, 'chadstart.json'));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('discovers chadstart.json5', () => {
|
|
70
|
+
fs.writeFileSync(path.join(tmpDir, 'chadstart.json5'), '{name:"Test"}');
|
|
71
|
+
assert.strictEqual(discoverConfigFile(tmpDir), path.join(tmpDir, 'chadstart.json5'));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('prefers yaml over json when both exist', () => {
|
|
75
|
+
fs.writeFileSync(path.join(tmpDir, 'chadstart.yaml'), 'name: YAML\n');
|
|
76
|
+
fs.writeFileSync(path.join(tmpDir, 'chadstart.json'), '{"name":"JSON"}');
|
|
77
|
+
const found = discoverConfigFile(tmpDir);
|
|
78
|
+
assert.strictEqual(path.basename(found), 'chadstart.yaml');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('falls back to json when yaml is absent', () => {
|
|
82
|
+
fs.writeFileSync(path.join(tmpDir, 'chadstart.json'), '{"name":"JSON"}');
|
|
83
|
+
const found = discoverConfigFile(tmpDir);
|
|
84
|
+
assert.strictEqual(path.basename(found), 'chadstart.json');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── loadConfig ───────────────────────────────────────────────────────────
|
|
89
|
+
describe('loadConfig', () => {
|
|
90
|
+
it('loads the existing chadstart.yaml', () => {
|
|
91
|
+
const config = loadConfig(path.resolve(__dirname, '..', 'chadstart.yaml'));
|
|
92
|
+
assert.strictEqual(typeof config.name, 'string');
|
|
93
|
+
assert.ok(config.entities);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('throws on missing file', () => {
|
|
97
|
+
assert.throws(() => loadConfig('/nonexistent.yaml'), /not found/i);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('loads a JSON config file', () => {
|
|
101
|
+
const tmp = tmpPath('.json');
|
|
102
|
+
fs.writeFileSync(tmp, JSON.stringify({ name: 'JSONApp', port: 4000 }), 'utf8');
|
|
103
|
+
try {
|
|
104
|
+
const config = loadConfig(tmp);
|
|
105
|
+
assert.strictEqual(config.name, 'JSONApp');
|
|
106
|
+
assert.strictEqual(config.port, 4000);
|
|
107
|
+
} finally {
|
|
108
|
+
fs.unlinkSync(tmp);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('loads a JSON5 config file', () => {
|
|
113
|
+
const tmp = tmpPath('.json5');
|
|
114
|
+
fs.writeFileSync(tmp, '{\n // App name\n name: "JSON5App",\n port: 5000,\n}\n', 'utf8');
|
|
115
|
+
try {
|
|
116
|
+
const config = loadConfig(tmp);
|
|
117
|
+
assert.strictEqual(config.name, 'JSON5App');
|
|
118
|
+
assert.strictEqual(config.port, 5000);
|
|
119
|
+
} finally {
|
|
120
|
+
fs.unlinkSync(tmp);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('loads a .config.js config file', () => {
|
|
125
|
+
const tmp = path.join(os.tmpdir(), `chadstart-cl-${Date.now()}.config.js`);
|
|
126
|
+
fs.writeFileSync(tmp, 'module.exports = { name: "JSConfig", port: 6000 };\n', 'utf8');
|
|
127
|
+
try {
|
|
128
|
+
const config = loadConfig(tmp);
|
|
129
|
+
assert.strictEqual(config.name, 'JSConfig');
|
|
130
|
+
assert.strictEqual(config.port, 6000);
|
|
131
|
+
} finally {
|
|
132
|
+
fs.unlinkSync(tmp);
|
|
133
|
+
try { delete require.cache[require.resolve(tmp)]; } catch { /* */ }
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('loads a YAML (.yml) config file', () => {
|
|
138
|
+
const tmp = tmpPath('.yml');
|
|
139
|
+
fs.writeFileSync(tmp, 'name: YMLApp\nport: 7000\n', 'utf8');
|
|
140
|
+
try {
|
|
141
|
+
const config = loadConfig(tmp);
|
|
142
|
+
assert.strictEqual(config.name, 'YMLApp');
|
|
143
|
+
assert.strictEqual(config.port, 7000);
|
|
144
|
+
} finally {
|
|
145
|
+
fs.unlinkSync(tmp);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── saveConfig ───────────────────────────────────────────────────────────
|
|
151
|
+
describe('saveConfig', () => {
|
|
152
|
+
it('saves and round-trips a YAML file', () => {
|
|
153
|
+
const tmp = tmpPath('.yaml');
|
|
154
|
+
try {
|
|
155
|
+
saveConfig(tmp, { name: 'SaveYAML', port: 3000 });
|
|
156
|
+
const config = loadConfig(tmp);
|
|
157
|
+
assert.strictEqual(config.name, 'SaveYAML');
|
|
158
|
+
assert.strictEqual(config.port, 3000);
|
|
159
|
+
} finally {
|
|
160
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('saves and round-trips a JSON file', () => {
|
|
165
|
+
const tmp = tmpPath('.json');
|
|
166
|
+
try {
|
|
167
|
+
saveConfig(tmp, { name: 'SaveJSON', port: 4000 });
|
|
168
|
+
const raw = fs.readFileSync(tmp, 'utf8');
|
|
169
|
+
assert.ok(raw.includes('"name"'), 'JSON file should contain double-quoted keys');
|
|
170
|
+
const config = loadConfig(tmp);
|
|
171
|
+
assert.strictEqual(config.name, 'SaveJSON');
|
|
172
|
+
assert.strictEqual(config.port, 4000);
|
|
173
|
+
} finally {
|
|
174
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('saves and round-trips a JSON5 file', () => {
|
|
179
|
+
const tmp = tmpPath('.json5');
|
|
180
|
+
try {
|
|
181
|
+
saveConfig(tmp, { name: 'SaveJSON5', port: 5000 });
|
|
182
|
+
const config = loadConfig(tmp);
|
|
183
|
+
assert.strictEqual(config.name, 'SaveJSON5');
|
|
184
|
+
assert.strictEqual(config.port, 5000);
|
|
185
|
+
} finally {
|
|
186
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('throws for read-only formats (js)', () => {
|
|
191
|
+
const tmp = path.join(os.tmpdir(), `chadstart-cl-${Date.now()}.config.js`);
|
|
192
|
+
assert.throws(
|
|
193
|
+
() => saveConfig(tmp, { name: 'Nope' }),
|
|
194
|
+
/read-only/i,
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('throws for read-only formats (jsonnet)', () => {
|
|
199
|
+
const tmp = tmpPath('.jsonnet');
|
|
200
|
+
assert.throws(
|
|
201
|
+
() => saveConfig(tmp, { name: 'Nope' }),
|
|
202
|
+
/read-only/i,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('preserves YAML comments in unchanged sections', () => {
|
|
207
|
+
const tmp = tmpPath('.yaml');
|
|
208
|
+
try {
|
|
209
|
+
fs.writeFileSync(tmp, '# App name\nname: Blog\n\n# Port\nport: 3000\n', 'utf8');
|
|
210
|
+
saveConfig(tmp, { name: 'Blog', port: 4000 });
|
|
211
|
+
const raw = fs.readFileSync(tmp, 'utf8');
|
|
212
|
+
assert.ok(raw.includes('# App name'), 'Comment on name should be preserved');
|
|
213
|
+
assert.ok(raw.includes('# Port'), 'Comment on port should be preserved');
|
|
214
|
+
const config = loadConfig(tmp);
|
|
215
|
+
assert.strictEqual(config.port, 4000);
|
|
216
|
+
} finally {
|
|
217
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ── parseRaw ─────────────────────────────────────────────────────────────
|
|
223
|
+
describe('parseRaw', () => {
|
|
224
|
+
it('parses raw YAML', () => {
|
|
225
|
+
const obj = parseRaw('name: Test\nport: 3000\n', 'yaml');
|
|
226
|
+
assert.strictEqual(obj.name, 'Test');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('parses raw JSON', () => {
|
|
230
|
+
const obj = parseRaw('{"name":"Test","port":3000}', 'json');
|
|
231
|
+
assert.strictEqual(obj.name, 'Test');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('parses raw JSON5', () => {
|
|
235
|
+
const obj = parseRaw('{name:"Test",port:3000}', 'json5');
|
|
236
|
+
assert.strictEqual(obj.name, 'Test');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('defaults to YAML for unknown format', () => {
|
|
240
|
+
const obj = parseRaw('name: Fallback\n', 'unknown');
|
|
241
|
+
assert.strictEqual(obj.name, 'Fallback');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ── CONFIG_FILENAMES ─────────────────────────────────────────────────────
|
|
246
|
+
describe('CONFIG_FILENAMES', () => {
|
|
247
|
+
it('includes yaml, yml, json, json5, jsonnet, and js formats', () => {
|
|
248
|
+
assert.ok(CONFIG_FILENAMES.includes('chadstart.yaml'));
|
|
249
|
+
assert.ok(CONFIG_FILENAMES.includes('chadstart.yml'));
|
|
250
|
+
assert.ok(CONFIG_FILENAMES.includes('chadstart.json'));
|
|
251
|
+
assert.ok(CONFIG_FILENAMES.includes('chadstart.json5'));
|
|
252
|
+
assert.ok(CONFIG_FILENAMES.includes('chadstart.jsonnet'));
|
|
253
|
+
assert.ok(CONFIG_FILENAMES.includes('chadstart.config.js'));
|
|
254
|
+
assert.ok(CONFIG_FILENAMES.includes('chadstart.config.cjs'));
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
});
|