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/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
|
-
|
|
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
|
+
};
|