the-campfire 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.
Files changed (180) hide show
  1. package/bin/cli.ts +182 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/MermaidDiagram-By1J3Whj.js +2 -0
  4. package/dist/assets/_basePickBy-Dq6gnD-p.js +1 -0
  5. package/dist/assets/_baseUniq-SpgN6cGc.js +1 -0
  6. package/dist/assets/arc-D9SEA8dX.js +1 -0
  7. package/dist/assets/architectureDiagram-VXUJARFQ-CoTcwLeR.js +36 -0
  8. package/dist/assets/blockDiagram-VD42YOAC-Dvc63voV.js +122 -0
  9. package/dist/assets/c4Diagram-YG6GDRKO-tuDcG4_4.js +10 -0
  10. package/dist/assets/channel-CxhcHn7S.js +1 -0
  11. package/dist/assets/chunk-4BX2VUAB-x1hChYu9.js +1 -0
  12. package/dist/assets/chunk-55IACEB6-BX6HWnWP.js +1 -0
  13. package/dist/assets/chunk-B4BG7PRW-B4IlF03G.js +165 -0
  14. package/dist/assets/chunk-DI55MBZ5-TpYb5rLg.js +220 -0
  15. package/dist/assets/chunk-FMBD7UC4-DZX4LUPx.js +15 -0
  16. package/dist/assets/chunk-QN33PNHL-CuQs0NDY.js +1 -0
  17. package/dist/assets/chunk-QZHKN3VN-CygWwVx8.js +1 -0
  18. package/dist/assets/chunk-TZMSLE5B-CtI-IdKV.js +1 -0
  19. package/dist/assets/classDiagram-2ON5EDUG-BM0LptJF.js +1 -0
  20. package/dist/assets/classDiagram-v2-WZHVMYZB-BM0LptJF.js +1 -0
  21. package/dist/assets/clone-C6kt1rE7.js +1 -0
  22. package/dist/assets/cose-bilkent-S5V4N54A-PdySs991.js +1 -0
  23. package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
  24. package/dist/assets/dagre-6UL2VRFP-J0SxEiEu.js +4 -0
  25. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  26. package/dist/assets/diagram-PSM6KHXK-CW0HLXaH.js +24 -0
  27. package/dist/assets/diagram-QEK2KX5R-DADHF-k4.js +43 -0
  28. package/dist/assets/diagram-S2PKOQOG-9S0-PC3S.js +24 -0
  29. package/dist/assets/erDiagram-Q2GNP2WA-BEHAiY_q.js +60 -0
  30. package/dist/assets/flowDiagram-NV44I4VS-Cu6n9Xnd.js +162 -0
  31. package/dist/assets/ganttDiagram-JELNMOA3-Cr_BHryu.js +267 -0
  32. package/dist/assets/gitGraphDiagram-V2S2FVAM-D5pT35v5.js +65 -0
  33. package/dist/assets/graph-DKRbrxcs.js +1 -0
  34. package/dist/assets/index-Byjg9zgI.css +32 -0
  35. package/dist/assets/index-Cwj3m0hT.js +284 -0
  36. package/dist/assets/infoDiagram-HS3SLOUP-BaJlrgKV.js +2 -0
  37. package/dist/assets/init-Gi6I4Gst.js +1 -0
  38. package/dist/assets/journeyDiagram-XKPGCS4Q-CXC4-DJv.js +139 -0
  39. package/dist/assets/kanban-definition-3W4ZIXB7-BtHBu9cx.js +89 -0
  40. package/dist/assets/katex-BlHpptmG.js +261 -0
  41. package/dist/assets/layout-DnxSwLDJ.js +1 -0
  42. package/dist/assets/linear-CWbbIT8l.js +1 -0
  43. package/dist/assets/mermaid.core-DXn1cFAK.js +256 -0
  44. package/dist/assets/mindmap-definition-VGOIOE7T-BR8br1hs.js +68 -0
  45. package/dist/assets/ordinal-BENe2yWM.js +1 -0
  46. package/dist/assets/pieDiagram-ADFJNKIX-B7RmXV7I.js +30 -0
  47. package/dist/assets/quadrantDiagram-AYHSOK5B-DK0FhyV_.js +7 -0
  48. package/dist/assets/requirementDiagram-UZGBJVZJ-3zAGxSpL.js +64 -0
  49. package/dist/assets/sankeyDiagram-TZEHDZUN-DZppKtjc.js +10 -0
  50. package/dist/assets/sequenceDiagram-WL72ISMW-D1x3r3wV.js +145 -0
  51. package/dist/assets/stateDiagram-FKZM4ZOC-2ByD08Vq.js +1 -0
  52. package/dist/assets/stateDiagram-v2-4FDKWEC3-Vm1cYjgV.js +1 -0
  53. package/dist/assets/timeline-definition-IT6M3QCI-l5S35biR.js +61 -0
  54. package/dist/assets/treemap-GDKQZRPO-DIPadPiF.js +162 -0
  55. package/dist/assets/xychartDiagram-PRI3JC2R-9GUvwS2b.js +7 -0
  56. package/dist/favicon.svg +3 -0
  57. package/dist/fonts/Geist-Variable.woff2 +0 -0
  58. package/dist/fonts/GeistMono-Variable.woff2 +0 -0
  59. package/dist/icon-192.png +0 -0
  60. package/dist/icon-512.png +0 -0
  61. package/dist/index.html +33 -0
  62. package/dist/logo-codex.svg +14 -0
  63. package/dist/logo.svg +3 -0
  64. package/dist/manifest.json +26 -0
  65. package/dist/og-image.png +0 -0
  66. package/dist/sw.js +130 -0
  67. package/package.json +92 -0
  68. package/server/ADAPTERS.md +121 -0
  69. package/server/adapter-registry-types.ts +28 -0
  70. package/server/adapter-registry.ts +293 -0
  71. package/server/adapter-types.ts +51 -0
  72. package/server/agent-executor.ts +249 -0
  73. package/server/agent-mcp-bridge.ts +148 -0
  74. package/server/agent-mcp-stdio.ts +171 -0
  75. package/server/agent-mcp-tools.ts +76 -0
  76. package/server/agent-store.ts +183 -0
  77. package/server/agent-types.ts +79 -0
  78. package/server/aider-adapter.ts +525 -0
  79. package/server/auth.ts +153 -0
  80. package/server/auto-namer.ts +132 -0
  81. package/server/capability-discovery.ts +431 -0
  82. package/server/claude-container-auth.ts +40 -0
  83. package/server/claude-stdio-adapter.ts +452 -0
  84. package/server/clawhub-export.ts +218 -0
  85. package/server/cli-launcher.ts +1613 -0
  86. package/server/codex-adapter.ts +1987 -0
  87. package/server/codex-container-auth.ts +40 -0
  88. package/server/codex-home.ts +26 -0
  89. package/server/collective-intelligence.ts +401 -0
  90. package/server/commands-discovery.ts +160 -0
  91. package/server/constants.ts +5 -0
  92. package/server/container-manager.ts +328 -0
  93. package/server/cron-scheduler.ts +243 -0
  94. package/server/cron-store.ts +149 -0
  95. package/server/cron-types.ts +63 -0
  96. package/server/deliberation-engine.ts +323 -0
  97. package/server/embedding.ts +89 -0
  98. package/server/env-manager.ts +146 -0
  99. package/server/environment-detector.ts +106 -0
  100. package/server/environment-rules.ts +106 -0
  101. package/server/gallery-store.ts +216 -0
  102. package/server/gallery-types.ts +52 -0
  103. package/server/gallery-votes.ts +101 -0
  104. package/server/git-utils.ts +384 -0
  105. package/server/github-pr.ts +379 -0
  106. package/server/goose-adapter.ts +929 -0
  107. package/server/image-pull-manager.ts +144 -0
  108. package/server/index.ts +363 -0
  109. package/server/linear-project-manager.ts +78 -0
  110. package/server/moltbook-client.ts +160 -0
  111. package/server/openclaw-adapter.ts +916 -0
  112. package/server/openclaw-channel/channel.ts +188 -0
  113. package/server/openclaw-channel/index.ts +68 -0
  114. package/server/openclaw-channel/package.json +19 -0
  115. package/server/openclaw-channel/types.ts +93 -0
  116. package/server/opencode-adapter.ts +951 -0
  117. package/server/openhands-adapter.ts +775 -0
  118. package/server/orchestrator-executor.ts +184 -0
  119. package/server/orchestrator-store.ts +106 -0
  120. package/server/orchestrator-types.ts +86 -0
  121. package/server/path-resolver.ts +168 -0
  122. package/server/pr-poller.ts +162 -0
  123. package/server/proactive-keepalive.ts +121 -0
  124. package/server/prompt-manager.ts +103 -0
  125. package/server/protocol-monitor.ts +197 -0
  126. package/server/race-controller.ts +295 -0
  127. package/server/race-store.ts +82 -0
  128. package/server/recorder.ts +359 -0
  129. package/server/recording-hub/compat-validator.ts +122 -0
  130. package/server/recording-hub/diagnostics.ts +289 -0
  131. package/server/recording-hub/hub-routes.ts +146 -0
  132. package/server/recording-hub/hub-store.ts +224 -0
  133. package/server/replay.ts +78 -0
  134. package/server/routes/adapter-routes.ts +31 -0
  135. package/server/routes/agent-mcp-routes.ts +35 -0
  136. package/server/routes/agent-routes.ts +185 -0
  137. package/server/routes/auth-routes.ts +101 -0
  138. package/server/routes/ci-routes.ts +165 -0
  139. package/server/routes/commands-routes.ts +94 -0
  140. package/server/routes/cron-routes.ts +100 -0
  141. package/server/routes/env-routes.ts +50 -0
  142. package/server/routes/folder-routes.ts +56 -0
  143. package/server/routes/fs-routes.ts +403 -0
  144. package/server/routes/gallery-routes.ts +410 -0
  145. package/server/routes/git-routes.ts +108 -0
  146. package/server/routes/index.ts +62 -0
  147. package/server/routes/linear-routes.ts +254 -0
  148. package/server/routes/monitor-routes.ts +11 -0
  149. package/server/routes/orchestrator-routes.ts +210 -0
  150. package/server/routes/prompt-routes.ts +47 -0
  151. package/server/routes/race-routes.ts +103 -0
  152. package/server/routes/recording-routes.ts +101 -0
  153. package/server/routes/route-deps.ts +32 -0
  154. package/server/routes/session-routes.ts +560 -0
  155. package/server/routes/settings-routes.ts +108 -0
  156. package/server/routes/skills-routes.ts +52 -0
  157. package/server/routes/system-routes.ts +243 -0
  158. package/server/routes/webhook-routes.ts +135 -0
  159. package/server/routes.ts +44 -0
  160. package/server/security-middleware.ts +100 -0
  161. package/server/semantic-memory.ts +389 -0
  162. package/server/service.ts +733 -0
  163. package/server/session-folders.ts +107 -0
  164. package/server/session-git-info.ts +89 -0
  165. package/server/session-linear-issues.ts +94 -0
  166. package/server/session-names.ts +67 -0
  167. package/server/session-store.ts +169 -0
  168. package/server/session-types.ts +415 -0
  169. package/server/settings-manager.ts +120 -0
  170. package/server/shared-context.ts +401 -0
  171. package/server/skills-manager.ts +250 -0
  172. package/server/sub-session-manager.ts +263 -0
  173. package/server/terminal-manager.ts +185 -0
  174. package/server/update-checker.ts +114 -0
  175. package/server/usage-limits.ts +192 -0
  176. package/server/webhook-manager.ts +228 -0
  177. package/server/webhook-store.ts +168 -0
  178. package/server/webhook-types.ts +66 -0
  179. package/server/worktree-tracker.ts +84 -0
  180. package/server/ws-bridge.ts +2322 -0
