@byfungsi/funforge 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 (59) hide show
  1. package/README.md +273 -0
  2. package/dist/__tests__/api.test.d.ts +5 -0
  3. package/dist/__tests__/api.test.d.ts.map +1 -0
  4. package/dist/__tests__/api.test.js +177 -0
  5. package/dist/__tests__/config.test.d.ts +5 -0
  6. package/dist/__tests__/config.test.d.ts.map +1 -0
  7. package/dist/__tests__/config.test.js +58 -0
  8. package/dist/__tests__/mcp.test.d.ts +7 -0
  9. package/dist/__tests__/mcp.test.d.ts.map +1 -0
  10. package/dist/__tests__/mcp.test.js +142 -0
  11. package/dist/__tests__/project-config.test.d.ts +5 -0
  12. package/dist/__tests__/project-config.test.d.ts.map +1 -0
  13. package/dist/__tests__/project-config.test.js +122 -0
  14. package/dist/__tests__/tarball.test.d.ts +5 -0
  15. package/dist/__tests__/tarball.test.d.ts.map +1 -0
  16. package/dist/__tests__/tarball.test.js +113 -0
  17. package/dist/api.d.ts +157 -0
  18. package/dist/api.d.ts.map +1 -0
  19. package/dist/api.js +165 -0
  20. package/dist/cli.d.ts +8 -0
  21. package/dist/cli.d.ts.map +1 -0
  22. package/dist/cli.js +129 -0
  23. package/dist/commands/apps.d.ts +29 -0
  24. package/dist/commands/apps.d.ts.map +1 -0
  25. package/dist/commands/apps.js +151 -0
  26. package/dist/commands/auth.d.ts +27 -0
  27. package/dist/commands/auth.d.ts.map +1 -0
  28. package/dist/commands/auth.js +127 -0
  29. package/dist/commands/config.d.ts +31 -0
  30. package/dist/commands/config.d.ts.map +1 -0
  31. package/dist/commands/config.js +287 -0
  32. package/dist/commands/deploy.d.ts +24 -0
  33. package/dist/commands/deploy.d.ts.map +1 -0
  34. package/dist/commands/deploy.js +196 -0
  35. package/dist/commands/domains.d.ts +35 -0
  36. package/dist/commands/domains.d.ts.map +1 -0
  37. package/dist/commands/domains.js +217 -0
  38. package/dist/commands/env.d.ts +26 -0
  39. package/dist/commands/env.d.ts.map +1 -0
  40. package/dist/commands/env.js +183 -0
  41. package/dist/config.d.ts +22 -0
  42. package/dist/config.d.ts.map +1 -0
  43. package/dist/config.js +23 -0
  44. package/dist/credentials.d.ts +46 -0
  45. package/dist/credentials.d.ts.map +1 -0
  46. package/dist/credentials.js +60 -0
  47. package/dist/index.d.ts +14 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +18 -0
  50. package/dist/mcp.d.ts +19 -0
  51. package/dist/mcp.d.ts.map +1 -0
  52. package/dist/mcp.js +480 -0
  53. package/dist/project-config.d.ts +47 -0
  54. package/dist/project-config.d.ts.map +1 -0
  55. package/dist/project-config.js +55 -0
  56. package/dist/tarball.d.ts +29 -0
  57. package/dist/tarball.d.ts.map +1 -0
  58. package/dist/tarball.js +148 -0
  59. package/package.json +45 -0
