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
@@ -0,0 +1,265 @@
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 { buildCore } = require('../core/entity-engine');
8
+ const {
9
+ getBaseUrl,
10
+ getMonthFolder,
11
+ isS3Configured,
12
+ sanitizeFilename,
13
+ generateUniquePrefix,
14
+ saveLocally,
15
+ getImageOptions,
16
+ } = require('../core/upload');
17
+
18
+ describe('upload helpers', () => {
19
+ it('getMonthFolder returns correct format', () => {
20
+ const result = getMonthFolder(new Date(2024, 9, 1)); // October 2024
21
+ assert.strictEqual(result, 'Oct2024');
22
+ });
23
+
24
+ it('getMonthFolder uses current date when no arg provided', () => {
25
+ const result = getMonthFolder();
26
+ const now = new Date();
27
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
28
+ assert.ok(result.startsWith(months[now.getMonth()]));
29
+ assert.ok(result.endsWith(String(now.getFullYear())));
30
+ });
31
+
32
+ it('getBaseUrl uses BASE_URL env var when set', () => {
33
+ const orig = process.env.BASE_URL;
34
+ process.env.BASE_URL = 'https://example.com';
35
+ const url = getBaseUrl({ port: 3000 });
36
+ if (orig === undefined) delete process.env.BASE_URL; else process.env.BASE_URL = orig;
37
+ assert.strictEqual(url, 'https://example.com');
38
+ });
39
+
40
+ it('getBaseUrl defaults to localhost with port', () => {
41
+ const orig = process.env.BASE_URL;
42
+ delete process.env.BASE_URL;
43
+ const url = getBaseUrl({ port: 4000 });
44
+ if (orig !== undefined) process.env.BASE_URL = orig;
45
+ assert.strictEqual(url, 'http://localhost:4000');
46
+ });
47
+
48
+ it('isS3Configured returns false when env vars are absent', () => {
49
+ const vars = ['S3_BUCKET', 'S3_ENDPOINT', 'S3_REGION', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY'];
50
+ const saved = {};
51
+ vars.forEach((v) => { saved[v] = process.env[v]; delete process.env[v]; });
52
+ const result = isS3Configured();
53
+ vars.forEach((v) => { if (saved[v] !== undefined) process.env[v] = saved[v]; });
54
+ assert.strictEqual(result, false);
55
+ });
56
+
57
+ it('isS3Configured returns true when all S3 env vars are set', () => {
58
+ const vars = ['S3_BUCKET', 'S3_ENDPOINT', 'S3_REGION', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY'];
59
+ const saved = {};
60
+ vars.forEach((v) => { saved[v] = process.env[v]; process.env[v] = 'test-value'; });
61
+ const result = isS3Configured();
62
+ vars.forEach((v) => { if (saved[v] !== undefined) process.env[v] = saved[v]; else delete process.env[v]; });
63
+ assert.strictEqual(result, true);
64
+ });
65
+
66
+ it('isS3Configured returns false when only some S3 vars are set', () => {
67
+ const vars = ['S3_BUCKET', 'S3_ENDPOINT', 'S3_REGION', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY'];
68
+ const saved = {};
69
+ vars.forEach((v) => { saved[v] = process.env[v]; delete process.env[v]; });
70
+ process.env.S3_BUCKET = 'my-bucket';
71
+ const result = isS3Configured();
72
+ vars.forEach((v) => { if (saved[v] !== undefined) process.env[v] = saved[v]; else delete process.env[v]; });
73
+ assert.strictEqual(result, false);
74
+ });
75
+
76
+ it('sanitizeFilename strips directory traversal', () => {
77
+ assert.strictEqual(sanitizeFilename('../../../etc/passwd'), 'passwd');
78
+ });
79
+
80
+ it('sanitizeFilename replaces spaces and special chars', () => {
81
+ const safe = sanitizeFilename('my file (1).pdf');
82
+ assert.ok(!/[ ()]/.test(safe));
83
+ });
84
+
85
+ it('sanitizeFilename replaces leading dots', () => {
86
+ const safe = sanitizeFilename('.hidden');
87
+ assert.ok(!safe.startsWith('.'));
88
+ });
89
+
90
+ it('sanitizeFilename preserves safe characters', () => {
91
+ assert.strictEqual(sanitizeFilename('my-file_01.pdf'), 'my-file_01.pdf');
92
+ });
93
+
94
+ it('generateUniquePrefix returns a non-empty string', () => {
95
+ const prefix = generateUniquePrefix();
96
+ assert.ok(typeof prefix === 'string' && prefix.length > 0);
97
+ });
98
+
99
+ it('generateUniquePrefix returns different values each call', () => {
100
+ const a = generateUniquePrefix();
101
+ const b = generateUniquePrefix();
102
+ assert.notStrictEqual(a, b);
103
+ });
104
+
105
+ it('saveLocally creates directory and writes file', () => {
106
+ const dir = path.join(os.tmpdir(), `upload-test-${Date.now()}`);
107
+ const filename = 'test.txt';
108
+ const content = Buffer.from('hello world');
109
+ saveLocally(content, dir, filename);
110
+ const dest = path.join(dir, filename);
111
+ assert.ok(fs.existsSync(dest));
112
+ assert.strictEqual(fs.readFileSync(dest, 'utf8'), 'hello world');
113
+ fs.rmSync(dir, { recursive: true, force: true });
114
+ });
115
+
116
+ it('saveLocally creates nested directories', () => {
117
+ const dir = path.join(os.tmpdir(), `upload-nested-${Date.now()}`, 'a', 'b', 'c');
118
+ saveLocally(Buffer.from('x'), dir, 'f.txt');
119
+ assert.ok(fs.existsSync(path.join(dir, 'f.txt')));
120
+ fs.rmSync(path.join(os.tmpdir(), path.relative(os.tmpdir(), dir).split(path.sep)[0]), { recursive: true, force: true });
121
+ });
122
+
123
+ it('getImageOptions defaults: compress=true, quality=80, sizes=null', () => {
124
+ const core = buildCore({ name: 'App', entities: {} });
125
+ const opts = getImageOptions(core, 'cats', 'avatar');
126
+ assert.strictEqual(opts.compress, true);
127
+ assert.strictEqual(opts.quality, 80);
128
+ assert.strictEqual(opts.sizes, null);
129
+ });
130
+
131
+ it('getImageOptions: compress=false disables compression', () => {
132
+ const core = buildCore({
133
+ name: 'App',
134
+ entities: { Cat: { properties: [{ name: 'avatar', type: 'image', options: { compress: false } }] } },
135
+ });
136
+ const opts = getImageOptions(core, 'Cat', 'avatar');
137
+ assert.strictEqual(opts.compress, false);
138
+ assert.strictEqual(opts.quality, 80);
139
+ assert.strictEqual(opts.sizes, null);
140
+ });
141
+
142
+ it('getImageOptions: custom quality is respected', () => {
143
+ const core = buildCore({
144
+ name: 'App',
145
+ entities: { Cat: { properties: [{ name: 'avatar', type: 'image', options: { quality: 60 } }] } },
146
+ });
147
+ const opts = getImageOptions(core, 'Cat', 'avatar');
148
+ assert.strictEqual(opts.compress, true);
149
+ assert.strictEqual(opts.quality, 60);
150
+ });
151
+
152
+ it('getImageOptions: sizes enables resize mode', () => {
153
+ const core = buildCore({
154
+ name: 'App',
155
+ entities: { Cat: { properties: [{ name: 'avatar', type: 'image', options: { sizes: { small: [40, 40], large: [400, 400] } } }] } },
156
+ });
157
+ const opts = getImageOptions(core, 'Cat', 'avatar');
158
+ assert.deepStrictEqual(opts.sizes, { small: [40, 40], large: [400, 400] });
159
+ assert.strictEqual(opts.compress, true);
160
+ });
161
+
162
+ it('getImageOptions: no sizes when not configured', () => {
163
+ const core = buildCore({
164
+ name: 'App',
165
+ entities: { Cat: { properties: [{ name: 'avatar', type: 'image' }] } },
166
+ });
167
+ const opts = getImageOptions(core, 'Cat', 'avatar');
168
+ assert.strictEqual(opts.sizes, null);
169
+ });
170
+
171
+ it('getImageOptions looks up by entity tableName', () => {
172
+ const core = buildCore({
173
+ name: 'App',
174
+ entities: { BlogPost: { properties: [{ name: 'cover', type: 'image', options: { sizes: { thumb: [100, 100] }, quality: 70 } }] } },
175
+ });
176
+ const opts = getImageOptions(core, 'blog_post', 'cover');
177
+ assert.deepStrictEqual(opts.sizes, { thumb: [100, 100] });
178
+ assert.strictEqual(opts.quality, 70);
179
+ });
180
+ });
181
+
182
+ describe('upload – sharp integration', () => {
183
+ const SAMPLE_PNG_B64 =
184
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
185
+
186
+ it('sharp compresses a PNG to JPEG at quality 80 (default)', async () => {
187
+ const sharp = require('sharp');
188
+ const input = Buffer.from(SAMPLE_PNG_B64, 'base64');
189
+ const output = await sharp(input).jpeg({ quality: 80 }).toBuffer();
190
+ const meta = await sharp(output).metadata();
191
+ assert.strictEqual(meta.format, 'jpeg');
192
+ });
193
+
194
+ it('sharp compresses a PNG to JPEG at custom quality', async () => {
195
+ const sharp = require('sharp');
196
+ const input = Buffer.from(SAMPLE_PNG_B64, 'base64');
197
+ const q60 = await sharp(input).jpeg({ quality: 60 }).toBuffer();
198
+ const q90 = await sharp(input).jpeg({ quality: 90 }).toBuffer();
199
+ const metaQ60 = await sharp(q60).metadata();
200
+ const metaQ90 = await sharp(q90).metadata();
201
+ assert.strictEqual(metaQ60.format, 'jpeg');
202
+ assert.strictEqual(metaQ90.format, 'jpeg');
203
+ });
204
+
205
+ it('sharp resizes a 1x1 PNG to specified dimensions with quality', async () => {
206
+ const sharp = require('sharp');
207
+ const input = Buffer.from(SAMPLE_PNG_B64, 'base64');
208
+ const output = await sharp(input).resize(80, 80, { fit: 'cover' }).jpeg({ quality: 80 }).toBuffer();
209
+ const meta = await sharp(output).metadata();
210
+ assert.strictEqual(meta.width, 80);
211
+ assert.strictEqual(meta.height, 80);
212
+ assert.strictEqual(meta.format, 'jpeg');
213
+ });
214
+
215
+ it('sharp resize with quality:100 produces valid JPEG', async () => {
216
+ const sharp = require('sharp');
217
+ const input = Buffer.from(SAMPLE_PNG_B64, 'base64');
218
+ const output = await sharp(input).resize(160, 160, { fit: 'cover' }).jpeg({ quality: 100 }).toBuffer();
219
+ const meta = await sharp(output).metadata();
220
+ assert.strictEqual(meta.width, 160);
221
+ assert.strictEqual(meta.height, 160);
222
+ assert.strictEqual(meta.format, 'jpeg');
223
+ });
224
+ });
225
+
226
+ describe('upload – route content-type check', () => {
227
+ it('/api/upload/file rejects non-multipart requests', () => {
228
+ const contentType = 'application/json';
229
+ const isMultipart = contentType.includes('multipart/form-data');
230
+ assert.strictEqual(isMultipart, false);
231
+ });
232
+
233
+ it('file path format: prefix-filename in month folder', () => {
234
+ const prefix = 'abc123';
235
+ const safeName = sanitizeFilename('my-contract.pdf');
236
+ const finalName = `${prefix}-${safeName}`;
237
+ const month = getMonthFolder(new Date(2024, 9, 1));
238
+ const relPath = `storage/invoices/contract/${month}/${finalName}`;
239
+ assert.strictEqual(relPath, 'storage/invoices/contract/Oct2024/abc123-my-contract.pdf');
240
+ });
241
+
242
+ it('image path format (no sizes): prefix-basename.jpg in month folder', () => {
243
+ const prefix = 'abc123';
244
+ const month = getMonthFolder(new Date(2024, 9, 1));
245
+ const baseName = path.basename(sanitizeFilename('my-photo.png'), '.png');
246
+ const finalName = `${prefix}-${baseName}.jpg`;
247
+ assert.strictEqual(finalName, 'abc123-my-photo.jpg');
248
+ assert.ok(`storage/cats/avatar/${month}/${finalName}`.includes('Oct2024'));
249
+ });
250
+
251
+ it('image path format (with sizes): prefix-sizeName.jpg in month folder', () => {
252
+ const prefix = 'xyz789';
253
+ const month = getMonthFolder(new Date(2024, 9, 1));
254
+ const thumbName = `${prefix}-thumbnail.jpg`;
255
+ assert.ok(thumbName.endsWith('-thumbnail.jpg'));
256
+ assert.ok(`storage/cats/avatar/${month}/${thumbName}`.includes('Oct2024'));
257
+ });
258
+
259
+ it('image path format (compress disabled): prefix-original-name preserved', () => {
260
+ const prefix = 'def456';
261
+ const originalName = sanitizeFilename('photo.png');
262
+ const finalName = `${prefix}-${originalName}`;
263
+ assert.strictEqual(finalName, 'def456-photo.png');
264
+ });
265
+ });
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ const assert = require('assert');
4
+ const { validateBody, applyDefaults, hideHiddenProps } = require('../core/api-generator');
5
+
6
+ describe('validation', () => {
7
+ it('validates required', () => {
8
+ assert.ok(validateBody({}, { properties: [{ name: 't', type: 'string' }], validation: { t: { required: true } } }).errors);
9
+ });
10
+ it('validates minLength', () => {
11
+ assert.ok(validateBody({ n: 'ab' }, { properties: [{ name: 'n', type: 'string' }], validation: { n: { minLength: 3 } } }).errors);
12
+ });
13
+ it('passes valid data', () => {
14
+ assert.strictEqual(validateBody({ n: 'Alice' }, { properties: [{ name: 'n', type: 'string' }], validation: { n: { minLength: 3 } } }).errors, null);
15
+ });
16
+ it('validates min/max', () => {
17
+ assert.ok(validateBody({ age: 50 }, { properties: [{ name: 'age', type: 'number' }], validation: { age: { max: 30 } } }).errors);
18
+ });
19
+ it('isOptional skips undefined', () => {
20
+ assert.strictEqual(validateBody({}, { properties: [{ name: 'e', type: 'email' }], validation: { e: { isOptional: true, contains: '@co.com' } } }).errors, null);
21
+ });
22
+ it('validates contains', () => {
23
+ assert.ok(validateBody({ e: 'john@gmail.com' }, { properties: [{ name: 'e', type: 'email' }], validation: { e: { contains: '@co.com' } } }).errors);
24
+ });
25
+
26
+ const validators = [
27
+ ['isAlpha', { isAlpha: true }, { n: 'abc123' }, { n: 'abc' }],
28
+ ['isAlphanumeric', { isAlphanumeric: true }, { n: 'abc!@#' }, { n: 'abc123' }],
29
+ ['isAscii', { isAscii: true }, { n: 'héllo' }, { n: 'hello' }],
30
+ ['isJSON', { isJSON: true }, { n: 'not json' }, { n: '{"a":1}' }],
31
+ ['isDefined', { isDefined: true }, {}, { n: '' }],
32
+ ['isEmpty', { isEmpty: true }, { n: 'x' }, { n: '' }],
33
+ ['isIn', { isIn: ['a', 'b'] }, { n: 'c' }, { n: 'a' }],
34
+ ['isNotIn', { isNotIn: ['a', 'b'] }, { n: 'a' }, { n: 'c' }],
35
+ ['notContains', { notContains: 'world' }, { n: 'hello world' }, { n: 'hello' }],
36
+ ['equals', { equals: 'b' }, { n: 'a' }, { n: 'b' }],
37
+ ['notEquals', { notEquals: 'b' }, { n: 'b' }, { n: 'c' }],
38
+ ['matches', { matches: '^[0-9]+$' }, { n: 'hello' }, { n: '123' }],
39
+ ];
40
+
41
+ for (const [label, ruleObj, bad, good] of validators) {
42
+ it(`validates ${label}`, () => {
43
+ const ent = { properties: [{ name: 'n', type: 'string' }], validation: { n: ruleObj } };
44
+ assert.ok(validateBody(bad, ent).errors, `${label}: invalid input should fail`);
45
+ assert.strictEqual(validateBody(good, ent).errors, null, `${label}: valid input should pass`);
46
+ });
47
+ }
48
+ });
49
+
50
+ describe('validation – additional validators', () => {
51
+ it('validates isEmail', () => {
52
+ const ent = { properties: [{ name: 'e', type: 'email' }], validation: { e: { isEmail: true } } };
53
+ assert.ok(validateBody({ e: 'not-an-email' }, ent).errors, 'invalid email should fail');
54
+ assert.strictEqual(validateBody({ e: 'user@example.com' }, ent).errors, null);
55
+ });
56
+
57
+ it('validates isMimeType', () => {
58
+ const ent = { properties: [{ name: 'm', type: 'string' }], validation: { m: { isMimeType: true } } };
59
+ assert.ok(validateBody({ m: 'not a mime type' }, ent).errors, 'invalid mime type should fail');
60
+ assert.strictEqual(validateBody({ m: 'image/png' }, ent).errors, null);
61
+ });
62
+
63
+ it('validates maxLength', () => {
64
+ const ent = { properties: [{ name: 'n', type: 'string' }], validation: { n: { maxLength: 5 } } };
65
+ assert.ok(validateBody({ n: 'toolongstring' }, ent).errors, 'too long should fail');
66
+ assert.strictEqual(validateBody({ n: 'ok' }, ent).errors, null);
67
+ });
68
+
69
+ it('validates isNotEmpty', () => {
70
+ const ent = { properties: [{ name: 'n', type: 'string' }], validation: { n: { isNotEmpty: true } } };
71
+ assert.ok(validateBody({ n: '' }, ent).errors, 'empty string should fail');
72
+ assert.strictEqual(validateBody({ n: 'hello' }, ent).errors, null);
73
+ });
74
+ });
75
+
76
+ describe('hidden properties & defaults', () => {
77
+ it('hideHiddenProps removes hidden fields', () => {
78
+ const entity = { properties: [{ name: 'title', type: 'string', hidden: false }, { name: 'secret', type: 'string', hidden: true }] };
79
+ const result = hideHiddenProps({ id: '1', title: 'Hi', secret: 'shhh' }, entity);
80
+ assert.strictEqual(result.title, 'Hi');
81
+ assert.ok(!('secret' in result));
82
+ });
83
+
84
+ it('applyDefaults fills missing with defaults', () => {
85
+ const entity = { properties: [{ name: 'status', type: 'string', default: 'draft' }, { name: 'title', type: 'string' }] };
86
+ const result = applyDefaults({ title: 'Hello' }, entity);
87
+ assert.strictEqual(result.status, 'draft');
88
+ assert.strictEqual(result.title, 'Hello');
89
+ });
90
+
91
+ it('applyDefaults does not override existing values', () => {
92
+ const entity = { properties: [{ name: 'status', type: 'string', default: 'draft' }] };
93
+ const result = applyDefaults({ status: 'published' }, entity);
94
+ assert.strictEqual(result.status, 'published');
95
+ });
96
+ });
@@ -0,0 +1,93 @@
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 { loadYaml, saveYaml } = require('../core/yaml-loader');
8
+
9
+ describe('yaml-loader', () => {
10
+ it('loads chadstart.yaml', () => {
11
+ const config = loadYaml(path.resolve(__dirname, '..', 'chadstart.yaml'));
12
+ assert.strictEqual(config.name, 'Blog');
13
+ assert.ok(config.entities.Admin.authenticable);
14
+ assert.ok(config.entities.Post);
15
+ });
16
+
17
+ it('throws on missing file', () => assert.throws(() => loadYaml('/nonexistent'), /not found/i));
18
+
19
+ describe('saveYaml', () => {
20
+ let tmpFile;
21
+
22
+ beforeEach(() => {
23
+ tmpFile = path.join(os.tmpdir(), `chadstart-test-${Date.now()}.yaml`);
24
+ });
25
+
26
+ afterEach(() => {
27
+ if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile);
28
+ });
29
+
30
+ it('creates a new YAML file when the file does not exist', () => {
31
+ const config = { name: 'TestApp', port: 4000 };
32
+ saveYaml(tmpFile, config);
33
+ assert.ok(fs.existsSync(tmpFile));
34
+ const saved = loadYaml(tmpFile);
35
+ assert.strictEqual(saved.name, 'TestApp');
36
+ assert.strictEqual(saved.port, 4000);
37
+ });
38
+
39
+ it('updates an existing YAML file and preserves values', () => {
40
+ const original = { name: 'Original', port: 3000, entities: { User: { authenticable: true, properties: ['name'] } } };
41
+ saveYaml(tmpFile, original);
42
+
43
+ const updated = { name: 'Updated', port: 5000, entities: { User: { authenticable: true, properties: ['name', 'email'] } } };
44
+ saveYaml(tmpFile, updated);
45
+
46
+ const saved = loadYaml(tmpFile);
47
+ assert.strictEqual(saved.name, 'Updated');
48
+ assert.strictEqual(saved.port, 5000);
49
+ assert.ok(saved.entities.User);
50
+ });
51
+
52
+ it('removes keys no longer present in the new config', () => {
53
+ saveYaml(tmpFile, { name: 'App', port: 3000, database: 'data/db.sqlite' });
54
+ saveYaml(tmpFile, { name: 'App', port: 3000 });
55
+ const saved = loadYaml(tmpFile);
56
+ assert.strictEqual(saved.database, undefined);
57
+ });
58
+
59
+ it('round-trips complex entity config correctly', () => {
60
+ const config = {
61
+ name: 'Blog',
62
+ port: 3000,
63
+ entities: {
64
+ Post: {
65
+ properties: ['title', { name: 'content', type: 'text' }],
66
+ policies: { read: [{ access: 'public' }], create: [{ access: 'restricted', allow: 'Admin' }] },
67
+ },
68
+ },
69
+ };
70
+ saveYaml(tmpFile, config);
71
+ const saved = loadYaml(tmpFile);
72
+ assert.strictEqual(saved.name, 'Blog');
73
+ assert.ok(saved.entities.Post);
74
+ assert.strictEqual(saved.entities.Post.policies.read[0].access, 'public');
75
+ assert.strictEqual(saved.entities.Post.policies.create[0].allow, 'Admin');
76
+ });
77
+
78
+ it('preserves YAML comments in unchanged top-level sections', () => {
79
+ const originalYaml = '# Application name\nname: Blog\n\n# Server port\nport: 3000\n';
80
+ fs.writeFileSync(tmpFile, originalYaml, 'utf8');
81
+
82
+ saveYaml(tmpFile, { name: 'Blog', port: 4000 });
83
+
84
+ const raw = fs.readFileSync(tmpFile, 'utf8');
85
+ assert.ok(raw.includes('# Application name'), 'Comment on name should be preserved');
86
+ assert.ok(raw.includes('# Server port'), 'Comment on port should be preserved');
87
+
88
+ // Verify the value was actually updated
89
+ const saved = loadYaml(tmpFile);
90
+ assert.strictEqual(saved.port, 4000);
91
+ });
92
+ });
93
+ });
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
4
+ const currentLevel = LEVELS[process.env.LOG_LEVEL] ?? LEVELS.info;
5
+
6
+ function log(level, ...args) {
7
+ if (LEVELS[level] < currentLevel) return;
8
+ const ts = new Date().toISOString();
9
+ const prefix = `[${ts}] [${level.toUpperCase()}]`;
10
+ if (level === 'error') {
11
+ console.error(prefix, ...args);
12
+ } else if (level === 'warn') {
13
+ console.warn(prefix, ...args);
14
+ } else {
15
+ console.log(prefix, ...args);
16
+ }
17
+ }
18
+
19
+ module.exports = {
20
+ debug: (...args) => log('debug', ...args),
21
+ info: (...args) => log('info', ...args),
22
+ warn: (...args) => log('warn', ...args),
23
+ error: (...args) => log('error', ...args),
24
+ };