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/db.js CHANGED
@@ -235,6 +235,10 @@ function buildColumnDefs(entity, allEntities) {
235
235
  if (entity.authenticable) {
236
236
  cols.push({ name: 'email', def: `${q('email')} ${authStrType()} NOT NULL UNIQUE` });
237
237
  cols.push({ name: 'password', def: `${q('password')} ${authStrType()} NOT NULL` });
238
+ cols.push({ name: 'emailVerified', def: `${q('emailVerified')} INTEGER DEFAULT 0` });
239
+ cols.push({ name: 'emailVerificationToken', def: `${q('emailVerificationToken')} TEXT` });
240
+ cols.push({ name: 'passwordResetToken', def: `${q('passwordResetToken')} TEXT` });
241
+ cols.push({ name: 'passwordResetExpiry', def: `${q('passwordResetExpiry')} TEXT` });
238
242
  }
239
243
 
240
244
  for (const p of entity.properties) {
package/core/email.js ADDED
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Email sending service for ChadStart.
5
+ *
6
+ * SMTP connection details can be configured in the YAML `email` section
7
+ * or via environment variables (env vars always take precedence):
8
+ *
9
+ * SMTP_HOST SMTP server hostname
10
+ * SMTP_PORT SMTP server port (default: 587)
11
+ * SMTP_USER SMTP username / login
12
+ * SMTP_PASS SMTP password (secret — env var only)
13
+ * SMTP_FROM Default "From" address (e.g. "App <noreply@example.com>")
14
+ *
15
+ * Templates support simple {{variable}} interpolation.
16
+ */
17
+
18
+ const nodemailer = require('nodemailer');
19
+ const logger = require('../utils/logger');
20
+
21
+ /** @type {import('nodemailer').Transporter | null} */
22
+ let _transporter = null;
23
+
24
+ /** @type {{ host: string, port: number, from: string, secure: boolean } | null} */
25
+ let _emailConfig = null;
26
+
27
+ /**
28
+ * Derive email/SMTP configuration from YAML + environment variables.
29
+ * Returns null when no SMTP host is configured (email sending is disabled).
30
+ *
31
+ * @param {object|null} emailYaml Value of `core.email` (may be null).
32
+ * @returns {{ host: string, port: number, user: string, pass: string, from: string, secure: boolean } | null}
33
+ */
34
+ function getEmailConfig(emailYaml) {
35
+ const cfg = emailYaml || {};
36
+
37
+ const host = process.env.SMTP_HOST || cfg.host || '';
38
+ if (!host) return null;
39
+
40
+ const port = parseInt(process.env.SMTP_PORT || cfg.port || '587', 10);
41
+ const user = process.env.SMTP_USER || cfg.username || '';
42
+ const pass = process.env.SMTP_PASS || '';
43
+ const from = process.env.SMTP_FROM || cfg.from || '';
44
+ const secure = cfg.secure !== undefined
45
+ ? cfg.secure
46
+ : port === 465;
47
+
48
+ return { host, port, user, pass, from, secure };
49
+ }
50
+
51
+ /**
52
+ * Initialize the email transporter.
53
+ * Safe to call multiple times (recreates on each call for hot-reload support).
54
+ *
55
+ * @param {object|null} emailYaml Value of `core.email` (may be null).
56
+ * @returns {{ host: string, port: number, from: string, secure: boolean } | null} The resolved config, or null if disabled.
57
+ */
58
+ function initEmail(emailYaml) {
59
+ _transporter = null;
60
+ _emailConfig = null;
61
+
62
+ const cfg = getEmailConfig(emailYaml);
63
+ if (!cfg) {
64
+ logger.info(' Email/SMTP not configured — email sending disabled.');
65
+ return null;
66
+ }
67
+
68
+ const transportOpts = {
69
+ host: cfg.host,
70
+ port: cfg.port,
71
+ secure: cfg.secure,
72
+ };
73
+
74
+ if (cfg.user) {
75
+ transportOpts.auth = { user: cfg.user, pass: cfg.pass };
76
+ }
77
+
78
+ _transporter = nodemailer.createTransport(transportOpts);
79
+ _emailConfig = { host: cfg.host, port: cfg.port, from: cfg.from, secure: cfg.secure };
80
+
81
+ logger.info(` Email/SMTP configured (host: ${cfg.host}:${cfg.port})`);
82
+ return _emailConfig;
83
+ }
84
+
85
+ /**
86
+ * Replace `{{variable}}` placeholders in a template string.
87
+ *
88
+ * @param {string} template Template with `{{key}}` placeholders.
89
+ * @param {Record<string, string>} vars Key→value map.
90
+ * @returns {string}
91
+ */
92
+ function interpolate(template, vars) {
93
+ if (!template) return '';
94
+ return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
95
+ return Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : '';
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Send an email using the configured SMTP transporter.
101
+ *
102
+ * @param {object} options
103
+ * @param {string} options.to Recipient email address.
104
+ * @param {string} options.subject Email subject (supports {{var}} interpolation).
105
+ * @param {string} [options.text] Plain-text body (supports {{var}} interpolation).
106
+ * @param {string} [options.html] HTML body (supports {{var}} interpolation).
107
+ * @param {string} [options.from] Override the default "From" address.
108
+ * @param {Record<string, string>} [options.vars] Variables for template interpolation.
109
+ * @returns {Promise<object>} Nodemailer send result.
110
+ * @throws {Error} When SMTP is not configured or sending fails.
111
+ */
112
+ async function sendEmail({ to, subject, text, html, from, vars }) {
113
+ if (!_transporter || !_emailConfig) {
114
+ throw new Error('Email is not configured. Set SMTP_HOST or configure the email section in your YAML config.');
115
+ }
116
+
117
+ const templateVars = vars || {};
118
+ const mailOptions = {
119
+ from: from || _emailConfig.from,
120
+ to,
121
+ subject: interpolate(subject, templateVars),
122
+ };
123
+
124
+ if (text) mailOptions.text = interpolate(text, templateVars);
125
+ if (html) mailOptions.html = interpolate(html, templateVars);
126
+
127
+ return _transporter.sendMail(mailOptions);
128
+ }
129
+
130
+ /**
131
+ * Verify the SMTP connection by attempting a handshake with the server.
132
+ *
133
+ * @returns {Promise<{ success: boolean, message: string }>}
134
+ */
135
+ async function verifyConnection() {
136
+ if (!_transporter || !_emailConfig) {
137
+ return { success: false, message: 'Email is not configured. Set SMTP_HOST or configure the email section in your YAML config.' };
138
+ }
139
+ try {
140
+ await _transporter.verify();
141
+ return { success: true, message: `SMTP connection to ${_emailConfig.host}:${_emailConfig.port} verified.` };
142
+ } catch (err) {
143
+ return { success: false, message: `SMTP verification failed: ${err.message}` };
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Returns the current email configuration metadata (without secrets).
149
+ *
150
+ * @returns {{ configured: boolean, host?: string, port?: number, from?: string, secure?: boolean }}
151
+ */
152
+ function getEmailStatus() {
153
+ if (!_emailConfig) return { configured: false };
154
+ return {
155
+ configured: true,
156
+ host: _emailConfig.host,
157
+ port: _emailConfig.port,
158
+ from: _emailConfig.from,
159
+ secure: _emailConfig.secure,
160
+ };
161
+ }
162
+
163
+ module.exports = {
164
+ getEmailConfig,
165
+ initEmail,
166
+ interpolate,
167
+ sendEmail,
168
+ verifyConnection,
169
+ getEmailStatus,
170
+ };
@@ -60,6 +60,7 @@ function buildEntities(config) {
60
60
  tableName: toSnakeCase(name),
61
61
  slug: def.slug || toKebabCase(name),
62
62
  authenticable: def.authenticable === true,
63
+ requireEmailVerification: def.requireEmailVerification === true,
63
64
  single: def.single === true,
64
65
  mainProp: def.mainProp || null,
65
66
  nameSingular: def.nameSingular || null,
@@ -155,6 +156,9 @@ function buildCore(config) {
155
156
  port: parseInt(process.env.CHADSTART_PORT || process.env.PORT || config.port || 3000, 10),
156
157
  rateLimits,
157
158
  telemetry,
159
+ email: config.email || null,
160
+ logs: config.logs || null,
161
+ backup: config.backup || null,
158
162
  oauth: config.oauth || null,
159
163
  admin: {
160
164
  enable_app: adminCfg.enable_app !== false,
package/core/logs.js ADDED
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Request logging module for ChadStart.
5
+ *
6
+ * Stores API request logs in the `_cs_logs` system table and provides
7
+ * a paginated, filterable query API for the admin dashboard.
8
+ *
9
+ * Configuration via YAML `logs` section:
10
+ * logs:
11
+ * retention: 30 # Days to keep logs (default: 30, 0 = forever)
12
+ * exclude: # Paths to exclude from logging
13
+ * - /health
14
+ * - /admin/vendor
15
+ */
16
+
17
+ const db = require('./db');
18
+ const { q } = db;
19
+ const logger = require('../utils/logger');
20
+
21
+ const _DB_ENGINE = db.DB_ENGINE;
22
+ const _ID_T = _DB_ENGINE === 'mysql' ? 'VARCHAR(36)' : 'TEXT';
23
+ const _NAME_T = _DB_ENGINE === 'mysql' ? 'VARCHAR(255)' : 'TEXT';
24
+
25
+ /** Default log retention in days. 0 = keep forever. */
26
+ const DEFAULT_RETENTION_DAYS = 30;
27
+
28
+ /**
29
+ * Initialize the _cs_logs system table.
30
+ * Must be called after initDb().
31
+ */
32
+ async function initLogs() {
33
+ await db.exec(`
34
+ CREATE TABLE IF NOT EXISTS ${q('_cs_logs')} (
35
+ ${q('id')} ${_ID_T} PRIMARY KEY,
36
+ ${q('method')} ${_NAME_T},
37
+ ${q('path')} TEXT,
38
+ ${q('statusCode')} INTEGER,
39
+ ${q('duration')} INTEGER,
40
+ ${q('ip')} ${_NAME_T},
41
+ ${q('userId')} ${_NAME_T},
42
+ ${q('userEntity')} ${_NAME_T},
43
+ ${q('createdAt')} TEXT NOT NULL
44
+ )
45
+ `);
46
+ }
47
+
48
+ /**
49
+ * Insert a log entry.
50
+ */
51
+ async function insertLog({ method, path, statusCode, duration, ip, userId, userEntity }) {
52
+ const id = require('crypto').randomUUID();
53
+ const now = new Date().toISOString();
54
+ await db.queryRun(
55
+ `INSERT INTO ${q('_cs_logs')} (${q('id')},${q('method')},${q('path')},${q('statusCode')},${q('duration')},${q('ip')},${q('userId')},${q('userEntity')},${q('createdAt')})
56
+ VALUES (?,?,?,?,?,?,?,?,?)`,
57
+ [id, method, path, statusCode, duration, ip || null, userId || null, userEntity || null, now]
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Express middleware that logs each request to the _cs_logs table.
63
+ *
64
+ * @param {object} opts
65
+ * @param {string[]} opts.exclude Path prefixes to skip logging (e.g. ['/health']).
66
+ */
67
+ function requestLoggerMiddleware(opts = {}) {
68
+ const exclude = opts.exclude || [];
69
+ return (req, res, next) => {
70
+ // Skip excluded paths
71
+ for (const prefix of exclude) {
72
+ if (req.path.startsWith(prefix)) return next();
73
+ }
74
+
75
+ const start = Date.now();
76
+
77
+ // Hook into response finish event
78
+ const originalEnd = res.end;
79
+ res.end = function (...args) {
80
+ res.end = originalEnd;
81
+ res.end(...args);
82
+
83
+ const duration = Date.now() - start;
84
+ const userId = (req.user && req.user.id) || null;
85
+ const userEntity = (req.user && req.user.entity) || null;
86
+
87
+ // Insert asynchronously (best-effort, don't block response)
88
+ insertLog({
89
+ method: req.method,
90
+ path: req.originalUrl || req.path,
91
+ statusCode: res.statusCode,
92
+ duration,
93
+ ip: req.ip || req.socket?.remoteAddress || null,
94
+ userId,
95
+ userEntity,
96
+ }).catch((e) => logger.warn('Log insert failed:', e.message));
97
+ };
98
+
99
+ next();
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Query logs with pagination and filters.
105
+ *
106
+ * @param {object} filters Optional filters: { method, statusCode, path, from, to }
107
+ * @param {object} opts { page, perPage, order }
108
+ * @returns {{ data: object[], total: number, currentPage: number, lastPage: number, perPage: number }}
109
+ */
110
+ async function queryLogs(filters = {}, opts = {}) {
111
+ const page = Math.max(1, parseInt(opts.page || 1, 10));
112
+ const perPage = Math.min(100, Math.max(1, parseInt(opts.perPage || 50, 10)));
113
+ const order = (opts.order || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
114
+
115
+ const where = [];
116
+ const params = [];
117
+
118
+ if (filters.method) {
119
+ where.push(`${q('method')} = ?`);
120
+ params.push(filters.method.toUpperCase());
121
+ }
122
+ if (filters.statusCode) {
123
+ where.push(`${q('statusCode')} = ?`);
124
+ params.push(parseInt(filters.statusCode, 10));
125
+ }
126
+ if (filters.path) {
127
+ where.push(`${q('path')} LIKE ?`);
128
+ params.push(`%${filters.path}%`);
129
+ }
130
+ if (filters.from) {
131
+ where.push(`${q('createdAt')} >= ?`);
132
+ params.push(filters.from);
133
+ }
134
+ if (filters.to) {
135
+ where.push(`${q('createdAt')} <= ?`);
136
+ params.push(filters.to);
137
+ }
138
+
139
+ const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '';
140
+
141
+ const countRow = await db.queryOne(
142
+ `SELECT COUNT(*) AS cnt FROM ${q('_cs_logs')} ${whereClause}`,
143
+ params
144
+ );
145
+ const total = countRow ? countRow.cnt : 0;
146
+ const lastPage = Math.max(1, Math.ceil(total / perPage));
147
+ const offset = (page - 1) * perPage;
148
+
149
+ const data = await db.queryAll(
150
+ `SELECT * FROM ${q('_cs_logs')} ${whereClause} ORDER BY ${q('createdAt')} ${order} LIMIT ? OFFSET ?`,
151
+ [...params, perPage, offset]
152
+ );
153
+
154
+ return { data, total, currentPage: page, lastPage, perPage };
155
+ }
156
+
157
+ /**
158
+ * Delete logs older than the specified number of days.
159
+ *
160
+ * @param {number} days Log retention in days. 0 or negative = keep all.
161
+ * @returns {number} Number of rows deleted.
162
+ */
163
+ async function cleanupOldLogs(days) {
164
+ if (!days || days <= 0) return 0;
165
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
166
+ const result = await db.queryRun(
167
+ `DELETE FROM ${q('_cs_logs')} WHERE ${q('createdAt')} < ?`,
168
+ [cutoff]
169
+ );
170
+ return result?.changes || 0;
171
+ }
172
+
173
+ module.exports = {
174
+ initLogs,
175
+ insertLog,
176
+ requestLoggerMiddleware,
177
+ queryLogs,
178
+ cleanupOldLogs,
179
+ };
@@ -8,16 +8,22 @@ const logger = require('../utils/logger');
8
8
 
9
9
  const { buildCore, toSnakeCase } = require('./entity-engine');
10
10
  const { DB_ENGINE, q, sqlType, idColType, authStrType } = require('./db');
11
+ const { detectFormat, parseRaw, loadConfig } = require('./config-loader');
11
12
 
12
13
  // ─── Git helpers ──────────────────────────────────────────────────────────────
13
14
 
14
15
  /**
15
- * Retrieve the last committed version of a file using git.
16
+ * Retrieve the last committed version of a config file using git.
16
17
  * Returns null if the file has no committed history (brand-new / untracked).
17
18
  */
18
- function getLastCommittedYaml(yamlPath) {
19
+ function getLastCommittedConfig(configPath) {
19
20
  try {
20
- const resolved = path.resolve(yamlPath);
21
+ const resolved = path.resolve(configPath);
22
+ const format = detectFormat(resolved);
23
+
24
+ // JS / Jsonnet configs can't be reconstructed from raw git content alone
25
+ if (format === 'js' || format === 'jsonnet') return null;
26
+
21
27
  const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
22
28
  cwd: path.dirname(resolved),
23
29
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -30,21 +36,17 @@ function getLastCommittedYaml(yamlPath) {
30
36
  stdio: ['pipe', 'pipe', 'pipe'],
31
37
  }).toString();
32
38
 
33
- return YAML.parse(raw);
39
+ return parseRaw(raw, format);
34
40
  } catch {
35
41
  return null;
36
42
  }
37
43
  }
38
44
 
39
45
  /**
40
- * Load the current YAML file from disk and return the parsed object.
46
+ * Load the current config file from disk and return the parsed object.
41
47
  */
42
- function loadCurrentYaml(yamlPath) {
43
- const resolved = path.resolve(yamlPath);
44
- if (!fs.existsSync(resolved)) {
45
- throw new Error(`YAML config not found: ${resolved}`);
46
- }
47
- return YAML.parse(fs.readFileSync(resolved, 'utf8'));
48
+ function loadCurrentConfig(configPath) {
49
+ return loadConfig(configPath);
48
50
  }
49
51
 
50
52
 
@@ -374,17 +376,17 @@ async function getMigrationStatus(migrationsDir, execQueryFn) {
374
376
  // ─── High-level commands ──────────────────────────────────────────────────────
375
377
 
376
378
  /**
377
- * Generate a migration by diffing the current YAML against the last committed
379
+ * Generate a migration by diffing the current config against the last committed
378
380
  * version in git. Writes numbered SQL files to the migrations directory.
379
381
  *
380
- * @param {string} yamlPath Path to the chadstart YAML config file.
382
+ * @param {string} configPath Path to the config file (any supported format).
381
383
  * @param {string} migrationsDir Path to the migrations directory.
382
384
  * @param {string} [description] Optional description for the migration.
383
385
  * @returns {{ doPath, undoPath, version, isEmpty } | null}
384
386
  */
385
- function generateMigration(yamlPath, migrationsDir, description) {
386
- const currentConfig = loadCurrentYaml(yamlPath);
387
- const oldConfig = getLastCommittedYaml(yamlPath);
387
+ function generateMigration(configPath, migrationsDir, description) {
388
+ const currentConfig = loadCurrentConfig(configPath);
389
+ const oldConfig = getLastCommittedConfig(configPath);
388
390
 
389
391
  const newCore = buildCore(currentConfig);
390
392
  const oldCore = oldConfig ? buildCore(oldConfig) : null;
@@ -408,8 +410,11 @@ function generateMigration(yamlPath, migrationsDir, description) {
408
410
 
409
411
  module.exports = {
410
412
  // Git helpers
411
- getLastCommittedYaml,
412
- loadCurrentYaml,
413
+ getLastCommittedConfig,
414
+ loadCurrentConfig,
415
+ // Backward-compatible aliases
416
+ getLastCommittedYaml: getLastCommittedConfig,
417
+ loadCurrentYaml: loadCurrentConfig,
413
418
  // Diff engine
414
419
  diffCores,
415
420
  // SQL generation
package/core/openapi.js CHANGED
@@ -30,8 +30,12 @@ function generateOpenApiSpec(core) {
30
30
  spec.components.schemas[`${e.name}AuthResponse`] = { type: 'object', properties: { token: { type: 'string' }, user: { $ref: `#/components/schemas/${e.name}` } } };
31
31
 
32
32
  spec.paths[`/api/auth/${slug}/signup`] = { post: { tags: [`Auth – ${e.name}`], summary: `Sign up as ${e.name}`, requestBody: jsonBody(`${e.name}Input`), responses: { 201: jsonResp(e.name + 'AuthResponse'), 400: desc('Validation error'), 409: desc('Email already registered') } } };
33
- spec.paths[`/api/auth/${slug}/login`] = { post: { tags: [`Auth – ${e.name}`], summary: `Login as ${e.name}`, requestBody: jsonBody(`${e.name}LoginInput`), responses: { 200: jsonResp(e.name + 'AuthResponse'), 401: desc('Invalid credentials') } } };
33
+ spec.paths[`/api/auth/${slug}/login`] = { post: { tags: [`Auth – ${e.name}`], summary: `Login as ${e.name}`, requestBody: jsonBody(`${e.name}LoginInput`), responses: { 200: jsonResp(e.name + 'AuthResponse'), 401: desc('Invalid credentials'), 403: desc('Email not verified') } } };
34
34
  spec.paths[`/api/auth/${slug}/me`] = { get: { tags: [`Auth – ${e.name}`], summary: `Get current ${e.name}`, security: [{ bearerAuth: [] }], responses: { 200: jsonResp(e.name), 401: desc('Unauthorized') } } };
35
+ spec.paths[`/api/auth/${slug}/request-verification`] = { post: { tags: [`Auth – ${e.name}`], summary: `Request email verification for ${e.name}`, security: [{ bearerAuth: [] }], responses: { 200: desc('Verification email sent'), 401: desc('Unauthorized') } } };
36
+ spec.paths[`/api/auth/${slug}/confirm-verification`] = { post: { tags: [`Auth – ${e.name}`], summary: `Confirm email verification for ${e.name}`, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['token'], properties: { token: { type: 'string' } } } } } }, responses: { 200: desc('Email verified'), 400: desc('Invalid token') } } };
37
+ spec.paths[`/api/auth/${slug}/request-password-reset`] = { post: { tags: [`Auth – ${e.name}`], summary: `Request password reset for ${e.name}`, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['email'], properties: { email: { type: 'string', format: 'email' } } } } } }, responses: { 200: desc('Password reset email sent (if account exists)') } } };
38
+ spec.paths[`/api/auth/${slug}/confirm-password-reset`] = { post: { tags: [`Auth – ${e.name}`], summary: `Confirm password reset for ${e.name}`, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['token', 'password'], properties: { token: { type: 'string' }, password: { type: 'string', format: 'password' } } } } } }, responses: { 200: desc('Password reset successful'), 400: desc('Invalid or expired token') } } };
35
39
  }
36
40
 
37
41
  // Entity CRUD endpoints
@@ -138,7 +142,7 @@ function entityInputSchema(e, all) {
138
142
  }
139
143
 
140
144
  function authSchema(e) {
141
- const props = { id: { type: 'string', format: 'uuid', readOnly: true }, email: { type: 'string', format: 'email' }, createdAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' } };
145
+ const props = { id: { type: 'string', format: 'uuid', readOnly: true }, email: { type: 'string', format: 'email' }, emailVerified: { type: 'boolean' }, createdAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' } };
142
146
  for (const p of e.properties) {
143
147
  if (!p.hidden) props[p.name] = { type: OPENAPI_TYPE[p.type] || 'string' };
144
148
  }
@@ -1,64 +1,19 @@
1
1
  'use strict';
2
2
 
3
- const fs = require('fs');
4
- const path = require('path');
5
- const YAML = require('yaml');
6
- const logger = require('../utils/logger');
7
-
8
3
  /**
9
- * Load and parse the chadstart.yaml file.
10
- * Returns the raw parsed object.
4
+ * Backward-compatible re-exports.
5
+ *
6
+ * All new code should import from `./config-loader` directly.
7
+ * These wrappers keep existing callers (tests, plugins) working unchanged.
11
8
  */
9
+ const { loadConfig, saveConfig } = require('./config-loader');
10
+
12
11
  function loadYaml(filePath) {
13
- const resolved = path.resolve(filePath);
14
- if (!fs.existsSync(resolved)) {
15
- throw new Error(`YAML config not found: ${resolved}`);
16
- }
17
- const raw = fs.readFileSync(resolved, 'utf8');
18
- const parsed = YAML.parse(raw);
19
- logger.debug('Loaded YAML from', resolved);
20
- return parsed;
12
+ return loadConfig(filePath);
21
13
  }
22
14
 
23
- /**
24
- * Save an updated config object back to a YAML file.
25
- * Uses yaml's Document API so that comments in unchanged top-level sections
26
- * are preserved as much as possible.
27
- *
28
- * @param {string} filePath Path to the YAML file.
29
- * @param {object} config Plain-JS config object (already validated).
30
- */
31
15
  function saveYaml(filePath, config) {
32
- const resolved = path.resolve(filePath);
33
-
34
- let doc;
35
- if (fs.existsSync(resolved)) {
36
- // Parse into a live Document to keep comments / blank lines on unchanged nodes
37
- const raw = fs.readFileSync(resolved, 'utf8');
38
- doc = YAML.parseDocument(raw);
39
-
40
- const existing = doc.toJS() || {};
41
- const existingKeys = Object.keys(existing);
42
- const newKeys = Object.keys(config);
43
-
44
- // Update or add every key from the incoming config
45
- for (const key of newKeys) {
46
- doc.set(key, config[key]);
47
- }
48
-
49
- // Remove top-level keys that are no longer present
50
- for (const key of existingKeys) {
51
- if (!newKeys.includes(key)) {
52
- doc.delete(key);
53
- }
54
- }
55
- } else {
56
- // Create a fresh Document when the file does not yet exist
57
- doc = new YAML.Document(config);
58
- }
59
-
60
- fs.writeFileSync(resolved, doc.toString(), 'utf8');
61
- logger.debug('Saved YAML to', resolved);
16
+ return saveConfig(filePath, config);
62
17
  }
63
18
 
64
19
  module.exports = { loadYaml, saveYaml };
package/docs/llm-rules.md CHANGED
@@ -75,7 +75,7 @@ If you already have an existing project and want to add AI rules manually, creat
75
75
  | **GitHub Copilot** | `.github/copilot-instructions.md` |
76
76
  | **Windsurf** | `.windsurf/rules/chadstart.md` |
77
77
 
78
- You can base your rules file on the [chadstart.example.yml](https://github.com/saulmmendoza/chadstart.com/blob/main/chadstart.example.yml) reference file, which documents every available configuration option.
78
+ You can base your rules file on the [chadstart.example.yaml](https://github.com/saulmmendoza/chadstart.com/blob/main/chadstart.example.yaml) reference file, which documents every available configuration option.
79
79
 
80
80
  !!! tip
81
81
  Keep your rules file up to date whenever you upgrade ChadStart. Newer versions may introduce new field types, options, or top-level blocks that your AI assistant won't know about unless the rules file is updated.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chadstart",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "YAML-first Backend as a Service — define your entire backend in one YAML file",
5
5
  "main": "server/express-server.js",
6
6
  "bin": {
@@ -49,9 +49,11 @@
49
49
  "express-rate-limit": "^8.3.1",
50
50
  "grant": "^5.4.24",
51
51
  "htmx.org": "2.0.4",
52
+ "json5": "^2.2.3",
52
53
  "jsonwebtoken": "^9.0.3",
53
54
  "mysql2": "^3.20.0",
54
55
  "node-cron": "^4.2.1",
56
+ "nodemailer": "^8.0.4",
55
57
  "pg": "^8.20.0",
56
58
  "postgrator": "^8.0.0",
57
59
  "sharp": "^0.34.5",