create-surf-app 0.1.4 → 0.1.6

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 (77) hide show
  1. package/dist/chunk-AMAMASIV.js +1016 -0
  2. package/dist/cli.js +51 -0
  3. package/dist/index.js +5 -379
  4. package/dist/templates/default/CLAUDE.md +46 -0
  5. package/dist/templates/default/backend/db/index.js +23 -0
  6. package/dist/templates/default/backend/db/schema.js +20 -0
  7. package/dist/templates/{backend → default/backend}/eslint.config.mjs +1 -1
  8. package/dist/templates/default/backend/lib/db.js +67 -0
  9. package/dist/templates/default/backend/package.json +25 -0
  10. package/dist/templates/default/backend/routes/proxy.js +66 -0
  11. package/dist/templates/default/backend/server.js +444 -0
  12. package/dist/templates/default/frontend/components.json +21 -0
  13. package/{templates → dist/templates/default}/frontend/eslint.config.js +1 -1
  14. package/dist/templates/default/frontend/package.json +82 -0
  15. package/dist/templates/default/frontend/src/App.tsx +23 -0
  16. package/dist/templates/default/frontend/src/ErrorBoundary.tsx +106 -0
  17. package/dist/templates/default/frontend/src/components/ui/accordion.tsx +55 -0
  18. package/dist/templates/default/frontend/src/components/ui/alert.tsx +59 -0
  19. package/dist/templates/default/frontend/src/components/ui/aspect-ratio.tsx +5 -0
  20. package/dist/templates/default/frontend/src/components/ui/avatar.tsx +48 -0
  21. package/dist/templates/default/frontend/src/components/ui/badge.tsx +36 -0
  22. package/dist/templates/default/frontend/src/components/ui/breadcrumb.tsx +115 -0
  23. package/dist/templates/default/frontend/src/components/ui/button.tsx +57 -0
  24. package/dist/templates/default/frontend/src/components/ui/calendar.tsx +211 -0
  25. package/dist/templates/default/frontend/src/components/ui/card.tsx +76 -0
  26. package/dist/templates/default/frontend/src/components/ui/carousel.tsx +262 -0
  27. package/dist/templates/default/frontend/src/components/ui/checkbox.tsx +30 -0
  28. package/dist/templates/default/frontend/src/components/ui/collapsible.tsx +9 -0
  29. package/dist/templates/default/frontend/src/components/ui/command.tsx +153 -0
  30. package/dist/templates/default/frontend/src/components/ui/context-menu.tsx +200 -0
  31. package/dist/templates/default/frontend/src/components/ui/dialog.tsx +120 -0
  32. package/dist/templates/default/frontend/src/components/ui/drawer.tsx +118 -0
  33. package/dist/templates/default/frontend/src/components/ui/dropdown-menu.tsx +201 -0
  34. package/dist/templates/default/frontend/src/components/ui/form.tsx +176 -0
  35. package/dist/templates/default/frontend/src/components/ui/hover-card.tsx +29 -0
  36. package/dist/templates/default/frontend/src/components/ui/input.tsx +22 -0
  37. package/dist/templates/default/frontend/src/components/ui/label.tsx +26 -0
  38. package/dist/templates/default/frontend/src/components/ui/menubar.tsx +256 -0
  39. package/dist/templates/default/frontend/src/components/ui/navigation-menu.tsx +128 -0
  40. package/dist/templates/default/frontend/src/components/ui/popover.tsx +33 -0
  41. package/dist/templates/default/frontend/src/components/ui/progress.tsx +26 -0
  42. package/dist/templates/default/frontend/src/components/ui/radio-group.tsx +42 -0
  43. package/dist/templates/default/frontend/src/components/ui/resizable.tsx +43 -0
  44. package/dist/templates/default/frontend/src/components/ui/scroll-area.tsx +46 -0
  45. package/dist/templates/default/frontend/src/components/ui/select.tsx +157 -0
  46. package/dist/templates/default/frontend/src/components/ui/separator.tsx +31 -0
  47. package/dist/templates/default/frontend/src/components/ui/sheet.tsx +140 -0
  48. package/dist/templates/default/frontend/src/components/ui/skeleton.tsx +15 -0
  49. package/dist/templates/default/frontend/src/components/ui/slider.tsx +26 -0
  50. package/dist/templates/default/frontend/src/components/ui/sonner.tsx +29 -0
  51. package/dist/templates/default/frontend/src/components/ui/switch.tsx +29 -0
  52. package/dist/templates/default/frontend/src/components/ui/table.tsx +120 -0
  53. package/dist/templates/default/frontend/src/components/ui/tabs.tsx +53 -0
  54. package/dist/templates/default/frontend/src/components/ui/textarea.tsx +22 -0
  55. package/dist/templates/default/frontend/src/components/ui/toast.tsx +129 -0
  56. package/dist/templates/default/frontend/src/components/ui/toaster.tsx +35 -0
  57. package/dist/templates/default/frontend/src/components/ui/toggle-group.tsx +59 -0
  58. package/dist/templates/default/frontend/src/components/ui/toggle.tsx +43 -0
  59. package/dist/templates/default/frontend/src/components/ui/tooltip.tsx +30 -0
  60. package/dist/templates/default/frontend/src/db/schema.ts +16 -0
  61. package/{templates → dist/templates/default}/frontend/src/entry-client.tsx +11 -8
  62. package/dist/templates/default/frontend/src/hooks/use-toast.ts +95 -0
  63. package/dist/templates/default/frontend/src/index.css +314 -0
  64. package/dist/templates/default/frontend/src/lib/api.ts +31 -0
  65. package/dist/templates/default/frontend/src/lib/fetch.ts +38 -0
  66. package/dist/templates/default/frontend/src/lib/utils.ts +6 -0
  67. package/dist/templates/default/frontend/src/vite-env.d.ts +11 -0
  68. package/dist/templates/default/frontend/tsconfig.json +22 -0
  69. package/dist/templates/default/frontend/vite.config.ts +162 -0
  70. package/package.json +7 -7
  71. package/dist/templates/frontend/eslint.config.js +0 -42
  72. package/dist/templates/frontend/src/entry-client.tsx +0 -109
  73. package/templates/backend/eslint.config.mjs +0 -21
  74. package/templates/frontend/index.html +0 -43
  75. package/templates/frontend/src/entry-server.tsx +0 -13
  76. /package/dist/templates/{frontend → default/frontend}/index.html +0 -0
  77. /package/dist/templates/{frontend → default/frontend}/src/entry-server.tsx +0 -0
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Catch-all proxy to data APIs.
3
+ *
4
+ * Sandbox mode: forwards /proxy/* to OutboundProxy at 127.0.0.1:9999
5
+ * Deployed mode: transparent pass-through /proxy/{path} -> hermod /gateway/v1/{path}
6
+ *
7
+ * IMPORTANT: All proxy middlewares use selfHandleResponse + responseInterceptor
8
+ * to buffer the full upstream response before sending. This forces Express to
9
+ * respond with Content-Length instead of Transfer-Encoding: chunked, which is
10
+ * required for compatibility with the KEDA HTTP interceptor (it drops chunked
11
+ * response bodies when the upstream sends Connection: close).
12
+ */
13
+
14
+ const { createProxyMiddleware, responseInterceptor } = require('http-proxy-middleware');
15
+
16
+ const GATEWAY_URL = process.env.GATEWAY_URL;
17
+ const APP_TOKEN = process.env.APP_TOKEN;
18
+ const IS_DEPLOYED = Boolean(GATEWAY_URL && APP_TOKEN);
19
+
20
+ // Shared response interceptor: buffer full response so Express sends Content-Length
21
+ // instead of Transfer-Encoding: chunked (fixes KEDA HTTP interceptor body-drop bug).
22
+ const bufferResponse = responseInterceptor(async (responseBuffer, _proxyRes, _req, _res) => {
23
+ return responseBuffer;
24
+ });
25
+
26
+ function setupProxyRoutes(app) {
27
+ if (IS_DEPLOYED) {
28
+ // Transparent pass-through: /proxy/{path} -> hermod /gateway/v1/{path}
29
+ // pathRewrite strips /proxy prefix and prepends /gateway/v1
30
+ app.use('/proxy', createProxyMiddleware({
31
+ target: GATEWAY_URL,
32
+ changeOrigin: true,
33
+ selfHandleResponse: true,
34
+ pathRewrite: (p) => '/gateway/v1' + p,
35
+ headers: { Authorization: `Bearer ${APP_TOKEN}`, 'Accept-Encoding': 'identity' },
36
+ on: { proxyRes: bufferResponse },
37
+ }));
38
+ } else {
39
+ // Sandbox: forward to OutboundProxy (handles all routing internally)
40
+ app.use(
41
+ createProxyMiddleware({
42
+ target: process.env.DATA_PROXY_BASE ? process.env.DATA_PROXY_BASE.replace(/\/proxy$/, '') : 'http://127.0.0.1:9999',
43
+ changeOrigin: true,
44
+ selfHandleResponse: true,
45
+ pathFilter: '/proxy',
46
+ on: {
47
+ proxyReq: (proxyReq, req) => {
48
+ console.log(`[proxy] >> ${req.method} ${req.originalUrl}`);
49
+ },
50
+ proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, _res) => {
51
+ const status = proxyRes.statusCode;
52
+ const tag = status >= 400 ? 'ERR' : 'OK';
53
+ console.log(`[proxy] << ${status} ${tag} ${req.method} ${req.originalUrl} bytes=${responseBuffer.length}`);
54
+ return responseBuffer;
55
+ }),
56
+ error: (err, req, res) => {
57
+ console.error(`[proxy] !! ${req.method} ${req.originalUrl} error: ${err.message}`);
58
+ if (!res.headersSent) res.status(502).json({ error: err.message });
59
+ },
60
+ },
61
+ })
62
+ );
63
+ }
64
+ }
65
+
66
+ module.exports = { setupProxyRoutes };
@@ -0,0 +1,444 @@
1
+ const express = require('express');
2
+ const cors = require('cors');
3
+ const { Cron } = require('croner');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { setupProxyRoutes } = require('./routes/proxy');
7
+
8
+ if (!process.env.PORT) {
9
+ const envPath = path.join(__dirname, '.env');
10
+ if (fs.existsSync(envPath)) {
11
+ for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
12
+ const trimmed = line.trim();
13
+ if (!trimmed || trimmed.startsWith('#')) continue;
14
+ const idx = trimmed.indexOf('=');
15
+ if (idx === -1) continue;
16
+ const key = trimmed.slice(0, idx).trim();
17
+ const value = trimmed.slice(idx + 1).trim();
18
+ if (key && !(key in process.env)) {
19
+ process.env[key] = value;
20
+ }
21
+ }
22
+ }
23
+ }
24
+
25
+ const app = express();
26
+ const PORT = Number.parseInt(process.env.PORT || '', 10);
27
+ if (!Number.isInteger(PORT)) {
28
+ throw new Error('PORT env var is required');
29
+ }
30
+
31
+ // CORS: allow any origin (external frontends can connect)
32
+ app.use(cors());
33
+
34
+ // Forward /proxy/* → OutboundProxy (API key injection)
35
+ // NOTE: Must be registered BEFORE express.json() so that the raw request body
36
+ // stream is preserved for http-proxy-middleware to forward POST requests.
37
+ setupProxyRoutes(app);
38
+
39
+ // Parse JSON bodies (after proxy routes to avoid consuming body stream)
40
+ app.use(express.json());
41
+
42
+ // Health check
43
+ app.get('/api/health', (_req, res) => {
44
+ res.json({ status: 'ok' });
45
+ });
46
+
47
+ // ── Auto-register API routes ─────────────────────────────────────────────
48
+ // Every .js file in routes/ (except proxy.js) is auto-mounted at /api/{name}.
49
+ // e.g. routes/todos.js → /api/todos, routes/auth.js → /api/auth
50
+ const routesDir = path.join(__dirname, 'routes');
51
+ for (const file of fs.readdirSync(routesDir)) {
52
+ if (file === 'proxy.js' || !file.endsWith('.js')) continue;
53
+ const name = file.replace('.js', '');
54
+ try {
55
+ app.use(`/api/${name}`, require(`./routes/${file}`));
56
+ console.log(`Route registered: /api/${name}`);
57
+ } catch (err) {
58
+ console.error(`Failed to load route ${file}: ${err.message}`);
59
+ }
60
+ }
61
+
62
+ // ── Auto-create DB tables from schema.js ─────────────────────────────────
63
+ // The agent writes db/schema.js AFTER the server starts, so we need two
64
+ // trigger paths: (1) POST /api/__sync-schema for explicit agent calls,
65
+ // (2) fs.watchFile as a fallback in case the agent doesn't call the endpoint.
66
+ //
67
+ // Both paths funnel into doSyncSchema() which has:
68
+ // - Concurrency lock (syncing flag) to prevent overlapping DDL
69
+ // - SyntaxError guard for half-written files
70
+ // - Retry with backoff for Neon cold-start errors
71
+ let schemaReady = false;
72
+ let syncing = false;
73
+
74
+ async function doSyncSchema() {
75
+ if (syncing) return;
76
+ syncing = true;
77
+ try {
78
+ // Clear require cache so we pick up the latest schema.js
79
+ try { delete require.cache[require.resolve('./db/schema')]; } catch (_e) { /* not cached — ignore */ }
80
+
81
+ // Try to load schema — SyntaxError means the file is half-written
82
+ let schema;
83
+ try {
84
+ schema = require('./db/schema');
85
+ } catch (err) {
86
+ if (err instanceof SyntaxError) {
87
+ console.log('DB: schema.js has syntax error, waiting for next change...');
88
+ return;
89
+ }
90
+ if (err.message.includes('Cannot find module') || err.message.includes('is not a function')) {
91
+ return; // No schema or no drizzle-orm — skip silently
92
+ }
93
+ throw err;
94
+ }
95
+
96
+ const { getTableConfig } = require('drizzle-orm/pg-core');
97
+ const tables = Object.values(schema).filter(t =>
98
+ t && typeof t === 'object' && Symbol.for('drizzle:Name') in t
99
+ );
100
+ if (tables.length === 0) return;
101
+
102
+ const { dbTables, dbProvision, dbQuery, dbTableSchema } = require('./lib/db');
103
+ await dbProvision('auto-sync schema tables');
104
+
105
+ const existing = (await dbTables()).map(t => t.name);
106
+ const missing = tables.filter(t => !existing.includes(getTableConfig(t).name));
107
+
108
+ if (missing.length > 0) {
109
+ // Use drizzle-kit to generate correct DDL (handles UNIQUE, FK, defaults, etc.)
110
+ const { generateDrizzleJson, generateMigration } = require('drizzle-kit/api');
111
+ const missingSchema = {};
112
+ for (const t of missing) missingSchema[getTableConfig(t).name] = t;
113
+ const sqls = await generateMigration(generateDrizzleJson({}), generateDrizzleJson(missingSchema));
114
+
115
+ for (const sql of sqls) {
116
+ for (let attempt = 0; attempt < 2; attempt++) {
117
+ try {
118
+ await dbQuery(sql, []);
119
+ console.log(`DB: Executed: ${sql.slice(0, 80)}...`);
120
+ break;
121
+ } catch (err) {
122
+ if (attempt === 0) {
123
+ console.warn(`DB: Retrying after: ${err.message}`);
124
+ await new Promise(r => setTimeout(r, 1500));
125
+ } else {
126
+ console.error(`DB: Failed: ${sql.slice(0, 80)}... — ${err.message}`);
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ // Check existing tables for missing columns (runs even when new tables were also created)
134
+ const existingTables = tables.filter(t => existing.includes(getTableConfig(t).name));
135
+ for (const t of existingTables) {
136
+ const cfg = getTableConfig(t);
137
+ try {
138
+ const live = await dbTableSchema(cfg.name);
139
+ const liveCols = new Set(live.columns.map(c => c.name));
140
+ for (const col of cfg.columns) {
141
+ if (!liveCols.has(col.name)) {
142
+ const colType = col.getSQLType();
143
+ const ddl = 'ALTER TABLE "' + cfg.name + '" ADD COLUMN IF NOT EXISTS "' + col.name + '" ' + colType;
144
+ try {
145
+ await dbQuery(ddl, []);
146
+ console.log('DB: Added column ' + col.name + ' to ' + cfg.name);
147
+ } catch (err) {
148
+ console.warn('DB: Failed to add column ' + col.name + ' to ' + cfg.name + ': ' + err.message);
149
+ }
150
+ }
151
+ }
152
+ } catch (err) {
153
+ console.warn('DB: Column check failed for ' + cfg.name + ': ' + err.message);
154
+ }
155
+ }
156
+ } finally {
157
+ syncing = false;
158
+ }
159
+ }
160
+
161
+ // Retry wrapper for Neon cold-start transient errors.
162
+ async function syncSchemaWithRetry(retries = 3, delay = 2000) {
163
+ for (let i = 0; i < retries; i++) {
164
+ try {
165
+ await doSyncSchema();
166
+ return;
167
+ } catch (err) {
168
+ console.error(`DB schema sync attempt ${i + 1}/${retries} failed: ${err.message}`);
169
+ if (i < retries - 1) await new Promise(r => setTimeout(r, delay * (i + 1)));
170
+ }
171
+ }
172
+ console.error('DB schema sync failed after all retries');
173
+ }
174
+
175
+ // Explicit sync endpoint — agent calls this after writing schema.js.
176
+ app.post('/api/__sync-schema', async (_req, res) => {
177
+ try {
178
+ await syncSchemaWithRetry(2, 1500);
179
+ res.json({ ok: true });
180
+ } catch (err) {
181
+ res.status(500).json({ ok: false, error: err.message });
182
+ }
183
+ });
184
+
185
+ // ── Cron job system ──────────────────────────────────────────────────────
186
+ // Reads backend/cron.json, schedules tasks with croner, exposes CRUD API.
187
+ const _cronInstances = new Map(); // id -> Cron instance
188
+ const _cronState = new Map(); // id -> { lastRunAt, lastStatus, lastError }
189
+
190
+ function _loadCronJobs() {
191
+ // Stop all existing cron jobs
192
+ for (const [, job] of _cronInstances) {
193
+ try { job.stop(); } catch (_e) { /* already stopped — ignore */ }
194
+ }
195
+ _cronInstances.clear();
196
+
197
+ const cronPath = path.join(__dirname, 'cron.json');
198
+ if (!fs.existsSync(cronPath)) return;
199
+
200
+ let tasks;
201
+ try {
202
+ tasks = JSON.parse(fs.readFileSync(cronPath, 'utf8'));
203
+ } catch (err) {
204
+ console.error('Cron: failed to parse cron.json:', err.message);
205
+ return;
206
+ }
207
+
208
+ if (!Array.isArray(tasks)) {
209
+ console.error('Cron: cron.json must be a JSON array');
210
+ return;
211
+ }
212
+
213
+ for (const task of tasks) {
214
+ if (!task.enabled) continue;
215
+ if (!task.id || !task.schedule || !task.handler) {
216
+ console.warn('Cron: skipping task with missing fields:', JSON.stringify(task));
217
+ continue;
218
+ }
219
+
220
+ let handlerMod;
221
+ try {
222
+ const handlerPath = path.resolve(__dirname, task.handler);
223
+ delete require.cache[handlerPath];
224
+ handlerMod = require(handlerPath);
225
+ } catch (err) {
226
+ console.error(`Cron: failed to load handler ${task.handler}: ${err.message}`);
227
+ continue;
228
+ }
229
+
230
+ if (typeof handlerMod.handler !== 'function') {
231
+ console.warn(`Cron: handler ${task.handler} does not export a handler function`);
232
+ continue;
233
+ }
234
+
235
+ const timeoutMs = (task.timeout || 300) * 1000;
236
+
237
+ // Initialize runtime state if not present
238
+ if (!_cronState.has(task.id)) {
239
+ _cronState.set(task.id, { lastRunAt: null, lastStatus: null, lastError: null });
240
+ }
241
+
242
+ const runHandler = async () => {
243
+ const state = _cronState.get(task.id);
244
+ state.lastRunAt = new Date().toISOString();
245
+ try {
246
+ await Promise.race([
247
+ handlerMod.handler(),
248
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs)),
249
+ ]);
250
+ state.lastStatus = 'success';
251
+ state.lastError = null;
252
+ console.log(`Cron: task ${task.id} completed successfully`);
253
+ } catch (err) {
254
+ state.lastStatus = 'error';
255
+ state.lastError = err.message;
256
+ console.error(`Cron: task ${task.id} failed: ${err.message}`);
257
+ }
258
+ };
259
+
260
+ try {
261
+ const job = new Cron(task.schedule, runHandler);
262
+ _cronInstances.set(task.id, job);
263
+ console.log(`Cron: scheduled ${task.id} (${task.name || task.id}) with schedule "${task.schedule}"`);
264
+ } catch (err) {
265
+ console.error(`Cron: invalid schedule for ${task.id}: ${err.message}`);
266
+ }
267
+ }
268
+ }
269
+
270
+ // ── Cron CRUD API routes (before schema readiness guard) ─────────────────
271
+ // Auth middleware for /api/cron routes
272
+ app.use('/api/cron', (req, res, next) => {
273
+ const appToken = process.env.APP_TOKEN;
274
+ if (!appToken) return next(); // dev mode: skip auth
275
+ const auth = req.headers.authorization;
276
+ if (!auth || auth !== `Bearer ${appToken}`) {
277
+ return res.status(401).json({ error: 'Unauthorized' });
278
+ }
279
+ next();
280
+ });
281
+
282
+ app.get('/api/cron', (_req, res) => {
283
+ const cronPath = path.join(__dirname, 'cron.json');
284
+ let tasks = [];
285
+ try {
286
+ if (fs.existsSync(cronPath)) {
287
+ tasks = JSON.parse(fs.readFileSync(cronPath, 'utf8'));
288
+ }
289
+ } catch { tasks = []; }
290
+
291
+ const result = tasks.map(t => {
292
+ const state = _cronState.get(t.id) || { lastRunAt: null, lastStatus: null, lastError: null };
293
+ const job = _cronInstances.get(t.id);
294
+ return {
295
+ ...t,
296
+ lastRunAt: state.lastRunAt,
297
+ lastStatus: state.lastStatus,
298
+ lastError: state.lastError,
299
+ nextRun: job ? job.nextRun()?.toISOString() || null : null,
300
+ };
301
+ });
302
+ res.json(result);
303
+ });
304
+
305
+ app.post('/api/cron', (req, res) => {
306
+ const { id, name, schedule, handler, enabled = true, timeout = 300 } = req.body || {};
307
+ if (!id || !name || !schedule || !handler) {
308
+ return res.status(400).json({ error: 'Missing required fields: id, name, schedule, handler' });
309
+ }
310
+
311
+ // Validate cron expression and minimum interval (>= 1 minute)
312
+ try {
313
+ const testJob = new Cron(schedule);
314
+ const next1 = testJob.nextRun();
315
+ const next2 = testJob.nextRun(next1);
316
+ testJob.stop();
317
+ if (next1 && next2 && (next2.getTime() - next1.getTime()) < 60000) {
318
+ return res.status(400).json({ error: 'Minimum interval between runs must be at least 1 minute' });
319
+ }
320
+ } catch (err) {
321
+ return res.status(400).json({ error: `Invalid cron expression: ${err.message}` });
322
+ }
323
+
324
+ const cronPath = path.join(__dirname, 'cron.json');
325
+ let tasks = [];
326
+ try {
327
+ if (fs.existsSync(cronPath)) tasks = JSON.parse(fs.readFileSync(cronPath, 'utf8'));
328
+ } catch { tasks = []; }
329
+
330
+ if (tasks.some(t => t.id === id)) {
331
+ return res.status(409).json({ error: `Task with id "${id}" already exists` });
332
+ }
333
+
334
+ const newTask = { id, name, schedule, handler, enabled, timeout };
335
+ tasks.push(newTask);
336
+ fs.writeFileSync(cronPath, JSON.stringify(tasks, null, 2));
337
+ _loadCronJobs();
338
+ res.status(201).json(newTask);
339
+ });
340
+
341
+ app.patch('/api/cron/:id', (req, res) => {
342
+ const cronPath = path.join(__dirname, 'cron.json');
343
+ let tasks = [];
344
+ try {
345
+ if (fs.existsSync(cronPath)) tasks = JSON.parse(fs.readFileSync(cronPath, 'utf8'));
346
+ } catch { tasks = []; }
347
+
348
+ const idx = tasks.findIndex(t => t.id === req.params.id);
349
+ if (idx === -1) return res.status(404).json({ error: 'Task not found' });
350
+
351
+ const updates = req.body || {};
352
+
353
+ // If schedule is being updated, validate it
354
+ if (updates.schedule) {
355
+ try {
356
+ const testJob = new Cron(updates.schedule);
357
+ const next1 = testJob.nextRun();
358
+ const next2 = testJob.nextRun(next1);
359
+ testJob.stop();
360
+ if (next1 && next2 && (next2.getTime() - next1.getTime()) < 60000) {
361
+ return res.status(400).json({ error: 'Minimum interval between runs must be at least 1 minute' });
362
+ }
363
+ } catch (err) {
364
+ return res.status(400).json({ error: `Invalid cron expression: ${err.message}` });
365
+ }
366
+ }
367
+
368
+ tasks[idx] = { ...tasks[idx], ...updates, id: req.params.id };
369
+ fs.writeFileSync(cronPath, JSON.stringify(tasks, null, 2));
370
+ _loadCronJobs();
371
+ res.json(tasks[idx]);
372
+ });
373
+
374
+ app.delete('/api/cron/:id', (req, res) => {
375
+ const cronPath = path.join(__dirname, 'cron.json');
376
+ let tasks = [];
377
+ try {
378
+ if (fs.existsSync(cronPath)) tasks = JSON.parse(fs.readFileSync(cronPath, 'utf8'));
379
+ } catch { tasks = []; }
380
+
381
+ const idx = tasks.findIndex(t => t.id === req.params.id);
382
+ if (idx === -1) return res.status(404).json({ error: 'Task not found' });
383
+
384
+ tasks.splice(idx, 1);
385
+ fs.writeFileSync(cronPath, JSON.stringify(tasks, null, 2));
386
+ _cronState.delete(req.params.id);
387
+ _loadCronJobs();
388
+ res.json({ ok: true });
389
+ });
390
+
391
+ app.post('/api/cron/:id/run', async (req, res) => {
392
+ const job = _cronInstances.get(req.params.id);
393
+ if (!job) return res.status(404).json({ error: 'Task not found or not enabled' });
394
+ try {
395
+ job.trigger();
396
+ res.json({ ok: true, message: `Task ${req.params.id} triggered` });
397
+ } catch (err) {
398
+ res.status(500).json({ error: err.message });
399
+ }
400
+ });
401
+
402
+ // Block /api/* (except health + sync + cron) until initial schema sync completes.
403
+ app.use('/api', (req, res, next) => {
404
+ if (schemaReady || req.path === '/health' || req.path === '/__sync-schema' || req.path.startsWith('/cron')) return next();
405
+ res.status(503).json({ error: 'Database schema initializing...' });
406
+ });
407
+
408
+ function startServer(retries = 10, delay = 1000) {
409
+ const server = app.listen(PORT, '0.0.0.0', async () => {
410
+ console.log(`Backend listening on port ${PORT}`);
411
+ await syncSchemaWithRetry();
412
+ schemaReady = true;
413
+ console.log('Schema sync complete, API ready');
414
+
415
+ // Load cron jobs after DB is ready
416
+ _loadCronJobs();
417
+
418
+ // Fallback: watch schema.js for changes in case agent doesn't call __sync-schema.
419
+ const schemaPath = path.join(__dirname, 'db', 'schema.js');
420
+ let syncDebounce = null;
421
+ fs.watchFile(schemaPath, { interval: 2000 }, () => {
422
+ if (syncDebounce) clearTimeout(syncDebounce);
423
+ syncDebounce = setTimeout(async () => {
424
+ console.log('DB: schema.js changed, re-syncing tables...');
425
+ try {
426
+ await syncSchemaWithRetry(2, 1500);
427
+ console.log('DB: schema re-sync complete');
428
+ } catch (err) {
429
+ console.error(`DB: schema re-sync failed: ${err.message}`);
430
+ }
431
+ }, 1000);
432
+ });
433
+ });
434
+ server.on('error', (err) => {
435
+ if (err.code === 'EADDRINUSE' && retries > 0) {
436
+ console.log(`Port ${PORT} in use, retrying in ${delay}ms... (${retries} left)`);
437
+ setTimeout(() => startServer(retries - 1, delay), delay);
438
+ } else {
439
+ console.error(`Failed to start server: ${err.message}`);
440
+ process.exit(1);
441
+ }
442
+ });
443
+ }
444
+ startServer();
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "default",
4
+ "rsc": false,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "src/index.css",
9
+ "baseColor": "slate",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "aliases": {
14
+ "components": "@/components",
15
+ "utils": "@/lib/utils",
16
+ "ui": "@/components/ui",
17
+ "lib": "@/lib",
18
+ "hooks": "@/hooks"
19
+ },
20
+ "iconLibrary": "lucide"
21
+ }
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
5
5
  import tseslint from 'typescript-eslint'
6
6
 
7
7
  export default tseslint.config(
8
- { ignores: ['dist', 'node_modules'] },
8
+ { ignores: ['dist', 'node_modules', 'src/api/**', 'src/lib/api*.ts', 'src/lib/types-*.ts'] },
9
9
  {
10
10
  extends: [js.configs.recommended, ...tseslint.configs.recommended],
11
11
  files: ['**/*.{js,jsx,ts,tsx}'],
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "npm run build:client && npm run build:server",
9
+ "build:client": "vite build --outDir dist/client",
10
+ "build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server",
11
+ "lint": "eslint .",
12
+ "preview": "vite preview",
13
+ "type-check": "tsc --noEmit --incremental"
14
+ },
15
+ "dependencies": {
16
+ "react": "19.2.4",
17
+ "react-dom": "19.2.4",
18
+ "class-variance-authority": "0.7.1",
19
+ "clsx": "2.1.1",
20
+ "tailwind-merge": "2.6.1",
21
+ "lucide-react": "0.454.0",
22
+ "next-themes": "0.4.6",
23
+ "echarts": "5.6.0",
24
+ "echarts-for-react": "3.0.6",
25
+ "@radix-ui/react-accordion": "1.2.12",
26
+ "@radix-ui/react-aspect-ratio": "1.1.8",
27
+ "@radix-ui/react-avatar": "1.1.11",
28
+ "@radix-ui/react-checkbox": "1.3.3",
29
+ "@radix-ui/react-collapsible": "1.1.12",
30
+ "@radix-ui/react-context-menu": "2.2.16",
31
+ "@radix-ui/react-dialog": "1.1.15",
32
+ "@radix-ui/react-dropdown-menu": "2.1.16",
33
+ "@radix-ui/react-hover-card": "1.1.15",
34
+ "@radix-ui/react-label": "2.1.8",
35
+ "@radix-ui/react-menubar": "1.1.16",
36
+ "@radix-ui/react-navigation-menu": "1.2.14",
37
+ "@radix-ui/react-popover": "1.1.15",
38
+ "@radix-ui/react-progress": "1.1.8",
39
+ "@radix-ui/react-radio-group": "1.3.8",
40
+ "@radix-ui/react-scroll-area": "1.2.10",
41
+ "@radix-ui/react-select": "2.2.6",
42
+ "@radix-ui/react-separator": "1.1.8",
43
+ "@radix-ui/react-slider": "1.3.6",
44
+ "@radix-ui/react-slot": "1.2.4",
45
+ "@radix-ui/react-switch": "1.2.6",
46
+ "@radix-ui/react-tabs": "1.1.13",
47
+ "@radix-ui/react-toast": "1.2.15",
48
+ "@radix-ui/react-toggle": "1.1.10",
49
+ "@radix-ui/react-toggle-group": "1.1.11",
50
+ "@radix-ui/react-tooltip": "1.2.8",
51
+ "sonner": "1.7.4",
52
+ "cmdk": "1.1.1",
53
+ "vaul": "1.1.2",
54
+ "embla-carousel-react": "8.6.0",
55
+ "react-day-picker": "9.14.0",
56
+ "react-resizable-panels": "4.7.6",
57
+ "react-hook-form": "7.72.0",
58
+ "@hookform/resolvers": "5.2.2",
59
+ "date-fns": "4.1.0",
60
+ "zod": "3.25.76",
61
+ "@tanstack/react-query": "5.94.5",
62
+ "@tanstack/query-core": "5.94.5",
63
+ "scheduler": "0.27.0"
64
+ },
65
+ "devDependencies": {
66
+ "@types/react": "19.2.14",
67
+ "@types/react-dom": "19.2.3",
68
+ "@types/node": "22.19.15",
69
+ "@eslint/js": "9.39.4",
70
+ "@vitejs/plugin-react": "4.7.0",
71
+ "@tailwindcss/vite": "4.2.2",
72
+ "eslint": "9.39.4",
73
+ "eslint-plugin-react-hooks": "5.2.0",
74
+ "eslint-plugin-react-refresh": "0.4.26",
75
+ "globals": "16.5.0",
76
+ "tailwindcss": "4.2.2",
77
+ "tw-animate-css": "1.4.0",
78
+ "typescript-eslint": "8.57.1",
79
+ "typescript": "5.9.3",
80
+ "vite": "6.4.1"
81
+ }
82
+ }
@@ -0,0 +1,23 @@
1
+ const SURF_LOGO_PATH = "M14.6875 13.333C15.0977 13.333 15.4859 13.5196 15.7422 13.8398L17.8721 16.502C17.9009 16.538 17.9308 16.5721 17.9619 16.6035C18.3091 16.9542 18.7529 17.3405 18.7529 17.834C18.7528 18.3399 18.3428 18.75 17.8369 18.75H17.5713C17.2123 18.75 16.8727 18.5869 16.6484 18.3066L15.4678 16.8311C14.761 15.9479 13.3369 16.4478 13.3369 17.5791V18.6494C13.3369 18.7062 13.2882 18.75 13.2314 18.75H11.3584C10.7127 18.75 10.1016 18.4564 9.69824 17.9521L8.80176 16.8311C8.09497 15.9477 6.66993 16.4478 6.66992 17.5791V18.6494C6.66991 18.7062 6.62124 18.75 6.56445 18.75H4.69141C4.04589 18.75 3.43548 18.4562 3.03223 17.9521L2.13477 16.8311C1.77885 16.3862 1.25293 15.931 1.25293 15.3613V13.4395C1.253 13.3822 1.29729 13.3331 1.35449 13.333C1.7646 13.3332 2.15295 13.5196 2.40918 13.8398L4.53809 16.502C5.24478 17.3853 6.66962 16.886 6.66992 15.7549V14.6836C6.67018 13.9379 7.27483 13.3332 8.02051 13.333C8.43065 13.333 8.81884 13.5197 9.0752 13.8398L11.2051 16.502C11.9097 17.3827 13.3282 16.8887 13.3369 15.7646V14.6836C13.3372 13.9378 13.9417 13.3331 14.6875 13.333ZM14.6875 6.66699C15.0978 6.66702 15.4859 6.85347 15.7422 7.17383L17.8721 9.83594C18.2276 10.2801 18.7526 10.7339 18.7529 11.3027V13.2266C18.7528 13.2833 18.7091 13.333 18.6523 13.333C18.2421 13.3329 17.8539 13.1465 17.5977 12.8262L15.4678 10.1641C14.7609 9.28113 13.3369 9.78089 13.3369 10.9121V11.9824C13.3369 12.7282 12.7321 13.3328 11.9863 13.333C11.5761 13.3329 11.187 13.1465 10.9307 12.8262L8.80176 10.1641C8.09497 9.28072 6.66993 9.78076 6.66992 10.9121V11.9824C6.66992 12.7284 6.06525 13.3329 5.31934 13.333C4.90908 13.3329 4.52094 13.1465 4.26465 12.8262L2.13477 10.1641C1.7788 9.71925 1.25293 9.26403 1.25293 8.69434V6.77344C1.253 6.71621 1.29728 6.6671 1.35449 6.66699C1.76461 6.66714 2.15295 6.85359 2.40918 7.17383L4.53809 9.83594C5.24487 10.7193 6.66989 10.2192 6.66992 9.08789V8.01758C6.66992 7.27168 7.27467 6.66722 8.02051 6.66699C8.43082 6.66699 8.81885 6.85345 9.0752 7.17383L11.2051 9.83594C11.9097 10.7163 13.3279 10.2223 13.3369 9.09863V8.01758C13.3369 7.27161 13.9416 6.6671 14.6875 6.66699ZM2.43555 1.25C2.79488 1.25 3.13499 1.41367 3.35938 1.69434L4.53809 3.16895C5.24485 4.05241 6.66991 3.55228 6.66992 2.4209V1.35059C6.66993 1.29391 6.71874 1.25017 6.77539 1.25H8.64844C9.29394 1.25 9.90438 1.5438 10.3076 2.04785L11.2051 3.16895C11.9097 4.04967 13.3282 3.55564 13.3369 2.43164V1.35059C13.3369 1.29391 13.3857 1.25017 13.4424 1.25H15.3154C15.9609 1.25 16.5714 1.5438 16.9746 2.04785L17.8721 3.16895C18.2275 3.61314 18.7526 4.06689 18.7529 4.63574V6.56055C18.7528 6.61728 18.7091 6.66699 18.6523 6.66699C18.2421 6.66688 17.8539 6.4795 17.5977 6.15918L15.4678 3.49805C14.761 2.61474 13.3371 3.11388 13.3369 4.24512V5.31543C13.3369 6.06126 12.7321 6.66673 11.9863 6.66699C11.5761 6.66693 11.187 6.47955 10.9307 6.15918L8.80176 3.49805C8.095 2.61474 6.67006 3.11388 6.66992 4.24512V5.31543C6.66992 6.06137 6.06525 6.66691 5.31934 6.66699C4.90908 6.66693 4.52094 6.47955 4.26465 6.15918L2.13477 3.49805C2.10558 3.46158 2.07543 3.42725 2.04395 3.39551C1.6966 3.04528 1.25293 2.6583 1.25293 2.16504C1.25317 1.65963 1.66348 1.25 2.16895 1.25H2.43555Z"
2
+
3
+ function SurfLogo() {
4
+ return (
5
+ <svg width="40" height="40" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
6
+ <path d={SURF_LOGO_PATH} fill="var(--fg-base, #212121)" />
7
+ </svg>
8
+ )
9
+ }
10
+
11
+ export default function App() {
12
+ return (
13
+ <div data-surf-placeholder className="min-h-screen flex flex-col items-center justify-center gap-4 rounded-[14px]" style={{ background: 'var(--bg-chat-nav, #f4f4f4)', border: '1px solid var(--border-strong, rgba(42,42,42,0.08))' }}>
14
+ <SurfLogo />
15
+ <h1 className="font-black text-[36px] leading-[44px] text-[var(--fg-base,#212121)]">
16
+ GM, Builder<span style={{ color: 'var(--brand-100, #ff2882)' }}>.</span>
17
+ </h1>
18
+ <p className="text-xl leading-7 text-[var(--fg-subtle,#7a7a7a)]">
19
+ Start your crypto project here!
20
+ </p>
21
+ </div>
22
+ )
23
+ }