@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.
- package/README.md +273 -0
- package/dist/__tests__/api.test.d.ts +5 -0
- package/dist/__tests__/api.test.d.ts.map +1 -0
- package/dist/__tests__/api.test.js +177 -0
- package/dist/__tests__/config.test.d.ts +5 -0
- package/dist/__tests__/config.test.d.ts.map +1 -0
- package/dist/__tests__/config.test.js +58 -0
- package/dist/__tests__/mcp.test.d.ts +7 -0
- package/dist/__tests__/mcp.test.d.ts.map +1 -0
- package/dist/__tests__/mcp.test.js +142 -0
- package/dist/__tests__/project-config.test.d.ts +5 -0
- package/dist/__tests__/project-config.test.d.ts.map +1 -0
- package/dist/__tests__/project-config.test.js +122 -0
- package/dist/__tests__/tarball.test.d.ts +5 -0
- package/dist/__tests__/tarball.test.d.ts.map +1 -0
- package/dist/__tests__/tarball.test.js +113 -0
- package/dist/api.d.ts +157 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +165 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +129 -0
- package/dist/commands/apps.d.ts +29 -0
- package/dist/commands/apps.d.ts.map +1 -0
- package/dist/commands/apps.js +151 -0
- package/dist/commands/auth.d.ts +27 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +127 -0
- package/dist/commands/config.d.ts +31 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +287 -0
- package/dist/commands/deploy.d.ts +24 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +196 -0
- package/dist/commands/domains.d.ts +35 -0
- package/dist/commands/domains.d.ts.map +1 -0
- package/dist/commands/domains.js +217 -0
- package/dist/commands/env.d.ts +26 -0
- package/dist/commands/env.d.ts.map +1 -0
- package/dist/commands/env.js +183 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +23 -0
- package/dist/credentials.d.ts +46 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +60 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +480 -0
- package/dist/project-config.d.ts +47 -0
- package/dist/project-config.d.ts.map +1 -0
- package/dist/project-config.js +55 -0
- package/dist/tarball.d.ts +29 -0
- package/dist/tarball.d.ts.map +1 -0
- package/dist/tarball.js +148 -0
- 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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
1
|
+
{"version":3,"file":"project-config.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/project-config.test.ts"],"names":[],"mappings":"AAAA;;GAEG"}
|