compend 0.0.1 → 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.
@@ -0,0 +1,497 @@
1
+ :root {
2
+ --background: #101010;
3
+ --foreground: #F4F4F5;
4
+ --strings: #DEBAAB;
5
+ --variables: #A1DCF8;
6
+ --accent: #2a2a2a;
7
+ --border: #2a2a2a;
8
+ --success: #4caf50;
9
+ --warning: #ff9800;
10
+ --info: #2196f3;
11
+ --focus-ring: #A1DCF8;
12
+ }
13
+
14
+ html[data-theme="light"] {
15
+ --background: #FFFFFF;
16
+ --foreground: #030507;
17
+ --strings: #A31E22;
18
+ --variables: #2B5484;
19
+ --accent: #F3F3F3;
20
+ --border: #d1d5db;
21
+ --focus-ring: #2B5484;
22
+ }
23
+
24
+ *,
25
+ *::before,
26
+ *::after {
27
+ box-sizing: border-box;
28
+ margin: 0;
29
+ padding: 0;
30
+ }
31
+
32
+ .sr-only {
33
+ position: absolute;
34
+ width: 1px;
35
+ height: 1px;
36
+ padding: 0;
37
+ margin: -1px;
38
+ overflow: hidden;
39
+ clip: rect(0, 0, 0, 0);
40
+ border: 0;
41
+ }
42
+
43
+ body {
44
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
45
+ background: var(--background);
46
+ color: var(--foreground);
47
+ padding: 0 20px;
48
+ font-size: 14px;
49
+ line-height: 1.5;
50
+ }
51
+
52
+ #app {
53
+ max-width: 960px;
54
+ margin: 0 auto;
55
+ }
56
+
57
+ header {
58
+ display: flex;
59
+ justify-content: space-between;
60
+ align-items: baseline;
61
+ padding: 12px 0;
62
+ border-bottom: 1px solid var(--border);
63
+ margin-bottom: 16px;
64
+ }
65
+
66
+ header h1 {
67
+ font-size: 18px;
68
+ font-weight: 600;
69
+ color: var(--foreground);
70
+ }
71
+
72
+ .logo-title {
73
+ display: flex;
74
+ align-items: center;
75
+ gap: 8px;
76
+ }
77
+
78
+ header div {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 8px;
82
+ }
83
+
84
+ .theme-btn {
85
+ background: none;
86
+ border: unset;
87
+ color: var(--foreground);
88
+ cursor: pointer;
89
+ font-size: 16px;
90
+ padding: 2px 8px;
91
+ border-radius: 4px;
92
+ line-height: 1;
93
+ display: flex;
94
+ }
95
+
96
+ .theme-btn:hover {
97
+ background: var(--accent);
98
+ }
99
+
100
+ .theme-btn:focus-visible {
101
+ outline: 2px solid var(--focus-ring);
102
+ outline-offset: 2px;
103
+ }
104
+
105
+ .filters {
106
+ margin-bottom: 12px;
107
+ }
108
+
109
+ .filter-row {
110
+ display: flex;
111
+ gap: 12px;
112
+ flex-wrap: wrap;
113
+ align-items: end;
114
+ }
115
+
116
+ .field {
117
+ flex: 1;
118
+ display: flex;
119
+ flex-direction: column;
120
+ gap: 4px;
121
+ }
122
+
123
+ .field label {
124
+ font-size: 11px;
125
+ text-transform: uppercase;
126
+ color: var(--foreground);
127
+ letter-spacing: .5px;
128
+ }
129
+
130
+ .field input,
131
+ .field select {
132
+ background: var(--accent);
133
+ border: 1px solid var(--border);
134
+ color: var(--foreground);
135
+ padding: 6px 10px;
136
+ border-radius: 4px;
137
+ font-size: 13px;
138
+ outline: none;
139
+ width: 100%;
140
+ }
141
+
142
+ .field input:focus,
143
+ .field select:focus {
144
+ border-color: var(--focus-ring);
145
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-ring) 30%, transparent);
146
+ }
147
+
148
+ .search-field input {
149
+ width: 100%;
150
+ }
151
+
152
+ .tags-bar {
153
+ display: flex;
154
+ gap: 8px;
155
+ flex-wrap: wrap;
156
+ margin-top: 8px;
157
+ padding: 6px 0;
158
+ }
159
+
160
+ .tag-check {
161
+ display: inline-flex;
162
+ align-items: center;
163
+ gap: 4px;
164
+ font-size: 12px;
165
+ color: var(--foreground);
166
+ cursor: pointer;
167
+ padding: 2px 8px;
168
+ border-radius: 12px;
169
+ background: var(--accent);
170
+ border: 1px solid var(--border);
171
+ }
172
+
173
+ .tag-check:hover {
174
+ border-color: var(--focus-ring);
175
+ }
176
+
177
+ .tag-check input[type="checkbox"] {
178
+ margin: 0;
179
+ accent-color: var(--variables);
180
+ }
181
+
182
+ .tag-count {
183
+ color: color-mix(in srgb, var(--foreground) 50%, transparent);
184
+ font-size: 11px;
185
+ }
186
+
187
+ #stats {
188
+ font-size: 13px;
189
+ color: var(--foreground);
190
+ margin-bottom: 8px;
191
+ padding: 0 4px;
192
+ }
193
+
194
+ #loading-skeleton table {
195
+ width: 100%;
196
+ border-collapse: collapse;
197
+ }
198
+
199
+ #loading-skeleton th {
200
+ text-align: left;
201
+ padding: 8px 12px;
202
+ font-size: 12px;
203
+ text-transform: uppercase;
204
+ color: var(--foreground);
205
+ letter-spacing: .5px;
206
+ border-bottom: 1px solid var(--border);
207
+ font-weight: 500;
208
+ background: var(--accent);
209
+ }
210
+
211
+ .skeleton-row td {
212
+ padding: 10px 12px;
213
+ border-bottom: 1px solid var(--border);
214
+ }
215
+
216
+ .skeleton-cell {
217
+ height: 14px;
218
+ border-radius: 4px;
219
+ background: linear-gradient(90deg, var(--accent) 25%, color-mix(in srgb, var(--accent) 140%, var(--foreground)) 50%, var(--accent) 75%);
220
+ background-size: 200% 100%;
221
+ animation: shimmer 1.5s ease-in-out infinite;
222
+ }
223
+
224
+ .skeleton-cell.wide { width: 85%; }
225
+ .skeleton-cell.narrow { width: 30%; }
226
+ .skeleton-cell.tiny { width: 20%; }
227
+
228
+ @keyframes shimmer {
229
+ 0% { background-position: 200% 0; }
230
+ 100% { background-position: -200% 0; }
231
+ }
232
+
233
+ #table-wrap {
234
+ border: 1px solid var(--border);
235
+ border-radius: 6px;
236
+ overflow-x: auto;
237
+ position: relative;
238
+ }
239
+
240
+ table {
241
+ width: 100%;
242
+ border-collapse: collapse;
243
+ }
244
+
245
+ thead {
246
+ background: var(--accent);
247
+ }
248
+
249
+ th {
250
+ text-align: left;
251
+ padding: 8px 12px;
252
+ font-size: 12px;
253
+ text-transform: uppercase;
254
+ color: var(--foreground);
255
+ letter-spacing: .5px;
256
+ border-bottom: 1px solid var(--border);
257
+ font-weight: 500;
258
+ }
259
+
260
+ td {
261
+ padding: 10px 12px;
262
+ border-bottom: 1px solid var(--border);
263
+ font-size: 13px;
264
+ }
265
+
266
+ tbody tr:last-child td {
267
+ border-bottom: none;
268
+ }
269
+
270
+ tbody tr.concept-row {
271
+ cursor: pointer;
272
+ transition: background .15s;
273
+ }
274
+
275
+ tbody tr.concept-row:hover {
276
+ background: var(--accent);
277
+ }
278
+
279
+ tbody tr.concept-row:focus-within {
280
+ outline: 2px solid var(--focus-ring);
281
+ outline-offset: -2px;
282
+ }
283
+
284
+ .type-badge {
285
+ display: inline-block;
286
+ padding: 2px 8px;
287
+ border-radius: 3px;
288
+ font-size: 11px;
289
+ font-weight: 400;
290
+ letter-spacing: .4px;
291
+ }
292
+
293
+ .type-skill,
294
+ .type-agent,
295
+ .type-instruction,
296
+ .type-prompt,
297
+ .type-workflow,
298
+ .type-reference,
299
+ .type-default {
300
+ background: none;
301
+ border: 1px solid color-mix(in srgb, var(--foreground) 75%, transparent);
302
+ color: color-mix(in srgb, var(--foreground) 75%, transparent);
303
+ }
304
+
305
+ .tag-pill {
306
+ display: inline-block;
307
+ padding: 1px 6px;
308
+ border-radius: 3px;
309
+ font-size: 11px;
310
+ background: color-mix(in srgb, var(--accent) 40%, transparent);
311
+ color: var(--foreground);
312
+ margin: 1px;
313
+ }
314
+
315
+ .title-cell {
316
+ font-weight: 500;
317
+ max-width: 200px;
318
+ overflow: hidden;
319
+ text-overflow: ellipsis;
320
+ white-space: nowrap;
321
+ }
322
+
323
+ .desc-cell {
324
+ max-width: 300px;
325
+ overflow: hidden;
326
+ text-overflow: ellipsis;
327
+ white-space: nowrap;
328
+ }
329
+
330
+ .tags-cell {
331
+ max-width: 200px;
332
+ white-space: nowrap;
333
+ overflow: hidden;
334
+ text-overflow: ellipsis;
335
+ }
336
+
337
+ .status-badge {
338
+ display: inline-block;
339
+ padding: 1px 8px;
340
+ border-radius: 3px;
341
+ font-size: 11px;
342
+ font-weight: 400;
343
+ text-transform: lowercase;
344
+ background: color-mix(in srgb, var(--foreground) 15%, transparent);
345
+ color: var(--foreground);
346
+ }
347
+
348
+ .status-badge.status-stable {
349
+ background: color-mix(in srgb, var(--success) 30%, transparent);
350
+ }
351
+
352
+ .status-badge.status-draft {
353
+ background: color-mix(in srgb, var(--warning) 30%, transparent);
354
+ }
355
+
356
+ .status-badge.status-deprecated {
357
+ background: color-mix(in srgb, var(--strings) 30%, transparent);
358
+ }
359
+
360
+ .related-none {
361
+ color: color-mix(in srgb, var(--foreground) 30%, transparent);
362
+ font-size: 11px;
363
+ }
364
+
365
+ .detail-row td {
366
+ padding: 0;
367
+ background: color-mix(in srgb, var(--accent) 90%, var(--foreground));
368
+ }
369
+
370
+ .detail {
371
+ max-height: 0;
372
+ overflow: hidden;
373
+ transition: max-height .2s ease;
374
+ padding: 0 12px;
375
+ }
376
+
377
+ .detail.open {
378
+ max-height: 500px;
379
+ overflow-y: auto;
380
+ padding: 10px 12px;
381
+ }
382
+
383
+ .detail pre {
384
+ white-space: pre-wrap;
385
+ word-break: break-all;
386
+ color: var(--foreground);
387
+ font-size: 12px;
388
+ line-height: 1.6;
389
+ opacity: .85;
390
+ }
391
+
392
+ #pagination {
393
+ display: flex;
394
+ justify-content: center;
395
+ align-items: center;
396
+ gap: 8px;
397
+ padding: 12px;
398
+ font-size: 13px;
399
+ color: var(--foreground);
400
+ }
401
+
402
+ #pagination button {
403
+ background: var(--accent);
404
+ border: 1px solid var(--border);
405
+ color: var(--foreground);
406
+ padding: 4px 12px;
407
+ border-radius: 4px;
408
+ cursor: pointer;
409
+ font-size: 13px;
410
+ }
411
+
412
+ #pagination button:disabled {
413
+ opacity: .3;
414
+ cursor: default;
415
+ }
416
+
417
+ #pagination button:not(:disabled):hover {
418
+ background: color-mix(in srgb, var(--accent) 110%, var(--foreground));
419
+ }
420
+
421
+ #pagination button:not(:disabled):focus-visible {
422
+ outline: 2px solid var(--focus-ring);
423
+ outline-offset: 2px;
424
+ }
425
+
426
+ .empty {
427
+ text-align: center;
428
+ padding: 40px;
429
+ color: var(--foreground);
430
+ font-size: 14px;
431
+ }
432
+
433
+ #error {
434
+ display: none;
435
+ background: color-mix(in srgb, var(--strings) 15%, var(--background));
436
+ color: var(--foreground);
437
+ padding: 8px 12px;
438
+ border-radius: 4px;
439
+ margin-bottom: 8px;
440
+ font-size: 13px;
441
+ }
442
+
443
+ /* Toast notifications */
444
+ #toast-container {
445
+ position: fixed;
446
+ bottom: 20px;
447
+ right: 20px;
448
+ z-index: 10000;
449
+ display: flex;
450
+ flex-direction: column;
451
+ gap: 8px;
452
+ pointer-events: none;
453
+ }
454
+
455
+ .toast {
456
+ pointer-events: auto;
457
+ display: flex;
458
+ align-items: center;
459
+ gap: 10px;
460
+ padding: 10px 16px;
461
+ border-radius: 6px;
462
+ font-size: 13px;
463
+ line-height: 1.4;
464
+ background: color-mix(in srgb, var(--accent) 90%, var(--foreground));
465
+ border: 1px solid var(--border);
466
+ color: var(--foreground);
467
+ box-shadow: 0 4px 12px rgba(0,0,0,.3);
468
+ animation: toast-in .25s ease-out;
469
+ min-width: 240px;
470
+ max-width: 400px;
471
+ }
472
+
473
+ .toast.removing {
474
+ animation: toast-out .2s ease-in forwards;
475
+ }
476
+
477
+ .toast-success {
478
+ border-left: 3px solid var(--success);
479
+ }
480
+
481
+ .toast-error {
482
+ border-left: 3px solid var(--strings);
483
+ }
484
+
485
+ .toast-info {
486
+ border-left: 3px solid var(--info);
487
+ }
488
+
489
+ @keyframes toast-in {
490
+ from { transform: translateY(20px); opacity: 0; }
491
+ to { transform: translateY(0); opacity: 1; }
492
+ }
493
+
494
+ @keyframes toast-out {
495
+ from { transform: translateY(0); opacity: 1; }
496
+ to { transform: translateY(20px); opacity: 0; }
497
+ }
package/dashboard.js ADDED
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env node
2
+
3
+ import http from 'node:http';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { execSync } from 'node:child_process';
8
+ import { initDb } from './db.js';
9
+ import { getConfig } from './config.js';
10
+ import { createApiHandler, err } from './dashboard/api-handler.js';
11
+
12
+ const arg = process.argv[2] || 'start';
13
+ const PORT = getConfig().port;
14
+
15
+ function killOnPort(port) {
16
+ try {
17
+ const pids = execSync('lsof -ti:' + port, { encoding: 'utf-8' }).trim();
18
+ if (!pids) return;
19
+ for (const pid of pids.split('\n')) {
20
+ try {
21
+ const cmd = execSync('ps -p ' + pid + ' -o comm=', { encoding: 'utf-8' }).trim();
22
+ if (cmd === 'node') process.kill(parseInt(pid), 'SIGTERM');
23
+ } catch {}
24
+ }
25
+ } catch {}
26
+ }
27
+
28
+ if (arg === 'stop') {
29
+ killOnPort(PORT);
30
+ console.log('Compend stopped');
31
+ process.exit(0);
32
+ }
33
+ if (arg === 'restart') {
34
+ killOnPort(PORT);
35
+ }
36
+
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
+ const PUBLIC = path.join(__dirname, 'dashboard', 'public');
39
+
40
+ const DB = initDb();
41
+
42
+ const MIME = {
43
+ '.html': 'text/html; charset=utf-8',
44
+ '.css': 'text/css; charset=utf-8',
45
+ '.js': 'application/javascript; charset=utf-8',
46
+ '.json': 'application/json',
47
+ '.png': 'image/png',
48
+ '.ico': 'image/x-icon',
49
+ '.svg': 'image/svg+xml',
50
+ };
51
+
52
+ const sseClients = new Set();
53
+
54
+ const rateLimit = new Map();
55
+ function checkRate(ip, limit, windowMs) {
56
+ const now = Date.now();
57
+ const entry = rateLimit.get(ip) || { count: 0, reset: now + windowMs };
58
+ if (now > entry.reset) { entry.count = 0; entry.reset = now + windowMs; }
59
+ entry.count++;
60
+ rateLimit.set(ip, entry);
61
+ return entry.count <= limit;
62
+ }
63
+
64
+ function broadcast(type, data) {
65
+ const cleanType = String(type).replace(/[\r\n]/g, '');
66
+ const payload = `event: ${cleanType}\ndata: ${JSON.stringify(data)}\n\n`;
67
+ for (const client of sseClients) {
68
+ client.write(payload);
69
+ }
70
+ }
71
+
72
+ const server = http.createServer((req, res) => {
73
+ res.setHeader('Access-Control-Allow-Origin', 'http://localhost:' + PORT);
74
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
75
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
76
+
77
+ if (req.method === 'OPTIONS') {
78
+ res.writeHead(200);
79
+ res.end();
80
+ return;
81
+ }
82
+
83
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
84
+ const pathname = url.pathname;
85
+ const params = url.searchParams;
86
+
87
+ if (pathname === '/api/events' && req.method === 'GET') {
88
+ if (sseClients.size >= 100) {
89
+ res.writeHead(429, { 'Content-Type': 'text/plain' });
90
+ res.end('Too many connections');
91
+ return;
92
+ }
93
+ res.writeHead(200, {
94
+ 'Content-Type': 'text/event-stream',
95
+ 'Cache-Control': 'no-cache',
96
+ 'Connection': 'keep-alive'
97
+ });
98
+ res.write('\n');
99
+ sseClients.add(res);
100
+ req.on('close', () => { sseClients.delete(res); });
101
+ return;
102
+ }
103
+
104
+ if (pathname === '/api/notify' && req.method === 'POST') {
105
+ let body = '';
106
+ let bodyBytes = 0;
107
+ req.on('data', chunk => {
108
+ bodyBytes += chunk.length;
109
+ if (bodyBytes > 65536) { res.writeHead(413); res.end('Payload too large'); req.destroy(); return; }
110
+ body += chunk.toString();
111
+ });
112
+ req.on('end', () => {
113
+ try {
114
+ const { event, ...data } = JSON.parse(body);
115
+ broadcast(event, data);
116
+ res.writeHead(200, { 'Content-Type': 'application/json' });
117
+ res.end(JSON.stringify({ ok: true }));
118
+ } catch (e) {
119
+ res.writeHead(400, { 'Content-Type': 'application/json' });
120
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
121
+ }
122
+ });
123
+ return;
124
+ }
125
+
126
+ const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || '127.0.0.1';
127
+ if (pathname.startsWith('/api/') && !checkRate(ip, 50, 1000)) {
128
+ res.writeHead(429, { 'Content-Type': 'text/plain' });
129
+ res.end('Rate limited');
130
+ return;
131
+ }
132
+
133
+ try {
134
+ if (pathname.startsWith('/api/')) {
135
+ const handleApi = createApiHandler(DB);
136
+ const result = handleApi(pathname, req.method, params) || err('Not found', 404);
137
+
138
+ if (result.status === 200 && result.body) {
139
+ const indexMatch = pathname.match(/^\/api\/notify$/);
140
+ if (indexMatch && req.method === 'POST') {
141
+ try {
142
+ const data = JSON.parse(result.body);
143
+ if (data && data.event) {
144
+ broadcast(data.event, { counts: { added: data.added, updated: data.updated, removed: data.removed, total: data.total } });
145
+ }
146
+ } catch {}
147
+ }
148
+ }
149
+
150
+ res.writeHead(result.status, result.headers);
151
+ res.end(result.body);
152
+ return;
153
+ }
154
+
155
+ const filePath = path.join(PUBLIC, pathname === '/' ? 'index.html' : pathname);
156
+ const resolved = path.resolve(filePath);
157
+ if (!resolved.startsWith(PUBLIC + path.sep)) {
158
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
159
+ res.end('Not found');
160
+ return;
161
+ }
162
+ const ext = path.extname(resolved);
163
+
164
+ fs.readFile(resolved, (err, data) => {
165
+ if (err) {
166
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
167
+ res.end('Not found');
168
+ return;
169
+ }
170
+ res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
171
+ res.end(data);
172
+ });
173
+ } catch (e) {
174
+ console.error('Server error:', e);
175
+ res.writeHead(500, { 'Content-Type': 'application/json' });
176
+ res.end(JSON.stringify({ error: 'Internal server error' }));
177
+ }
178
+ });
179
+
180
+ let closing = false;
181
+ function shutdown() {
182
+ if (closing) return;
183
+ closing = true;
184
+ for (const client of sseClients) { try { client.end(); } catch {} }
185
+ server.close(() => {
186
+ try { DB.close(); } catch {}
187
+ process.exit(0);
188
+ });
189
+ setTimeout(() => process.exit(0), 1000);
190
+ }
191
+ process.on('SIGTERM', shutdown);
192
+ process.on('SIGINT', shutdown);
193
+
194
+ server.listen(PORT, () => {
195
+ console.log('\nCompend dashboard: http://localhost:' + PORT + '/\n');
196
+ });
197
+ server.on('error', (e) => {
198
+ if (e.code === 'EADDRINUSE') {
199
+ console.error('Port ' + PORT + ' is in use. Run "compend stop" first.');
200
+ process.exit(1);
201
+ }
202
+ throw e;
203
+ });