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.
@@ -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 { loadYaml, saveYaml } = require('../core/yaml-loader');
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 YAML config.
87
+ * Build an Express application for the given config file.
85
88
  *
86
- * @param {string} yamlPath Path to the chadstart.yaml file.
87
- * @param {Function|null} reloadFn When provided, the PUT /admin/config route will
88
- * trigger this callback after saving so the running
89
- * server picks up the new config without a restart.
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(yamlPath, reloadFn) {
93
- const config = loadYaml(yamlPath);
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(yamlPath), core.database)
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 YAML config as JSON (auth required)
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.json(loadYaml(yamlPath));
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
- // PUT /admin/config — receive JSON config, validate, save as YAML, then hot-reload
252
+ // GET /admin/config-inforeturn 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
- saveYaml(yamlPath, newConfig);
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: 'Failed to save config' });
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>&quot;${safeName}&quot;</strong>.</p><p>If you received this, your SMTP configuration is working correctly. &#x2705;</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(yamlPath) {
507
- const { app, core } = await buildApp(yamlPath, null);
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(yamlPath) {
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(yamlPath, reload);
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
+ });