@davvidbaker/flambe-cli 0.1.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.
@@ -0,0 +1,391 @@
1
+ import { createReadStream, existsSync, statSync } from 'node:fs';
2
+ import { extname, join, normalize, resolve } from 'node:path';
3
+
4
+ function sendJson(res, status, body) {
5
+ const payload = JSON.stringify(body);
6
+ res.writeHead(status, {
7
+ 'content-type': 'application/json; charset=utf-8',
8
+ 'content-length': Buffer.byteLength(payload),
9
+ });
10
+ res.end(payload);
11
+ }
12
+
13
+ function sendEmpty(res, status) {
14
+ res.writeHead(status);
15
+ res.end();
16
+ }
17
+
18
+ function setSessionCookie(resHeaders, userId) {
19
+ resHeaders.push(['set-cookie', `flambe_user=${userId}; Path=/; HttpOnly; SameSite=Lax`]);
20
+ }
21
+
22
+ function parseCookies(header) {
23
+ const cookies = {};
24
+ if (!header) return cookies;
25
+ for (const part of header.split(';')) {
26
+ const [name, ...rest] = part.trim().split('=');
27
+ if (name) cookies[name] = rest.join('=');
28
+ }
29
+ return cookies;
30
+ }
31
+
32
+ function bearer(req) {
33
+ const header = req.headers.authorization;
34
+ if (typeof header === 'string' && header.startsWith('Bearer ')) return header.slice(7);
35
+ return null;
36
+ }
37
+
38
+ function readBody(req) {
39
+ return new Promise((resolveBody, reject) => {
40
+ const chunks = [];
41
+ req.on('data', chunk => chunks.push(chunk));
42
+ req.on('end', () => resolveBody(Buffer.concat(chunks)));
43
+ req.on('error', reject);
44
+ });
45
+ }
46
+
47
+ function mimeFor(filePath) {
48
+ switch (extname(filePath)) {
49
+ case '.html': return 'text/html; charset=utf-8';
50
+ case '.js': return 'text/javascript; charset=utf-8';
51
+ case '.css': return 'text/css; charset=utf-8';
52
+ case '.json': return 'application/json; charset=utf-8';
53
+ case '.svg': return 'image/svg+xml';
54
+ case '.png': return 'image/png';
55
+ case '.ico': return 'image/x-icon';
56
+ case '.woff2': return 'font/woff2';
57
+ default: return 'application/octet-stream';
58
+ }
59
+ }
60
+
61
+ function authenticate(store, req) {
62
+ const token = bearer(req);
63
+ if (token) {
64
+ const auth = store.authenticateToken(token);
65
+ if (auth) return { user: auth.user, tokenName: auth.tokenName };
66
+ }
67
+ const userId = parseCookies(req.headers.cookie).flambe_user;
68
+ if (userId) {
69
+ const user = store.userById(userId);
70
+ if (user) return { user, tokenName: null };
71
+ }
72
+ return null;
73
+ }
74
+
75
+ function parseJson(buffer) {
76
+ if (!buffer.length) return {};
77
+ return JSON.parse(buffer.toString('utf8'));
78
+ }
79
+
80
+ function headerBag() {
81
+ const headers = [];
82
+ return {
83
+ push: (name, value) => headers.push([name, value]),
84
+ apply(res, status, extra = {}) {
85
+ res.writeHead(status, Object.fromEntries([...headers, ...Object.entries(extra)]));
86
+ },
87
+ list: headers,
88
+ };
89
+ }
90
+
91
+ async function dispatch(store, req, url, body) {
92
+ const method = req.method ?? 'GET';
93
+ const path = url.pathname;
94
+ const auth = authenticate(store, req);
95
+ const agentId = req.headers['x-flambe-agent-id'];
96
+ const agentName = req.headers['x-flambe-agent-name'];
97
+
98
+ if (method === 'GET' && path === '/api/health') {
99
+ return { status: 200, json: { status: 'ok' } };
100
+ }
101
+
102
+ if (method === 'POST' && path === '/auth/identity/callback') {
103
+ const { user, traceId } = store.credentials();
104
+ const headers = headerBag();
105
+ headers.push('set-cookie', `flambe_user=${user.id}; Path=/; HttpOnly; SameSite=Lax`);
106
+ return {
107
+ status: 200,
108
+ json: { data: { id: user.id, username: user.username, trace_id: traceId } },
109
+ headers,
110
+ };
111
+ }
112
+
113
+ if (method === 'DELETE' && path === '/auth/logout') {
114
+ const headers = headerBag();
115
+ headers.push('set-cookie', 'flambe_user=; Path=/; HttpOnly; Max-Age=0');
116
+ return { status: 204, headers };
117
+ }
118
+
119
+ const needAuth = path.startsWith('/api/') && path !== '/api/health' && path !== '/api/register';
120
+ if (needAuth && !auth) {
121
+ return { status: 401, json: { error: 'UNAUTHENTICATED' } };
122
+ }
123
+
124
+ const userId = auth?.user?.id;
125
+
126
+ if (method === 'GET' && path === '/api/export') {
127
+ return { status: 200, json: store.exportBundle() };
128
+ }
129
+
130
+ if (method === 'GET' && path === '/api/categories') {
131
+ return { status: 200, json: { data: store.listCategories(userId) } };
132
+ }
133
+
134
+ if (method === 'POST' && path === '/api/categories') {
135
+ const category = store.createCategory(userId, body.category ?? {}, body.activity_ids ?? []);
136
+ return { status: 201, json: { data: category } };
137
+ }
138
+
139
+ const categoryMatch = path.match(/^\/api\/categories\/(\d+)$/);
140
+ if (categoryMatch) {
141
+ const id = categoryMatch[1];
142
+ if (method === 'GET') {
143
+ const categories = store.listCategories(userId);
144
+ const category = categories.find(row => String(row.id) === id);
145
+ if (!category) return { status: 404, json: { error: 'NOT_FOUND' } };
146
+ return { status: 200, json: { data: category } };
147
+ }
148
+ if (method === 'PUT') {
149
+ const category = store.updateCategory(userId, id, body.category ?? {});
150
+ if (!category) return { status: 404, json: { error: 'NOT_FOUND' } };
151
+ return { status: 200, json: { data: category } };
152
+ }
153
+ if (method === 'DELETE') {
154
+ if (!store.deleteCategory(userId, id)) return { status: 404, json: { error: 'NOT_FOUND' } };
155
+ return { status: 204 };
156
+ }
157
+ }
158
+
159
+ if (method === 'GET' && path === '/api/traces') {
160
+ return { status: 200, json: { data: store.listTraces(userId) } };
161
+ }
162
+
163
+ if (method === 'POST' && path === '/api/traces') {
164
+ const trace = store.createTrace(userId, body.trace?.name ?? 'Untitled');
165
+ return { status: 201, json: { data: { id: trace.id, name: trace.name, events: [], threads: trace.threads } } };
166
+ }
167
+
168
+ const threadOrder = path.match(/^\/api\/traces\/(\d+)\/thread_order$/);
169
+ if (threadOrder && method === 'PUT') {
170
+ const result = store.reorderThreads(userId, threadOrder[1], body.thread_ids ?? []);
171
+ if (result.error === 'not_found') return { status: 404, json: { error: 'NOT_FOUND' } };
172
+ if (result.error === 'invalid') {
173
+ return { status: 422, json: { errors: { thread_ids: ['must include each thread in this trace exactly once'] } } };
174
+ }
175
+ return { status: 200, json: { data: { threads: result.threads } } };
176
+ }
177
+
178
+ const traceMatch = path.match(/^\/api\/traces\/(\d+)$/);
179
+ if (traceMatch) {
180
+ const id = traceMatch[1];
181
+ if (method === 'GET') {
182
+ const trace = store.getTrace(userId, id);
183
+ if (!trace) return { status: 404, json: { error: 'NOT_FOUND' } };
184
+ return { status: 200, json: { data: trace } };
185
+ }
186
+ if (method === 'PUT') {
187
+ const trace = store.updateTrace(userId, id, body.trace ?? {});
188
+ if (!trace) return { status: 404, json: { error: 'NOT_FOUND' } };
189
+ return { status: 200, json: { data: trace } };
190
+ }
191
+ if (method === 'DELETE') {
192
+ if (!store.deleteTrace(userId, id)) return { status: 404, json: { error: 'NOT_FOUND' } };
193
+ return { status: 204 };
194
+ }
195
+ }
196
+
197
+ if (method === 'POST' && path === '/api/threads') {
198
+ const thread = store.createThread(userId, body.trace_id, body.thread ?? {});
199
+ if (!thread) return { status: 404, json: { error: 'NOT_FOUND' } };
200
+ return { status: 201, json: { data: thread } };
201
+ }
202
+
203
+ const threadMatch = path.match(/^\/api\/threads\/(\d+)$/);
204
+ if (threadMatch) {
205
+ const id = threadMatch[1];
206
+ if (method === 'GET') {
207
+ const thread = store.getThread(userId, id);
208
+ if (!thread) return { status: 404, json: { error: 'NOT_FOUND' } };
209
+ return { status: 200, json: { data: thread } };
210
+ }
211
+ if (method === 'PUT') {
212
+ const thread = store.updateThread(userId, id, body.thread ?? {});
213
+ if (!thread) return { status: 404, json: { error: 'NOT_FOUND' } };
214
+ return { status: 200, json: { data: thread } };
215
+ }
216
+ if (method === 'DELETE') {
217
+ if (!store.deleteThread(userId, id)) return { status: 404, json: { error: 'NOT_FOUND' } };
218
+ return { status: 204 };
219
+ }
220
+ }
221
+
222
+ if (method === 'POST' && path === '/api/activities') {
223
+ const result = store.createActivity(userId, {
224
+ traceId: body.trace_id,
225
+ threadId: body.thread_id,
226
+ activity: body.activity ?? {},
227
+ event: body.event ?? {},
228
+ agentId: typeof agentId === 'string' ? agentId : undefined,
229
+ agentName: typeof agentName === 'string' ? agentName : undefined,
230
+ tokenName: auth.tokenName,
231
+ });
232
+ if (result.error) return { status: 404, json: { error: 'NOT_FOUND' } };
233
+ return { status: 201, json: { data: result } };
234
+ }
235
+
236
+ const activityMatch = path.match(/^\/api\/activities\/(\d+)$/);
237
+ if (activityMatch) {
238
+ const id = activityMatch[1];
239
+ if (method === 'GET') {
240
+ const activity = store.getActivity(userId, id);
241
+ if (!activity) return { status: 404, json: { error: 'NOT_FOUND' } };
242
+ return { status: 200, json: { data: activity } };
243
+ }
244
+ if (method === 'PUT') {
245
+ const activity = store.updateActivity(userId, id, body.activity ?? {});
246
+ if (activity?.error === 'not_found' || !activity) return { status: 404, json: { error: 'NOT_FOUND' } };
247
+ return { status: 200, json: { data: activity } };
248
+ }
249
+ if (method === 'DELETE') {
250
+ if (!store.deleteActivity(userId, id)) return { status: 404, json: { error: 'NOT_FOUND' } };
251
+ return { status: 204 };
252
+ }
253
+ }
254
+
255
+ if (method === 'POST' && path === '/api/events') {
256
+ const result = store.createEvent(userId, {
257
+ traceId: body.trace_id,
258
+ activityId: body.activity_id,
259
+ event: body.event ?? {},
260
+ });
261
+ if (result.error) return { status: 404, json: { error: 'NOT_FOUND' } };
262
+ return { status: 201, json: { data: result } };
263
+ }
264
+
265
+ const eventMatch = path.match(/^\/api\/events\/(\d+)$/);
266
+ if (eventMatch) {
267
+ const id = eventMatch[1];
268
+ if (method === 'PUT') {
269
+ const event = store.updateEvent(userId, id, body.event ?? {});
270
+ if (!event) return { status: 404, json: { error: 'NOT_FOUND' } };
271
+ return { status: 200, json: { data: event } };
272
+ }
273
+ if (method === 'DELETE') {
274
+ if (!store.deleteEvent(userId, id)) return { status: 404, json: { error: 'NOT_FOUND' } };
275
+ return { status: 204 };
276
+ }
277
+ }
278
+
279
+ const userMatch = path.match(/^\/api\/users\/(\d+)$/);
280
+ if (userMatch && method === 'GET') {
281
+ if (String(userId) !== userMatch[1]) return { status: 404, json: { error: 'NOT_FOUND' } };
282
+ return { status: 200, json: { data: store.userDashboard(userId) } };
283
+ }
284
+
285
+ if (method === 'POST' && path === '/api/todos') {
286
+ return { status: 201, json: { data: store.createTodo(userId, body.todo ?? {}) } };
287
+ }
288
+
289
+ if (method === 'POST' && path === '/api/mantras') {
290
+ return { status: 201, json: { data: store.createMantra(userId, body.mantra ?? {}) } };
291
+ }
292
+
293
+ if (method === 'POST' && path === '/api/attentions') {
294
+ const attention = store.createAttention(userId, body.attention ?? {});
295
+ if (attention.error) return { status: 404, json: { error: 'NOT_FOUND' } };
296
+ return { status: 201, json: { data: attention } };
297
+ }
298
+
299
+ if (method === 'POST' && path === '/api/observations') {
300
+ const { observation, created } = store.upsertObservation(userId, body.observation ?? {});
301
+ return { status: created ? 201 : 200, json: { data: observation } };
302
+ }
303
+
304
+ if (method === 'GET' && path === '/api/observations') {
305
+ return { status: 200, json: { data: store.listObservations(userId) } };
306
+ }
307
+
308
+ if (method === 'GET' && path === '/api/api-tokens') {
309
+ const { rawToken } = store.credentials();
310
+ return {
311
+ status: 200,
312
+ json: { data: [{ id: 1, name: 'local', inserted_at: isoNow(), last_used_at: null, hint: 'see flambe serve output' }] },
313
+ };
314
+ }
315
+
316
+ if (method === 'POST' && path === '/api/register') {
317
+ return { status: 404, json: { error: 'LOCAL_MODE' } };
318
+ }
319
+
320
+ return { status: 404, json: { error: 'NOT_FOUND' } };
321
+ }
322
+
323
+ function isoNow() {
324
+ return new Date().toISOString();
325
+ }
326
+
327
+ function tryStatic(staticDir, urlPath, res) {
328
+ if (!staticDir) return false;
329
+ const root = resolve(staticDir);
330
+ const indexPath = join(root, 'assets', 'index.html');
331
+ const requested = urlPath === '/' || !urlPath.includes('.')
332
+ ? indexPath
333
+ : join(root, urlPath.replace(/^\//, ''));
334
+ const normalized = normalize(requested);
335
+ if (!normalized.startsWith(root)) return false;
336
+ if (!existsSync(normalized) || !statSync(normalized).isFile()) {
337
+ if (existsSync(indexPath)) {
338
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
339
+ createReadStream(indexPath).pipe(res);
340
+ return true;
341
+ }
342
+ return false;
343
+ }
344
+ res.writeHead(200, { 'content-type': mimeFor(normalized) });
345
+ createReadStream(normalized).pipe(res);
346
+ return true;
347
+ }
348
+
349
+ export async function handleLocalRequest(store, req, res, { staticDir } = {}) {
350
+ const host = req.headers.host ?? '127.0.0.1';
351
+ const url = new URL(req.url ?? '/', `http://${host}`);
352
+
353
+ if (!url.pathname.startsWith('/api') && !url.pathname.startsWith('/auth')) {
354
+ if (tryStatic(staticDir, url.pathname, res)) return;
355
+ const { rawToken, traceId } = store.credentials();
356
+ const html = `<!doctype html><meta charset="utf-8"><title>Flambe local</title>
357
+ <body style="font-family:sans-serif;max-width:40rem;margin:3rem auto;line-height:1.4">
358
+ <h1>Flambe local</h1>
359
+ <p>CLI API is running. Build the frontend (<code>cd frontend &amp;&amp; npm run build</code>) and restart with <code>--static</code> pointing at <code>backend/priv/static</code> to open the chart here. Vite can also proxy to this port.</p>
360
+ <pre>FLAMBE_URL=http://${host}
361
+ FLAMBE_API_TOKEN=${rawToken}
362
+ FLAMBE_TRACE_ID=${traceId}</pre>
363
+ </body>`;
364
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
365
+ res.end(html);
366
+ return;
367
+ }
368
+
369
+ let body = {};
370
+ try {
371
+ body = parseJson(await readBody(req));
372
+ } catch {
373
+ sendJson(res, 400, { error: 'INVALID_JSON' });
374
+ return;
375
+ }
376
+
377
+ const result = await dispatch(store, req, url, body);
378
+ if (result.status === 204) {
379
+ if (result.headers) result.headers.apply(res, 204);
380
+ else sendEmpty(res, 204);
381
+ return;
382
+ }
383
+
384
+ const payload = JSON.stringify(result.json);
385
+ const extra = { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(payload) };
386
+ if (result.headers) result.headers.apply(res, result.status, extra);
387
+ else res.writeHead(result.status, extra);
388
+ res.end(payload);
389
+ }
390
+
391
+ export { sendJson, setSessionCookie };
@@ -0,0 +1,61 @@
1
+ import http from 'node:http';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ import { handleLocalRequest } from './http.mjs';
7
+ import { LocalStore } from './store.mjs';
8
+
9
+ /** Kept in sync with the copy in cli.mjs, which must not import this module eagerly. */
10
+ export function defaultDbPath() {
11
+ return join(homedir(), '.flambe', 'local.sqlite');
12
+ }
13
+
14
+ export function defaultStaticDir() {
15
+ return resolve(fileURLToPath(new URL('../../../../backend/priv/static', import.meta.url)));
16
+ }
17
+
18
+ export function createLocalServer({ dbPath = defaultDbPath(), staticDir } = {}) {
19
+ const store = new LocalStore(dbPath);
20
+ const resolvedStatic = staticDir === undefined ? defaultStaticDir() : staticDir;
21
+ const server = http.createServer((req, res) => {
22
+ handleLocalRequest(store, req, res, { staticDir: resolvedStatic }).catch(error => {
23
+ if (!res.headersSent) {
24
+ res.writeHead(500, { 'content-type': 'application/json' });
25
+ res.end(JSON.stringify({ error: 'INTERNAL' }));
26
+ }
27
+ console.error(error);
28
+ });
29
+ });
30
+ server.store = store;
31
+ return server;
32
+ }
33
+
34
+ export function listenLocal({
35
+ host = '127.0.0.1',
36
+ port = 4001,
37
+ dbPath,
38
+ staticDir,
39
+ } = {}) {
40
+ if (host !== '127.0.0.1' && host !== 'localhost') {
41
+ throw new Error('Local mode binds loopback only (127.0.0.1). Refusing --host ' + host);
42
+ }
43
+
44
+ const server = createLocalServer({ dbPath, staticDir });
45
+ return new Promise((resolveListen, reject) => {
46
+ server.listen(port, host, () => {
47
+ const address = server.address();
48
+ resolveListen({ server, store: server.store, host, port: address.port });
49
+ });
50
+ server.on('error', reject);
51
+ });
52
+ }
53
+
54
+ export function printServeBanner({ host, port, store }, stdout = process.stdout) {
55
+ const { rawToken, traceId } = store.credentials();
56
+ const origin = `http://${host}:${port}`;
57
+ stdout.write(`Flambe local on ${origin} (SQLite, loopback only)\n`);
58
+ stdout.write(`FLAMBE_URL=${origin}\n`);
59
+ stdout.write(`FLAMBE_API_TOKEN=${rawToken}\n`);
60
+ stdout.write(`FLAMBE_TRACE_ID=${traceId}\n`);
61
+ }