chadstart 1.0.0

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.
Files changed (115) hide show
  1. package/.dockerignore +10 -0
  2. package/.env.example +46 -0
  3. package/.github/workflows/browser-test.yml +34 -0
  4. package/.github/workflows/docker-publish.yml +54 -0
  5. package/.github/workflows/docs.yml +31 -0
  6. package/.github/workflows/npm-chadstart.yml +27 -0
  7. package/.github/workflows/npm-sdk.yml +38 -0
  8. package/.github/workflows/test.yml +85 -0
  9. package/.weblate +9 -0
  10. package/Dockerfile +23 -0
  11. package/README.md +348 -0
  12. package/admin/index.html +2802 -0
  13. package/admin/login.html +207 -0
  14. package/chadstart.example.yml +416 -0
  15. package/chadstart.schema.json +367 -0
  16. package/chadstart.yaml +53 -0
  17. package/cli/cli.js +295 -0
  18. package/core/api-generator.js +606 -0
  19. package/core/auth.js +298 -0
  20. package/core/db.js +384 -0
  21. package/core/entity-engine.js +166 -0
  22. package/core/error-reporter.js +132 -0
  23. package/core/file-storage.js +97 -0
  24. package/core/functions-engine.js +353 -0
  25. package/core/openapi.js +171 -0
  26. package/core/plugin-loader.js +92 -0
  27. package/core/realtime.js +93 -0
  28. package/core/schema-validator.js +50 -0
  29. package/core/seeder.js +231 -0
  30. package/core/telemetry.js +119 -0
  31. package/core/upload.js +372 -0
  32. package/core/workers/php_worker.php +19 -0
  33. package/core/workers/python_worker.py +33 -0
  34. package/core/workers/ruby_worker.rb +21 -0
  35. package/core/yaml-loader.js +64 -0
  36. package/demo/chadstart.yaml +178 -0
  37. package/demo/docker-compose.yml +31 -0
  38. package/demo/functions/greet.go +39 -0
  39. package/demo/functions/hello.cpp +18 -0
  40. package/demo/functions/hello.py +13 -0
  41. package/demo/functions/hello.rb +10 -0
  42. package/demo/functions/onTodoCreated.js +13 -0
  43. package/demo/functions/ping.sh +13 -0
  44. package/demo/functions/stats.js +22 -0
  45. package/demo/public/index.html +522 -0
  46. package/docker-compose.yml +17 -0
  47. package/docs/access-policies.md +155 -0
  48. package/docs/admin-ui.md +29 -0
  49. package/docs/angular.md +69 -0
  50. package/docs/astro.md +71 -0
  51. package/docs/auth.md +160 -0
  52. package/docs/cli.md +56 -0
  53. package/docs/config.md +127 -0
  54. package/docs/crud.md +627 -0
  55. package/docs/deploy.md +113 -0
  56. package/docs/docker.md +59 -0
  57. package/docs/entities.md +385 -0
  58. package/docs/functions.md +196 -0
  59. package/docs/getting-started.md +79 -0
  60. package/docs/groups.md +85 -0
  61. package/docs/index.md +5 -0
  62. package/docs/llm-rules.md +81 -0
  63. package/docs/middlewares.md +78 -0
  64. package/docs/overrides/home.html +350 -0
  65. package/docs/plugins.md +59 -0
  66. package/docs/react.md +75 -0
  67. package/docs/realtime.md +43 -0
  68. package/docs/s3-storage.md +40 -0
  69. package/docs/security.md +23 -0
  70. package/docs/stylesheets/extra.css +375 -0
  71. package/docs/svelte.md +71 -0
  72. package/docs/telemetry.md +97 -0
  73. package/docs/upload.md +168 -0
  74. package/docs/validation.md +115 -0
  75. package/docs/vue.md +86 -0
  76. package/docs/webhooks.md +87 -0
  77. package/index.js +11 -0
  78. package/locales/en/admin.json +169 -0
  79. package/mkdocs.yml +82 -0
  80. package/package.json +65 -0
  81. package/playwright.config.js +24 -0
  82. package/public/.gitkeep +0 -0
  83. package/sdk/README.md +284 -0
  84. package/sdk/package.json +39 -0
  85. package/sdk/scripts/build.js +58 -0
  86. package/sdk/src/index.js +368 -0
  87. package/sdk/test/sdk.test.cjs +340 -0
  88. package/sdk/types/index.d.ts +217 -0
  89. package/server/express-server.js +734 -0
  90. package/test/access-policies.test.js +96 -0
  91. package/test/ai.test.js +81 -0
  92. package/test/api-keys.test.js +361 -0
  93. package/test/auth.test.js +122 -0
  94. package/test/browser/admin-ui.spec.js +127 -0
  95. package/test/browser/global-setup.js +71 -0
  96. package/test/browser/global-teardown.js +11 -0
  97. package/test/db.test.js +227 -0
  98. package/test/entity-engine.test.js +193 -0
  99. package/test/error-reporter.test.js +140 -0
  100. package/test/functions-engine.test.js +240 -0
  101. package/test/groups.test.js +212 -0
  102. package/test/hot-reload.test.js +153 -0
  103. package/test/i18n.test.js +173 -0
  104. package/test/middleware.test.js +76 -0
  105. package/test/openapi.test.js +67 -0
  106. package/test/schema-validator.test.js +83 -0
  107. package/test/sdk.test.js +90 -0
  108. package/test/seeder.test.js +279 -0
  109. package/test/settings.test.js +109 -0
  110. package/test/telemetry.test.js +254 -0
  111. package/test/test.js +17 -0
  112. package/test/upload.test.js +265 -0
  113. package/test/validation.test.js +96 -0
  114. package/test/yaml-loader.test.js +93 -0
  115. package/utils/logger.js +24 -0
