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/core/auth.js CHANGED
@@ -7,6 +7,10 @@
7
7
  * POST /api/auth/:slug/signup
8
8
  * POST /api/auth/:slug/login
9
9
  * GET /api/auth/:slug/me
10
+ * POST /api/auth/:slug/request-verification
11
+ * POST /api/auth/:slug/confirm-verification
12
+ * POST /api/auth/:slug/request-password-reset
13
+ * POST /api/auth/:slug/confirm-password-reset
10
14
  * GET /api/auth/:slug/api-keys
11
15
  * POST /api/auth/:slug/api-keys
12
16
  * DELETE /api/auth/:slug/api-keys/:id
@@ -42,6 +46,25 @@ function signToken(payload, expiresIn) {
42
46
  }
43
47
  function verifyToken(token) { return jwt.verify(token, JWT_SECRET); }
44
48
 
49
+ /** Generate a cryptographically secure random hex token. */
50
+ function generateSecureToken() {
51
+ return crypto.randomBytes(32).toString('hex');
52
+ }
53
+
54
+ // ─── Default email templates ─────────────────────────────────────────────────
55
+
56
+ const DEFAULT_VERIFICATION_TEMPLATE = {
57
+ subject: 'Verify your email for {{appName}}',
58
+ text: 'Hi {{name}},\n\nPlease verify your email address by using the following token:\n\n{{token}}\n\nOr click this link: {{link}}\n\nThanks,\n{{appName}}',
59
+ html: '<h2>Verify your email</h2><p>Hi {{name}},</p><p>Please verify your email address by clicking the link below:</p><p><a href="{{link}}">Verify Email</a></p><p>Or use this token: <code>{{token}}</code></p><p>Thanks,<br>{{appName}}</p>',
60
+ };
61
+
62
+ const DEFAULT_PASSWORD_RESET_TEMPLATE = {
63
+ subject: 'Reset your password for {{appName}}',
64
+ text: 'Hi {{name}},\n\nYou requested a password reset. Use the following token:\n\n{{token}}\n\nOr click this link: {{link}}\n\nThis link expires in 1 hour.\n\nIf you did not request this, please ignore this email.\n\nThanks,\n{{appName}}',
65
+ html: '<h2>Reset your password</h2><p>Hi {{name}},</p><p>You requested a password reset. Click the link below:</p><p><a href="{{link}}">Reset Password</a></p><p>Or use this token: <code>{{token}}</code></p><p>This link expires in 1 hour.</p><p>If you did not request this, please ignore this email.</p><p>Thanks,<br>{{appName}}</p>',
66
+ };
67
+
45
68
  // ─── API Keys ─────────────────────────────────────────────────────────────────
46
69
 
