chadstart 1.0.3 → 1.0.5

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 CHANGED
@@ -9,7 +9,7 @@ RUN npm ci --omit=dev
9
9
  FROM node:lts-alpine AS runner
10
10
  WORKDIR /app
11
11
  # Runtime dependencies
12
- RUN apk add --no-cache bash python3 go ruby g++ \
12
+ RUN apk add --no-cache bash python3 go ruby g++ jsonnet \
13
13
  && addgroup -S nodejs && adduser -S nodejs -G nodejs \
14
14
  && mkdir -p /app/{uploads,public,functions} && chown -R nodejs:nodejs /app
15
15
  COPY --from=deps --chown=nodejs:nodejs /app/node_modules ./node_modules
package/admin/index.html CHANGED
@@ -261,6 +261,7 @@
261
261
  <button onclick="showConfigTab('files')" id="cfg-tab-files" class="cfg-tab px-3 py-1.5 text-xs rounded border border-cs-border text-cs-muted hover:text-cs-text" style="transition:color 150ms,background 150ms;" data-i18n="config.tabs.files">Files &amp; Uploads</button>
262
262
  <button onclick="showConfigTab('settings')" id="cfg-tab-settings" class="cfg-tab px-3 py-1.5 text-xs rounded border border-cs-border text-cs-muted hover:text-cs-text" style="transition:color 150ms,background 150ms;" data-i18n="config.tabs.settings">Settings</button>
263
263
  <button onclick="showConfigTab('all')" id="cfg-tab-all" class="cfg-tab px-3 py-1.5 text-xs rounded border border-cs-border text-cs-muted hover:text-cs-text" style="transition:color 150ms,background 150ms;" data-i18n="config.tabs.all">All</button>
264
+ <span id="cfg-format-badge" class="ml-auto px-2 py-1 text-xs rounded font-mono" style="background:var(--color-cs-border);color:var(--color-cs-muted);" title="Config format">YAML</span>
264
265
  </div>
265
266
  <p id="cfg-section-desc" class="text-xs mb-3" style="color:var(--color-cs-muted);"></p>
266
267
  <div id="cfg-form" class="hidden mb-6"></div>
@@ -674,6 +675,8 @@
674
675
  // Config editor
675
676
  let configMode = false;
676
677
  let configData = null;
678
+ let configFormat = 'yaml';
679
+ let configWritable = true;
677
680
  let configActiveTab = 'general';
678
681
  let _cfgEntityId = 0, _cfgEndpointId = 0, _cfgFileId = 0, _cfgRlId = 0, _cfgTriggerId = 0;
679
682
 
@@ -1706,13 +1709,36 @@ const _LBL = 'block text-xs mb-1';
1706
1709
 
1707
1710
  async function loadConfig() {
1708
1711
  try {
1709
- const r = await apiFetch('/admin/config');
1712
+ const [r, infoRes] = await Promise.all([
1713
+ apiFetch('/admin/config'),
1714
+ apiFetch('/admin/config-info'),
1715
+ ]);
1710
1716
  if (!r.ok) throw new Error('Failed to load config');
1711
1717
  configData = await r.json();
1718
+ if (infoRes.ok) {
1719
+ const info = await infoRes.json();
1720
+ configFormat = info.format || 'yaml';
1721
+ configWritable = info.writable !== false;
1722
+ }
1723
+ updateConfigFormatBadge();
1712
1724
  showConfigTab('general');
1713
1725
  } catch (e) { toast(e.message, 'error'); }
1714
1726
  }
1715
1727
 