package/core/upload.js ADDED
@@ -0,0 +1,372 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const logger = require('../utils/logger');
6
+
7
+ const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
8
+
9
+ /** Returns a month folder string like "Mar2026". */
10
+ function getMonthFolder(date) {
11
+ const d = date || new Date();
12
+ return `${MONTH_NAMES[d.getMonth()]}${d.getFullYear()}`;
13
+ }
14
+
15
+ /** Returns the configured base URL (defaults to http://localhost:<port>). */
16
+ function getBaseUrl(core) {
17
+ return process.env.BASE_URL || `http://localhost:${core.port}`;
18
+ }
19
+
20
+ /** Returns true when all required S3 env vars are set. */
21
+ function isS3Configured() {
22
+ return !!(
23
+ process.env.S3_BUCKET &&
24
+ process.env.S3_ENDPOINT &&
25
+ process.env.S3_REGION &&
26
+ process.env.S3_ACCESS_KEY_ID &&
27
+ process.env.S3_SECRET_ACCESS_KEY
28
+ );
29
+ }
30
+
31
+ /** Sanitizes a filename to prevent path traversal and special chars. */
32
+ function sanitizeFilename(filename) {
33
+ return path.basename(filename)
34
+ .replace(/[^a-zA-Z0-9._-]/g, '_')
35
+ .replace(/^\.+/, '_');
36
+ }
37
+
38
+ /** Generates a short random unique prefix for filenames. */
39
+ function generateUniquePrefix() {
40
+ return (
41
+ Math.random().toString(36).slice(2, 10) +
42
+ Math.random().toString(36).slice(2, 10)
43
+ );
44
+ }
45
+
46
+ /** Saves a buffer to a local directory and returns the full file path. */
47
+ function saveLocally(buffer, dir, filename) {
48
+ fs.mkdirSync(dir, { recursive: true });
49
+ const dest = path.join(dir, filename);
50
+ fs.writeFileSync(dest, buffer);
51
+ return dest;
52
+ }
53
+
54
+ /**
55
+ * Uploads a buffer to S3 and returns the public URL.
56
+ * The path is optionally prefixed by S3_FOLDER_PREFIX.
57
+ */
58
+ async function uploadToS3(buffer, key, contentType) {
59
+ const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
60
+ const folderPrefix = process.env.S3_FOLDER_PREFIX
61
+ ? `${process.env.S3_FOLDER_PREFIX}/`
62
+ : '';
63
+ const fullKey = `${folderPrefix}${key}`;
64
+
65
+ const client = new S3Client({
66
+ region: process.env.S3_REGION,
67
+ endpoint: process.env.S3_ENDPOINT,
68
+ credentials: {
69
+ accessKeyId: process.env.S3_ACCESS_KEY_ID,
70
+ secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
71
+ },
72
+ forcePathStyle: true,
73
+ });
74
+
75
+ await client.send(
76
+ new PutObjectCommand({
77
+ Bucket: process.env.S3_BUCKET,
78
+ Key: fullKey,
79
+ Body: buffer,
80
+ ContentType: contentType,
81
+ })
82
+ );
83
+
84
+ return `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET}/${fullKey}`;
85
+ }
86
+
87
+ /**
88
+ * Returns image processing options for the given entity + property.
89
+ *
90
+ * Defaults:
91
+ * - compress: true (convert to JPEG at quality 80; disable with `options.compress: false`)
92
+ * - quality: 80 (JPEG quality 1-100; override with `options.quality`)
93
+ * - sizes: null (no resizing; enable with `options.sizes: { name: [w, h], ... }`)
94
+ */
95
+ function getImageOptions(core, entity, property) {
96
+ const entityDef = Object.values(core.entities || {}).find(
97
+ (e) =>
98
+ e.slug === entity ||
99
+ e.tableName === entity ||
100
+ e.name === entity
101
+ );
102
+ const propDef = entityDef && entityDef.properties.find((p) => p.name === property);
103
+ const opts = (propDef && propDef.options) || {};
104
+
105
+ return {
106
+ compress: opts.compress !== false,
107
+ quality: typeof opts.quality === 'number' ? opts.quality : 80,
108
+ sizes: opts.sizes || null,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Register upload routes:
114
+ * POST /api/upload/file — upload any file
115
+ * POST /api/upload/image — upload + resize a PNG/JPG image
116
+ */
117
+ function registerUploadRoutes(app, core) {
118
+ const Busboy = getBusboy();
119
+
120
+ // ── POST /api/upload/file ───────────────────────────────────────────────────
121
+ app.post('/api/upload/file', (req, res) => {
122
+ const contentType = req.headers['content-type'] || '';
123
+ if (!contentType.includes('multipart/form-data')) {
124
+ return res.status(400).json({ error: 'Expected multipart/form-data' });
125
+ }
126
+
127
+ const bb = Busboy({ headers: req.headers });
128
+ const fields = {};
129
+ let fileBuffer = null;
130
+ let fileInfo = null;
131
+
132
+ bb.on('field', (name, value) => {
133
+ fields[name] = value;
134
+ });
135
+
136
+ bb.on('file', (_fieldname, stream, info) => {
137
+ const chunks = [];
138
+ stream.on('data', (chunk) => chunks.push(chunk));
139
+ stream.on('end', () => {
140
+ fileBuffer = Buffer.concat(chunks);
141
+ fileInfo = info;
142
+ });
143
+ });
144
+
145
+ bb.on('finish', async () => {
146
+ try {
147
+ if (!fileBuffer || !fileInfo || !fileInfo.filename) {
148
+ return res.status(400).json({ error: 'No file provided' });
149
+ }
150
+
151
+ const { entity, property } = fields;
152
+ if (!entity || !property) {
153
+ return res.status(400).json({ error: 'Missing entity or property fields' });
154
+ }
155
+
156
+ const safeName = sanitizeFilename(fileInfo.filename);
157
+ const prefix = generateUniquePrefix();
158
+ const finalName = `${prefix}-${safeName}`;
159
+ const monthFolder = getMonthFolder();
160
+ const relPath = `storage/${entity}/${property}/${monthFolder}/${finalName}`;
161
+
162
+ let url;
163
+ if (isS3Configured()) {
164
+ url = await uploadToS3(
165
+ fileBuffer,
166
+ relPath,
167
+ fileInfo.mimeType || 'application/octet-stream'
168
+ );
169
+ } else {
170
+ const publicFolder = (core.public && core.public.folder) || './public';
171
+ const dir = path.resolve(
172
+ publicFolder,
173
+ 'storage',
174
+ entity,
175
+ property,
176
+ monthFolder
177
+ );
178
+ saveLocally(fileBuffer, dir, finalName);
179
+ url = `${getBaseUrl(core)}/storage/${entity}/${property}/${monthFolder}/${finalName}`;
180
+ }
181
+
182
+ res.json({ path: url });
183
+ } catch (err) {
184
+ logger.error('File upload error', err.message);
185
+ res.status(500).json({ error: err.message });
186
+ }
187
+ });
188
+
189
+ bb.on('error', (err) => {
190
+ logger.error('Upload parse error', err.message);
191
+ res.status(500).json({ error: err.message });
192
+ });
193
+
194
+ req.pipe(bb);
195
+ });
196
+
197
+ // ── POST /api/upload/image ──────────────────────────────────────────────────
198
+ app.post('/api/upload/image', (req, res) => {
199
+ const contentType = req.headers['content-type'] || '';
200
+ if (!contentType.includes('multipart/form-data')) {
201
+ return res.status(400).json({ error: 'Expected multipart/form-data' });
202
+ }
203
+
204
+ const bb = Busboy({ headers: req.headers });
205
+ const fields = {};
206
+ let imageBuffer = null;
207
+ let imageInfo = null;
208
+
209
+ bb.on('field', (name, value) => {
210
+ fields[name] = value;
211
+ });
212
+
213
+ bb.on('file', (_fieldname, stream, info) => {
214
+ const chunks = [];
215
+ stream.on('data', (chunk) => chunks.push(chunk));
216
+ stream.on('end', () => {
217
+ imageBuffer = Buffer.concat(chunks);
218
+ imageInfo = info;
219
+ });
220
+ });
221
+
222
+ bb.on('finish', async () => {
223
+ try {
224
+ if (!imageBuffer || !imageInfo || !imageInfo.filename) {
225
+ return res.status(400).json({ error: 'No image provided' });
226
+ }
227
+
228
+ // Validate PNG/JPG only
229
+ const mime = (imageInfo.mimeType || '').toLowerCase();
230
+ const ext = path.extname(imageInfo.filename).toLowerCase();
231
+ const validMime = mime === 'image/png' || mime === 'image/jpeg';
232
+ const validExt = ext === '.png' || ext === '.jpg' || ext === '.jpeg';
233
+ if (!validMime && !validExt) {
234
+ return res
235
+ .status(400)
236
+ .json({ error: 'Only PNG and JPG images are accepted' });
237
+ }
238
+
239
+ const { entity, property } = fields;
240
+ if (!entity || !property) {
241
+ return res
242
+ .status(400)
243
+ .json({ error: 'Missing entity or property fields' });
244
+ }
245
+
246
+ const { compress, quality, sizes } = getImageOptions(core, entity, property);
247
+ const prefix = generateUniquePrefix();
248
+ const monthFolder = getMonthFolder();
249
+ const sharp = getSharp();
250
+
251
+ if (sizes) {
252
+ // ── Resize mode: one output file per configured size ──────────────────
253
+ const result = {};
254
+
255
+ for (const [sizeName, dims] of Object.entries(sizes)) {
256
+ const [width, height] = dims;
257
+ const filename = `${prefix}-${sizeName}.jpg`;
258
+ let pipeline = sharp(imageBuffer).resize(width, height, { fit: 'cover' });
259
+ pipeline = compress
260
+ ? pipeline.jpeg({ quality })
261
+ : pipeline.jpeg({ quality: 100 });
262
+ const processed = await pipeline.toBuffer();
263
+
264
+ if (isS3Configured()) {
265
+ const key = `storage/${entity}/${property}/${monthFolder}/${filename}`;
266
+ result[sizeName] = await uploadToS3(processed, key, 'image/jpeg');
267
+ } else {
268
+ const publicFolder = (core.public && core.public.folder) || './public';
269
+ const dir = path.resolve(
270
+ publicFolder,
271
+ 'storage',
272
+ entity,
273
+ property,
274
+ monthFolder
275
+ );
276
+ saveLocally(processed, dir, filename);
277
+ result[sizeName] =
278
+ `${getBaseUrl(core)}/storage/${entity}/${property}/${monthFolder}/${filename}`;
279
+ }
280
+ }
281
+
282
+ return res.json(result);
283
+ }
284
+
285
+ // ── No-resize mode: single output file ───────────────────────────────
286
+ let processedBuffer;
287
+ let outputMime;
288
+ let finalName;
289
+
290
+ if (compress) {
291
+ // Convert to JPEG with lossy compression
292
+ processedBuffer = await sharp(imageBuffer).jpeg({ quality }).toBuffer();
293
+ outputMime = 'image/jpeg';
294
+ const baseName = path.basename(
295
+ sanitizeFilename(imageInfo.filename),
296
+ path.extname(imageInfo.filename)
297
+ );
298
+ finalName = `${prefix}-${baseName}.jpg`;
299
+ } else {
300
+ // Keep original bytes untouched
301
+ processedBuffer = imageBuffer;
302
+ outputMime = imageInfo.mimeType || 'image/octet-stream';
303
+ finalName = `${prefix}-${sanitizeFilename(imageInfo.filename)}`;
304
+ }
305
+
306
+ let url;
307
+ if (isS3Configured()) {
308
+ const key = `storage/${entity}/${property}/${monthFolder}/${finalName}`;
309
+ url = await uploadToS3(processedBuffer, key, outputMime);
310
+ } else {
311
+ const publicFolder = (core.public && core.public.folder) || './public';
312
+ const dir = path.resolve(
313
+ publicFolder,
314
+ 'storage',
315
+ entity,
316
+ property,
317
+ monthFolder
318
+ );
319
+ saveLocally(processedBuffer, dir, finalName);
320
+ url = `${getBaseUrl(core)}/storage/${entity}/${property}/${monthFolder}/${finalName}`;
321
+ }
322
+
323
+ res.json({ path: url });
324
+ } catch (err) {
325
+ logger.error('Image upload error', err.message);
326
+ res.status(500).json({ error: err.message });
327
+ }
328
+ });
329
+
330
+ bb.on('error', (err) => {
331
+ logger.error('Upload parse error', err.message);
332
+ res.status(500).json({ error: err.message });
333
+ });
334
+
335
+ req.pipe(bb);
336
+ });
337
+
338
+ logger.info(
339
+ ' Registered upload routes at /api/upload/file and /api/upload/image'
340
+ );
341
+ }
342
+
343
+ function getBusboy() {
344
+ try {
345
+ return require('busboy');
346
+ } catch {
347
+ throw new Error(
348
+ 'busboy is required for file uploads. Install it with: npm install busboy'
349
+ );
350
+ }
351
+ }
352
+
353
+ function getSharp() {
354
+ try {
355
+ return require('sharp');
356
+ } catch {
357
+ throw new Error(
358
+ 'sharp is required for image uploads. Install it with: npm install sharp'
359
+ );
360
+ }
361
+ }
362
+
363
+ module.exports = {
364
+ registerUploadRoutes,
365
+ getBaseUrl,
366
+ getMonthFolder,
367
+ isS3Configured,
368
+ sanitizeFilename,
369
+ generateUniquePrefix,
370
+ saveLocally,
371
+ getImageOptions,
372
+ };
@@ -0,0 +1,19 @@
1
+ <?php
2
+ // ChadStart PHP runtime worker.
3
+ // Protocol: {"id": N, "entry": "/path/to/fn.php", "event": {...}, "ctx": {...}}
4
+
5
+ while (($line = fgets(STDIN)) !== false) {
6
+ $line = trim($line);
7
+ if ($line === '') continue;
8
+ $req = json_decode($line, true);
9
+ try {
10
+ require_once $req['entry'];
11
+ $event = $req['event'] ?? [];
12
+ $ctx = $req['ctx'] ?? [];
13
+ $result = function_exists('handler') ? handler($event, $ctx) : null;
14
+ echo json_encode(['id' => $req['id'], 'result' => $result]) . "\n";
15
+ } catch (Throwable $e) {
16
+ echo json_encode(['id' => $req['id'], 'error' => $e->getMessage()]) . "\n";
17
+ }
18
+ fflush(STDOUT);
19
+ }
@@ -0,0 +1,33 @@
1
+ """
2
+ ChadStart Python runtime worker.
3
+ Reads newline-delimited JSON requests from stdin, invokes the function, writes JSON result to stdout.
4
+ Protocol: {"id": N, "entry": "/path/to/fn.py", "event": {...}, "ctx": {...}}
5
+ Response: {"id": N, "result": ...} or {"id": N, "error": "message"}
6
+ """
7
+ import sys, json, importlib.util, asyncio, os
8
+
9
+ def load_module(entry):
10
+ spec = importlib.util.spec_from_file_location("fn", entry)
11
+ mod = importlib.util.module_from_spec(spec)
12
+ spec.loader.exec_module(mod)
13
+ return mod
14
+
15
+ def run(mod, event, ctx):
16
+ fn = getattr(mod, 'handler', None) or getattr(mod, 'default', None)
17
+ if fn is None:
18
+ raise RuntimeError("No handler or default export found")
19
+ if asyncio.iscoroutinefunction(fn):
20
+ return asyncio.run(fn(event, ctx))
21
+ return fn(event, ctx)
22
+
23
+ for line in sys.stdin:
24
+ line = line.strip()
25
+ if not line:
26
+ continue
27
+ try:
28
+ req = json.loads(line)
29
+ mod = load_module(req["entry"])
30
+ result = run(mod, req.get("event", {}), req.get("ctx", {}))
31
+ print(json.dumps({"id": req["id"], "result": result}), flush=True)
32
+ except Exception as e:
33
+ print(json.dumps({"id": req.get("id"), "error": str(e)}), flush=True)
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env ruby
2
+ # ChadStart Ruby runtime worker.
3
+ # Protocol: {"id": N, "entry": "/path/to/fn.rb", "event": {...}, "ctx": {...}}
4
+ require 'json'
5
+
6
+ $stdout.sync = true
7
+
8
+ $stdin.each_line do |line|
9
+ line.strip!
10
+ next if line.empty?
11
+ begin
12
+ req = JSON.parse(line)
13
+ load req['entry']
14
+ event = req['event'] || {}
15
+ ctx = req['ctx'] || {}
16
+ result = defined?(handler) ? handler(event, ctx) : (defined?(default) ? method(:default).call(event, ctx) : nil)
17
+ puts JSON.generate({ id: req['id'], result: result })
18
+ rescue => e
19
+ puts JSON.generate({ id: req['id'], error: e.message })
20
+ end
21
+ end
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const YAML = require('yaml');
6
+ const logger = require('../utils/logger');
7
+
8
+ /**
9
+ * Load and parse the chadstart.yaml file.
10
+ * Returns the raw parsed object.
11
+ */
12
+ 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;
21
+ }
22
+
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
+ 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);
62
+ }
63
+
64
+ module.exports = { loadYaml, saveYaml };
@@ -0,0 +1,178 @@
1
+ # demo/chadstart.yaml
2
+ # ─────────────────────────────────────────────────────────────────────────────
3
+ # ChadStart Todo App — demonstration of a complete chadstart.yaml config.
4
+ # Shows: entities, access policies, realtime, CRUD, and functions in every
5
+ # supported runtime (js, bash, python, go, ruby, c++).
6
+ #
7
+ # To run locally:
8
+ # cd demo
9
+ # CHADSTART_CONFIG=./chadstart.yaml node ../index.js
10
+ #
11
+ # To run with Docker Compose:
12
+ # cd demo
13
+ # docker compose up --build
14
+ # ─────────────────────────────────────────────────────────────────────────────
15
+
16
+ name: Todo App
17
+ port: 3000
18
+ database: ./data/todo.db
19
+
20
+ entities:
21
+ User:
22
+ authenticable: true
23
+ mainProp: name
24
+ seedCount: 5
25
+ properties:
26
+ - name
27
+ - { name: email, type: email }
28
+ - { name: password, type: password }
29
+ policies:
30
+ create:
31
+ - access: public # Anyone can sign up
32
+ read:
33
+ - access: restricted
34
+ allow: User
35
+ condition: self # Users can only read their own profile
36
+ update:
37
+ - access: restricted
38
+ allow: User
39
+ condition: self
40
+ delete:
41
+ - access: admin
42
+ signup:
43
+ - access: public
44
+ validation:
45
+ name:
46
+ required: true
47
+ isNotEmpty: true
48
+ minLength: 2
49
+ maxLength: 100
50
+ email:
51
+ required: true
52
+ isEmail: true
53
+
54
+ Todo:
55
+ mainProp: title
56
+ seedCount: 20
57
+ belongsTo:
58
+ - User
59
+ properties:
60
+ - title
61
+ - { name: description, type: text }
62
+ - { name: completed, type: boolean, default: false }
63
+ - { name: priority, type: choice, options: { values: [low, medium, high] } }
64
+ - { name: dueDate, type: date }
65
+ policies:
66
+ create:
67
+ - { access: restricted, allow: User, condition: self } # Set owner on create
68
+ read:
69
+ - { access: restricted, allow: User, condition: self }
70
+ update:
71
+ - { access: restricted, allow: User, condition: self }
72
+ delete:
73
+ - { access: restricted, allow: User, condition: self }
74
+ validation:
75
+ title:
76
+ required: true
77
+ isNotEmpty: true
78
+ maxLength: 255
79
+ priority:
80
+ isOptional: true
81
+ isIn: [low, medium, high]
82
+
83
+ public:
84
+ folder: ./public
85
+
86
+ functions:
87
+ # JS (default runtime) — returns per-status todo counts.
88
+ stats:
89
+ runtime: js
90
+ function: stats.js
91
+ description: Returns todo statistics (total, completed, pending).
92
+ triggers:
93
+ - type: http
94
+ method: GET
95
+ path: /api/fn/stats
96
+ policies:
97
+ - access: public
98
+
99
+ # Bash — simple health/ping endpoint.
100
+ ping:
101
+ runtime: bash
102
+ function: ping.sh
103
+ description: Health check / ping — returns pong.
104
+ triggers:
105
+ - type: http
106
+ method: GET
107
+ path: /api/fn/ping
108
+ policies:
109
+ - access: public
110
+ - type: cron
111
+ schedule: "@hourly" # Log a heartbeat every hour
112
+
113
+ # Python — greets the caller by name.
114
+ greetPython:
115
+ runtime: python
116
+ function: hello.py
117
+ description: Returns a greeting from the Python runtime.
118
+ triggers:
119
+ - type: http
120
+ method: GET
121
+ path: /api/fn/greet/python
122
+ policies:
123
+ - access: public
124
+
125
+ # Go — greets the caller from the Go runtime.
126
+ greetGo:
127
+ runtime: go
128
+ function: greet.go
129
+ description: Returns a greeting from the Go runtime.
130
+ triggers:
131
+ - type: http
132
+ method: GET
133
+ path: /api/fn/greet/go
134
+ policies:
135
+ - access: public
136
+
137
+ # Ruby — greets the caller from the Ruby runtime.
138
+ greetRuby:
139
+ runtime: ruby
140
+ function: hello.rb
141
+ description: Returns a greeting from the Ruby runtime.
142
+ triggers:
143
+ - type: http
144
+ method: GET
145
+ path: /api/fn/greet/ruby
146
+ policies:
147
+ - access: public
148
+
149
+ # C++ — greets the caller from the C++ runtime.
150
+ greetCpp:
151
+ runtime: c++
152
+ function: hello.cpp
153
+ description: Returns a greeting from the C++ runtime.
154
+ triggers:
155
+ - type: http
156
+ method: GET
157
+ path: /api/fn/greet/cpp
158
+ policies:
159
+ - access: public
160
+
161
+ # Event-driven — fires when a new todo is created (via the event bus).
162
+ onTodoCreated:
163
+ runtime: js
164
+ function: onTodoCreated.js
165
+ description: Runs after a new Todo is created (event trigger demo).
166
+ triggers:
167
+ - type: event
168
+ name: todo.created
169
+
170
+ files:
171
+ uploads:
172
+ path: ./uploads
173
+ public: true
174
+
175
+ rateLimits:
176
+ - name: default
177
+ limit: 200
178
+ ttl: 60000