47
70
  /**
@@ -231,6 +254,34 @@ function registerApiKeyRoutes(app, core) {
231
254
 
232
255
  function registerAuthRoutes(app, core, emit) {
233
256
  const _emit = typeof emit === 'function' ? emit : () => {};
257
+
258
+ // Lazily load email module to avoid circular dependency at module load time
259
+ let _emailMod;
260
+ function _getEmail() {
261
+ if (!_emailMod) _emailMod = require('./email');
262
+ return _emailMod;
263
+ }
264
+
265
+ /** Try to send an email — logs and swallows errors so auth flow still succeeds when SMTP is down. */
266
+ async function _trySend(opts) {
267
+ try {
268
+ await _getEmail().sendEmail(opts);
269
+ } catch (e) {
270
+ logger.warn(`Email send failed (${opts.to}): ${e.message}`);
271
+ }
272
+ }
273
+
274
+ /** Merge user-defined template with built-in default. */
275
+ function _tpl(kind) {
276
+ const defaults = kind === 'verification' ? DEFAULT_VERIFICATION_TEMPLATE : DEFAULT_PASSWORD_RESET_TEMPLATE;
277
+ const custom = ((core.email || {}).templates || {})[kind] || {};
278
+ return {
279
+ subject: custom.subject || defaults.subject,
280
+ text: custom.text || defaults.text,
281
+ html: custom.html || defaults.html,
282
+ };
283
+ }
284
+
234
285
  for (const entity of Object.values(core.authenticableEntities || {})) {
235
286
  const slug = entity.slug;
236
287
  const table = entity.tableName;
@@ -240,34 +291,135 @@ function registerAuthRoutes(app, core, emit) {
240
291
  const signupPolicies = (entity.policies || {}).signup;
241
292
  const signupForbidden = signupPolicies && signupPolicies.length > 0 && signupPolicies[0].access === 'forbidden';
242
293
 
294
+ // ── Signup ────────────────────────────────────────────────────────────
243
295
  app.post(`/api/auth/${slug}/signup`, async (req, res) => {
244
296
  try {
245
297
  if (signupForbidden) return res.status(403).json({ error: 'Signup is forbidden for this entity' });
246
298
  const { email, password, ...rest } = req.body || {};
247
299
  if (!email || !password) return res.status(400).json({ error: 'email and password are required' });
248
300
  if ((await db.findAllSimple(table, { email })).length) return res.status(409).json({ error: 'Email already registered' });
249
- const user = await db.create(table, { email, password: await bcrypt.hash(password, BCRYPT_ROUNDS), ...sanitize(rest) });
301
+
302
+ // Generate email verification token
303
+ const verificationToken = generateSecureToken();
304
+ const user = await db.create(table, {
305
+ email,
306
+ password: await bcrypt.hash(password, BCRYPT_ROUNDS),
307
+ emailVerified: 0,
308
+ emailVerificationToken: verificationToken,
309
+ ...sanitize(rest),
310
+ });
250
311
  _emit(`${entity.name}.created`, omitPassword(user));
312
+
313
+ // Send verification email (best-effort — does not block signup)
314
+ const tpl = _tpl('verification');
315
+ const vars = { appName: core.name, name: email, token: verificationToken, link: `${_appUrl()}/verify?token=${verificationToken}` };
316
+ _trySend({ to: email, subject: tpl.subject, text: tpl.text, html: tpl.html, vars });
317
+
251
318
  res.status(201).json({ token: signToken({ id: user.id, entity: entity.name }), user: omitPassword(user) });
252
319
  } catch (e) { logger.error('signup error', e.message); res.status(500).json({ error: e.message }); }
253
320
  });
254
321
 
322
+ // ── Login ─────────────────────────────────────────────────────────────
255
323
  app.post(`/api/auth/${slug}/login`, async (req, res) => {
256
324
  try {
257
325
  const { email, password } = req.body || {};
258
326
  if (!email || !password) return res.status(400).json({ error: 'email and password are required' });
259
327
  const user = (await db.findAllSimple(table, { email }))[0];
260
328
  if (!user || !(await bcrypt.compare(password, user.password))) return res.status(401).json({ error: 'Invalid credentials' });
329
+
330
+ // Block login if email verification is required but not done
331
+ if (entity.requireEmailVerification && !user.emailVerified) {
332
+ return res.status(403).json({ error: 'Email not verified. Please verify your email before logging in.' });
333
+ }
334
+
261
335
  res.json({ token: signToken({ id: user.id, entity: entity.name }), user: omitPassword(user) });
262
336
  } catch (e) { logger.error('login error', e.message); res.status(500).json({ error: e.message }); }
263
337
  });
264
338
 
339
+ // ── Me ─────────────────────────────────────────────────────────────────
265
340
  app.get(`/api/auth/${slug}/me`, requireAuth(entity.name), async (req, res) => {
266
341
  const user = await db.findById(table, req.user.id);
267
342
  if (!user) return res.status(404).json({ error: 'User not found' });
268
343
  res.json(omitPassword(user));
269
344
  });
270
345
 
346
+ // ── Request Verification ──────────────────────────────────────────────
347
+ app.post(`/api/auth/${slug}/request-verification`, requireAuth(entity.name), async (req, res) => {
348
+ try {
349
+ const user = await db.findById(table, req.user.id);
350
+ if (!user) return res.status(404).json({ error: 'User not found' });
351
+ if (user.emailVerified) return res.json({ message: 'Email already verified' });
352
+
353
+ const token = generateSecureToken();
354
+ await db.update(table, user.id, { emailVerificationToken: token });
355
+
356
+ const tpl = _tpl('verification');
357
+ const vars = { appName: core.name, name: user.email, token, link: `${_appUrl()}/verify?token=${token}` };
358
+ await _trySend({ to: user.email, subject: tpl.subject, text: tpl.text, html: tpl.html, vars });
359
+
360
+ res.json({ message: 'Verification email sent' });
361
+ } catch (e) { logger.error('request-verification error', e.message); res.status(500).json({ error: e.message }); }
362
+ });
363
+
364
+ // ── Confirm Verification ──────────────────────────────────────────────
365
+ app.post(`/api/auth/${slug}/confirm-verification`, async (req, res) => {
366
+ try {
367
+ const { token } = req.body || {};
368
+ if (!token) return res.status(400).json({ error: 'token is required' });
369
+
370
+ const user = (await db.findAllSimple(table, { emailVerificationToken: token }))[0];
371
+ if (!user) return res.status(400).json({ error: 'Invalid or expired verification token' });
372
+
373
+ await db.update(table, user.id, { emailVerified: 1, emailVerificationToken: null });
374
+ res.json({ message: 'Email verified successfully' });
375
+ } catch (e) { logger.error('confirm-verification error', e.message); res.status(500).json({ error: e.message }); }
376
+ });
377
+
378
+ // ── Request Password Reset ────────────────────────────────────────────
379
+ app.post(`/api/auth/${slug}/request-password-reset`, async (req, res) => {
380
+ try {
381
+ const { email } = req.body || {};
382
+ if (!email) return res.status(400).json({ error: 'email is required' });
383
+
384
+ // Always return 200 to avoid leaking whether email exists
385
+ const user = (await db.findAllSimple(table, { email }))[0];
386
+ if (user) {
387
+ const token = generateSecureToken();
388
+ const expiry = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
389
+ await db.update(table, user.id, { passwordResetToken: token, passwordResetExpiry: expiry });
390
+
391
+ const tpl = _tpl('passwordReset');
392
+ const vars = { appName: core.name, name: user.email, token, link: `${_appUrl()}/reset-password?token=${token}` };
393
+ _trySend({ to: user.email, subject: tpl.subject, text: tpl.text, html: tpl.html, vars });
394
+ }
395
+
396
+ res.json({ message: 'If an account with that email exists, a password reset email has been sent.' });
397
+ } catch (e) { logger.error('request-password-reset error', e.message); res.status(500).json({ error: e.message }); }
398
+ });
399
+
400
+ // ── Confirm Password Reset ────────────────────────────────────────────
401
+ app.post(`/api/auth/${slug}/confirm-password-reset`, async (req, res) => {
402
+ try {
403
+ const { token, password } = req.body || {};
404
+ if (!token || !password) return res.status(400).json({ error: 'token and password are required' });
405
+
406
+ const user = (await db.findAllSimple(table, { passwordResetToken: token }))[0];
407
+ if (!user) return res.status(400).json({ error: 'Invalid or expired reset token' });
408
+
409
+ // Check expiry
410
+ if (!user.passwordResetExpiry || new Date(user.passwordResetExpiry) < new Date()) {
411
+ return res.status(400).json({ error: 'Reset token has expired' });
412
+ }
413
+
414
+ await db.update(table, user.id, {
415
+ password: await bcrypt.hash(password, BCRYPT_ROUNDS),
416
+ passwordResetToken: null,
417
+ passwordResetExpiry: null,
418
+ });
419
+ res.json({ message: 'Password reset successfully' });
420
+ } catch (e) { logger.error('confirm-password-reset error', e.message); res.status(500).json({ error: e.message }); }
421
+ });
422
+
271
423
  logger.info(` Registered auth routes at /api/auth/${slug}/`);
272
424
  }
273
425
  }
