chadstart 1.0.4 → 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 +1 -1
- package/admin/index.html +27 -1
- package/{chadstart.example.yml → chadstart.example.yaml} +1 -1
- package/cli/cli.js +46 -35
- package/core/config-loader.js +266 -0
- package/core/migrations.js +23 -18
- package/core/yaml-loader.js +8 -53
- package/docs/llm-rules.md +1 -1
- package/package.json +2 -1
- package/server/express-server.js +36 -18
- package/test/config-loader.test.js +257 -0
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 & 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
|
|
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.
|
|
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
|
|
10
|
+
const DEFAULT_CONFIG = 'chadstart.yaml';
|
|
11
11
|
|
|
12
12
|
function printUsage() {
|
|
13
13
|
console.log(`
|
|
14
|
-
ChadStart -
|
|
14
|
+
ChadStart - Config-driven Backend as a Service
|
|
15
15
|
|
|
16
16
|
Usage:
|
|
17
|
-
npx chadstart dev Start server with hot-reload on
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
123
|
-
console.error(`Config not found: ${
|
|
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 {
|
|
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 =
|
|
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(
|
|
162
|
-
console.error(`Config not found: ${
|
|
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(
|
|
179
|
+
await startServer(configPath);
|
|
169
180
|
}
|
|
170
181
|
|
|
171
182
|
async function runDev() {
|
|
172
|
-
if (!fs.existsSync(
|
|
173
|
-
console.error(`Config not found: ${
|
|
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(
|
|
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(
|
|
212
|
+
const watcher = chokidar.watch(configPath, { ignoreInitial: true });
|
|
202
213
|
watcher.on('change', async () => {
|
|
203
|
-
console.log(`\n[dev] ${path.basename(
|
|
214
|
+
console.log(`\n[dev] ${path.basename(configPath)} changed — restarting...\n`);
|
|
204
215
|
await boot();
|
|
205
216
|
});
|
|
206
|
-
console.log(`[dev] Watching ${
|
|
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(
|
|
232
|
-
console.error(`Config not found: ${
|
|
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 {
|
|
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 =
|
|
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(
|
|
294
|
-
console.error(`Config not found: ${
|
|
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 {
|
|
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 =
|
|
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(
|
|
335
|
-
console.error(`Config not found: ${
|
|
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(
|
|
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(
|
|
362
|
-
console.error(`Config not found: ${
|
|
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 {
|
|
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 =
|
|
385
|
+
const config = loadConfig(configPath);
|
|
375
386
|
validateSchema(config);
|
|
376
387
|
const core = buildCore(config);
|
|
377
388
|
await initDb(core);
|
|
@@ -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/migrations.js
CHANGED
|
@@ -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
|
|
19
|
+
function getLastCommittedConfig(configPath) {
|
|
19
20
|
try {
|
|
20
|
-
const resolved = path.resolve(
|
|
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
|
|
39
|
+
return parseRaw(raw, format);
|
|
34
40
|
} catch {
|
|
35
41
|
return null;
|
|
36
42
|
}
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
/**
|
|
40
|
-
* Load the current
|
|
46
|
+
* Load the current config file from disk and return the parsed object.
|
|
41
47
|
*/
|
|
42
|
-
function
|
|
43
|
-
|
|
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
|
|
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}
|
|
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(
|
|
386
|
-
const currentConfig =
|
|
387
|
-
const oldConfig =
|
|
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
|
-
|
|
412
|
-
|
|
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/yaml-loader.js
CHANGED
|
@@ -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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
"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",
|
package/server/express-server.js
CHANGED
|
@@ -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 {
|
|
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
|
|
84
|
+
* Build an Express application for the given config file.
|
|
85
85
|
*
|
|
86
|
-
* @param {string}
|
|
87
|
-
* @param {Function|null} reloadFn
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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(
|
|
93
|
-
const config =
|
|
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(
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
232
|
+
// GET /admin/config-info — return 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
|
-
|
|
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:
|
|
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(
|
|
507
|
-
const { app, core } = await buildApp(
|
|
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(
|
|
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(
|
|
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
|
+
});
|