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,522 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>ChadStart · Todo Demo</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+
10
+ :root {
11
+ --bg: #121212;
12
+ --surface: #1e1e1e;
13
+ --border: #2a2a2a;
14
+ --primary: #34b1eb;
15
+ --accent: #03dac6;
16
+ --text: #e1e1e1;
17
+ --muted: #888;
18
+ --danger: #f87171;
19
+ --warn: #fbbf24;
20
+ --radius: 6px;
21
+ }
22
+
23
+ body {
24
+ background: var(--bg);
25
+ color: var(--text);
26
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
27
+ min-height: 100vh;
28
+ }
29
+
30
+ /* ── Layout ── */
31
+ .app { max-width: 760px; margin: 0 auto; padding: 32px 16px 64px; }
32
+
33
+ /* ── Header ── */
34
+ header { margin-bottom: 32px; }
35
+ header h1 { font-size: 1.5rem; font-weight: 700; color: var(--primary); }
36
+ header p { font-size: .875rem; color: var(--muted); margin-top: 4px; }
37
+
38
+ /* ── Auth bar ── */
39
+ .auth-bar {
40
+ display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px;
41
+ padding: 12px 14px; background: var(--surface);
42
+ border: 1px solid var(--border); border-radius: var(--radius);
43
+ }
44
+ .auth-bar input {
45
+ flex: 1; min-width: 120px; background: var(--bg); border: 1px solid var(--border);
46
+ border-radius: 4px; color: var(--text); padding: 6px 10px; font-size: .85rem;
47
+ outline: none; transition: border-color 150ms;
48
+ }
49
+ .auth-bar input:focus { border-color: var(--primary); }
50
+ .auth-bar button { font-size: .82rem; padding: 6px 14px; border-radius: 4px; cursor: pointer; border: none; font-weight: 500; transition: opacity 150ms; }
51
+ .auth-bar button:hover { opacity: .8; }
52
+ #btn-login { background: var(--primary); color: #121212; }
53
+ #btn-signup { background: transparent; border: 1px solid var(--border) !important; color: var(--text); }
54
+ #btn-logout { background: transparent; border: 1px solid var(--border) !important; color: var(--danger); }
55
+ .auth-status { font-size: .8rem; color: var(--muted); align-self: center; margin-left: auto; }
56
+
57
+ /* ── Add todo form ── */
58
+ .add-form {
59
+ display: flex; gap: 8px; margin-bottom: 20px;
60
+ }
61
+ .add-form input {
62
+ flex: 1; background: var(--surface); border: 1px solid var(--border);
63
+ border-radius: var(--radius); color: var(--text); padding: 9px 12px;
64
+ font-size: .9rem; outline: none; transition: border-color 150ms;
65
+ }
66
+ .add-form input:focus { border-color: var(--primary); }
67
+ .add-form select {
68
+ background: var(--surface); border: 1px solid var(--border);
69
+ border-radius: var(--radius); color: var(--text); padding: 9px 10px;
70
+ font-size: .85rem; outline: none; cursor: pointer;
71
+ }
72
+ .add-form button {
73
+ background: var(--primary); color: #121212; border: none;
74
+ border-radius: var(--radius); padding: 9px 18px; font-size: .9rem;
75
+ font-weight: 600; cursor: pointer; transition: opacity 150ms;
76
+ }
77
+ .add-form button:hover { opacity: .85; }
78
+ .add-form button:disabled { opacity: .45; cursor: not-allowed; }
79
+
80
+ /* ── Filters ── */
81
+ .filters { display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; }
82
+ .filter-btn {
83
+ font-size: .78rem; padding: 4px 12px; border-radius: 4px; cursor: pointer;
84
+ border: 1px solid var(--border); background: transparent; color: var(--muted);
85
+ transition: all 150ms;
86
+ }
87
+ .filter-btn.active { border-color: var(--primary); color: var(--primary); background: rgba(52,177,235,.08); }
88
+ .filter-btn:hover:not(.active) { color: var(--text); }
89
+
90
+ /* ── Todo list ── */
91
+ #todo-list { list-style: none; }
92
+ .todo-item {
93
+ display: flex; align-items: center; gap: 10px;
94
+ padding: 11px 14px; background: var(--surface);
95
+ border: 1px solid var(--border); border-radius: var(--radius);
96
+ margin-bottom: 8px; transition: opacity 150ms;
97
+ }
98
+ .todo-item.completed { opacity: .55; }
99
+ .todo-item input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--accent); flex-shrink: 0; cursor: pointer; }
100
+ .todo-title { flex: 1; font-size: .9rem; }
101
+ .todo-item.completed .todo-title { text-decoration: line-through; color: var(--muted); }
102
+ .priority-badge {
103
+ font-size: .7rem; padding: 2px 7px; border-radius: 3px; font-weight: 600;
104
+ }
105
+ .p-high { background: rgba(248,113,113,.15); color: var(--danger); }
106
+ .p-medium { background: rgba(251,191,36,.15); color: var(--warn); }
107
+ .p-low { background: rgba(52,177,235,.15); color: var(--primary); }
108
+ .btn-delete {
109
+ background: transparent; border: none; color: var(--muted); cursor: pointer;
110
+ font-size: .85rem; padding: 2px 6px; border-radius: 3px; transition: color 150ms;
111
+ }
112
+ .btn-delete:hover { color: var(--danger); }
113
+
114
+ /* ── Stats bar ── */
115
+ .stats-bar {
116
+ display: flex; gap: 20px; flex-wrap: wrap; padding: 12px 14px;
117
+ background: var(--surface); border: 1px solid var(--border);
118
+ border-radius: var(--radius); margin-bottom: 20px; font-size: .82rem; color: var(--muted);
119
+ }
120
+ .stats-bar span b { color: var(--text); }
121
+
122
+ /* ── Runtime showcase ── */
123
+ .runtime-grid {
124
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
125
+ gap: 10px; margin-top: 32px;
126
+ }
127
+ .runtime-card {
128
+ padding: 14px; background: var(--surface); border: 1px solid var(--border);
129
+ border-radius: var(--radius); font-size: .82rem;
130
+ }
131
+ .runtime-card h3 { font-size: .8rem; color: var(--primary); margin-bottom: 6px; font-weight: 600; }
132
+ .runtime-card pre { color: var(--muted); white-space: pre-wrap; word-break: break-all; font-size: .75rem; }
133
+
134
+ /* ── Realtime log ── */
135
+ .rt-log {
136
+ margin-top: 32px; padding: 12px 14px; background: var(--surface);
137
+ border: 1px solid var(--border); border-radius: var(--radius);
138
+ }
139
+ .rt-log h2 { font-size: .85rem; font-weight: 600; color: var(--muted); margin-bottom: 8px; }
140
+ #rt-entries { font-size: .75rem; font-family: monospace; color: var(--muted); max-height: 140px; overflow-y: auto; }
141
+ .rt-entry { padding: 2px 0; border-bottom: 1px solid var(--border); }
142
+ .rt-create { color: var(--primary); }
143
+ .rt-update { color: var(--warn); }
144
+ .rt-delete { color: var(--danger); }
145
+
146
+ /* ── Toast ── */
147
+ #toast {
148
+ position: fixed; bottom: 20px; right: 20px; z-index: 999;
149
+ background: var(--surface); border: 1px solid var(--border);
150
+ border-radius: var(--radius); padding: 10px 16px;
151
+ font-size: .85rem; display: none; max-width: 300px;
152
+ }
153
+ #toast.show { display: block; }
154
+ #toast.error { border-color: rgba(248,113,113,.6); color: var(--danger); }
155
+ #toast.success { border-color: rgba(52,177,235,.6); color: var(--primary); }
156
+
157
+ /* ── Empty state ── */
158
+ .empty-state { text-align: center; padding: 32px; color: var(--muted); font-size: .9rem; }
159
+
160
+ /* ── Section title ── */
161
+ .section-title { font-size: .75rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; margin: 24px 0 10px; }
162
+ </style>
163
+ </head>
164
+ <body>
165
+ <div class="app">
166
+
167
+ <header>
168
+ <h1>⚡ ChadStart · Todo Demo</h1>
169
+ <p>Full-stack Todo app — CRUD · Auth · Realtime · Multi-runtime Functions</p>
170
+ </header>
171
+
172
+ <!-- Auth -->
173
+ <div class="auth-bar">
174
+ <input id="inp-email" type="email" placeholder="email@example.com" autocomplete="email" />
175
+ <input id="inp-password" type="password" placeholder="password" autocomplete="current-password" />
176
+ <button id="btn-signup" onclick="doSignup()">Sign up</button>
177
+ <button id="btn-login" onclick="doLogin()">Log in</button>
178
+ <button id="btn-logout" onclick="doLogout()" style="display:none;">Log out</button>
179
+ <span class="auth-status" id="auth-status">Not signed in</span>
180
+ </div>
181
+
182
+ <!-- Stats -->
183
+ <div class="stats-bar" id="stats-bar">
184
+ <span>Total <b id="stat-total">—</b></span>
185
+ <span>Completed <b id="stat-done">—</b></span>
186
+ <span>Pending <b id="stat-pending">—</b></span>
187
+ </div>
188
+
189
+ <!-- Add todo -->
190
+ <form class="add-form" onsubmit="addTodo(event)">
191
+ <input id="inp-title" placeholder="New todo…" autocomplete="off" required />
192
+ <select id="inp-priority">
193
+ <option value="">Priority</option>
194
+ <option value="low">Low</option>
195
+ <option value="medium">Medium</option>
196
+ <option value="high">High</option>
197
+ </select>
198
+ <button type="submit" id="btn-add">Add</button>
199
+ </form>
200
+
201
+ <!-- Filter bar -->
202
+ <div class="filters">
203
+ <button class="filter-btn active" onclick="setFilter('all', this)">All</button>
204
+ <button class="filter-btn" onclick="setFilter('active', this)">Active</button>
205
+ <button class="filter-btn" onclick="setFilter('done', this)">Completed</button>
206
+ </div>
207
+
208
+ <!-- Todo list -->
209
+ <ul id="todo-list"></ul>
210
+ <div id="empty-state" class="empty-state" style="display:none;">No todos yet — add one above!</div>
211
+
212
+ <!-- Realtime log -->
213
+ <div class="rt-log">
214
+ <h2>🔴 Live events (WebSocket)</h2>
215
+ <div id="rt-entries"><span style="color:var(--muted);">Connecting…</span></div>
216
+ </div>
217
+
218
+ <!-- Runtime function showcase -->
219
+ <div class="section-title">Custom function responses — multiple runtimes</div>
220
+ <div class="runtime-grid" id="runtime-grid">
221
+ <!-- filled by JS -->
222
+ </div>
223
+
224
+ </div>
225
+
226
+ <div id="toast"></div>
227
+
228
+ <script>
229
+ 'use strict';
230
+
231
+ // ── Config ────────────────────────────────────────────────────────────────────
232
+ const BASE = ''; // same origin
233
+ let token = localStorage.getItem('cs_todo_token') || null;
234
+ let currentFilter = 'all';
235
+ let todos = [];
236
+
237
+ // ── Auth helpers ──────────────────────────────────────────────────────────────
238
+ function authHeaders() {
239
+ return token
240
+ ? { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }
241
+ : { 'Content-Type': 'application/json' };
242
+ }
243
+
244
+ function setToken(t) {
245
+ token = t;
246
+ if (t) localStorage.setItem('cs_todo_token', t);
247
+ else localStorage.removeItem('cs_todo_token');
248
+ updateAuthUI();
249
+ }
250
+
251
+ function updateAuthUI() {
252
+ const status = document.getElementById('auth-status');
253
+ const btnOut = document.getElementById('btn-logout');
254
+ const btnIn = document.getElementById('btn-login');
255
+ const btnUp = document.getElementById('btn-signup');
256
+ if (token) {
257
+ try {
258
+ const payload = JSON.parse(atob(token.split('.')[1]));
259
+ status.textContent = 'Signed in as ' + (payload.name || payload.email || payload.sub || 'User');
260
+ } catch { status.textContent = 'Signed in'; }
261
+ btnOut.style.display = '';
262
+ btnIn.style.display = 'none';
263
+ btnUp.style.display = 'none';
264
+ } else {
265
+ status.textContent = 'Not signed in';
266
+ btnOut.style.display = 'none';
267
+ btnIn.style.display = '';
268
+ btnUp.style.display = '';
269
+ }
270
+ }
271
+
272
+ async function doSignup() {
273
+ const email = document.getElementById('inp-email').value.trim();
274
+ const password = document.getElementById('inp-password').value;
275
+ const name = email.split('@')[0];
276
+ if (!email || !password) return toast('Enter email and password', 'error');
277
+ try {
278
+ const r = await fetch(BASE + '/api/auth/user/signup', {
279
+ method: 'POST',
280
+ headers: { 'Content-Type': 'application/json' },
281
+ body: JSON.stringify({ name, email, password }),
282
+ });
283
+ const d = await r.json();
284
+ if (!r.ok) throw new Error(d.error || 'Signup failed');
285
+ setToken(d.token);
286
+ toast('Account created!', 'success');
287
+ loadTodos();
288
+ } catch (e) { toast(e.message, 'error'); }
289
+ }
290
+
291
+ async function doLogin() {
292
+ const email = document.getElementById('inp-email').value.trim();
293
+ const password = document.getElementById('inp-password').value;
294
+ if (!email || !password) return toast('Enter email and password', 'error');
295
+ try {
296
+ const r = await fetch(BASE + '/api/auth/user/login', {
297
+ method: 'POST',
298
+ headers: { 'Content-Type': 'application/json' },
299
+ body: JSON.stringify({ email, password }),
300
+ });
301
+ const d = await r.json();
302
+ if (!r.ok) throw new Error(d.error || 'Login failed');
303
+ setToken(d.token);
304
+ toast('Welcome back!', 'success');
305
+ loadTodos();
306
+ } catch (e) { toast(e.message, 'error'); }
307
+ }
308
+
309
+ function doLogout() {
310
+ setToken(null);
311
+ todos = [];
312
+ renderList();
313
+ toast('Signed out', 'success');
314
+ }
315
+
316
+ // ── CRUD ──────────────────────────────────────────────────────────────────────
317
+ async function loadTodos() {
318
+ try {
319
+ const r = await fetch(BASE + '/api/collections/todo?orderBy=createdAt&order=DESC', { headers: authHeaders() });
320
+ const d = await r.json();
321
+ todos = d.data || d || [];
322
+ renderList();
323
+ loadStats();
324
+ } catch (e) { toast('Failed to load todos', 'error'); }
325
+ }
326
+
327
+ async function addTodo(e) {
328
+ e.preventDefault();
329
+ if (!token) return toast('Sign in to create todos', 'error');
330
+ const title = document.getElementById('inp-title').value.trim();
331
+ const priority = document.getElementById('inp-priority').value || 'medium';
332
+ if (!title) return;
333
+ try {
334
+ const r = await fetch(BASE + '/api/collections/todo', {
335
+ method: 'POST',
336
+ headers: authHeaders(),
337
+ body: JSON.stringify({ title, priority }),
338
+ });
339
+ const d = await r.json();
340
+ if (!r.ok) throw new Error(d.error || 'Create failed');
341
+ document.getElementById('inp-title').value = '';
342
+ todos.unshift(d);
343
+ renderList();
344
+ loadStats();
345
+ toast('Todo added!', 'success');
346
+ } catch (err) { toast(err.message, 'error'); }
347
+ }
348
+
349
+ async function toggleTodo(id, completed) {
350
+ try {
351
+ const r = await fetch(BASE + '/api/collections/todo/' + id, {
352
+ method: 'PATCH',
353
+ headers: authHeaders(),
354
+ body: JSON.stringify({ completed }),
355
+ });
356
+ const d = await r.json();
357
+ if (!r.ok) throw new Error(d.error || 'Update failed');
358
+ const idx = todos.findIndex(t => t.id === id);
359
+ if (idx !== -1) todos[idx] = d;
360
+ renderList();
361
+ loadStats();
362
+ } catch (e) { toast(e.message, 'error'); }
363
+ }
364
+
365
+ async function deleteTodo(id) {
366
+ try {
367
+ const r = await fetch(BASE + '/api/collections/todo/' + id, { method: 'DELETE', headers: authHeaders() });
368
+ if (!r.ok) { const d = await r.json(); throw new Error(d.error || 'Delete failed'); }
369
+ todos = todos.filter(t => t.id !== id);
370
+ renderList();
371
+ loadStats();
372
+ toast('Deleted', 'success');
373
+ } catch (e) { toast(e.message, 'error'); }
374
+ }
375
+
376
+ // ── Filter + Render ───────────────────────────────────────────────────────────
377
+ function setFilter(f, el) {
378
+ currentFilter = f;
379
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
380
+ el.classList.add('active');
381
+ renderList();
382
+ }
383
+
384
+ function filtered() {
385
+ if (currentFilter === 'done') return todos.filter(t => t.completed);
386
+ if (currentFilter === 'active') return todos.filter(t => !t.completed);
387
+ return todos;
388
+ }
389
+
390
+ function priorityBadge(p) {
391
+ if (!p) return '';
392
+ const cls = { high: 'p-high', medium: 'p-medium', low: 'p-low' }[p] || '';
393
+ return `<span class="priority-badge ${cls}">${p}</span>`;
394
+ }
395
+
396
+ function renderList() {
397
+ const ul = document.getElementById('todo-list');
398
+ const empty = document.getElementById('empty-state');
399
+ const items = filtered();
400
+ if (!items.length) {
401
+ ul.innerHTML = '';
402
+ empty.style.display = '';
403
+ return;
404
+ }
405
+ empty.style.display = 'none';
406
+ ul.innerHTML = items.map(t => `
407
+ <li class="todo-item${t.completed ? ' completed' : ''}" data-id="${t.id}">
408
+ <input type="checkbox" ${t.completed ? 'checked' : ''} onchange="toggleTodo('${t.id}', this.checked)" />
409
+ <span class="todo-title">${escHtml(t.title)}</span>
410
+ ${priorityBadge(t.priority)}
411
+ <button class="btn-delete" onclick="deleteTodo('${t.id}')" title="Delete">✕</button>
412
+ </li>`).join('');
413
+ }
414
+
415
+ function escHtml(s) {
416
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
417
+ }
418
+
419
+ // ── Stats (JS function endpoint) ──────────────────────────────────────────────
420
+ async function loadStats() {
421
+ try {
422
+ const r = await fetch(BASE + '/api/fn/stats');
423
+ if (!r.ok) return;
424
+ const d = await r.json();
425
+ document.getElementById('stat-total').textContent = d.total ?? '—';
426
+ document.getElementById('stat-done').textContent = d.completed ?? '—';
427
+ document.getElementById('stat-pending').textContent = d.pending ?? '—';
428
+ } catch { /* stats are optional */ }
429
+ }
430
+
431
+ // ── Runtime showcase ──────────────────────────────────────────────────────────
432
+ const RUNTIMES = [
433
+ { label: 'JS', url: '/api/fn/stats', note: 'Built-in Node.js runtime' },
434
+ { label: 'Bash', url: '/api/fn/ping', note: 'Shell script runtime' },
435
+ { label: 'Python', url: '/api/fn/greet/python', note: 'python3 runtime' },
436
+ { label: 'Go', url: '/api/fn/greet/go', note: 'go run runtime' },
437
+ { label: 'Ruby', url: '/api/fn/greet/ruby', note: 'ruby runtime' },
438
+ { label: 'C++', url: '/api/fn/greet/cpp', note: 'g++ compile + run runtime' },
439
+ ];
440
+
441
+ async function loadRuntimeShowcase() {
442
+ const grid = document.getElementById('runtime-grid');
443
+ const results = await Promise.all(RUNTIMES.map(async rt => {
444
+ try {
445
+ const r = await fetch(BASE + rt.url);
446
+ const text = await r.text();
447
+ let pretty;
448
+ try { pretty = JSON.stringify(JSON.parse(text), null, 2); } catch { pretty = text; }
449
+ return { ...rt, result: pretty, ok: r.ok };
450
+ } catch (e) {
451
+ return { ...rt, result: e.message, ok: false };
452
+ }
453
+ }));
454
+ grid.innerHTML = results.map(rt => `
455
+ <div class="runtime-card">
456
+ <h3>${rt.label} <span style="color:var(--muted);font-weight:400;">${rt.ok ? '✓' : '✗'}</span></h3>
457
+ <div style="color:var(--muted);font-size:.7rem;margin-bottom:6px;">${rt.note}</div>
458
+ <pre>${escHtml(rt.result)}</pre>
459
+ </div>`).join('');
460
+ }
461
+
462
+ // ── Realtime WebSocket ────────────────────────────────────────────────────────
463
+ function connectRealtime() {
464
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
465
+ const ws = new WebSocket(`${proto}//${location.host}/realtime`);
466
+ const log = document.getElementById('rt-entries');
467
+
468
+ ws.onopen = () => {
469
+ log.innerHTML = '';
470
+ appendRtLog('system', 'Connected to realtime WebSocket');
471
+ // Subscribe to Todo entity events
472
+ ws.send(JSON.stringify({ type: 'subscribe', channel: 'Todo' }));
473
+ };
474
+
475
+ ws.onmessage = (e) => {
476
+ try {
477
+ const msg = JSON.parse(e.data);
478
+ // Realtime format: { type: 'event', event: 'Todo.created', data: {...} }
479
+ if (msg.type !== 'event' || !msg.event) return;
480
+ const [entity, action] = msg.event.split('.');
481
+ const cls = { created: 'rt-create', updated: 'rt-update', deleted: 'rt-delete' }[action] || '';
482
+ const text = `[${entity}] ${action} — id:${msg.data?.id || '?'}`;
483
+ appendRtLog(cls, text);
484
+ // Refresh list on Todo changes
485
+ if (entity === 'Todo') { loadTodos(); loadStats(); }
486
+ } catch { /* ignore parse errors */ }
487
+ };
488
+
489
+ ws.onclose = () => appendRtLog('', 'Disconnected — reconnecting in 5 s…');
490
+ ws.onerror = () => appendRtLog('', 'WebSocket error');
491
+
492
+ setTimeout(() => { if (ws.readyState !== WebSocket.OPEN) connectRealtime(); }, 5000);
493
+ }
494
+
495
+ function appendRtLog(cls, text) {
496
+ const log = document.getElementById('rt-entries');
497
+ const entry = document.createElement('div');
498
+ entry.className = `rt-entry ${cls}`.trim();
499
+ entry.textContent = `${new Date().toLocaleTimeString()} — ${text}`;
500
+ log.insertBefore(entry, log.firstChild);
501
+ if (log.children.length > 30) log.removeChild(log.lastChild);
502
+ }
503
+
504
+ // ── Toast ─────────────────────────────────────────────────────────────────────
505
+ let toastTimer;
506
+ function toast(msg, type = 'info') {
507
+ const el = document.getElementById('toast');
508
+ el.textContent = msg;
509
+ el.className = `show ${type}`;
510
+ clearTimeout(toastTimer);
511
+ toastTimer = setTimeout(() => { el.className = ''; }, 3000);
512
+ }
513
+
514
+ // ── Init ──────────────────────────────────────────────────────────────────────
515
+ updateAuthUI();
516
+ loadTodos();
517
+ loadStats();
518
+ loadRuntimeShowcase();
519
+ connectRealtime();
520
+ </script>
521
+ </body>
522
+ </html>
@@ -0,0 +1,17 @@
1
+ services:
2
+ app:
3
+ image: ghcr.io/saulmmendoza/chadstart.com:latest
4
+ # build: . # uncomment to build locally instead
5
+ # command: ["npm", "run", "dev"]
6
+ ports:
7
+ - "3000:3000"
8
+ env_file:
9
+ - .env
10
+ environment:
11
+ NODE_ENV: production
12
+ volumes:
13
+ - ./chadstart.yaml:/app/chadstart.yaml:ro
14
+ - ./data:/app/data
15
+ - ./public:/app/public
16
+ - ./functions:/app/functions:ro
17
+ restart: unless-stopped