@limeade-labs/sparkui 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.
package/bin/sparkui.js ADDED
@@ -0,0 +1,390 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const http = require('http');
5
+ const https = require('https');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const crypto = require('crypto');
9
+ const { spawn } = require('child_process');
10
+
11
+ const args = process.argv.slice(2);
12
+ const command = args[0];
13
+
14
+ // Resolve the package root (where server.js lives)
15
+ const PKG_ROOT = path.resolve(__dirname, '..');
16
+
17
+ // ── Helpers ──────────────────────────────────────────────────────────────────
18
+
19
+ function getArg(name) {
20
+ const idx = args.indexOf(name);
21
+ return idx >= 0 && args[idx + 1] ? args[idx + 1] : null;
22
+ }
23
+
24
+ function hasFlag(name) {
25
+ return args.includes(name);
26
+ }
27
+
28
+ /**
29
+ * Load .env from a given directory into an object (does NOT modify process.env).
30
+ */
31
+ function loadEnvFile(dir) {
32
+ const envPath = path.join(dir, '.env');
33
+ const vars = {};
34
+ if (fs.existsSync(envPath)) {
35
+ for (const line of fs.readFileSync(envPath, 'utf-8').split('\n')) {
36
+ const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
37
+ if (m) vars[m[1]] = m[2];
38
+ }
39
+ }
40
+ return vars;
41
+ }
42
+
43
+ /**
44
+ * Resolve config: CWD .env > PKG_ROOT .env > defaults
45
+ */
46
+ function resolveConfig() {
47
+ const cwdEnv = loadEnvFile(process.cwd());
48
+ const pkgEnv = loadEnvFile(PKG_ROOT);
49
+ const env = { ...pkgEnv, ...cwdEnv };
50
+
51
+ // CLI overrides
52
+ const portOverride = getArg('--port');
53
+ if (portOverride) env.SPARKUI_PORT = portOverride;
54
+
55
+ return {
56
+ port: parseInt(env.SPARKUI_PORT, 10) || 3457,
57
+ pushToken: env.PUSH_TOKEN || '',
58
+ baseUrl: env.SPARKUI_BASE_URL || `http://localhost:${parseInt(env.SPARKUI_PORT, 10) || 3457}`,
59
+ envFile: fs.existsSync(path.join(process.cwd(), '.env'))
60
+ ? path.join(process.cwd(), '.env')
61
+ : fs.existsSync(path.join(PKG_ROOT, '.env'))
62
+ ? path.join(PKG_ROOT, '.env')
63
+ : null,
64
+ };
65
+ }
66
+
67
+ function usage() {
68
+ console.log(`
69
+ ⚡ SparkUI CLI
70
+
71
+ Usage:
72
+ sparkui init Initialize SparkUI in current directory
73
+ sparkui start [--port <port>] Start the SparkUI server
74
+ sparkui stop Stop the SparkUI server
75
+ sparkui push --html <file> Push raw HTML
76
+ sparkui push --template <name> --data <json> Push from template
77
+ sparkui update <id> --html <file> Update a page
78
+ sparkui delete <id> Delete a page
79
+ sparkui status Check server health
80
+
81
+ Options:
82
+ --port <port> Override server port (start command)
83
+ --ttl <seconds> Page TTL (default: 3600)
84
+ --token <token> Push token (or set PUSH_TOKEN env)
85
+
86
+ Environment:
87
+ SPARKUI_URL Server base URL (default: http://localhost:3457)
88
+ PUSH_TOKEN Authentication token
89
+ `);
90
+ }
91
+
92
+ // ── Init Command ─────────────────────────────────────────────────────────────
93
+
94
+ function cmdInit() {
95
+ const cwd = process.cwd();
96
+ const envDest = path.join(cwd, '.env');
97
+
98
+ // Check if .env already exists
99
+ if (fs.existsSync(envDest)) {
100
+ console.log('⚠️ .env already exists in this directory. Skipping init.');
101
+ console.log(' Delete it first if you want to re-initialize.');
102
+ process.exit(0);
103
+ }
104
+
105
+ // Read .env.example from the package
106
+ const examplePath = path.join(PKG_ROOT, '.env.example');
107
+ if (!fs.existsSync(examplePath)) {
108
+ console.error('Error: .env.example not found in package. Something is wrong with the installation.');
109
+ process.exit(1);
110
+ }
111
+
112
+ let envContent = fs.readFileSync(examplePath, 'utf-8');
113
+
114
+ // Generate a random push token
115
+ const token = 'spk_' + crypto.randomBytes(24).toString('hex');
116
+ envContent = envContent.replace('your-random-token-here', token);
117
+
118
+ // Write .env
119
+ fs.writeFileSync(envDest, envContent);
120
+
121
+ console.log(`
122
+ ⚡ SparkUI initialized!
123
+
124
+ 📁 Config: ${envDest}
125
+ 🔑 Push token: ${token.slice(0, 12)}...${token.slice(-4)}
126
+ 🌐 Port: 3457
127
+ 🔗 Base URL: http://localhost:3457
128
+
129
+ Next steps:
130
+ 1. Review .env and adjust settings if needed
131
+ 2. Run: sparkui start
132
+ 3. Push a page:
133
+ curl -X POST http://localhost:3457/api/push \\
134
+ -H "Authorization: Bearer ${token}" \\
135
+ -H "Content-Type: application/json" \\
136
+ -d '{"template":"feedback-form","data":{"title":"Hello SparkUI!"}}'
137
+ `);
138
+ }
139
+
140
+ // ── Start Command ────────────────────────────────────────────────────────────
141
+
142
+ function cmdStart() {
143
+ const config = resolveConfig();
144
+ const pidFile = path.join(process.cwd(), '.sparkui.pid');
145
+
146
+ // Check if already running
147
+ if (fs.existsSync(pidFile)) {
148
+ const existingPid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
149
+ try {
150
+ process.kill(existingPid, 0); // check if process exists
151
+ console.log(`⚠️ SparkUI is already running (PID ${existingPid}).`);
152
+ console.log(' Run "sparkui stop" first, or delete .sparkui.pid if stale.');
153
+ process.exit(1);
154
+ } catch {
155
+ // Process doesn't exist — stale PID file, clean it up
156
+ fs.unlinkSync(pidFile);
157
+ }
158
+ }
159
+
160
+ if (!config.envFile) {
161
+ console.log('⚠️ No .env file found. Run "sparkui init" first, or create a .env file.');
162
+ console.log(' Starting with defaults...');
163
+ }
164
+
165
+ // Build env for the child process
166
+ const childEnv = { ...process.env };
167
+ if (config.envFile) {
168
+ const envVars = loadEnvFile(path.dirname(config.envFile));
169
+ Object.assign(childEnv, envVars);
170
+ }
171
+ // CLI overrides
172
+ if (getArg('--port')) childEnv.SPARKUI_PORT = getArg('--port');
173
+
174
+ const serverScript = path.join(PKG_ROOT, 'server.js');
175
+ const child = spawn(process.execPath, [serverScript], {
176
+ env: childEnv,
177
+ stdio: ['ignore', 'pipe', 'pipe'],
178
+ detached: true,
179
+ });
180
+
181
+ // Write PID file
182
+ fs.writeFileSync(pidFile, String(child.pid));
183
+
184
+ // Capture initial output briefly to confirm startup
185
+ let started = false;
186
+ const timeout = setTimeout(() => {
187
+ if (!started) {
188
+ console.log(`⚡ SparkUI server starting (PID ${child.pid})...`);
189
+ console.log(` Port: ${config.port}`);
190
+ console.log(` PID file: ${pidFile}`);
191
+ console.log(` Stop with: sparkui stop`);
192
+ child.unref();
193
+ process.exit(0);
194
+ }
195
+ }, 3000);
196
+
197
+ child.stdout.on('data', (data) => {
198
+ const output = data.toString();
199
+ if (output.includes('SparkUI server running')) {
200
+ started = true;
201
+ clearTimeout(timeout);
202
+ console.log(`⚡ SparkUI server started (PID ${child.pid})`);
203
+ console.log(` 🌐 http://localhost:${config.port}/`);
204
+ if (config.pushToken) {
205
+ console.log(` 🔑 Token: ${config.pushToken.slice(0, 12)}...${config.pushToken.slice(-4)}`);
206
+ }
207
+ console.log(` 📄 PID file: ${pidFile}`);
208
+ console.log(` Stop with: sparkui stop`);
209
+ child.unref();
210
+ process.exit(0);
211
+ }
212
+ });
213
+
214
+ child.stderr.on('data', (data) => {
215
+ const output = data.toString().trim();
216
+ if (output) {
217
+ console.error(`[server] ${output}`);
218
+ }
219
+ });
220
+
221
+ child.on('error', (err) => {
222
+ clearTimeout(timeout);
223
+ console.error(`Error starting server: ${err.message}`);
224
+ try { fs.unlinkSync(pidFile); } catch {}
225
+ process.exit(1);
226
+ });
227
+
228
+ child.on('exit', (code) => {
229
+ if (!started) {
230
+ clearTimeout(timeout);
231
+ console.error(`Server exited with code ${code} before starting.`);
232
+ try { fs.unlinkSync(pidFile); } catch {}
233
+ process.exit(1);
234
+ }
235
+ });
236
+ }
237
+
238
+ // ── Stop Command ─────────────────────────────────────────────────────────────
239
+
240
+ function cmdStop() {
241
+ const pidFile = path.join(process.cwd(), '.sparkui.pid');
242
+
243
+ if (!fs.existsSync(pidFile)) {
244
+ console.log('⚠️ No .sparkui.pid file found. Is SparkUI running from this directory?');
245
+ process.exit(1);
246
+ }
247
+
248
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
249
+
250
+ try {
251
+ process.kill(pid, 'SIGTERM');
252
+ console.log(`⚡ SparkUI server stopped (PID ${pid}).`);
253
+ } catch (err) {
254
+ if (err.code === 'ESRCH') {
255
+ console.log(`⚠️ Process ${pid} not found. Cleaning up stale PID file.`);
256
+ } else {
257
+ console.error(`Error stopping server: ${err.message}`);
258
+ }
259
+ }
260
+
261
+ try { fs.unlinkSync(pidFile); } catch {}
262
+ }
263
+
264
+ // ── Client Commands (push/update/delete/status) ─────────────────────────────
265
+
266
+ const BASE_URL = process.env.SPARKUI_URL || 'http://localhost:3457';
267
+ const TOKEN = process.env.PUSH_TOKEN || getArg('--token') || '';
268
+
269
+ function request(method, urlPath, body) {
270
+ return new Promise((resolve, reject) => {
271
+ const url = new URL(urlPath, BASE_URL);
272
+ const mod = url.protocol === 'https:' ? https : http;
273
+ const opts = {
274
+ method,
275
+ hostname: url.hostname,
276
+ port: url.port,
277
+ path: url.pathname,
278
+ headers: {
279
+ 'Content-Type': 'application/json',
280
+ 'Authorization': `Bearer ${TOKEN}`,
281
+ },
282
+ };
283
+
284
+ const req = mod.request(opts, (res) => {
285
+ let data = '';
286
+ res.on('data', c => data += c);
287
+ res.on('end', () => {
288
+ try {
289
+ resolve({ status: res.statusCode, body: JSON.parse(data) });
290
+ } catch {
291
+ resolve({ status: res.statusCode, body: data });
292
+ }
293
+ });
294
+ });
295
+ req.on('error', reject);
296
+ if (body) req.write(JSON.stringify(body));
297
+ req.end();
298
+ });
299
+ }
300
+
301
+ async function cmdStatus() {
302
+ const res = await request('GET', '/');
303
+ console.log(JSON.stringify(res.body, null, 2));
304
+ }
305
+
306
+ async function cmdPush() {
307
+ const htmlFile = getArg('--html');
308
+ const template = getArg('--template');
309
+ const dataStr = getArg('--data');
310
+ const ttl = parseInt(getArg('--ttl') || '3600', 10);
311
+
312
+ const body = { ttl };
313
+ if (htmlFile) {
314
+ body.html = fs.readFileSync(path.resolve(htmlFile), 'utf-8');
315
+ } else if (template) {
316
+ body.template = template;
317
+ body.data = dataStr ? JSON.parse(dataStr) : {};
318
+ } else {
319
+ console.error('Error: provide --html <file> or --template <name>');
320
+ process.exit(1);
321
+ }
322
+
323
+ const res = await request('POST', '/api/push', body);
324
+ console.log(JSON.stringify(res.body, null, 2));
325
+ }
326
+
327
+ async function cmdUpdate() {
328
+ const id = args[1];
329
+ if (!id) { console.error('Error: provide page ID'); process.exit(1); }
330
+ const htmlFile = getArg('--html');
331
+ const template = getArg('--template');
332
+ const dataStr = getArg('--data');
333
+ const body = {};
334
+ if (htmlFile) body.html = fs.readFileSync(path.resolve(htmlFile), 'utf-8');
335
+ if (template) { body.template = template; body.data = dataStr ? JSON.parse(dataStr) : {}; }
336
+ const ttl = getArg('--ttl');
337
+ if (ttl) body.ttl = parseInt(ttl, 10);
338
+
339
+ const res = await request('PATCH', `/api/pages/${id}`, body);
340
+ console.log(JSON.stringify(res.body, null, 2));
341
+ }
342
+
343
+ async function cmdDelete() {
344
+ const id = args[1];
345
+ if (!id) { console.error('Error: provide page ID'); process.exit(1); }
346
+ const res = await request('DELETE', `/api/pages/${id}`);
347
+ console.log(JSON.stringify(res.body, null, 2));
348
+ }
349
+
350
+ // ── Main ─────────────────────────────────────────────────────────────────────
351
+
352
+ async function main() {
353
+ if (!command || command === '--help' || command === '-h') {
354
+ usage();
355
+ process.exit(0);
356
+ }
357
+
358
+ switch (command) {
359
+ case 'init':
360
+ cmdInit();
361
+ break;
362
+ case 'start':
363
+ cmdStart();
364
+ break;
365
+ case 'stop':
366
+ cmdStop();
367
+ break;
368
+ case 'status':
369
+ await cmdStatus();
370
+ break;
371
+ case 'push':
372
+ await cmdPush();
373
+ break;
374
+ case 'update':
375
+ await cmdUpdate();
376
+ break;
377
+ case 'delete':
378
+ await cmdDelete();
379
+ break;
380
+ default:
381
+ console.error(`Unknown command: ${command}`);
382
+ usage();
383
+ process.exit(1);
384
+ }
385
+ }
386
+
387
+ main().catch(err => {
388
+ console.error('Error:', err.message);
389
+ process.exit(1);
390
+ });
package/docs/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # ⚡ SparkUI Documentation
2
+
3
+ **Ephemeral interactive UIs for AI agents.** Generate rich web pages from chat — no app install required.
4
+
5
+ SparkUI lets AI agents create polished, interactive web pages on demand. Instead of walls of text, your agent generates a URL the user clicks to see dashboards, forms, timers, and more. Pages auto-expire after a configurable TTL.
6
+
7
+ ## Quick Navigation
8
+
9
+ | Guide | Description |
10
+ |-------|-------------|
11
+ | [Getting Started](./getting-started.md) | Install, configure, and push your first page |
12
+ | [API Reference](./api-reference.md) | Full REST API and WebSocket protocol documentation |
13
+ | [Templates](./templates.md) | 5 built-in templates with schemas and examples |
14
+ | [Components](./components.md) | 8 composable components for custom pages |
15
+ | [MCP Setup](./mcp-setup.md) | Use SparkUI from Claude Desktop, Cursor, or Windsurf |
16
+ | [OpenClaw Setup](./openclaw-setup.md) | Integrate SparkUI as an OpenClaw agent skill |
17
+ | [ChatGPT Setup](./chatgpt-setup.md) | Use SparkUI with ChatGPT Custom GPTs via Actions |
18
+
19
+ ## Architecture
20
+
21
+ ```
22
+ ┌─────────┐ POST /api/push ┌──────────────┐ GET /s/:id ┌─────────┐
23
+ │ Agent │ ──────────────────────▶│ SparkUI │◀────────────────── │ Browser │
24
+ │ (AI/MCP) │ │ Server │ ──────────────────▶│ (User) │
25
+ │ │◀── WebSocket ──────────│ :3457 │──── WebSocket ────▶│ │
26
+ └─────────┘ completion events └──────────────┘ user actions └─────────┘
27
+ ```
28
+
29
+ **Flow:**
30
+ 1. Agent pushes a page (template, composed, or raw HTML)
31
+ 2. Server generates HTML, stores in memory, returns URL
32
+ 3. User opens URL in any browser
33
+ 4. User interacts (checks items, fills forms, clicks buttons)
34
+ 5. Actions flow back to the agent via WebSocket
35
+ 6. Page auto-expires after TTL
36
+
37
+ ## Key Features
38
+
39
+ - 🎯 **No app install** — just a URL that works in any browser
40
+ - ⏱️ **Ephemeral** — pages auto-expire (default 1 hour)
41
+ - 🔄 **Bidirectional** — user actions flow back to the agent via WebSocket
42
+ - 🧩 **Composable** — 8 components you can mix and match
43
+ - 📱 **Mobile-first** — designed for phones, works everywhere
44
+ - 🔌 **MCP compatible** — works with Claude Desktop, Cursor, Windsurf
45
+ - 🌙 **Dark theme** — easy on the eyes, polished look
46
+
47
+ ## Links
48
+
49
+ - **GitHub:** [github.com/Limeade-Labs/sparkui](https://github.com/Limeade-Labs/sparkui)
50
+ - **npm:** [npmjs.com/package/sparkui](https://www.npmjs.com/package/sparkui)
51
+ - **Homepage:** [sparkui.dev](https://sparkui.dev)