1728
+ function updateConfigFormatBadge() {
1729
+ const badge = document.getElementById('cfg-format-badge');
1730
+ if (!badge) return;
1731
+ badge.textContent = configFormat.toUpperCase();
1732
+ badge.title = configWritable
1733
+ ? 'Config format: ' + configFormat
1734
+ : 'Config format: ' + configFormat + ' (read-only)';
1735
+ const saveBtn = document.getElementById('cfg-save-btn');
1736
+ if (saveBtn) {
1737
+ saveBtn.disabled = !configWritable;
1738
+ saveBtn.title = configWritable ? '' : 'Saving is not supported for ' + configFormat + ' configs. Convert to YAML, JSON, or JSON5 to enable saving.';
1739
+ }
1740
+ }
1741
+
1716
1742
  function showConfigTab(tab) {
1717
1743
  configActiveTab = tab;
1718
1744
  document.querySelectorAll('.cfg-tab').forEach(b => b.classList.remove('active'));
@@ -1,4 +1,4 @@
1
- # chadstart.example.yml
1
+ # chadstart.example.yaml
2
2
  # ─────────────────────────────────────────────────────────────────────────────
3
3
  # Reference file that lists every available option in a ChadStart YAML config.
4
4
  # Copy this file to chadstart.yaml and remove / adjust the sections you need.
package/cli/cli.js CHANGED
@@ -7,23 +7,24 @@ const fs = require('fs');
7
7
 
8
8
  const args = process.argv.slice(2);
9
9
  const command = args[0];
10
- const DEFAULT_YAML = 'chadstart.yaml';
10
+ const DEFAULT_CONFIG = 'chadstart.yaml';
11
11
 
12
12
  function printUsage() {
13
13
  console.log(`
14
- ChadStart - YAML-first Backend as a Service
14
+ ChadStart - Config-driven Backend as a Service
15
15
 
16
16
  Usage:
17
- npx chadstart dev Start server with hot-reload on YAML changes
17
+ npx chadstart dev Start server with hot-reload on config changes
18
18
  npx chadstart start Start server (production mode)
19
- npx chadstart build Validate YAML config and print schema summary
19
+ npx chadstart build Validate config and print schema summary
20
20
  npx chadstart seed Seed the database with dummy data
21
21
  npx chadstart migrate Run pending database migrations
22
- npx chadstart migrate:generate Generate migration from YAML diff (git-based)
22
+ npx chadstart migrate:generate Generate migration from config diff (git-based)
23
23
  npx chadstart migrate:status Show current migration status
24
24
 
25
25
  Options:
26
- --config <file> Path to YAML config (default: chadstart.yaml)
26
+ --config <file> Path to config file (default: auto-discover)
27
+ Supported formats: yaml, json, json5, jsonnet, js
27
28
  --port <number> Override port from config
28
29
  --migrations-dir <dir> Path to migrations directory (default: migrations)
29
30
  --description <text> Description for generated migration
@@ -31,6 +32,7 @@ Options:
31
32
  Examples:
32
33
  npx chadstart dev
33
34
  npx chadstart dev --config my-backend.yaml
35
+ npx chadstart dev --config chadstart.json
34
36
  npx chadstart start --port 8080
35
37
  npx chadstart migrate:generate --description add-posts-table
36
38
  npx chadstart migrate
@@ -42,7 +44,16 @@ function getOption(flag) {
42
44
  return idx !== -1 ? args[idx + 1] : null;
43
45
  }
44
46
 
45
- const yamlPath = path.resolve(getOption('--config') || process.env.CHADSTART_FILE_PATH || DEFAULT_YAML);
47
+ function resolveConfigPath() {
48
+ const explicit = getOption('--config') || process.env.CHADSTART_FILE_PATH;
49
+ if (explicit) return path.resolve(explicit);
50
+ // Auto-discover: try each supported filename in priority order
51
+ const { discoverConfigFile } = require('../core/config-loader');
52
+ const found = discoverConfigFile(process.cwd());
53
+ return found || path.resolve(DEFAULT_CONFIG); // fall back to default for error messages
54
+ }
55
+
56
+ const configPath = resolveConfigPath();
46
57
  const portOverride = getOption('--port');
47
58
 
48
59
  if (!command || command === 'help' || command === '--help' || command === '-h') {
@@ -119,19 +130,19 @@ async function runCreate() {
119
130
  }
120
131
 
121
132
  async function runSeed() {
122
- if (!fs.existsSync(yamlPath)) {
123
- console.error(`Config not found: ${yamlPath}`);
133
+ if (!fs.existsSync(configPath)) {
134
+ console.error(`Config not found: ${configPath}`);
124
135
  process.exit(1);
125
136
  }
126
137
 
127
138
  try {
128
- const { loadYaml } = require('../core/yaml-loader');
139
+ const { loadConfig } = require('../core/config-loader');
129
140
  const { validateSchema } = require('../core/schema-validator');
130
141
  const { buildCore } = require('../core/entity-engine');
131
142
  const { initDb } = require('../core/db');
132
143
  const { seedAll } = require('../core/seeder');
133
144
 
134
- const config = loadYaml(yamlPath);
145
+ const config = loadConfig(configPath);
135
146
  validateSchema(config);
136
147
  const core = buildCore(config);
137
148
  initDb(core);
@@ -158,19 +169,19 @@ async function runSeed() {
158
169
  }
159
170
 
160
171
  async function runStart() {
161
- if (!fs.existsSync(yamlPath)) {
162
- console.error(`Config not found: ${yamlPath}`);
172
+ if (!fs.existsSync(configPath)) {
173
+ console.error(`Config not found: ${configPath}`);
163
174
  process.exit(1);
164
175
  }
165
176
 
166
177
  applyPortOverride();
167
178
  const { startServer } = require('../server/express-server');
168
- await startServer(yamlPath);
179
+ await startServer(configPath);
169
180
  }
170
181
 
171
182
  async function runDev() {
172
- if (!fs.existsSync(yamlPath)) {
173
- console.error(`Config not found: ${yamlPath}`);
183
+ if (!fs.existsSync(configPath)) {
184
+ console.error(`Config not found: ${configPath}`);
174
185
  process.exit(1);
175
186
  }
176
187
 
@@ -186,7 +197,7 @@ async function runDev() {
186
197
  // Re-require fresh server module on each reload
187
198
  clearRequireCache();
188
199
  const { startServer } = require('../server/express-server');
189
- const result = await startServer(yamlPath);
200
+ const result = await startServer(configPath);
190
201
  currentServer = result.server;
191
202
  } catch (err) {
192
203
  console.error('[dev] Failed to start server:', err.message);
@@ -198,12 +209,12 @@ async function runDev() {
198
209
  try {
199
210
  const chokidar = require('chokidar');
200
211
  // Watch YAML config
201
- const watcher = chokidar.watch(yamlPath, { ignoreInitial: true });
212
+ const watcher = chokidar.watch(configPath, { ignoreInitial: true });
202
213
  watcher.on('change', async () => {
203
- console.log(`\n[dev] ${path.basename(yamlPath)} changed — restarting...\n`);
214
+ console.log(`\n[dev] ${path.basename(configPath)} changed — restarting...\n`);
204
215
  await boot();
205
216
  });
206
- console.log(`[dev] Watching ${yamlPath} for changes...\n`);
217
+ console.log(`[dev] Watching ${configPath} for changes...\n`);
207
218
 
208
219
  // Watch the functions folder for hot reload of function files
209
220
  const functionsDir = path.resolve(process.env.CHADSTART_FUNCTIONS_FOLDER || 'functions');
@@ -228,17 +239,17 @@ async function runDev() {
228
239
  }
229
240
 
230
241
  function runBuild() {
231
- if (!fs.existsSync(yamlPath)) {
232
- console.error(`Config not found: ${yamlPath}`);
242
+ if (!fs.existsSync(configPath)) {
243
+ console.error(`Config not found: ${configPath}`);
233
244
  process.exit(1);
234
245
  }
235
246
 
236
247
  try {
237
- const { loadYaml } = require('../core/yaml-loader');
248
+ const { loadConfig } = require('../core/config-loader');
238
249
  const { validateSchema } = require('../core/schema-validator');
239
250
  const { buildCore } = require('../core/entity-engine');
240
251
 
241
- const config = loadYaml(yamlPath);
252
+ const config = loadConfig(configPath);
242
253
  validateSchema(config);
243
254
  const core = buildCore(config);
244
255
 
@@ -290,20 +301,20 @@ const migrationsDir = path.resolve(getOption('--migrations-dir') || 'migrations'
290
301
  const migrationDescription = getOption('--description') || null;
291
302
 
292
303
  async function runMigrate() {
293
- if (!fs.existsSync(yamlPath)) {
294
- console.error(`Config not found: ${yamlPath}`);
304
+ if (!fs.existsSync(configPath)) {
305
+ console.error(`Config not found: ${configPath}`);
295
306
  process.exit(1);
296
307
  }
297
308
 
298
309
  try {
299
- const { loadYaml } = require('../core/yaml-loader');
310
+ const { loadConfig } = require('../core/config-loader');
300
311
  const { validateSchema } = require('../core/schema-validator');
301
312
  const { buildCore } = require('../core/entity-engine');
302
313
  const { initDb, closeDb } = require('../core/db');
303
314
  const { runMigrations, buildExecQueryFn } = require('../core/migrations');
304
315
  const dbModule = require('../core/db');
305
316
 
306
- const config = loadYaml(yamlPath);
317
+ const config = loadConfig(configPath);
307
318
  validateSchema(config);
308
319
  const core = buildCore(config);
309
320
  await initDb(core);
@@ -331,8 +342,8 @@ async function runMigrate() {
331
342
  }
332
343
 
333
344
  async function runMigrateGenerate() {
334
- if (!fs.existsSync(yamlPath)) {
335
- console.error(`Config not found: ${yamlPath}`);
345
+ if (!fs.existsSync(configPath)) {
346
+ console.error(`Config not found: ${configPath}`);
336
347
  process.exit(1);
337
348
  }
338
349
 
@@ -341,7 +352,7 @@ async function runMigrateGenerate() {
341
352
 
342
353
  console.log('\n📝 Generating migration from YAML diff...\n');
343
354
 
344
- const result = generateMigration(yamlPath, migrationsDir, migrationDescription);
355
+ const result = generateMigration(configPath, migrationsDir, migrationDescription);
345
356
 
346
357
  if (result.isEmpty) {
347
358
  console.log(' ℹ️ No schema changes detected — nothing to generate.\n');
@@ -358,20 +369,20 @@ async function runMigrateGenerate() {
358
369
  }
359
370
 
360
371
  async function runMigrateStatus() {
361
- if (!fs.existsSync(yamlPath)) {
362
- console.error(`Config not found: ${yamlPath}`);
372
+ if (!fs.existsSync(configPath)) {
373
+ console.error(`Config not found: ${configPath}`);
363
374
  process.exit(1);
364
375
  }
365
376
 
366
377
  try {
367
- const { loadYaml } = require('../core/yaml-loader');
378
+ const { loadConfig } = require('../core/config-loader');
368
379
  const { validateSchema } = require('../core/schema-validator');
369
380
  const { buildCore } = require('../core/entity-engine');
370
381
  const { initDb, closeDb } = require('../core/db');
371
382
  const { getMigrationStatus, buildExecQueryFn } = require('../core/migrations');
372
383
  const dbModule = require('../core/db');
373
384
 
374
- const config = loadYaml(yamlPath);
385
+ const config = loadConfig(configPath);
375
386
  validateSchema(config);
376
387
  const core = buildCore(config);
377
388
  await initDb(core);
@@ -310,10 +310,10 @@ function _apiKeyPermGuard(operation, entity) {
310
310
  return (req, res, next) => {
311
311
  if (!req._apiKeyPermissions) return next();
312
312
  const { operations, entities: keyEntities } = req._apiKeyPermissions;
313
- if (operations && operations.length > 0 && !operations.includes(operation)) {
313
+ if (operations?.length && !operations.includes(operation)) {
314
314
  return res.status(403).json({ error: 'API key does not have permission for this operation' });
315
315
  }
316
- if (keyEntities && keyEntities.length > 0 && !keyEntities.includes(entity.slug)) {
316
+ if (keyEntities?.length && !keyEntities.includes(entity.slug)) {
317
317
  return res.status(403).json({ error: 'API key does not have access to this entity' });
318
318
  }
319
319
  next();
package/core/auth.js CHANGED
@@ -16,6 +16,7 @@ const crypto = require('crypto');
16
16
  const jwt = require('jsonwebtoken');
17
17
  const bcrypt = require('bcryptjs');
18
18
  const db = require('./db');
19
+ const { q: _q, DB_ENGINE: _DB_ENGINE } = db;
19
20
  const logger = require('../utils/logger');
20
21
 
21
22
  const API_KEY_PREFIX = 'cs_';
@@ -29,10 +30,6 @@ const JWT_SECRET = process.env.JWT_SECRET || process.env.TOKEN_SECRET_KEY || (()
29
30
  const JWT_EXPIRES = process.env.JWT_EXPIRES || '7d';
30
31
  const BCRYPT_ROUNDS = 10;
31
32
 
32
- // Quote an identifier for the current database engine (mirrors db.js helper)
33
- const _DB_ENGINE = (process.env.DB_ENGINE || 'sqlite').toLowerCase();
34
- function _q(name) { return _DB_ENGINE === 'mysql' ? `\`${name}\`` : `"${name}"`; }
35
-
36
33
  // Column types for the API keys table (must be indexable in all engines)
37
34
  const _ID_T = _DB_ENGINE === 'mysql' ? 'VARCHAR(36)' : 'TEXT';
38
35
  const _HASH_T = _DB_ENGINE === 'mysql' ? 'VARCHAR(64)' : 'TEXT';
@@ -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
+ };
package/core/db.js CHANGED
@@ -552,6 +552,7 @@ async function closeDb() {
552
552
  }
553
553
 
554
554
  module.exports = {
555
+ DB_ENGINE, q, sqlType, idColType, authStrType, toPgPlaceholders,
555
556
  initDb, syncSchema, getDb, generateUUID, closeDb,
556
557
  exec, queryAll, queryOne, queryRun,
557
558
  findAll, findAllSimple, findById, create, update, remove,
@@ -4,6 +4,18 @@ const path = require('path');
4
4
  const fs = require('fs');
5
5
  const express = require('express');
6
6
  const logger = require('../utils/logger');
7
+ const { sanitizeFilename } = require('./upload');
8
+
9
+ // Lazy-load busboy (shared with upload.js)
10
+ function getBusboy() {
11
+ try {
12
+ return require('busboy');
13
+ } catch {
14
+ throw new Error(
15
+ 'busboy is required for file uploads. Install it with: npm install busboy'
16
+ );
17
+ }
18
+ }
7
19
 
8
20
  /**
9
21
  * Register file storage routes for all buckets defined in core.files.
@@ -50,11 +62,7 @@ function registerFileRoutes(app, core) {
50
62
  file.resume();
51
63
  return;
52
64
  }
53
- // Sanitize filename — strip directory traversal and disallow problematic characters
54
- const safeName = path
55
- .basename(filename)
56
- .replace(/[^a-zA-Z0-9._-]/g, '_')
57
- .replace(/^\.+/, '_');
65
+ const safeName = sanitizeFilename(filename);
58
66
  const dest = path.join(bucketPath, safeName);
59
67
  const writeStream = fs.createWriteStream(dest);
60
68
  file.pipe(writeStream);
@@ -84,14 +92,4 @@ function registerFileRoutes(app, core) {
84
92
  }
85
93
  }
86
94
 
87
- function getBusboy() {
88
- try {
89
- return require('busboy');
90
- } catch {
91
- throw new Error(
92
- 'busboy is required for file uploads. Install it with: npm install busboy'
93
- );
94
- }
95
- }
96
-
97
95
  module.exports = { registerFileRoutes };
@@ -7,16 +7,23 @@ const YAML = require('yaml');
7
7
  const logger = require('../utils/logger');
8
8
 
9
9
  const { buildCore, toSnakeCase } = require('./entity-engine');
10
+ const { DB_ENGINE, q, sqlType, idColType, authStrType } = require('./db');
11
+ const { detectFormat, parseRaw, loadConfig } = require('./config-loader');
10
12
 
11
13
  // ─── Git helpers ──────────────────────────────────────────────────────────────
12
14
 
13
15
  /**
14
- * Retrieve the last committed version of a file using git.
16
+ * Retrieve the last committed version of a config file using git.
15
17
  * Returns null if the file has no committed history (brand-new / untracked).
16
18
  */
17
- function getLastCommittedYaml(yamlPath) {
19
+ function getLastCommittedConfig(configPath) {
18
20
  try {
19
- 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
+
20
27
  const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
21
28
  cwd: path.dirname(resolved),
22
29
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -29,75 +36,19 @@ function getLastCommittedYaml(yamlPath) {
29
36
  stdio: ['pipe', 'pipe', 'pipe'],
30
37
  }).toString();
31
38
 
32
- return YAML.parse(raw);
39
+ return parseRaw(raw, format);
33
40
  } catch {
34
41
  return null;
35
42
  }
36
43
  }
37
44
 
38
45
  /**
39
- * 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.
40
47
  */
41
- function loadCurrentYaml(yamlPath) {
42
- const resolved = path.resolve(yamlPath);
43
- if (!fs.existsSync(resolved)) {
44
- throw new Error(`YAML config not found: ${resolved}`);
45
- }
46
- return YAML.parse(fs.readFileSync(resolved, 'utf8'));
47
- }
48
-
49
- // ─── SQL generation helpers ───────────────────────────────────────────────────
50
-
51
- const DB_ENGINE = (process.env.DB_ENGINE || 'sqlite').toLowerCase();
52
-
53
- const SQL_TYPE_SQLITE = {
54
- text: 'TEXT', string: 'TEXT', richText: 'TEXT',
55
- integer: 'INTEGER', int: 'INTEGER',
56
- number: 'REAL', float: 'REAL', real: 'REAL', money: 'REAL',
57
- boolean: 'INTEGER', bool: 'INTEGER',
58
- date: 'TEXT', timestamp: 'TEXT', email: 'TEXT', link: 'TEXT',
59
- password: 'TEXT', choice: 'TEXT', location: 'TEXT',
60
- file: 'TEXT', image: 'TEXT', group: 'TEXT', json: 'TEXT',
61
- };
62
-
63
- const SQL_TYPE_PG = {
64
- text: 'TEXT', string: 'TEXT', richText: 'TEXT',
65
- integer: 'INTEGER', int: 'INTEGER',
66
- number: 'NUMERIC', float: 'NUMERIC', real: 'NUMERIC', money: 'NUMERIC',
67
- boolean: 'BOOLEAN', bool: 'BOOLEAN',
68
- date: 'TEXT', timestamp: 'TEXT', email: 'TEXT', link: 'TEXT',
69
- password: 'TEXT', choice: 'TEXT', location: 'TEXT',
70
- file: 'TEXT', image: 'TEXT', group: 'TEXT', json: 'TEXT',
71
- };
72
-
73
- const SQL_TYPE_MYSQL = {
74
- text: 'TEXT', string: 'TEXT', richText: 'TEXT',
75
- integer: 'INT', int: 'INT',
76
- number: 'DECIMAL(15,4)', float: 'DECIMAL(15,4)', real: 'DECIMAL(15,4)', money: 'DECIMAL(15,4)',
77
- boolean: 'TINYINT(1)', bool: 'TINYINT(1)',
78
- date: 'TEXT', timestamp: 'TEXT', email: 'TEXT', link: 'TEXT',
79
- password: 'TEXT', choice: 'TEXT', location: 'TEXT',
80
- file: 'TEXT', image: 'TEXT', group: 'TEXT', json: 'TEXT',
81
- };
82
-
83
- function sqlType(type) {
84
- if (DB_ENGINE === 'postgres') return SQL_TYPE_PG[type] || 'TEXT';
85
- if (DB_ENGINE === 'mysql') return SQL_TYPE_MYSQL[type] || 'TEXT';
86
- return SQL_TYPE_SQLITE[type] || 'TEXT';
48
+ function loadCurrentConfig(configPath) {
49
+ return loadConfig(configPath);
87
50
  }
88
51
 
89
- function idColType() {
90
- return DB_ENGINE === 'mysql' ? 'VARCHAR(36)' : 'TEXT';
91
- }
92
-
93
- function authStrType() {
94
- return DB_ENGINE === 'mysql' ? 'VARCHAR(191)' : 'TEXT';
95
- }
96
-
97
- function q(name) {
98
- if (DB_ENGINE === 'mysql') return `\`${name}\``;
99
- return `"${name}"`;
100
- }
101
52
 
102
53
  // ─── Diff engine ──────────────────────────────────────────────────────────────
103
54
 
@@ -425,17 +376,17 @@ async function getMigrationStatus(migrationsDir, execQueryFn) {
425
376
  // ─── High-level commands ──────────────────────────────────────────────────────
426
377
 
427
378
  /**
428
- * 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
429
380
  * version in git. Writes numbered SQL files to the migrations directory.
430
381
  *
431
- * @param {string} yamlPath Path to the chadstart YAML config file.
382
+ * @param {string} configPath Path to the config file (any supported format).
432
383
  * @param {string} migrationsDir Path to the migrations directory.
433
384
  * @param {string} [description] Optional description for the migration.
434
385
  * @returns {{ doPath, undoPath, version, isEmpty } | null}
435
386
  */
436
- function generateMigration(yamlPath, migrationsDir, description) {
437
- const currentConfig = loadCurrentYaml(yamlPath);
438
- const oldConfig = getLastCommittedYaml(yamlPath);
387
+ function generateMigration(configPath, migrationsDir, description) {
388
+ const currentConfig = loadCurrentConfig(configPath);
389
+ const oldConfig = getLastCommittedConfig(configPath);
439
390
 
440
391
  const newCore = buildCore(currentConfig);
441
392
  const oldCore = oldConfig ? buildCore(oldConfig) : null;
@@ -459,8 +410,11 @@ function generateMigration(yamlPath, migrationsDir, description) {
459
410
 
460
411
  module.exports = {
461
412
  // Git helpers
462
- getLastCommittedYaml,
463
- loadCurrentYaml,
413
+ getLastCommittedConfig,
414
+ loadCurrentConfig,
415
+ // Backward-compatible aliases
416
+ getLastCommittedYaml: getLastCommittedConfig,
417
+ loadCurrentYaml: loadCurrentConfig,
464
418
  // Diff engine
465
419
  diffCores,
466
420
  // SQL generation
package/core/oauth.js CHANGED
@@ -15,7 +15,7 @@
15
15
  const crypto = require('crypto');
16
16
  const Grant = require('grant').express();
17
17
  const rateLimit = require('express-rate-limit');
18
- const { signToken } = require('./auth');
18
+ const { signToken, omitPassword } = require('./auth');
19
19
  const db = require('./db');
20
20
  const logger = require('../utils/logger');
21
21
 
@@ -195,7 +195,7 @@ function registerOAuthRoutes(app, core, emit) {
195
195
  return res.redirect(`${successRedirect}${sep}token=${encodeURIComponent(token)}`);
196
196
  }
197
197
 
198
- res.json({ token, user: _omitSensitive(user) });
198
+ res.json({ token, user: omitPassword(user) });
199
199
  } catch (e) {
200
200
  logger.error('OAuth callback error:', e.message);
201
201
  return _handleError(res, oauthConfig, e.message);
@@ -245,12 +245,6 @@ async function _findOrCreateOAuthUser(entity, { email, name, provider, providerI
245
245
  return created;
246
246
  }
247
247
 
248
- function _omitSensitive(user) {
249
- if (!user) return null;
250
- const { password: _, ...rest } = user;
251
- return rest;
252
- }
253
-
254
248
  function _handleError(res, oauthConfig, message) {
255
249
  const errorRedirect = oauthConfig.errorRedirect;
256
250
  if (errorRedirect) {
package/core/seeder.js CHANGED
@@ -44,7 +44,7 @@ function fakeValueForProp(prop, idx, groups = {}) {
44
44
  const { name, type, options } = prop;
45
45
  const n = idx + 1;
46
46
 
47
- if (options && Array.isArray(options) && options.length > 0) {
47
+ if (Array.isArray(options) && options.length) {
48
48
  return options[randomInt(0, options.length - 1)];
49
49
  }
50
50
 
@@ -184,7 +184,7 @@ async function seedAll(core) {
184
184
  for (const rel of entity.belongsTo || []) {
185
185
  const parentName = typeof rel === 'string' ? rel : (rel.entity || rel.name);
186
186
  const parentEntity = core.entities[parentName];
187
- if (parentEntity && seededIds[parentName] && seededIds[parentName].length > 0) {
187
+ if (parentEntity && seededIds[parentName]?.length) {
188
188
  const fk = `${parentEntity.tableName}_id`;
189
189
  const parentIds = seededIds[parentName];
190
190
  record[fk] = parentIds[randomInt(0, parentIds.length - 1)];
@@ -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.3",
3
+ "version": "1.0.5",
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,6 +49,7 @@
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",
@@ -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');
@@ -81,18 +81,19 @@ function buildApiLimiters(core) {
81
81
  }
82
82
 
83
83
  /**
84
- * Build an Express application for the given YAML config.
84
+ * Build an Express application for the given config file.
85
85
  *
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.
86
+ * @param {string} configPath Path to the config file (any supported format).
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.
90
90
  * @returns {{ app: import('express').Application, core: object }}
91
91
  */
92
- async function buildApp(yamlPath, reloadFn) {
93
- const config = loadYaml(yamlPath);
92
+ async function buildApp(configPath, reloadFn) {
93
+ const config = loadConfig(configPath);
94
94
  validateSchema(config);
95
95
  const core = buildCore(config);
96
+ const configFormat = detectFormat(configPath);
96
97
  logger.info(`Loading "${core.name}"...`);
97
98
 
98
99
  // Initialize OpenTelemetry (singleton — no-op on hot reload)
@@ -100,7 +101,7 @@ async function buildApp(yamlPath, reloadFn) {
100
101
  await initTelemetry(telConfig);
101
102
 
102
103
  const dbPath = core.database
103
- ? path.resolve(path.dirname(yamlPath), core.database)
104
+ ? path.resolve(path.dirname(configPath), core.database)
104
105
  : undefined;
105
106
  await initDb(core, dbPath);
106
107
  await initApiKeys();
@@ -211,7 +212,7 @@ async function buildApp(yamlPath, reloadFn) {
211
212
  });
212
213
 
213
214
  // ── Admin config endpoints ────────────────────────────────────────────
214
- // GET /admin/config — return the current YAML config as JSON (auth required)
215
+ // GET /admin/config — return the current config as JSON (auth required)
215
216
  app.get('/admin/config', adminRateLimiter, (req, res) => {
216
217
  const header = req.headers.authorization;
217
218
  if (!header || !header.startsWith('Bearer ')) {
@@ -221,13 +222,30 @@ async function buildApp(yamlPath, reloadFn) {
221
222
  return res.status(401).json({ error: 'Invalid token' });
222
223
  }
223
224
  try {
224
- res.json(loadYaml(yamlPath));
225
+ res.set('X-Config-Format', configFormat);
226
+ res.json(loadConfig(configPath));
225
227
  } catch (e) {
226
228
  res.status(500).json({ error: e.message });
227
229
  }
228
230
  });
229
231
 
230
- // PUT /admin/config — receive JSON config, validate, save as YAML, then hot-reload
232
+ // GET /admin/config-inforeturn metadata about the config file
233
+ app.get('/admin/config-info', adminRateLimiter, (req, res) => {
234
+ const header = req.headers.authorization;
235
+ if (!header || !header.startsWith('Bearer ')) {
236
+ return res.status(401).json({ error: 'Unauthorized' });
237
+ }
238
+ try { verifyToken(header.slice(7)); } catch {
239
+ return res.status(401).json({ error: 'Invalid token' });
240
+ }
241
+ res.json({
242
+ format: configFormat,
243
+ file: path.basename(configPath),
244
+ writable: isWritableFormat(configFormat),
245
+ });
246
+ });
247
+
248
+ // PUT /admin/config — receive JSON config, validate, save in original format, then hot-reload
231
249
  app.put('/admin/config', adminRateLimiter, (req, res) => {
232
250
  const header = req.headers.authorization;
233
251
  if (!header || !header.startsWith('Bearer ')) {
@@ -246,7 +264,7 @@ async function buildApp(yamlPath, reloadFn) {
246
264
  return res.status(400).json({ error: e.message });
247
265
  }
248
266
  try {
249
- saveYaml(yamlPath, newConfig);
267
+ saveConfig(configPath, newConfig);
250
268
  if (reloadFn) {
251
269
  // Schedule hot reload after the response has been fully flushed
252
270
  res.on('finish', () => {
@@ -258,7 +276,7 @@ async function buildApp(yamlPath, reloadFn) {
258
276
  }
259
277
  } catch (e) {
260
278
  logger.error('Failed to save config:', e.message);
261
- res.status(500).json({ error: 'Failed to save config' });
279
+ res.status(500).json({ error: e.message });
262
280
  }
263
281
  });
264
282
 
@@ -503,14 +521,14 @@ async function buildApp(yamlPath, reloadFn) {
503
521
  return { app, core };
504
522
  }
505
523
 
506
- async function createServer(yamlPath) {
507
- const { app, core } = await buildApp(yamlPath, null);
524
+ async function createServer(configPath) {
525
+ const { app, core } = await buildApp(configPath, null);
508
526
  const server = http.createServer(app);
509
527
  initRealtime(server);
510
528
  return { app, server, core };
511
529
  }
512
530
 
513
- async function startServer(yamlPath) {
531
+ async function startServer(configPath) {
514
532
  // ── Dispatcher pattern ───────────────────────────────────────────────
515
533
  // The HTTP server and WebSocket server are created once and never replaced.
516
534
  // Hot reload works by rebuilding the Express app and swapping the handler
@@ -523,7 +541,7 @@ async function startServer(yamlPath) {
523
541
 
524
542
  async function reload() {
525
543
  logger.info('Reloading config…');
526
- const result = await buildApp(yamlPath, reload);
544
+ const result = await buildApp(configPath, reload);
527
545
  currentApp = result.app;
528
546
  logger.info(`Config loaded: "${result.core.name}"`);
529
547
  return result;
@@ -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
+ });