@campfiire/spark-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.
package/dist/index.js ADDED
@@ -0,0 +1,523 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/commands/init.ts
7
+ import * as fs from "fs";
8
+ import * as path from "path";
9
+ import { fileURLToPath } from "url";
10
+ import prompts from "prompts";
11
+ import ora from "ora";
12
+ import kleur from "kleur";
13
+ function findTemplatesDir() {
14
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
15
+ let dir = currentDir;
16
+ for (let i = 0; i < 10; i++) {
17
+ const pkgPath = path.join(dir, "package.json");
18
+ if (fs.existsSync(pkgPath)) {
19
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
20
+ if (pkg.name === "@campfiire/spark-cli") {
21
+ return path.join(dir, "templates", "default");
22
+ }
23
+ }
24
+ const parent = path.dirname(dir);
25
+ if (parent === dir) break;
26
+ dir = parent;
27
+ }
28
+ throw new Error("Could not locate spark-cli templates directory");
29
+ }
30
+ function copyDir(src, dest) {
31
+ fs.mkdirSync(dest, { recursive: true });
32
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
33
+ const srcPath = path.join(src, entry.name);
34
+ const destPath = path.join(dest, entry.name);
35
+ if (entry.isDirectory()) {
36
+ copyDir(srcPath, destPath);
37
+ } else {
38
+ fs.copyFileSync(srcPath, destPath);
39
+ }
40
+ }
41
+ }
42
+ function slugify(text) {
43
+ return text.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
44
+ }
45
+ async function initCommand(nameArg) {
46
+ const TEMPLATES_DIR = findTemplatesDir();
47
+ console.log(kleur.bold().cyan("\n\u{1F525} Campfire Spark \u2014 init\n"));
48
+ const responses = await prompts([
49
+ {
50
+ type: "text",
51
+ name: "name",
52
+ message: "Spark name",
53
+ initial: nameArg ?? "my-spark",
54
+ validate: (v) => v.length >= 3 || "Name must be at least 3 characters"
55
+ },
56
+ {
57
+ type: "text",
58
+ name: "description",
59
+ message: "Short description",
60
+ initial: "A custom spark for Campfire"
61
+ },
62
+ {
63
+ type: "text",
64
+ name: "author",
65
+ message: "Author (email or username)"
66
+ },
67
+ {
68
+ type: "text",
69
+ name: "icon",
70
+ message: "Icon name (Tabler icon, e.g. IconFlame)",
71
+ initial: "IconSparkles"
72
+ }
73
+ ]);
74
+ if (!responses.name) {
75
+ console.log(kleur.yellow("Aborted."));
76
+ return;
77
+ }
78
+ const projectDir = path.resolve(process.cwd(), slugify(responses.name));
79
+ if (fs.existsSync(projectDir)) {
80
+ console.error(kleur.red(`Directory already exists: ${projectDir}`));
81
+ process.exit(1);
82
+ }
83
+ const spinner = ora("Creating spark project...").start();
84
+ try {
85
+ copyDir(TEMPLATES_DIR, projectDir);
86
+ const manifestPath = path.join(projectDir, "manifest.json");
87
+ const manifestContent = fs.readFileSync(manifestPath, "utf-8");
88
+ const manifest = JSON.parse(manifestContent);
89
+ manifest.slug = slugify(responses.name);
90
+ manifest.name = responses.name;
91
+ manifest.description = responses.description;
92
+ manifest.author = responses.author;
93
+ manifest.icon = responses.icon;
94
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
95
+ const pkgPath = path.join(projectDir, "package.json");
96
+ const pkgContent = fs.readFileSync(pkgPath, "utf-8");
97
+ const pkg = JSON.parse(pkgContent);
98
+ pkg.name = slugify(responses.name);
99
+ pkg.description = responses.description;
100
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2));
101
+ spinner.succeed(`Created ${kleur.cyan(projectDir)}`);
102
+ console.log(kleur.bold("\nNext steps:"));
103
+ console.log(` ${kleur.cyan(`cd ${path.basename(projectDir)}`)}`);
104
+ console.log(` ${kleur.cyan("npm install")} ${kleur.dim("# or yarn / pnpm")}`);
105
+ console.log(` ${kleur.cyan("campfire-spark dev")} ${kleur.dim("# start local dev server")}`);
106
+ console.log();
107
+ } catch (err) {
108
+ spinner.fail(`Failed to create project: ${err.message}`);
109
+ process.exit(1);
110
+ }
111
+ }
112
+
113
+ // src/commands/build.ts
114
+ import * as fs3 from "fs";
115
+ import * as path3 from "path";
116
+ import { build } from "vite";
117
+ import react from "@vitejs/plugin-react";
118
+ import ora2 from "ora";
119
+ import kleur2 from "kleur";
120
+
121
+ // src/lib/manifest.ts
122
+ import * as fs2 from "fs";
123
+ import * as path2 from "path";
124
+ import { customSparkManifestSchema } from "@campfiire/shared";
125
+ function readManifest(projectDir = process.cwd()) {
126
+ const manifestPath = path2.join(projectDir, "manifest.json");
127
+ if (!fs2.existsSync(manifestPath)) {
128
+ throw new Error(`manifest.json not found in ${projectDir}`);
129
+ }
130
+ const content = fs2.readFileSync(manifestPath, "utf-8");
131
+ const parsed = JSON.parse(content);
132
+ return customSparkManifestSchema.parse(parsed);
133
+ }
134
+
135
+ // src/commands/build.ts
136
+ import { getSparkTier, SPARK_BUNDLE_TIERS } from "@campfiire/shared";
137
+ async function buildCommand() {
138
+ const spinner = ora2("Reading manifest...").start();
139
+ try {
140
+ const manifest = readManifest();
141
+ spinner.text = `Building ${kleur2.cyan(manifest.name)} v${manifest.version}...`;
142
+ const projectDir = process.cwd();
143
+ const distDir = path3.join(projectDir, "dist");
144
+ if (fs3.existsSync(distDir)) {
145
+ fs3.rmSync(distDir, { recursive: true, force: true });
146
+ }
147
+ await build({
148
+ root: projectDir,
149
+ plugins: [react()],
150
+ build: {
151
+ outDir: "dist",
152
+ emptyOutDir: true,
153
+ lib: {
154
+ entry: path3.resolve(projectDir, "src/index.tsx"),
155
+ formats: ["iife"],
156
+ name: "CampfireSpark",
157
+ fileName: () => "bundle.js"
158
+ },
159
+ rollupOptions: {
160
+ external: ["react", "react-dom", "framer-motion", "@react-spring/web", "@campfiire/spark-sdk"],
161
+ output: {
162
+ globals: {
163
+ react: "CampfireRuntime.React",
164
+ "react-dom": "CampfireRuntime.ReactDOM",
165
+ "framer-motion": "CampfireRuntime.FramerMotion",
166
+ "@react-spring/web": "CampfireRuntime.ReactSpring",
167
+ "@campfiire/spark-sdk": "CampfireRuntime.SparkSDK"
168
+ }
169
+ }
170
+ },
171
+ minify: "esbuild",
172
+ sourcemap: false
173
+ },
174
+ logLevel: "warn"
175
+ });
176
+ fs3.writeFileSync(
177
+ path3.join(distDir, "manifest.json"),
178
+ JSON.stringify(manifest, null, 2)
179
+ );
180
+ const bundlePath = path3.join(distDir, "bundle.js");
181
+ const stats = fs3.statSync(bundlePath);
182
+ const tier = getSparkTier(stats.size);
183
+ if (!tier) {
184
+ spinner.fail(
185
+ `Bundle too large: ${(stats.size / 1024).toFixed(1)}KB (max ${SPARK_BUNDLE_TIERS.heavy.maxSize / 1024}KB)`
186
+ );
187
+ process.exit(1);
188
+ }
189
+ const tierInfo = SPARK_BUNDLE_TIERS[tier];
190
+ spinner.succeed(
191
+ `Built ${kleur2.cyan(manifest.name)} (${(stats.size / 1024).toFixed(1)}KB, ${kleur2.green(tierInfo.label)} tier)`
192
+ );
193
+ console.log(kleur2.bold("\nOutput:"));
194
+ console.log(` ${kleur2.cyan("dist/bundle.js")} ${kleur2.dim(`(${(stats.size / 1024).toFixed(1)}KB)`)}`);
195
+ console.log(` ${kleur2.cyan("dist/manifest.json")}`);
196
+ console.log(kleur2.bold("\nNext step:"));
197
+ console.log(` ${kleur2.cyan("campfire-spark submit")} ${kleur2.dim("# upload for review")}`);
198
+ console.log();
199
+ } catch (err) {
200
+ spinner.fail(`Build failed: ${err.message}`);
201
+ process.exit(1);
202
+ }
203
+ }
204
+
205
+ // src/commands/dev.ts
206
+ import * as path4 from "path";
207
+ import { createServer } from "vite";
208
+ import react2 from "@vitejs/plugin-react";
209
+ import kleur3 from "kleur";
210
+ async function devCommand(options) {
211
+ try {
212
+ const manifest = readManifest();
213
+ const port = parseInt(options.port ?? "4321", 10);
214
+ const projectDir = process.cwd();
215
+ console.log(kleur3.bold().cyan(`
216
+ \u{1F525} Campfire Spark \u2014 dev
217
+ `));
218
+ console.log(` Spark: ${kleur3.cyan(manifest.name)} v${manifest.version}`);
219
+ console.log(` Port: ${kleur3.cyan(String(port))}
220
+ `);
221
+ const server = await createServer({
222
+ root: projectDir,
223
+ plugins: [react2()],
224
+ server: {
225
+ port,
226
+ open: true
227
+ },
228
+ resolve: {
229
+ alias: {
230
+ "@campfiire/spark-sdk/mock": path4.resolve(
231
+ new URL(".", import.meta.url).pathname,
232
+ "../lib/mock-sdk.ts"
233
+ )
234
+ }
235
+ },
236
+ define: {
237
+ __SPARK_MANIFEST__: JSON.stringify(manifest)
238
+ }
239
+ });
240
+ await server.listen();
241
+ server.printUrls();
242
+ } catch (err) {
243
+ console.error(kleur3.red(`Dev server failed: ${err.message}`));
244
+ process.exit(1);
245
+ }
246
+ }
247
+
248
+ // src/commands/login.ts
249
+ import * as http from "http";
250
+ import open from "open";
251
+ import ora3 from "ora";
252
+ import kleur4 from "kleur";
253
+
254
+ // src/lib/config.ts
255
+ import * as fs4 from "fs";
256
+ import * as path5 from "path";
257
+ import * as os from "os";
258
+ var CONFIG_DIR = path5.join(os.homedir(), ".campfire");
259
+ var CREDENTIALS_FILE = path5.join(CONFIG_DIR, "credentials.json");
260
+ function readCredentials() {
261
+ try {
262
+ if (!fs4.existsSync(CREDENTIALS_FILE)) return null;
263
+ const content = fs4.readFileSync(CREDENTIALS_FILE, "utf-8");
264
+ return JSON.parse(content);
265
+ } catch {
266
+ return null;
267
+ }
268
+ }
269
+ function writeCredentials(credentials) {
270
+ if (!fs4.existsSync(CONFIG_DIR)) {
271
+ fs4.mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
272
+ }
273
+ fs4.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 384 });
274
+ }
275
+ function requireAuth() {
276
+ const creds = readCredentials();
277
+ if (!creds) {
278
+ console.error("Not logged in. Run 'campfire-spark login' first.");
279
+ process.exit(1);
280
+ }
281
+ return creds;
282
+ }
283
+
284
+ // src/commands/login.ts
285
+ async function loginCommand(options) {
286
+ const apiUrl = options.apiUrl ?? "http://localhost:3000";
287
+ console.log(kleur4.bold().cyan("\n\u{1F525} Campfire Spark \u2014 login\n"));
288
+ const callbackPort = 4320 + Math.floor(Math.random() * 100);
289
+ const callbackUrl = `http://localhost:${callbackPort}/callback`;
290
+ const spinner = ora3("Initiating authorization...").start();
291
+ try {
292
+ const initRes = await fetch(`${apiUrl}/api/cli-auth/initiate`, {
293
+ method: "POST",
294
+ headers: { "Content-Type": "application/json" },
295
+ body: JSON.stringify({ callbackUrl })
296
+ });
297
+ if (!initRes.ok) {
298
+ const body = await initRes.json().catch(() => ({}));
299
+ spinner.fail(`Failed to initiate: ${body.error ?? initRes.statusText}`);
300
+ process.exit(1);
301
+ }
302
+ const { authorizeUrl } = await initRes.json();
303
+ spinner.text = `Opening browser... (callback port: ${callbackPort})`;
304
+ const tokenPromise = new Promise(
305
+ (resolve4, reject) => {
306
+ const server = http.createServer((req, res) => {
307
+ if (req.url?.startsWith("/callback")) {
308
+ const url = new URL(req.url, `http://localhost:${callbackPort}`);
309
+ const token2 = url.searchParams.get("token");
310
+ const userId2 = url.searchParams.get("userId");
311
+ const email2 = url.searchParams.get("email");
312
+ if (!token2 || !userId2 || !email2) {
313
+ res.writeHead(400);
314
+ res.end("Missing parameters");
315
+ reject(new Error("Missing token, userId, or email in callback"));
316
+ return;
317
+ }
318
+ res.writeHead(200, { "Content-Type": "text/html" });
319
+ res.end(`<!DOCTYPE html>
320
+ <html>
321
+ <head>
322
+ <title>Logged in!</title>
323
+ <style>
324
+ body {
325
+ font-family: -apple-system, BlinkMacSystemFont, sans-serif;
326
+ background: #0a0a0a;
327
+ color: #fff;
328
+ display: flex;
329
+ align-items: center;
330
+ justify-content: center;
331
+ height: 100vh;
332
+ margin: 0;
333
+ }
334
+ .card {
335
+ text-align: center;
336
+ padding: 48px;
337
+ border-radius: 12px;
338
+ background: #18181b;
339
+ border: 1px solid #27272a;
340
+ }
341
+ h1 { margin: 0 0 8px; }
342
+ p { margin: 0; color: #a1a1aa; }
343
+ </style>
344
+ </head>
345
+ <body>
346
+ <div class="card">
347
+ <h1>\u2705 Logged in!</h1>
348
+ <p>You can close this window and return to your terminal.</p>
349
+ </div>
350
+ </body>
351
+ </html>`);
352
+ server.close();
353
+ resolve4({ token: token2, userId: userId2, email: email2 });
354
+ }
355
+ });
356
+ server.listen(callbackPort, () => {
357
+ open(authorizeUrl).catch(() => {
358
+ console.log(`
359
+ Open this URL manually: ${kleur4.cyan(authorizeUrl)}`);
360
+ });
361
+ });
362
+ setTimeout(() => {
363
+ server.close();
364
+ reject(new Error("Login timed out after 10 minutes"));
365
+ }, 10 * 60 * 1e3);
366
+ }
367
+ );
368
+ const { token, userId, email } = await tokenPromise;
369
+ writeCredentials({ token, userId, email, apiUrl });
370
+ spinner.succeed(`Logged in as ${kleur4.cyan(email)}`);
371
+ console.log(kleur4.dim("\n Token stored in ~/.campfire/credentials.json"));
372
+ console.log();
373
+ } catch (err) {
374
+ spinner.fail(`Login failed: ${err.message}`);
375
+ process.exit(1);
376
+ }
377
+ }
378
+
379
+ // src/commands/submit.ts
380
+ import * as fs5 from "fs";
381
+ import * as path6 from "path";
382
+ import ora4 from "ora";
383
+ import kleur5 from "kleur";
384
+ async function submitCommand() {
385
+ const creds = requireAuth();
386
+ console.log(kleur5.bold().cyan("\n\u{1F525} Campfire Spark \u2014 submit\n"));
387
+ const projectDir = process.cwd();
388
+ const distDir = path6.join(projectDir, "dist");
389
+ const bundlePath = path6.join(distDir, "bundle.js");
390
+ const manifestPath = path6.join(distDir, "manifest.json");
391
+ if (!fs5.existsSync(bundlePath) || !fs5.existsSync(manifestPath)) {
392
+ console.error(kleur5.red("No build found. Run 'campfire-spark build' first."));
393
+ process.exit(1);
394
+ }
395
+ const spinner = ora4("Uploading spark...").start();
396
+ try {
397
+ const bundleBuffer = fs5.readFileSync(bundlePath);
398
+ const manifestBuffer = fs5.readFileSync(manifestPath);
399
+ const formData = new FormData();
400
+ formData.append("bundle", new Blob([new Uint8Array(bundleBuffer)], { type: "application/javascript" }), "bundle.js");
401
+ formData.append("manifest", new Blob([new Uint8Array(manifestBuffer)], { type: "application/json" }), "manifest.json");
402
+ const res = await fetch(`${creds.apiUrl}/api/custom-sparks/submit`, {
403
+ method: "POST",
404
+ headers: {
405
+ Authorization: `Bearer ${creds.token}`
406
+ },
407
+ body: formData
408
+ });
409
+ if (!res.ok) {
410
+ const body = await res.json().catch(() => ({}));
411
+ spinner.fail(`Submit failed: ${body.error ?? res.statusText}`);
412
+ if (body.issues) {
413
+ console.error(kleur5.red("\nValidation issues:"));
414
+ console.error(JSON.stringify(body.issues, null, 2));
415
+ }
416
+ process.exit(1);
417
+ }
418
+ const spark = await res.json();
419
+ spinner.succeed(`Submitted ${kleur5.cyan(spark.name)} v${spark.version}`);
420
+ console.log(`
421
+ Status: ${kleur5.yellow(spark.status)}`);
422
+ console.log(` ID: ${kleur5.dim(spark.id)}`);
423
+ console.log(kleur5.bold("\nNext step:"));
424
+ console.log(` ${kleur5.cyan("campfire-spark status")} ${kleur5.dim("# check review status")}`);
425
+ console.log();
426
+ } catch (err) {
427
+ spinner.fail(`Submit failed: ${err.message}`);
428
+ process.exit(1);
429
+ }
430
+ }
431
+
432
+ // src/commands/status.ts
433
+ import ora5 from "ora";
434
+ import kleur6 from "kleur";
435
+ async function statusCommand(sparkId, _options) {
436
+ const creds = requireAuth();
437
+ console.log(kleur6.bold().cyan("\n\u{1F525} Campfire Spark \u2014 status\n"));
438
+ const spinner = ora5("Fetching submissions...").start();
439
+ try {
440
+ const url = sparkId ? `${creds.apiUrl}/api/custom-sparks/${sparkId}` : `${creds.apiUrl}/api/custom-sparks/registry/custom?author=me`;
441
+ const res = await fetch(url, {
442
+ headers: { Authorization: `Bearer ${creds.token}` }
443
+ });
444
+ if (!res.ok) {
445
+ spinner.fail("Failed to fetch status");
446
+ process.exit(1);
447
+ }
448
+ const data = await res.json();
449
+ spinner.stop();
450
+ const sparks = Array.isArray(data) ? data : [data];
451
+ if (sparks.length === 0) {
452
+ console.log(kleur6.yellow("No submissions yet."));
453
+ return;
454
+ }
455
+ for (const spark of sparks) {
456
+ const statusColor = spark.status === "approved" ? kleur6.green : spark.status === "rejected" ? kleur6.red : spark.status === "deprecated" ? kleur6.gray : kleur6.yellow;
457
+ console.log(` ${kleur6.bold(spark.name)} v${spark.version}`);
458
+ console.log(` Status: ${statusColor(spark.status)}`);
459
+ console.log(` ID: ${kleur6.dim(spark.id)}`);
460
+ if (spark.rejectionReason) {
461
+ console.log(` ${kleur6.red("Reason:")} ${spark.rejectionReason}`);
462
+ }
463
+ console.log();
464
+ }
465
+ } catch (err) {
466
+ spinner.fail(`Failed: ${err.message}`);
467
+ process.exit(1);
468
+ }
469
+ }
470
+
471
+ // src/commands/request-domain.ts
472
+ import prompts2 from "prompts";
473
+ import ora6 from "ora";
474
+ import kleur7 from "kleur";
475
+ async function requestDomainCommand(domain) {
476
+ const creds = requireAuth();
477
+ console.log(kleur7.bold().cyan("\n\u{1F525} Campfire Spark \u2014 request domain\n"));
478
+ console.log(` Domain: ${kleur7.cyan(domain)}
479
+ `);
480
+ const response = await prompts2({
481
+ type: "text",
482
+ name: "reason",
483
+ message: "Why do you need this domain?",
484
+ validate: (v) => v.length >= 10 || "Please provide at least 10 characters"
485
+ });
486
+ if (!response.reason) {
487
+ console.log(kleur7.yellow("Aborted."));
488
+ return;
489
+ }
490
+ const spinner = ora6("Submitting request...").start();
491
+ try {
492
+ const res = await fetch(`${creds.apiUrl}/api/domains/request`, {
493
+ method: "POST",
494
+ headers: {
495
+ "Content-Type": "application/json",
496
+ Authorization: `Bearer ${creds.token}`
497
+ },
498
+ body: JSON.stringify({ domain, reason: response.reason })
499
+ });
500
+ if (!res.ok) {
501
+ const body = await res.json().catch(() => ({}));
502
+ spinner.fail(`Request failed: ${body.error ?? res.statusText}`);
503
+ process.exit(1);
504
+ }
505
+ spinner.succeed(`Domain request submitted for review`);
506
+ console.log(kleur7.dim("\nYou will be notified when the domain is approved."));
507
+ } catch (err) {
508
+ spinner.fail(`Request failed: ${err.message}`);
509
+ process.exit(1);
510
+ }
511
+ }
512
+
513
+ // src/index.ts
514
+ var program = new Command();
515
+ program.name("campfire-spark").description("CLI for developing custom Campfire sparks").version("0.1.0");
516
+ program.command("init").description("Scaffold a new spark project").argument("[name]", "Project name").action(initCommand);
517
+ program.command("dev").description("Start local development server with mock SDK").option("-p, --port <number>", "Dev server port", "4321").action(devCommand);
518
+ program.command("build").description("Build the spark bundle for production").action(buildCommand);
519
+ program.command("login").description("Authenticate with the Campfire platform").option("--api-url <url>", "API URL", "http://localhost:3000").action(loginCommand);
520
+ program.command("submit").description("Submit the built spark for review").option("--api-url <url>", "API URL", "http://localhost:3000").action(submitCommand);
521
+ program.command("status").description("Check the status of your spark submissions").argument("[sparkId]", "Specific spark ID").option("--api-url <url>", "API URL", "http://localhost:3000").action(statusCommand);
522
+ program.command("request-domain").description("Request a domain to be added to the HTTP connector whitelist").argument("<domain>", "Domain to request").option("--api-url <url>", "API URL", "http://localhost:3000").action(requestDomainCommand);
523
+ program.parseAsync(process.argv);
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@campfiire/spark-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for developing custom Campfire sparks",
5
+ "type": "module",
6
+ "bin": {
7
+ "campfire-spark": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "templates",
12
+ "README.md"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "scripts": {
18
+ "build": "tsup",
19
+ "dev": "tsup --watch",
20
+ "lint": "tsc --noEmit",
21
+ "start": "tsx src/index.ts",
22
+ "prepublishOnly": "yarn build"
23
+ },
24
+ "dependencies": {
25
+ "@campfiire/shared": "0.1.0",
26
+ "@campfiire/spark-sdk": "0.1.0",
27
+ "@vitejs/plugin-react": "^5.0.0",
28
+ "commander": "^12.1.0",
29
+ "kleur": "^4.1.5",
30
+ "open": "^10.1.0",
31
+ "ora": "^8.1.1",
32
+ "prompts": "^2.4.2",
33
+ "vite": "^8.0.0",
34
+ "zod": "^4.3.6"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^24.0.0",
38
+ "@types/prompts": "^2.4.9",
39
+ "tsup": "^8.5.1",
40
+ "tsx": "^4.20.0",
41
+ "typescript": "^6.0.2"
42
+ },
43
+ "keywords": [
44
+ "campfire",
45
+ "spark",
46
+ "cli",
47
+ "plugin",
48
+ "sdk"
49
+ ]
50
+ }
@@ -0,0 +1,40 @@
1
+ # My Spark
2
+
3
+ A custom spark for Campfire.
4
+
5
+ ## Development
6
+
7
+ ```bash
8
+ # Install dependencies
9
+ npm install
10
+
11
+ # Start local dev server with mock SDK
12
+ npm run dev
13
+
14
+ # Build for production
15
+ npm run build
16
+
17
+ # Submit for review
18
+ npm run submit
19
+ ```
20
+
21
+ ## SDK
22
+
23
+ This spark uses `@campfiire/spark-sdk` to access platform data:
24
+
25
+ - `sdk.getCampfire()` — campfire info (name, member count)
26
+ - `sdk.getMembers()` — list of members
27
+ - `sdk.getChannels()` — list of channels
28
+ - `sdk.getMessages(channelId)` — messages in a channel
29
+ - `sdk.getCurrentUser()` — currently logged-in user
30
+ - `sdk.getTheme()` — current theme
31
+ - `sdk.storage` — persistent key-value storage
32
+
33
+ ## Configuration
34
+
35
+ Your spark's config is validated against the Zod schema in `src/index.tsx`. Campfire admins can edit the config in the layout editor.
36
+
37
+ ## Learn More
38
+
39
+ - [Spark SDK Docs](https://docs.campfire.app/sparks)
40
+ - [SDK Reference](https://docs.campfire.app/sparks/sdk)
@@ -0,0 +1,56 @@
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>Spark Dev</title>
7
+ <style>
8
+ * { box-sizing: border-box; }
9
+ html, body {
10
+ margin: 0;
11
+ padding: 0;
12
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
13
+ background: #f5f5f5;
14
+ color: #0a0a0a;
15
+ }
16
+ :root {
17
+ --primary: #0ea5e9;
18
+ --background: #ffffff;
19
+ --foreground: #0a0a0a;
20
+ --border: #e5e5e5;
21
+ --muted-foreground: #737373;
22
+ }
23
+ #dev-container {
24
+ max-width: 600px;
25
+ margin: 2rem auto;
26
+ padding: 1rem;
27
+ }
28
+ #dev-header {
29
+ background: white;
30
+ border: 1px solid #e5e5e5;
31
+ border-radius: 0.5rem;
32
+ padding: 1rem;
33
+ margin-bottom: 1rem;
34
+ font-size: 14px;
35
+ }
36
+ #dev-header h1 { margin: 0 0 0.5rem; font-size: 1rem; }
37
+ #dev-header p { margin: 0; color: #737373; font-size: 12px; }
38
+ #spark-root {
39
+ background: white;
40
+ border: 1px solid #e5e5e5;
41
+ border-radius: 0.5rem;
42
+ padding: 1rem;
43
+ }
44
+ </style>
45
+ </head>
46
+ <body>
47
+ <div id="dev-container">
48
+ <div id="dev-header">
49
+ <h1>🔥 Campfire Spark — Dev Mode</h1>
50
+ <p>This is your spark running with mock SDK data. Edit <code>src/index.tsx</code> to see changes.</p>
51
+ </div>
52
+ <div id="spark-root"></div>
53
+ </div>
54
+ <script type="module" src="/src/dev-entry.tsx"></script>
55
+ </body>
56
+ </html>
@@ -0,0 +1,15 @@
1
+ {
2
+ "slug": "my-spark",
3
+ "name": "My Spark",
4
+ "version": "1.0.0",
5
+ "description": "A custom spark for Campfire",
6
+ "author": "",
7
+ "icon": "IconSparkles",
8
+ "permissions": [],
9
+ "connectors": [],
10
+ "defaultConfig": {
11
+ "greeting": "Hello"
12
+ },
13
+ "defaultWidth": 1,
14
+ "minPlatformVersion": "1.0.0"
15
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "my-spark",
3
+ "version": "1.0.0",
4
+ "description": "A custom spark for Campfire",
5
+ "type": "module",
6
+ "private": true,
7
+ "scripts": {
8
+ "dev": "campfire-spark dev",
9
+ "build": "campfire-spark build",
10
+ "submit": "campfire-spark submit"
11
+ },
12
+ "dependencies": {
13
+ "@campfiire/spark-sdk": "^0.1.0",
14
+ "react": "^19.0.0",
15
+ "react-dom": "^19.0.0",
16
+ "zod": "^4.0.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/react": "^19.0.0",
20
+ "@types/react-dom": "^19.0.0",
21
+ "@vitejs/plugin-react": "^5.0.0",
22
+ "typescript": "^5.0.0",
23
+ "vite": "^8.0.0"
24
+ }
25
+ }
@@ -0,0 +1,217 @@
1
+ import React from "react";
2
+ import ReactDOM from "react-dom/client";
3
+ import spark from "./index";
4
+
5
+ // ── Mock SDK setup for local dev ───────────────────
6
+ // In production, the SDK communicates via postMessage with the Campfire host.
7
+ // In dev, we construct a mock SDK that returns fake data and pass it directly
8
+ // to the spark's render component as a prop (bypassing the postMessage bridge).
9
+
10
+ import type {
11
+ SparkSDK,
12
+ SparkSDKCampfire,
13
+ SparkSDKMember,
14
+ SparkSDKChannel,
15
+ SparkSDKMessage,
16
+ SparkSDKUser,
17
+ SparkSDKTheme,
18
+ GetMembersOptions,
19
+ GetMessagesOptions,
20
+ } from "@campfiire/spark-sdk";
21
+
22
+ class MockSDK implements SparkSDK {
23
+ public storage: SparkSDK["storage"];
24
+ public connectors: SparkSDK["connectors"];
25
+ private store = new Map<string, unknown>();
26
+
27
+ constructor() {
28
+ this.storage = {
29
+ get: async <T = unknown>(key: string) => (this.store.get(key) as T) ?? null,
30
+ set: async (key: string, value: unknown) => {
31
+ this.store.set(key, value);
32
+ },
33
+ delete: async (key: string) => {
34
+ this.store.delete(key);
35
+ },
36
+ keys: async () => Array.from(this.store.keys()),
37
+ };
38
+
39
+ // Mock connectors — return realistic fake data for common connectors
40
+ // (twitch, github, weather, crypto, rss, youtube, spotify, http).
41
+ this.connectors = new Proxy({} as SparkSDK["connectors"], {
42
+ get: (_target, connectorId: string) => {
43
+ return new Proxy({} as Record<string, (params: unknown) => Promise<unknown>>, {
44
+ get: (_t, method: string) => {
45
+ return async (params: unknown): Promise<unknown> => {
46
+ console.log(`[MockSDK] connectors.${connectorId}.${method}`, params);
47
+ return getMockConnectorResponse(connectorId, method);
48
+ };
49
+ },
50
+ });
51
+ },
52
+ });
53
+ }
54
+
55
+ async getCampfire(): Promise<SparkSDKCampfire> {
56
+ return {
57
+ id: "mock-campfire",
58
+ name: "Test Campfire",
59
+ slug: "test",
60
+ description: "A mock campfire for dev",
61
+ memberCount: 42,
62
+ channelCount: 5,
63
+ };
64
+ }
65
+
66
+ async getMembers(options?: GetMembersOptions): Promise<SparkSDKMember[]> {
67
+ const members: SparkSDKMember[] = [
68
+ { id: "u1", name: "Alice", image: null, isOnline: true, joinedAt: new Date().toISOString() },
69
+ { id: "u2", name: "Bob", image: null, isOnline: false, joinedAt: new Date().toISOString() },
70
+ { id: "u3", name: "Charlie", image: null, isOnline: true, joinedAt: new Date().toISOString() },
71
+ { id: "u4", name: "Diana", image: null, isOnline: false, joinedAt: new Date().toISOString() },
72
+ { id: "u5", name: "Eve", image: null, isOnline: true, joinedAt: new Date().toISOString() },
73
+ ];
74
+ return members.slice(0, options?.limit ?? 50);
75
+ }
76
+
77
+ async getChannels(): Promise<SparkSDKChannel[]> {
78
+ return [
79
+ { id: "c1", name: "general", description: "General chat", isDefault: true },
80
+ { id: "c2", name: "random", description: null, isDefault: false },
81
+ ];
82
+ }
83
+
84
+ async getMessages(channelId: string, options?: GetMessagesOptions): Promise<SparkSDKMessage[]> {
85
+ return [
86
+ {
87
+ id: "m1",
88
+ content: "Hello world!",
89
+ authorId: "u1",
90
+ author: { id: "u1", name: "Alice", image: null },
91
+ channelId,
92
+ createdAt: new Date().toISOString(),
93
+ updatedAt: new Date().toISOString(),
94
+ },
95
+ ].slice(0, options?.limit ?? 20);
96
+ }
97
+
98
+ async getCurrentUser(): Promise<SparkSDKUser> {
99
+ return { id: "u1", name: "Alice (you)", image: null };
100
+ }
101
+
102
+ async getTheme(): Promise<SparkSDKTheme> {
103
+ return {
104
+ colors: {
105
+ primary: "#0ea5e9",
106
+ background: "#ffffff",
107
+ foreground: "#0a0a0a",
108
+ },
109
+ isDark: false,
110
+ borderRadius: "md",
111
+ };
112
+ }
113
+ }
114
+
115
+ function getMockConnectorResponse(connectorId: string, method: string): unknown {
116
+ const mocks: Record<string, Record<string, unknown>> = {
117
+ twitch: {
118
+ getStream: {
119
+ isLive: true,
120
+ title: "Just chatting with the community!",
121
+ viewerCount: 45000,
122
+ gameName: "Just Chatting",
123
+ startedAt: new Date(Date.now() - 3600000).toISOString(),
124
+ thumbnailUrl: "https://via.placeholder.com/320x180",
125
+ },
126
+ getChannel: {
127
+ id: "mock-channel-id",
128
+ displayName: "MockStreamer",
129
+ description: "A mock Twitch channel for development",
130
+ profileImageUrl: "https://via.placeholder.com/100",
131
+ },
132
+ },
133
+ github: {
134
+ getRepo: {
135
+ name: "mock-repo",
136
+ fullName: "mock-user/mock-repo",
137
+ description: "A mock GitHub repository",
138
+ stars: 1234,
139
+ forks: 56,
140
+ openIssues: 7,
141
+ language: "TypeScript",
142
+ url: "https://github.com/mock-user/mock-repo",
143
+ },
144
+ getContributors: [
145
+ { login: "alice", avatarUrl: "https://via.placeholder.com/40", contributions: 42, url: "#" },
146
+ { login: "bob", avatarUrl: "https://via.placeholder.com/40", contributions: 15, url: "#" },
147
+ ],
148
+ },
149
+ weather: {
150
+ getCurrent: {
151
+ city: "Paris",
152
+ country: "FR",
153
+ temperature: 18.5,
154
+ feelsLike: 17.2,
155
+ description: "partly cloudy",
156
+ icon: "02d",
157
+ humidity: 65,
158
+ windSpeed: 3.4,
159
+ },
160
+ },
161
+ crypto: {
162
+ getPrice: {
163
+ coin: "bitcoin",
164
+ price: 43250,
165
+ currency: "usd",
166
+ },
167
+ },
168
+ rss: {
169
+ fetchFeed: [
170
+ { title: "Mock Article 1", link: "https://example.com/1", description: "Lorem ipsum...", pubDate: new Date().toISOString() },
171
+ { title: "Mock Article 2", link: "https://example.com/2", description: "Dolor sit amet...", pubDate: new Date().toISOString() },
172
+ ],
173
+ },
174
+ youtube: {
175
+ getChannel: {
176
+ id: "UC_mock",
177
+ title: "Mock Channel",
178
+ description: "A mock YouTube channel",
179
+ subscribers: 1000000,
180
+ videoCount: 250,
181
+ viewCount: 50000000,
182
+ thumbnailUrl: "https://via.placeholder.com/88",
183
+ },
184
+ },
185
+ spotify: {
186
+ getPlaylist: {
187
+ name: "Mock Playlist",
188
+ description: "A mock Spotify playlist",
189
+ owner: "mock-user",
190
+ trackCount: 50,
191
+ followers: 1234,
192
+ imageUrl: "https://via.placeholder.com/300",
193
+ },
194
+ },
195
+ http: {
196
+ get: {
197
+ status: 200,
198
+ body: { mock: true, message: "This is mock HTTP connector data" },
199
+ },
200
+ },
201
+ };
202
+
203
+ return mocks[connectorId]?.[method] ?? { mock: true, connectorId, method };
204
+ }
205
+
206
+ // Render the spark's component directly with the mock SDK as a prop.
207
+ // This bypasses the postMessage bridge used in production.
208
+ const mockSdk = new MockSDK();
209
+ const SparkComponent = spark.render;
210
+ const rootEl = document.getElementById("spark-root");
211
+ if (rootEl) {
212
+ ReactDOM.createRoot(rootEl).render(
213
+ <React.StrictMode>
214
+ <SparkComponent config={spark.defaultConfig} sdk={mockSdk} />
215
+ </React.StrictMode>
216
+ );
217
+ }
@@ -0,0 +1,77 @@
1
+ import { defineSpark } from "@campfiire/spark-sdk";
2
+ import { useEffect, useState } from "react";
3
+ import type { SparkSDKCampfire, SparkSDKMember } from "@campfiire/spark-sdk";
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * My Spark — example custom spark for Campfire.
8
+ *
9
+ * This is where you define your spark. It receives a `config` (validated against
10
+ * `configSchema`) and a `sdk` object to access platform data.
11
+ */
12
+ export default defineSpark({
13
+ name: "My Spark",
14
+ version: "1.0.0",
15
+ description: "A custom spark for Campfire",
16
+ icon: "IconSparkles",
17
+
18
+ // Define the config schema — this creates a Zod type and is used for
19
+ // validation in the campfire settings UI.
20
+ configSchema: z.object({
21
+ greeting: z.string().max(100),
22
+ }),
23
+
24
+ defaultConfig: {
25
+ greeting: "Hello",
26
+ },
27
+
28
+ render: ({ config, sdk }) => {
29
+ const [campfire, setCampfire] = useState<SparkSDKCampfire | null>(null);
30
+ const [members, setMembers] = useState<SparkSDKMember[]>([]);
31
+ const [loading, setLoading] = useState(true);
32
+
33
+ useEffect(() => {
34
+ Promise.all([sdk.getCampfire(), sdk.getMembers({ limit: 5 })])
35
+ .then(([c, m]) => {
36
+ setCampfire(c);
37
+ setMembers(m);
38
+ setLoading(false);
39
+ })
40
+ .catch(() => setLoading(false));
41
+ }, [sdk]);
42
+
43
+ if (loading) {
44
+ return <p style={{ color: "var(--muted-foreground)" }}>Loading...</p>;
45
+ }
46
+
47
+ return (
48
+ <div style={{ padding: "0.5rem" }}>
49
+ <h2 style={{ margin: "0 0 0.5rem", fontSize: "1.25rem" }}>
50
+ {config.greeting}, {campfire?.name}! 🔥
51
+ </h2>
52
+ <p style={{ margin: "0 0 1rem", color: "var(--muted-foreground)", fontSize: "0.875rem" }}>
53
+ This campfire has {campfire?.memberCount} members.
54
+ </p>
55
+ <div>
56
+ <h3 style={{ margin: "0 0 0.5rem", fontSize: "0.875rem", fontWeight: 600 }}>
57
+ Recent members:
58
+ </h3>
59
+ <ul style={{ margin: 0, padding: 0, listStyle: "none" }}>
60
+ {members.map((m) => (
61
+ <li
62
+ key={m.id}
63
+ style={{
64
+ padding: "0.375rem 0",
65
+ fontSize: "0.875rem",
66
+ borderTop: "1px solid var(--border)",
67
+ }}
68
+ >
69
+ {m.name} {m.isOnline && <span style={{ color: "#22c55e" }}>●</span>}
70
+ </li>
71
+ ))}
72
+ </ul>
73
+ </div>
74
+ </div>
75
+ );
76
+ },
77
+ });
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "allowImportingTsExtensions": true,
13
+ "noEmit": true,
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true
16
+ },
17
+ "include": ["src/**/*"],
18
+ "exclude": ["node_modules", "dist"]
19
+ }
@@ -0,0 +1,16 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import * as path from "node:path";
4
+
5
+ /**
6
+ * This config is used by `campfire-spark dev` for local development.
7
+ * Production builds use a different config (applied by `campfire-spark build`).
8
+ */
9
+ export default defineConfig({
10
+ plugins: [react()],
11
+ resolve: {
12
+ alias: {
13
+ "@": path.resolve(__dirname, "./src"),
14
+ },
15
+ },
16
+ });