package/README.md ADDED
@@ -0,0 +1,273 @@
1
+ # @byfungsi/funforge
2
+
3
+ > Deploy without git. Without leaving your editor.
4
+
5
+ FunForge CLI lets you deploy directly from your local machine to [FunForge](https://funforge.app) - no commits, no pushes, no CI/CD pipelines. Just run `funforge deploy` and you're live.
6
+
7
+ ## Features
8
+
9
+ - **Git-Free Deployment** - Deploy without committing. Perfect for rapid iteration.
10
+ - **Local-First Workflow** - Work locally, deploy directly. Skip the CI/CD dance.
11
+ - **Editor Integration** - MCP server for Claude Desktop, Cursor, and other AI assistants.
12
+ - **Full Control** - Manage environment variables, custom domains, and deployments from CLI.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ # npm
18
+ npm install -g @byfungsi/funforge
19
+
20
+ # pnpm
21
+ pnpm add -g @byfungsi/funforge
22
+
23
+ # bun
24
+ bun add -g @byfungsi/funforge
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ```bash
30
+ # 1. Authenticate (opens browser)
31
+ funforge login
32
+
33
+ # 2. Create an app (or link to existing)
34
+ funforge apps create my-app
35
+
36
+ # 3. Link your project
37
+ funforge link
38
+
39
+ # 4. Deploy
40
+ funforge deploy
41
+ ```
42
+
43
+ That's it. Your app is live at `https://your-app.funforge.app`.
44
+
45
+ ## Commands
46
+
47
+ ### Authentication
48
+
49
+ ```bash
50
+ funforge login # Authenticate via browser (device flow)
51
+ funforge logout # Clear stored credentials
52
+ funforge whoami # Show current authenticated user
53
+ ```
54
+
55
+ ### Apps
56
+
57
+ ```bash
58
+ funforge apps list # List all your apps
59
+ funforge apps create <name> # Create a new app
60
+ funforge apps create <name> -s <slug> # Create with custom subdomain
61
+ funforge link # Link current directory to an app (interactive)
62
+ funforge link <appId> # Link to specific app
63
+ ```
64
+
65
+ ### Deploy
66
+
67
+ ```bash
68
+ funforge deploy # Deploy current directory
69
+ funforge deploy -y # Skip confirmation prompt
70
+ funforge deploy --no-watch # Don't watch deployment logs
71
+ ```
72
+
73
+ ### Environment Variables
74
+
75
+ ```bash
76
+ funforge env list # List all env vars
77
+ funforge env set KEY=VALUE # Set single variable
78
+ funforge env set KEY1=V1 KEY2=V2 # Set multiple variables
79
+ funforge env unset KEY # Remove variable
80
+ funforge env unset KEY1 KEY2 # Remove multiple
81
+ ```
82
+
83
+ ### Custom Domains
84
+
85
+ ```bash
86
+ funforge domains list # List custom domains
87
+ funforge domains add example.com # Add domain (shows DNS instructions)
88
+ funforge domains add example.com -m txt # Use TXT verification
89
+ funforge domains remove example.com # Remove domain
90
+ funforge domains verify example.com # Verify DNS and provision SSL
91
+ ```
92
+
93
+ ### Config (Build Settings)
94
+
95
+ Manage build settings stored on the server:
96
+
97
+ ```bash
98
+ funforge config show # Compare local vs server settings
99
+ funforge config push # Push local settings to server
100
+ funforge config pull # Pull server settings to local file
101
+ ```
102
+
103
+ **Note:** The `config` commands sync settings to the database. However, during deployment, `funforge.json` in your project is automatically read and takes priority over database settings.
104
+
105
+ ## Configuration
106
+
107
+ ### funforge.json
108
+
109
+ When you run `funforge link`, a `funforge.json` file is created in your project root:
110
+
111
+ ```json
112
+ {
113
+ "appId": "your-app-uuid",
114
+ "appName": "My App",
115
+ "appSlug": "my-app",
116
+ "build": {
117
+ "buildCommand": "npm run build",
118
+ "installCommand": "npm ci",
119
+ "startCommand": "npm start",
120
+ "nodeVersion": "20"
121
+ },
122
+ "port": 3000
123
+ }
124
+ ```
125
+
126
+ **Fields:**
127
+
128
+ | Field | Description |
129
+ |-------|-------------|
130
+ | `appId` | (Required) The app UUID this project is linked to |
131
+ | `appName` | Display name of the app |
132
+ | `appSlug` | Subdomain slug (e.g., `my-app` for `my-app.funforge.app`) |
133
+ | `build.buildCommand` | Custom build command (overrides auto-detection) |
134
+ | `build.installCommand` | Custom install command (e.g., `npm ci` or `pnpm install`) |
135
+ | `build.startCommand` | Custom start command (e.g., `node server.js`) |
136
+ | `build.nodeVersion` | Node.js version: `"18"`, `"20"`, or `"22"` |
137
+ | `port` | Port your app listens on (default: 3000) |
138
+
139
+ **Build Settings Priority:**
140
+
141
+ During deployment, build settings are resolved in this order:
142
+
143
+ 1. **funforge.json** (in your project) - highest priority
144
+ 2. **Database settings** (set via web UI or `funforge config push`)
145
+ 3. **Railpack auto-detection** - fallback
146
+
147
+ This means you can commit `funforge.json` to your Git repo and it will automatically be used during builds - no need to configure anything in the web UI or run `funforge config push`.
148
+
149
+ **Git Users:** Just add `funforge.json` to your repo. The builder will automatically read it during deployment.
150
+
151
+ **CLI Users:** Your `funforge.json` is included in the upload and used during builds.
152
+
153
+ ### .funforgeignore
154
+
155
+ Control which files are included in the deployment tarball. Uses `.gitignore` syntax:
156
+
157
+ ```gitignore
158
+ # Ignore test files
159
+ __tests__/
160
+ *.test.ts
161
+
162
+ # Ignore local config
163
+ .env.local
164
+ config.local.json
165
+ ```
166
+
167
+ By default, FunForge respects your `.gitignore` file. Use `.funforgeignore` to add additional exclusions.
168
+
169
+ ### Size Limits
170
+
171
+ - Maximum tarball size: **50 MB**
172
+ - Use `.funforgeignore` to exclude large files (node_modules is automatically excluded)
173
+
174
+ ## MCP Server
175
+
176
+ FunForge includes an MCP (Model Context Protocol) server for AI assistant integration. Deploy directly from Claude Desktop, Cursor, or any MCP-compatible client.
177
+
178
+ ### Setup for Claude Desktop
179
+
180
+ Add to your `claude_desktop_config.json`:
181
+
182
+ ```json
183
+ {
184
+ "mcpServers": {
185
+ "funforge": {
186
+ "command": "npx",
187
+ "args": ["@byfungsi/funforge-mcp"]
188
+ }
189
+ }
190
+ }
191
+ ```
192
+
193
+ ### Setup for Cursor
194
+
195
+ Add to your MCP settings:
196
+
197
+ ```json
198
+ {
199
+ "funforge": {
200
+ "command": "npx",
201
+ "args": ["@byfungsi/funforge-mcp"]
202
+ }
203
+ }
204
+ ```
205
+
206
+ ### Available Tools
207
+
208
+ | Tool | Description |
209
+ |------|-------------|
210
+ | `funforge_whoami` | Check authentication status |
211
+ | `funforge_apps_list` | List all your apps |
212
+ | `funforge_apps_create` | Create a new app |
213
+ | `funforge_status` | Get deployment status |
214
+ | `funforge_deploy` | Deploy a directory |
215
+ | `funforge_env_list` | List environment variables |
216
+ | `funforge_env_set` | Set environment variables |
217
+ | `funforge_env_unset` | Remove environment variables |
218
+ | `funforge_domains_list` | List custom domains |
219
+ | `funforge_domains_add` | Add a custom domain |
220
+
221
+ ### Example Conversations
222
+
223
+ Once configured, you can use natural language:
224
+
225
+ - "Deploy my current project to FunForge"
226
+ - "Set the DATABASE_URL environment variable to postgres://..."
227
+ - "Add api.myapp.com as a custom domain"
228
+ - "What's the status of my deployment?"
229
+
230
+ ## Environment Variables
231
+
232
+ The CLI can be configured via environment variables:
233
+
234
+ | Variable | Description | Default |
235
+ |----------|-------------|---------|
236
+ | `FUNFORGE_API_URL` | API endpoint | `https://funforge.fungsi.app` |
237
+ | `FUNFORGE_API_KEY` | API key (skips device auth) | - |
238
+
239
+ ## Authentication
240
+
241
+ FunForge uses device authentication flow:
242
+
243
+ 1. Run `funforge login`
244
+ 2. Browser opens to FunForge login page
245
+ 3. Sign in with GitHub
246
+ 4. CLI automatically receives credentials
247
+ 5. Credentials stored locally in `~/.config/funforge/`
248
+
249
+ For CI/CD environments, set `FUNFORGE_API_KEY` instead.
250
+
251
+ ## How It Works
252
+
253
+ 1. **Tarball Creation** - CLI creates a `.tar.gz` of your project (respecting `.gitignore` and `.funforgeignore`)
254
+ 2. **Upload** - Tarball is uploaded to R2 storage
255
+ 3. **Build** - FunForge downloads and builds your app using Railpack (auto-detects framework)
256
+ 4. **Deploy** - Blue-green deployment with health checks
257
+ 5. **Live** - Your app is available at `https://your-app.funforge.app`
258
+
259
+ ## Links
260
+
261
+ - **Website**: [funforge.app](https://funforge.app)
262
+ - **Documentation**: [docs.fungsi.app/docs/funforge/guides/cli](https://docs.fungsi.app/docs/funforge/guides/cli)
263
+ - **CLI Marketing**: [funforge.app/cli](https://funforge.app/cli)
264
+ - **MCP Integration**: [funforge.app/mcp](https://funforge.app/mcp)
265
+
266
+ ## Requirements
267
+
268
+ - Node.js 18+
269
+ - FunForge account (sign up at [funforge.app](https://funforge.app))
270
+
271
+ ## License
272
+
273
+ MIT
@@ -0,0 +1,5 @@
1
+ /**
2
+ * API Client Tests
3
+ */
4
+ export {};
5
+ //# sourceMappingURL=api.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/api.test.ts"],"names":[],"mappings":"AAAA;;GAEG"}
@@ -0,0 +1,177 @@
1
+ /**
2
+ * API Client Tests
3
+ */
4
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi, } from "vitest";
5
+ import { ApiError, apiRequest, createApp, getApp, listApps } from "../api.js";
6
+ // Mock credentials module
7
+ vi.mock("../credentials.js", () => ({
8
+ getApiKey: vi.fn(() => "test-api-key-123"),
9
+ }));
10
+ // Mock config module
11
+ vi.mock("../config.js", () => ({
12
+ getConfig: vi.fn(() => ({
13
+ apiUrl: "https://api.test.com",
14
+ authUrl: "https://auth.test.com",
15
+ timeout: 5000,
16
+ })),
17
+ }));
18
+ describe("api", () => {
19
+ const mockFetch = vi.fn();
20
+ const originalFetch = global.fetch;
21
+ beforeAll(() => {
22
+ global.fetch = mockFetch;
23
+ });
24
+ afterAll(() => {
25
+ global.fetch = originalFetch;
26
+ });
27
+ beforeEach(() => {
28
+ mockFetch.mockReset();
29
+ });
30
+ describe("apiRequest", () => {
31
+ it("should make GET request with auth header", async () => {
32
+ mockFetch.mockResolvedValueOnce({
33
+ ok: true,
34
+ json: async () => ({ data: "test" }),
35
+ });
36
+ const result = await apiRequest("/api/test");
37
+ expect(result).toEqual({ data: "test" });
38
+ expect(mockFetch).toHaveBeenCalledWith("https://api.test.com/api/test", expect.objectContaining({
39
+ method: "GET",
40
+ headers: expect.objectContaining({
41
+ Authorization: "Bearer test-api-key-123",
42
+ "Content-Type": "application/json",
43
+ }),
44
+ }));
45
+ });
46
+ it("should make POST request with body", async () => {
47
+ mockFetch.mockResolvedValueOnce({
48
+ ok: true,
49
+ json: async () => ({ success: true }),
50
+ });
51
+ const result = await apiRequest("/api/create", {
52
+ method: "POST",
53
+ body: { name: "test" },
54
+ });
55
+ expect(result).toEqual({ success: true });
56
+ expect(mockFetch).toHaveBeenCalledWith("https://api.test.com/api/create", expect.objectContaining({
57
+ method: "POST",
58
+ body: JSON.stringify({ name: "test" }),
59
+ }));
60
+ });
61
+ it("should throw ApiError on non-ok response", async () => {
62
+ mockFetch.mockResolvedValue({
63
+ ok: false,
64
+ status: 404,
65
+ json: async () => ({ message: "Not found" }),
66
+ });
67
+ await expect(apiRequest("/api/notfound")).rejects.toThrow(ApiError);
68
+ // Reset and mock again for second assertion
69
+ mockFetch.mockResolvedValue({
70
+ ok: false,
71
+ status: 404,
72
+ json: async () => ({ message: "Not found" }),
73
+ });
74
+ await expect(apiRequest("/api/notfound")).rejects.toMatchObject({
75
+ statusCode: 404,
76
+ });
77
+ });
78
+ it("should include error body in ApiError", async () => {
79
+ mockFetch.mockResolvedValueOnce({
80
+ ok: false,
81
+ status: 400,
82
+ json: async () => ({ error: { message: "Invalid input" } }),
83
+ });
84
+ try {
85
+ await apiRequest("/api/invalid");
86
+ expect.fail("Should have thrown");
87
+ }
88
+ catch (error) {
89
+ expect(error).toBeInstanceOf(ApiError);
90
+ expect(error.body).toEqual({
91
+ error: { message: "Invalid input" },
92
+ });
93
+ }
94
+ });
95
+ it("should skip auth when useAuth is false", async () => {
96
+ mockFetch.mockResolvedValueOnce({
97
+ ok: true,
98
+ json: async () => ({}),
99
+ });
100
+ await apiRequest("/api/public", { useAuth: false });
101
+ expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
102
+ headers: expect.not.objectContaining({
103
+ Authorization: expect.any(String),
104
+ }),
105
+ }));
106
+ });
107
+ });
108
+ describe("listApps", () => {
109
+ it("should return list of apps", async () => {
110
+ const mockApps = {
111
+ apps: [
112
+ { id: "app-1", name: "App 1", slug: "app-1" },
113
+ { id: "app-2", name: "App 2", slug: "app-2" },
114
+ ],
115
+ };
116
+ mockFetch.mockResolvedValueOnce({
117
+ ok: true,
118
+ json: async () => mockApps,
119
+ });
120
+ const result = await listApps();
121
+ expect(result.apps).toHaveLength(2);
122
+ expect(result.apps[0].id).toBe("app-1");
123
+ });
124
+ });
125
+ describe("createApp", () => {
126
+ it("should create app with name", async () => {
127
+ mockFetch.mockResolvedValueOnce({
128
+ ok: true,
129
+ json: async () => ({
130
+ app: { id: "new-app", name: "New App", slug: "new-app" },
131
+ }),
132
+ });
133
+ const result = await createApp({ name: "New App" });
134
+ expect(result.app.name).toBe("New App");
135
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("/api/cli/apps"), expect.objectContaining({
136
+ method: "POST",
137
+ body: JSON.stringify({ name: "New App" }),
138
+ }));
139
+ });
140
+ it("should create app with name and slug", async () => {
141
+ mockFetch.mockResolvedValueOnce({
142
+ ok: true,
143
+ json: async () => ({
144
+ app: { id: "new-app", name: "New App", slug: "custom-slug" },
145
+ }),
146
+ });
147
+ const result = await createApp({ name: "New App", slug: "custom-slug" });
148
+ expect(result.app.slug).toBe("custom-slug");
149
+ });
150
+ });
151
+ describe("getApp", () => {
152
+ it("should get app by id", async () => {
153
+ mockFetch.mockResolvedValueOnce({
154
+ ok: true,
155
+ json: async () => ({
156
+ app: { id: "app-123", name: "My App", slug: "my-app" },
157
+ }),
158
+ });
159
+ const result = await getApp("app-123");
160
+ expect(result.app.id).toBe("app-123");
161
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("/api/cli/apps/app-123"), expect.any(Object));
162
+ });
163
+ });
164
+ });
165
+ describe("ApiError", () => {
166
+ it("should have correct properties", () => {
167
+ const error = new ApiError("Test error", 500, { detail: "info" });
168
+ expect(error.message).toBe("Test error");
169
+ expect(error.statusCode).toBe(500);
170
+ expect(error.body).toEqual({ detail: "info" });
171
+ expect(error.name).toBe("ApiError");
172
+ });
173
+ it("should be instance of Error", () => {
174
+ const error = new ApiError("Test", 400);
175
+ expect(error).toBeInstanceOf(Error);
176
+ });
177
+ });
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Config Tests
3
+ */
4
+ export {};
5
+ //# sourceMappingURL=config.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/config.test.ts"],"names":[],"mappings":"AAAA;;GAEG"}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Config Tests
3
+ */
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { config, getConfig } from "../config.js";
6
+ describe("config", () => {
7
+ const originalEnv = process.env;
8
+ beforeEach(() => {
9
+ // Reset env before each test
10
+ vi.resetModules();
11
+ process.env = { ...originalEnv };
12
+ });
13
+ afterEach(() => {
14
+ process.env = originalEnv;
15
+ });
16
+ describe("default config", () => {
17
+ it("should have production API URL", () => {
18
+ expect(config.apiUrl).toBe("https://api.fungsi.app");
19
+ });
20
+ it("should have production auth URL", () => {
21
+ expect(config.authUrl).toBe("https://cloud.fungsi.app");
22
+ });
23
+ it("should have 30s timeout", () => {
24
+ expect(config.timeout).toBe(30000);
25
+ });
26
+ });
27
+ describe("getConfig", () => {
28
+ it("should return default config when no env vars set", () => {
29
+ delete process.env.FUNFORGE_API_URL;
30
+ delete process.env.FUNFORGE_AUTH_URL;
31
+ delete process.env.FUNFORGE_TIMEOUT;
32
+ const result = getConfig();
33
+ expect(result.apiUrl).toBe("https://api.fungsi.app");
34
+ expect(result.authUrl).toBe("https://cloud.fungsi.app");
35
+ expect(result.timeout).toBe(30000);
36
+ });
37
+ it("should override apiUrl from environment", () => {
38
+ process.env.FUNFORGE_API_URL = "http://localhost:3000";
39
+ const result = getConfig();
40
+ expect(result.apiUrl).toBe("http://localhost:3000");
41
+ });
42
+ it("should override authUrl from environment", () => {
43
+ process.env.FUNFORGE_AUTH_URL = "http://localhost:3001";
44
+ const result = getConfig();
45
+ expect(result.authUrl).toBe("http://localhost:3001");
46
+ });
47
+ it("should override timeout from environment", () => {
48
+ process.env.FUNFORGE_TIMEOUT = "60000";
49
+ const result = getConfig();
50
+ expect(result.timeout).toBe(60000);
51
+ });
52
+ it("should use default timeout for invalid env value", () => {
53
+ process.env.FUNFORGE_TIMEOUT = "invalid";
54
+ const result = getConfig();
55
+ expect(result.timeout).toBe(30000);
56
+ });
57
+ });
58
+ });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * MCP Server Tools Tests
3
+ *
4
+ * Tests the MCP tool handlers with mocked API responses.
5
+ */
6
+ export {};
7
+ //# sourceMappingURL=mcp.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/mcp.test.ts"],"names":[],"mappings":"AAAA;;;;GAIG"}
@@ -0,0 +1,142 @@
1
+ /**
2
+ * MCP Server Tools Tests
3
+ *
4
+ * Tests the MCP tool handlers with mocked API responses.
5
+ */
6
+ import { mkdir, rm } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
10
+ import { ApiError } from "../api.js";
11
+ // Helper function mirroring the one in mcp.ts
12
+ function formatErrorHelper(error) {
13
+ if (error instanceof ApiError) {
14
+ const body = error.body;
15
+ const message = body?.message || body?.error?.message || error.message;
16
+ return `API Error ${error.statusCode}: ${message}`;
17
+ }
18
+ return error instanceof Error ? error.message : String(error);
19
+ }
20
+ describe("MCP Server", () => {
21
+ describe("module loading", () => {
22
+ it("should export required MCP SDK types", async () => {
23
+ // Verify the MCP SDK is properly installed and importable
24
+ const { Server } = await import("@modelcontextprotocol/sdk/server/index.js");
25
+ const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
26
+ expect(Server).toBeDefined();
27
+ expect(StdioServerTransport).toBeDefined();
28
+ });
29
+ });
30
+ });
31
+ describe("MCP Tool Helpers", () => {
32
+ describe("formatError", () => {
33
+ it("should format ApiError with message from body", () => {
34
+ const error = new ApiError("Not found", 404, {
35
+ message: "Resource missing",
36
+ });
37
+ const formatted = formatErrorHelper(error);
38
+ expect(formatted).toContain("404");
39
+ expect(formatted).toContain("Resource missing");
40
+ });
41
+ it("should format ApiError with nested error message", () => {
42
+ const error = new ApiError("Bad request", 400, {
43
+ error: { message: "Invalid input" },
44
+ });
45
+ const formatted = formatErrorHelper(error);
46
+ expect(formatted).toContain("400");
47
+ expect(formatted).toContain("Invalid input");
48
+ });
49
+ it("should format ApiError without body", () => {
50
+ const error = new ApiError("Server error", 500);
51
+ const formatted = formatErrorHelper(error);
52
+ expect(formatted).toBe("API Error 500: Server error");
53
+ });
54
+ it("should format regular Error", () => {
55
+ const error = new Error("Something went wrong");
56
+ const formatted = formatErrorHelper(error);
57
+ expect(formatted).toBe("Something went wrong");
58
+ });
59
+ it("should format string error", () => {
60
+ const formatted = formatErrorHelper("Plain string error");
61
+ expect(formatted).toBe("Plain string error");
62
+ });
63
+ });
64
+ });
65
+ describe("MCP Integration", () => {
66
+ let testDir;
67
+ beforeEach(async () => {
68
+ testDir = join(tmpdir(), `funforge-mcp-test-${Date.now()}`);
69
+ await mkdir(testDir, { recursive: true });
70
+ });
71
+ afterEach(async () => {
72
+ await rm(testDir, { recursive: true, force: true });
73
+ });
74
+ describe("tool input validation", () => {
75
+ it("should validate funforge_apps_create requires name", () => {
76
+ const toolSchema = {
77
+ type: "object",
78
+ properties: {
79
+ name: { type: "string" },
80
+ slug: { type: "string" },
81
+ },
82
+ required: ["name"],
83
+ };
84
+ // Valid input
85
+ const validInput = { name: "My App" };
86
+ expect(toolSchema.required.every((r) => r in validInput)).toBe(true);
87
+ // Invalid input (missing name)
88
+ const invalidInput = { slug: "my-app" };
89
+ expect(toolSchema.required.every((r) => r in invalidInput)).toBe(false);
90
+ });
91
+ it("should validate funforge_env_set requires envVars", () => {
92
+ const toolSchema = {
93
+ type: "object",
94
+ properties: {
95
+ directory: { type: "string" },
96
+ envVars: { type: "object" },
97
+ },
98
+ required: ["envVars"],
99
+ };
100
+ const validInput = { envVars: { NODE_ENV: "production" } };
101
+ expect(toolSchema.required.every((r) => r in validInput)).toBe(true);
102
+ const invalidInput = { directory: "/some/path" };
103
+ expect(toolSchema.required.every((r) => r in invalidInput)).toBe(false);
104
+ });
105
+ it("should validate funforge_domains_add requires domain", () => {
106
+ const toolSchema = {
107
+ type: "object",
108
+ properties: {
109
+ directory: { type: "string" },
110
+ domain: { type: "string" },
111
+ verificationMethod: { type: "string", enum: ["cname", "txt"] },
112
+ },
113
+ required: ["domain"],
114
+ };
115
+ const validInput = { domain: "example.com" };
116
+ expect(toolSchema.required.every((r) => r in validInput)).toBe(true);
117
+ const invalidInput = { verificationMethod: "cname" };
118
+ expect(toolSchema.required.every((r) => r in invalidInput)).toBe(false);
119
+ });
120
+ });
121
+ describe("tool definitions", () => {
122
+ // List of expected tools
123
+ const expectedTools = [
124
+ "funforge_whoami",
125
+ "funforge_apps_list",
126
+ "funforge_apps_create",
127
+ "funforge_status",
128
+ "funforge_deploy",
129
+ "funforge_env_list",
130
+ "funforge_env_set",
131
+ "funforge_env_unset",
132
+ "funforge_domains_list",
133
+ "funforge_domains_add",
134
+ ];
135
+ it("should define all expected tools", () => {
136
+ // This is a documentation test to ensure we have all tools
137
+ expect(expectedTools).toHaveLength(10);
138
+ expect(expectedTools).toContain("funforge_deploy");
139
+ expect(expectedTools).toContain("funforge_whoami");
140
+ });
141
+ });
142
+ });
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Project Config Tests
3
+ */
4
+ export {};
5
+ //# sourceMappingURL=project-config.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"project-config.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/project-config.test.ts"],"names":[],"mappings":"AAAA;;GAEG"}