@@ -0,0 +1,146 @@
1
+ import {
2
+ mkdirSync,
3
+ readdirSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ unlinkSync,
7
+ existsSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { homedir } from "node:os";
11
+
12
+ // ─── Types ──────────────────────────────────────────────────────────────────
13
+
14
+ export interface CampfireEnv {
15
+ name: string;
16
+ slug: string;
17
+ variables: Record<string, string>;
18
+ createdAt: number;
19
+ updatedAt: number;
20
+ }
21
+
22
+ // ─── Paths ──────────────────────────────────────────────────────────────────
23
+
24
+ const CAMPFIRE_DIR = join(homedir(), ".campfire");
25
+ const ENVS_DIR = join(CAMPFIRE_DIR, "envs");
26
+
27
+ function ensureDir(): void {
28
+ mkdirSync(ENVS_DIR, { recursive: true });
29
+ }
30
+
31
+ function filePath(slug: string): string {
32
+ return join(ENVS_DIR, `${slug}.json`);
33
+ }
34
+
35
+ // ─── Helpers ────────────────────────────────────────────────────────────────
36
+
37
+ function slugify(name: string): string {
38
+ return name
39
+ .toLowerCase()
40
+ .replace(/\s+/g, "-")
41
+ .replace(/[^a-z0-9-]/g, "")
42
+ .replace(/-+/g, "-")
43
+ .replace(/^-|-$/g, "");
44
+ }
45
+
46
+ // ─── CRUD ───────────────────────────────────────────────────────────────────
47
+
48
+ export function listEnvs(): CampfireEnv[] {
49
+ ensureDir();
50
+ try {
51
+ const files = readdirSync(ENVS_DIR).filter((f) => f.endsWith(".json"));
52
+ const envs: CampfireEnv[] = [];
53
+ for (const file of files) {
54
+ try {
55
+ const raw = readFileSync(join(ENVS_DIR, file), "utf-8");
56
+ envs.push(JSON.parse(raw));
57
+ } catch {
58
+ // Skip corrupt files
59
+ }
60
+ }
61
+ envs.sort((a, b) => a.name.localeCompare(b.name));
62
+ return envs;
63
+ } catch {
64
+ return [];
65
+ }
66
+ }
67
+
68
+ export function getEnv(slug: string): CampfireEnv | null {
69
+ ensureDir();
70
+ try {
71
+ const raw = readFileSync(filePath(slug), "utf-8");
72
+ return JSON.parse(raw) as CampfireEnv;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ export function createEnv(
79
+ name: string,
80
+ variables: Record<string, string> = {},
81
+ ): CampfireEnv {
82
+ if (!name || !name.trim()) throw new Error("Environment name is required");
83
+ const slug = slugify(name.trim());
84
+ if (!slug) throw new Error("Environment name must contain alphanumeric characters");
85
+
86
+ ensureDir();
87
+ if (existsSync(filePath(slug))) {
88
+ throw new Error(`An environment with a similar name already exists ("${slug}")`);
89
+ }
90
+
91
+ const now = Date.now();
92
+ const env: CampfireEnv = {
93
+ name: name.trim(),
94
+ slug,
95
+ variables,
96
+ createdAt: now,
97
+ updatedAt: now,
98
+ };
99
+ writeFileSync(filePath(slug), JSON.stringify(env, null, 2), "utf-8");
100
+ return env;
101
+ }
102
+
103
+ export function updateEnv(
104
+ slug: string,
105
+ updates: { name?: string; variables?: Record<string, string> },
106
+ ): CampfireEnv | null {
107
+ ensureDir();
108
+ const existing = getEnv(slug);
109
+ if (!existing) return null;
110
+
111
+ const newName = updates.name?.trim() || existing.name;
112
+ const newSlug = slugify(newName);
113
+ if (!newSlug) throw new Error("Environment name must contain alphanumeric characters");
114
+
115
+ // If name changed, check for slug collision with a different env
116
+ if (newSlug !== slug && existsSync(filePath(newSlug))) {
117
+ throw new Error(`An environment with a similar name already exists ("${newSlug}")`);
118
+ }
119
+
120
+ const env: CampfireEnv = {
121
+ name: newName,
122
+ slug: newSlug,
123
+ variables: updates.variables ?? existing.variables,
124
+ createdAt: existing.createdAt,
125
+ updatedAt: Date.now(),
126
+ };
127
+
128
+ // If slug changed, delete old file
129
+ if (newSlug !== slug) {
130
+ try { unlinkSync(filePath(slug)); } catch { /* ok */ }
131
+ }
132
+
133
+ writeFileSync(filePath(newSlug), JSON.stringify(env, null, 2), "utf-8");
134
+ return env;
135
+ }
136
+
137
+ export function deleteEnv(slug: string): boolean {
138
+ ensureDir();
139
+ if (!existsSync(filePath(slug))) return false;
140
+ try {
141
+ unlinkSync(filePath(slug));
142
+ return true;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
@@ -0,0 +1,106 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { DetectedEnvironment, McpServerConfig } from "./session-types.js";
4
+ import { ENVIRONMENT_RULES, toDetectedRule, type PackageJsonLike, type ProjectContext } from "./environment-rules.js";
5
+
6
+ const ENV_FILES = [".env", ".env.local", ".env.development"];
7
+ const MAX_ENV_BYTES = 256 * 1024;
8
+
9
+ function readPackageJson(cwd: string): PackageJsonLike | undefined {
10
+ try {
11
+ const raw = readFileSync(join(cwd, "package.json"), "utf-8");
12
+ return JSON.parse(raw) as PackageJsonLike;
13
+ } catch {
14
+ return undefined;
15
+ }
16
+ }
17
+
18
+ function parseEnvContent(content: string): Record<string, string> {
19
+ const vars: Record<string, string> = {};
20
+ for (const line of content.split(/\r?\n/)) {
21
+ const trimmed = line.trim();
22
+ if (!trimmed || trimmed.startsWith("#")) continue;
23
+ const match = /^([\w.-]+)\s*=\s*(.*)$/.exec(trimmed);
24
+ if (!match) continue;
25
+ let value = match[2].trim();
26
+ if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
27
+ value = value.slice(1, -1);
28
+ }
29
+ vars[match[1]] = value;
30
+ }
31
+ return vars;
32
+ }
33
+
34
+ function readEnvVars(cwd: string): Record<string, string> {
35
+ const envVars: Record<string, string> = {};
36
+ for (const file of ENV_FILES) {
37
+ const path = join(cwd, file);
38
+ try {
39
+ if (!existsSync(path)) continue;
40
+ const stat = statSync(path);
41
+ if (!stat.isFile() || stat.size > MAX_ENV_BYTES) continue;
42
+ Object.assign(envVars, parseEnvContent(readFileSync(path, "utf-8")));
43
+ } catch {
44
+ // Ignore unreadable env files. Detection is best effort.
45
+ }
46
+ }
47
+ return envVars;
48
+ }
49
+
50
+ function addIfExists(cwd: string, relPath: string, files: Set<string>, dirs: Set<string>): void {
51
+ const path = join(cwd, relPath);
52
+ try {
53
+ if (!existsSync(path)) return;
54
+ const stat = statSync(path);
55
+ if (stat.isDirectory()) dirs.add(relPath);
56
+ if (stat.isFile()) files.add(relPath);
57
+ } catch {
58
+ // Ignore transient filesystem errors.
59
+ }
60
+ }
61
+
62
+ function collectProjectContext(cwd: string): ProjectContext {
63
+ const files = new Set<string>();
64
+ const dirs = new Set<string>();
65
+
66
+ try {
67
+ for (const entry of readdirSync(cwd, { withFileTypes: true })) {
68
+ if (entry.isFile()) files.add(entry.name);
69
+ if (entry.isDirectory()) dirs.add(entry.name);
70
+ }
71
+ } catch {
72
+ // Leave sets empty; downstream rules will simply not match.
73
+ }
74
+
75
+ addIfExists(cwd, "prisma/schema.prisma", files, dirs);
76
+ addIfExists(cwd, ".github/workflows", files, dirs);
77
+
78
+ return {
79
+ cwd,
80
+ files,
81
+ dirs,
82
+ packageJson: readPackageJson(cwd),
83
+ envVars: readEnvVars(cwd),
84
+ };
85
+ }
86
+
87
+ export function detectEnvironment(cwd: string): DetectedEnvironment {
88
+ const ctx = collectProjectContext(cwd);
89
+ const rules = [];
90
+ const mcpServers: Record<string, McpServerConfig> = {};
91
+
92
+ for (const rule of ENVIRONMENT_RULES) {
93
+ if (!rule.detect(ctx)) continue;
94
+ rules.push(toDetectedRule(rule, ctx.envVars));
95
+ if (rule.mcpServer) {
96
+ mcpServers[rule.id] = rule.mcpServer;
97
+ }
98
+ }
99
+
100
+ return {
101
+ cwd,
102
+ scannedAt: Date.now(),
103
+ rules,
104
+ mcpServers,
105
+ };
106
+ }
@@ -0,0 +1,106 @@
1
+ import type { DetectedEnvironmentRule, McpServerConfig } from "./session-types.js";
2
+
3
+ export interface PackageJsonLike {
4
+ dependencies?: Record<string, string>;
5
+ devDependencies?: Record<string, string>;
6
+ scripts?: Record<string, string>;
7
+ }
8
+
9
+ export interface ProjectContext {
10
+ cwd: string;
11
+ files: Set<string>;
12
+ dirs: Set<string>;
13
+ packageJson?: PackageJsonLike;
14
+ envVars: Record<string, string>;
15
+ }
16
+
17
+ export interface DetectionRule {
18
+ id: string;
19
+ name: string;
20
+ description: string;
21
+ envRequired?: string[];
22
+ detect(ctx: ProjectContext): boolean;
23
+ mcpServer?: McpServerConfig;
24
+ }
25
+
26
+ function hasDependency(ctx: ProjectContext, dep: string): boolean {
27
+ return Boolean(ctx.packageJson?.dependencies?.[dep] || ctx.packageJson?.devDependencies?.[dep]);
28
+ }
29
+
30
+ function hasAnyDependency(ctx: ProjectContext, deps: string[]): boolean {
31
+ return deps.some((dep) => hasDependency(ctx, dep));
32
+ }
33
+
34
+ function envServer(command: string, args: string[] = []): McpServerConfig {
35
+ return { type: "stdio", command, args };
36
+ }
37
+
38
+ export const ENVIRONMENT_RULES: DetectionRule[] = [
39
+ {
40
+ id: "supabase",
41
+ name: "Supabase",
42
+ description: "Supabase project or SDK detected.",
43
+ envRequired: ["SUPABASE_URL"],
44
+ detect: (ctx) => Boolean(ctx.envVars.SUPABASE_URL) || hasAnyDependency(ctx, ["@supabase/supabase-js", "supabase"]),
45
+ mcpServer: envServer("npx", ["-y", "@supabase/mcp-server-supabase"]),
46
+ },
47
+ {
48
+ id: "stripe",
49
+ name: "Stripe",
50
+ description: "Stripe SDK or secret key detected.",
51
+ envRequired: ["STRIPE_SECRET_KEY"],
52
+ detect: (ctx) => Boolean(ctx.envVars.STRIPE_SECRET_KEY) || hasDependency(ctx, "stripe"),
53
+ mcpServer: envServer("npx", ["-y", "@stripe/mcp"]),
54
+ },
55
+ {
56
+ id: "vercel",
57
+ name: "Vercel",
58
+ description: "Vercel or Next.js deployment target detected.",
59
+ detect: (ctx) => ctx.files.has("vercel.json") || hasDependency(ctx, "next"),
60
+ mcpServer: envServer("npx", ["-y", "@vercel/mcp-server"]),
61
+ },
62
+ {
63
+ id: "prisma",
64
+ name: "Prisma",
65
+ description: "Prisma schema or dependency detected.",
66
+ envRequired: ["DATABASE_URL"],
67
+ detect: (ctx) => ctx.files.has("prisma/schema.prisma") || hasAnyDependency(ctx, ["prisma", "@prisma/client"]),
68
+ },
69
+ {
70
+ id: "docker",
71
+ name: "Docker",
72
+ description: "Dockerfile or compose configuration detected.",
73
+ detect: (ctx) => ctx.files.has("Dockerfile") || ctx.files.has("docker-compose.yml") || ctx.files.has("docker-compose.yaml"),
74
+ },
75
+ {
76
+ id: "flyio",
77
+ name: "Fly.io",
78
+ description: "Fly.io deployment configuration detected.",
79
+ detect: (ctx) => ctx.files.has("fly.toml"),
80
+ },
81
+ {
82
+ id: "github-actions",
83
+ name: "GitHub Actions",
84
+ description: "GitHub Actions workflow directory detected.",
85
+ detect: (ctx) => ctx.dirs.has(".github/workflows"),
86
+ },
87
+ {
88
+ id: "database",
89
+ name: "Database",
90
+ description: "Database connection configuration detected.",
91
+ envRequired: ["DATABASE_URL"],
92
+ detect: (ctx) => Boolean(ctx.envVars.DATABASE_URL),
93
+ },
94
+ ];
95
+
96
+ export function toDetectedRule(rule: DetectionRule, envVars: Record<string, string>): DetectedEnvironmentRule {
97
+ const envRequired = rule.envRequired ?? [];
98
+ return {
99
+ id: rule.id,
100
+ name: rule.name,
101
+ description: rule.description,
102
+ envRequired: envRequired.length ? envRequired : undefined,
103
+ envPresent: envRequired.filter((name) => Boolean(envVars[name])),
104
+ envMissing: envRequired.filter((name) => !envVars[name]),
105
+ };
106
+ }
@@ -0,0 +1,216 @@
1
+ import {
2
+ mkdirSync,
3
+ readdirSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ unlinkSync,
7
+ existsSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import type {
12
+ GalleryEntry,
13
+ GalleryEntryCreateInput,
14
+ GallerySessionSnapshot,
15
+ GalleryFilter,
16
+ } from "./gallery-types.js";
17
+
18
+ // ─── Paths ──────────────────────────────────────────────────────────────────
19
+
20
+ const CAMPFIRE_DIR = join(homedir(), ".campfire");
21
+ const GALLERY_DIR = join(CAMPFIRE_DIR, "gallery");
22
+
23
+ function ensureDir(): void {
24
+ mkdirSync(GALLERY_DIR, { recursive: true });
25
+ }
26
+
27
+ function filePath(id: string): string {
28
+ return join(GALLERY_DIR, `${id}.json`);
29
+ }
30
+
31
+ // ─── Helpers ────────────────────────────────────────────────────────────────
32
+
33
+ function slugify(name: string): string {
34
+ return name
35
+ .toLowerCase()
36
+ .replace(/\s+/g, "-")
37
+ .replace(/[^a-z0-9-]/g, "")
38
+ .replace(/-+/g, "-")
39
+ .replace(/^-|-$/g, "");
40
+ }
41
+
42
+ // ─── CRUD ───────────────────────────────────────────────────────────────────
43
+
44
+ export function listEntries(filter?: GalleryFilter): GalleryEntry[] {
45
+ ensureDir();
46
+ try {
47
+ const files = readdirSync(GALLERY_DIR).filter(
48
+ (f) => f.endsWith(".json") && f !== "votes.json",
49
+ );
50
+ let entries: GalleryEntry[] = [];
51
+ for (const file of files) {
52
+ try {
53
+ const raw = readFileSync(join(GALLERY_DIR, file), "utf-8");
54
+ entries.push(JSON.parse(raw));
55
+ } catch {
56
+ // Skip corrupt files
57
+ }
58
+ }
59
+
60
+ if (filter) {
61
+ entries = applyFilter(entries, filter);
62
+ } else {
63
+ // Default sort: votes descending
64
+ entries.sort((a, b) => b.votes - a.votes);
65
+ }
66
+
67
+ return entries;
68
+ } catch {
69
+ return [];
70
+ }
71
+ }
72
+
73
+ export function getEntry(id: string): GalleryEntry | null {
74
+ ensureDir();
75
+ try {
76
+ const raw = readFileSync(filePath(id), "utf-8");
77
+ return JSON.parse(raw) as GalleryEntry;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ export function createEntry(
84
+ input: GalleryEntryCreateInput,
85
+ snapshot: GallerySessionSnapshot,
86
+ ): GalleryEntry {
87
+ if (!input.name || !input.name.trim()) throw new Error("Entry name is required");
88
+ if (!input.sessionId || !input.sessionId.trim())
89
+ throw new Error("Session ID is required");
90
+
91
+ const id = slugify(input.name.trim());
92
+ if (!id) throw new Error("Entry name must contain alphanumeric characters");
93
+
94
+ ensureDir();
95
+ if (existsSync(filePath(id))) {
96
+ throw new Error(`A gallery entry with a similar name already exists ("${id}")`);
97
+ }
98
+
99
+ const now = Date.now();
100
+ const entry: GalleryEntry = {
101
+ id,
102
+ sessionId: input.sessionId.trim(),
103
+ name: input.name.trim(),
104
+ description: (input.description || "").trim(),
105
+ tags: (input.tags || []).map((t) => t.trim().toLowerCase()).filter(Boolean),
106
+ featured: false,
107
+ votes: 0,
108
+ createdAt: now,
109
+ updatedAt: now,
110
+ backendType: snapshot.backendType || "claude",
111
+ model: snapshot.model || "unknown",
112
+ totalCostUsd: snapshot.totalCostUsd || 0,
113
+ durationMs: snapshot.durationMs || 0,
114
+ totalLinesAdded: snapshot.totalLinesAdded || 0,
115
+ totalLinesRemoved: snapshot.totalLinesRemoved || 0,
116
+ numTurns: snapshot.numTurns || 0,
117
+ repoRoot: snapshot.repoRoot,
118
+ };
119
+
120
+ writeFileSync(filePath(id), JSON.stringify(entry, null, 2), "utf-8");
121
+ return entry;
122
+ }
123
+
124
+ export function updateEntry(
125
+ id: string,
126
+ updates: Partial<GalleryEntry>,
127
+ ): GalleryEntry | null {
128
+ ensureDir();
129
+ const existing = getEntry(id);
130
+ if (!existing) return null;
131
+
132
+ const newName = updates.name?.trim() || existing.name;
133
+ const newId = slugify(newName);
134
+ if (!newId) throw new Error("Entry name must contain alphanumeric characters");
135
+
136
+ // If name changed, check for slug collision
137
+ if (newId !== id && existsSync(filePath(newId))) {
138
+ throw new Error(`A gallery entry with a similar name already exists ("${newId}")`);
139
+ }
140
+
141
+ const entry: GalleryEntry = {
142
+ ...existing,
143
+ ...updates,
144
+ id: newId,
145
+ name: newName,
146
+ updatedAt: Date.now(),
147
+ createdAt: existing.createdAt,
148
+ };
149
+
150
+ // If id changed, delete old file
151
+ if (newId !== id) {
152
+ try {
153
+ unlinkSync(filePath(id));
154
+ } catch {
155
+ /* ok */
156
+ }
157
+ }
158
+
159
+ writeFileSync(filePath(newId), JSON.stringify(entry, null, 2), "utf-8");
160
+ return entry;
161
+ }
162
+
163
+ export function deleteEntry(id: string): boolean {
164
+ ensureDir();
165
+ if (!existsSync(filePath(id))) return false;
166
+ try {
167
+ unlinkSync(filePath(id));
168
+ return true;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ // ─── Filtering & Sorting ────────────────────────────────────────────────────
175
+
176
+ function applyFilter(entries: GalleryEntry[], filter: GalleryFilter): GalleryEntry[] {
177
+ let result = entries;
178
+
179
+ if (filter.backend) {
180
+ result = result.filter((e) => e.backendType === filter.backend);
181
+ }
182
+ if (filter.minCost !== undefined) {
183
+ result = result.filter((e) => e.totalCostUsd >= filter.minCost!);
184
+ }
185
+ if (filter.maxCost !== undefined) {
186
+ result = result.filter((e) => e.totalCostUsd <= filter.maxCost!);
187
+ }
188
+ if (filter.tags && filter.tags.length > 0) {
189
+ const filterTags = new Set(filter.tags.map((t) => t.toLowerCase()));
190
+ result = result.filter((e) => e.tags.some((t) => filterTags.has(t)));
191
+ }
192
+ if (filter.featuredOnly) {
193
+ result = result.filter((e) => e.featured);
194
+ }
195
+
196
+ const sortBy = filter.sortBy || "votes";
197
+ const sortOrder = filter.sortOrder || "desc";
198
+ const multiplier = sortOrder === "asc" ? 1 : -1;
199
+
200
+ result.sort((a, b) => {
201
+ switch (sortBy) {
202
+ case "votes":
203
+ return (a.votes - b.votes) * multiplier;
204
+ case "cost":
205
+ return (a.totalCostUsd - b.totalCostUsd) * multiplier;
206
+ case "recent":
207
+ return (a.createdAt - b.createdAt) * multiplier;
208
+ case "duration":
209
+ return (a.durationMs - b.durationMs) * multiplier;
210
+ default:
211
+ return (a.votes - b.votes) * multiplier;
212
+ }
213
+ });
214
+
215
+ return result;
216
+ }
@@ -0,0 +1,52 @@
1
+ import type { BackendType } from "./session-types.js";
2
+
3
+ // ─── Gallery Entry ─────────────────────────────────────────────────────────
4
+
5
+ export interface GalleryEntry {
6
+ id: string;
7
+ sessionId: string;
8
+ name: string;
9
+ description: string;
10
+ tags: string[];
11
+ featured: boolean;
12
+ votes: number;
13
+ createdAt: number;
14
+ updatedAt: number;
15
+ // Denormalized session snapshot for fast listing
16
+ backendType: BackendType;
17
+ model: string;
18
+ totalCostUsd: number;
19
+ durationMs: number;
20
+ totalLinesAdded: number;
21
+ totalLinesRemoved: number;
22
+ numTurns: number;
23
+ repoRoot?: string;
24
+ }
25
+
26
+ export interface GalleryEntryCreateInput {
27
+ sessionId: string;
28
+ name: string;
29
+ description: string;
30
+ tags?: string[];
31
+ }
32
+
33
+ export interface GallerySessionSnapshot {
34
+ backendType?: BackendType;
35
+ model?: string;
36
+ totalCostUsd?: number;
37
+ durationMs?: number;
38
+ totalLinesAdded?: number;
39
+ totalLinesRemoved?: number;
40
+ numTurns?: number;
41
+ repoRoot?: string;
42
+ }
43
+
44
+ export interface GalleryFilter {
45
+ backend?: BackendType;
46
+ minCost?: number;
47
+ maxCost?: number;
48
+ tags?: string[];
49
+ featuredOnly?: boolean;
50
+ sortBy?: "votes" | "cost" | "recent" | "duration";
51
+ sortOrder?: "asc" | "desc";
52
+ }