@@ -294,13 +446,18 @@ async function optionalAuth(req, _res, next) {
294
446
  }
295
447
 
296
448
  function omitPassword(user) {
297
- const { password: _, ...rest } = user;
449
+ const { password: _, emailVerificationToken: _2, passwordResetToken: _3, passwordResetExpiry: _4, ...rest } = user;
298
450
  return rest;
299
451
  }
300
452
 
453
+ /** Derive the app's base URL for email links. */
454
+ function _appUrl() {
455
+ return process.env.APP_URL || process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
456
+ }
457
+
301
458
  module.exports = {
302
459
  registerAuthRoutes, registerApiKeyRoutes, initApiKeys,
303
460
  requireAuth, optionalAuth, resolveAuthHeader,
304
- signToken, verifyToken, omitPassword, JWT_SECRET,
461
+ signToken, verifyToken, omitPassword, JWT_SECRET, generateSecureToken,
305
462
  createApiKey, listApiKeys, listAllApiKeys, deleteApiKey, verifyApiKeyStr,
306
463
  };
package/core/backup.js ADDED
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Backup & Restore module for ChadStart.
5
+ *
6
+ * Supports SQLite (file copy), PostgreSQL (pg_dump), and MySQL (mysqldump).
7
+ *
8
+ * Configuration via YAML `backup` section:
9
+ * backup:
10
+ * dir: backups # Directory for backup files (default: backups)
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const crypto = require('crypto');
16
+ const { execFileSync } = require('child_process');
17
+ const db = require('./db');
18
+ const logger = require('../utils/logger');
19
+
20
+ const DB_ENGINE = db.DB_ENGINE;
21
+
22
+ /**
23
+ * Get the backup directory. Creates it if it doesn't exist.
24
+ *
25
+ * @param {object|null} backupCfg Value of `core.backup` (may be null).
26
+ * @returns {string} Absolute path to backup directory.
27
+ */
28
+ function getBackupDir(backupCfg) {
29
+ const dir = (backupCfg && backupCfg.dir) || process.env.BACKUP_DIR || 'backups';
30
+ const resolved = path.resolve(dir);
31
+ fs.mkdirSync(resolved, { recursive: true });
32
+ return resolved;
33
+ }
34
+
35
+ /**
36
+ * Generate a backup filename with timestamp.
37
+ *
38
+ * @param {string} ext File extension (e.g. 'db', 'sql').
39
+ * @returns {string}
40
+ */
41
+ function generateBackupName(ext) {
42
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
43
+ const id = crypto.randomBytes(4).toString('hex');
44
+ return `backup-${ts}-${id}.${ext}`;
45
+ }
46
+
47
+ /**
48
+ * Create a backup of the database.
49
+ *
50
+ * @param {object|null} backupCfg Value of `core.backup` (may be null).
51
+ * @returns {{ file: string, path: string, size: number, engine: string }}
52
+ */
53
+ async function createBackup(backupCfg) {
54
+ const dir = getBackupDir(backupCfg);
55
+
56
+ if (DB_ENGINE === 'sqlite') {
57
+ const sqliteDb = db.getDb();
58
+ const name = generateBackupName('db');
59
+ const dest = path.join(dir, name);
60
+ await sqliteDb.backup(dest);
61
+ const stats = fs.statSync(dest);
62
+ logger.info(`Backup created: ${dest} (${stats.size} bytes)`);
63
+ return { file: name, path: dest, size: stats.size, engine: 'sqlite' };
64
+ }
65
+
66
+ if (DB_ENGINE === 'postgres') {
67
+ const name = generateBackupName('sql');
68
+ const dest = path.join(dir, name);
69
+ const args = [
70
+ '-h', process.env.DB_HOST || 'localhost',
71
+ '-p', process.env.DB_PORT || '5432',
72
+ '-U', process.env.DB_USERNAME || 'postgres',
73
+ '-d', process.env.DB_DATABASE || 'manifest',
74
+ '-f', dest,
75
+ ];
76
+ execFileSync('pg_dump', args, {
77
+ env: { ...process.env, PGPASSWORD: process.env.DB_PASSWORD || 'postgres' },
78
+ timeout: 120000,
79
+ });
80
+ const stats = fs.statSync(dest);
81
+ logger.info(`Backup created: ${dest} (${stats.size} bytes)`);
82
+ return { file: name, path: dest, size: stats.size, engine: 'postgres' };
83
+ }
84
+
85
+ if (DB_ENGINE === 'mysql') {
86
+ const name = generateBackupName('sql');
87
+ const dest = path.join(dir, name);
88
+ const args = [
89
+ '-h', process.env.DB_HOST || 'localhost',
90
+ '-P', process.env.DB_PORT || '3306',
91
+ '-u', process.env.DB_USERNAME || 'root',
92
+ `--result-file=${dest}`,
93
+ process.env.DB_DATABASE || 'manifest',
94
+ ];
95
+ const env = { ...process.env };
96
+ if (process.env.DB_PASSWORD) env.MYSQL_PWD = process.env.DB_PASSWORD;
97
+ execFileSync('mysqldump', args, { env, timeout: 120000 });
98
+ const stats = fs.statSync(dest);
99
+ logger.info(`Backup created: ${dest} (${stats.size} bytes)`);
100
+ return { file: name, path: dest, size: stats.size, engine: 'mysql' };
101
+ }
102
+
103
+ throw new Error(`Unsupported database engine for backup: ${DB_ENGINE}`);
104
+ }
105
+
106
+ /**
107
+ * Restore a database from a backup file.
108
+ *
109
+ * @param {string} backupFile Filename of the backup (relative to backup dir).
110
+ * @param {object|null} backupCfg Value of `core.backup` (may be null).
111
+ * @returns {{ success: boolean, message: string }}
112
+ */
113
+ async function restoreBackup(backupFile, backupCfg) {
114
+ const dir = getBackupDir(backupCfg);
115
+ const src = path.join(dir, path.basename(backupFile)); // basename to prevent path traversal
116
+
117
+ if (!fs.existsSync(src)) {
118
+ return { success: false, message: `Backup file not found: ${backupFile}` };
119
+ }
120
+
121
+ if (DB_ENGINE === 'sqlite') {
122
+ const sqliteDb = db.getDb();
123
+ // better-sqlite3 exposes .name as the file path of the opened database
124
+ const dbPath = sqliteDb.name;
125
+ // Close, copy, re-open would require server restart — use SQLite's deserialization
126
+ // For simplicity, copy the backup over the current DB file
127
+ sqliteDb.close();
128
+ fs.copyFileSync(src, dbPath);
129
+ logger.info(`Restored backup: ${src} → ${dbPath}`);
130
+ return { success: true, message: `Database restored from ${backupFile}. Server restart may be required.` };
131
+ }
132
+
133
+ if (DB_ENGINE === 'postgres') {
134
+ const args = [
135
+ '-h', process.env.DB_HOST || 'localhost',
136
+ '-p', process.env.DB_PORT || '5432',
137
+ '-U', process.env.DB_USERNAME || 'postgres',
138
+ '-d', process.env.DB_DATABASE || 'manifest',
139
+ '-f', src,
140
+ ];
141
+ execFileSync('psql', args, {
142
+ env: { ...process.env, PGPASSWORD: process.env.DB_PASSWORD || 'postgres' },
143
+ timeout: 120000,
144
+ });
145
+ logger.info(`Restored backup: ${src}`);
146
+ return { success: true, message: `Database restored from ${backupFile}` };
147
+ }
148
+
149
+ if (DB_ENGINE === 'mysql') {
150
+ const content = fs.readFileSync(src, 'utf-8');
151
+ const args = [
152
+ '-h', process.env.DB_HOST || 'localhost',
153
+ '-P', process.env.DB_PORT || '3306',
154
+ '-u', process.env.DB_USERNAME || 'root',
155
+ process.env.DB_DATABASE || 'manifest',
156
+ ];
157
+ const env = { ...process.env };
158
+ if (process.env.DB_PASSWORD) env.MYSQL_PWD = process.env.DB_PASSWORD;
159
+ execFileSync('mysql', args, { env, input: content, timeout: 120000 });
160
+ logger.info(`Restored backup: ${src}`);
161
+ return { success: true, message: `Database restored from ${backupFile}` };
162
+ }
163
+
164
+ return { success: false, message: `Unsupported database engine: ${DB_ENGINE}` };
165
+ }
166
+
167
+ /**
168
+ * List available backups.
169
+ *
170
+ * @param {object|null} backupCfg Value of `core.backup` (may be null).
171
+ * @returns {Array<{ file: string, size: number, createdAt: string }>}
172
+ */
173
+ function listBackups(backupCfg) {
174
+ const dir = getBackupDir(backupCfg);
175
+ if (!fs.existsSync(dir)) return [];
176
+
177
+ return fs.readdirSync(dir)
178
+ .filter((f) => f.startsWith('backup-'))
179
+ .map((f) => {
180
+ const stats = fs.statSync(path.join(dir, f));
181
+ return { file: f, size: stats.size, createdAt: stats.mtime.toISOString() };
182
+ })
183
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); // newest first
184
+ }
185
+
186
+ module.exports = {
187
+ getBackupDir,
188
+ createBackup,
189
+ restoreBackup,
190
+ listBackups,
191
+ };
@@ -0,0 +1,266 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const YAML = require('yaml');
6
+ const JSON5 = require('json5');
7
+ const logger = require('../utils/logger');
8
+
9
+ const JSONNET_TIMEOUT_MS = 10000;
10
+
11
+ // ─── Supported config file names (checked in priority order) ─────────────────
12
+
13
+ const CONFIG_FILENAMES = [
14
+ 'chadstart.yaml',
15
+ 'chadstart.yml',
16
+ 'chadstart.json',
17
+ 'chadstart.json5',
18
+ 'chadstart.jsonnet',
19
+ 'chadstart.config.js',
20
+ 'chadstart.config.cjs',
21
+ ];
22
+
23
+ // ─── Format detection ────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Map a file extension to a config format identifier.
27
+ *
28
+ * @param {string} filePath
29
+ * @returns {'yaml'|'json'|'json5'|'jsonnet'|'js'}
30
+ */
31
+ function detectFormat(filePath) {
32
+ const base = path.basename(filePath);
33
+ if (base.endsWith('.config.js') || base.endsWith('.config.cjs')) return 'js';
34
+ const ext = path.extname(filePath).toLowerCase();
35
+ switch (ext) {
36
+ case '.yaml':
37
+ case '.yml':
38
+ return 'yaml';
39
+ case '.json':
40
+ return 'json';
41
+ case '.json5':
42
+ return 'json5';
43
+ case '.jsonnet':
44
+ return 'jsonnet';
45
+ case '.js':
46
+ case '.cjs':
47
+ return 'js';
48
+ default:
49
+ return 'yaml'; // default fallback
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Returns true when the format supports writing back through saveConfig.
55
+ */
56
+ function isWritableFormat(format) {
57
+ return format === 'yaml' || format === 'json' || format === 'json5';
58
+ }
59
+
60
+ // ─── Auto-discovery ──────────────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Discover the first matching config file inside `dir`.
64
+ * Returns the absolute path, or null when nothing is found.
65
+ *
66
+ * @param {string} [dir=process.cwd()]
67
+ * @returns {string|null}
68
+ */
69
+ function discoverConfigFile(dir) {
70
+ const base = dir || process.cwd();
71
+ for (const name of CONFIG_FILENAMES) {
72
+ const candidate = path.resolve(base, name);
73
+ if (fs.existsSync(candidate)) return candidate;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ // ─── Parsers ─────────────────────────────────────────────────────────────────
79
+
80
+ function parseYaml(raw) {
81
+ return YAML.parse(raw);
82
+ }
83
+
84
+ function parseJson(raw) {
85
+ return JSON.parse(raw);
86
+ }
87
+
88
+ function parseJson5(raw) {
89
+ return JSON5.parse(raw);
90
+ }
91
+
92
+ function parseJsonnet(filePath) {
93
+ const { execFileSync } = require('child_process');
94
+ try {
95
+ const stdout = execFileSync('jsonnet', [filePath], {
96
+ stdio: ['pipe', 'pipe', 'pipe'],
97
+ timeout: JSONNET_TIMEOUT_MS,
98
+ }).toString();
99
+ return JSON.parse(stdout);
100
+ } catch (err) {
101
+ if (err.code === 'ENOENT') {
102
+ throw new Error(
103
+ 'Jsonnet config detected but the "jsonnet" CLI is not installed. ' +
104
+ 'Install it (https://jsonnet.org) or convert your config to YAML/JSON.',
105
+ );
106
+ }
107
+ throw new Error(`Failed to evaluate Jsonnet config: ${err.stderr || err.message}`);
108
+ }
109
+ }
110
+
111
+ function parseJsConfig(filePath) {
112
+ // Clear require cache so edits are picked up on hot-reload
113
+ try { delete require.cache[require.resolve(filePath)]; } catch { /* first load */ }
114
+ const mod = require(filePath);
115
+ const config = mod && mod.__esModule ? mod.default : mod;
116
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
117
+ throw new Error(`JS config must export a plain object: ${filePath}`);
118
+ }
119
+ return config;
120
+ }
121
+
122
+ // ─── Load ────────────────────────────────────────────────────────────────────
123
+
124
+ /**
125
+ * Load and parse a config file in any supported format.
126
+ *
127
+ * @param {string} filePath Absolute or relative path to the config file.
128
+ * @returns {object} Parsed config object.
129
+ */
130
+ function loadConfig(filePath) {
131
+ const resolved = path.resolve(filePath);
132
+ if (!fs.existsSync(resolved)) {
133
+ throw new Error(`Config file not found: ${resolved}`);
134
+ }
135
+
136
+ const format = detectFormat(resolved);
137
+ let config;
138
+
139
+ switch (format) {
140
+ case 'yaml': {
141
+ const raw = fs.readFileSync(resolved, 'utf8');
142
+ config = parseYaml(raw);
143
+ break;
144
+ }
145
+ case 'json': {
146
+ const raw = fs.readFileSync(resolved, 'utf8');
147
+ config = parseJson(raw);
148
+ break;
149
+ }
150
+ case 'json5': {
151
+ const raw = fs.readFileSync(resolved, 'utf8');
152
+ config = parseJson5(raw);
153
+ break;
154
+ }
155
+ case 'jsonnet':
156
+ config = parseJsonnet(resolved);
157
+ break;
158
+ case 'js':
159
+ config = parseJsConfig(resolved);
160
+ break;
161
+ default: {
162
+ const raw = fs.readFileSync(resolved, 'utf8');
163
+ config = parseYaml(raw);
164
+ }
165
+ }
166
+
167
+ logger.debug('Loaded config (%s) from %s', format, resolved);
168
+ return config;
169
+ }
170
+
171
+ // ─── Save ────────────────────────────────────────────────────────────────────
172
+
173
+ /**
174
+ * Save a config object back to disk in the file's native format.
175
+ *
176
+ * For YAML files, comments in unchanged top-level sections are preserved using
177
+ * the yaml Document API. JSON and JSON5 files are pretty-printed.
178
+ * Jsonnet and JS configs cannot be written back (they may contain logic).
179
+ *
180
+ * @param {string} filePath Path to the config file.
181
+ * @param {object} config Plain-JS config object (already validated).
182
+ */
183
+ function saveConfig(filePath, config) {
184
+ const resolved = path.resolve(filePath);
185
+ const format = detectFormat(resolved);
186
+
187
+ if (!isWritableFormat(format)) {
188
+ throw new Error(
189
+ `Cannot save config: ${format} format is read-only. ` +
190
+ 'Convert to YAML, JSON, or JSON5 to enable saving from the admin UI.',
191
+ );
192
+ }
193
+
194
+ switch (format) {
195
+ case 'yaml':
196
+ saveYamlFile(resolved, config);
197
+ break;
198
+ case 'json':
199
+ fs.writeFileSync(resolved, JSON.stringify(config, null, 2) + '\n', 'utf8');
200
+ break;
201
+ case 'json5':
202
+ fs.writeFileSync(resolved, JSON5.stringify(config, null, 2) + '\n', 'utf8');
203
+ break;
204
+ default:
205
+ break;
206
+ }
207
+
208
+ logger.debug('Saved config (%s) to %s', format, resolved);
209
+ }
210
+
211
+ /**
212
+ * YAML-specific save that preserves comments via the Document API.
213
+ * Extracted from the original yaml-loader.js.
214
+ */
215
+ function saveYamlFile(resolved, config) {
216
+ let doc;
217
+ if (fs.existsSync(resolved)) {
218
+ const raw = fs.readFileSync(resolved, 'utf8');
219
+ doc = YAML.parseDocument(raw);
220
+
221
+ const existing = doc.toJS() || {};
222
+ const existingKeys = Object.keys(existing);
223
+ const newKeys = Object.keys(config);
224
+
225
+ for (const key of newKeys) {
226
+ doc.set(key, config[key]);
227
+ }
228
+ for (const key of existingKeys) {
229
+ if (!newKeys.includes(key)) {
230
+ doc.delete(key);
231
+ }
232
+ }
233
+ } else {
234
+ doc = new YAML.Document(config);
235
+ }
236
+
237
+ fs.writeFileSync(resolved, doc.toString(), 'utf8');
238
+ }
239
+
240
+ // ─── Parse raw content by format (used by migrations git-show) ───────────────
241
+
242
+ /**
243
+ * Parse raw file content using the parser matching the given format.
244
+ *
245
+ * @param {string} raw Raw file content (UTF-8 string).
246
+ * @param {'yaml'|'json'|'json5'} format
247
+ * @returns {object}
248
+ */
249
+ function parseRaw(raw, format) {
250
+ switch (format) {
251
+ case 'json': return parseJson(raw);
252
+ case 'json5': return parseJson5(raw);
253
+ case 'yaml':
254
+ default: return parseYaml(raw);
255
+ }
256
+ }
257
+
258
+ module.exports = {
259
+ CONFIG_FILENAMES,
260
+ detectFormat,
261
+ isWritableFormat,
262
+ discoverConfigFile,
263
+ loadConfig,
264
+ saveConfig,
265
+ parseRaw,
266
+ };