@prajwolkc/stk 0.2.0 → 0.3.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 +47 -0
- package/dist/commands/health.js +27 -3
- package/dist/lib/output.d.ts +7 -0
- package/dist/lib/output.js +14 -0
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.js +1027 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -158,6 +158,53 @@ GITHUB_TOKEN=
|
|
|
158
158
|
GITHUB_REPO=owner/repo # or auto-detected from git remote
|
|
159
159
|
```
|
|
160
160
|
|
|
161
|
+
## Claude Code / MCP Integration
|
|
162
|
+
|
|
163
|
+
`stk` ships with a built-in MCP server so Claude Code can use your infrastructure as native tools.
|
|
164
|
+
|
|
165
|
+
### Setup
|
|
166
|
+
|
|
167
|
+
1. Install stk globally:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
npm install -g @prajwolkc/stk
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
2. Add to your project's `.mcp.json` (create it in your project root):
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"mcpServers": {
|
|
178
|
+
"stk": {
|
|
179
|
+
"command": "stk-mcp",
|
|
180
|
+
"args": []
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
3. Restart Claude Code. Approve the stk MCP server when prompted.
|
|
187
|
+
|
|
188
|
+
### What Claude can do
|
|
189
|
+
|
|
190
|
+
| Tool | Description |
|
|
191
|
+
|------|-------------|
|
|
192
|
+
| `stk_health` | Check if all services are up before writing code |
|
|
193
|
+
| `stk_status` | Full overview: git, services, deploys, issues |
|
|
194
|
+
| `stk_doctor` | Diagnose misconfig and missing env vars |
|
|
195
|
+
| `stk_logs` | Read production logs to understand bugs |
|
|
196
|
+
| `stk_todo_list` | See what needs to be worked on |
|
|
197
|
+
| `stk_todo_add` | Create GitHub issues |
|
|
198
|
+
| `stk_deploy` | Push code and trigger deploys |
|
|
199
|
+
| `stk_config` | Read the project's stack config |
|
|
200
|
+
|
|
201
|
+
### Example prompts
|
|
202
|
+
|
|
203
|
+
- *"Check if all my services are healthy"*
|
|
204
|
+
- *"What errors are in my production logs?"*
|
|
205
|
+
- *"What should I work on next?"*
|
|
206
|
+
- *"Deploy this and verify it worked"*
|
|
207
|
+
|
|
161
208
|
## Development
|
|
162
209
|
|
|
163
210
|
```bash
|
package/dist/commands/health.js
CHANGED
|
@@ -3,6 +3,7 @@ import chalk from "chalk";
|
|
|
3
3
|
import ora from "ora";
|
|
4
4
|
import { loadConfig, enabledServices } from "../lib/config.js";
|
|
5
5
|
import { getChecker, allCheckerNames, loadPluginCheckers } from "../services/registry.js";
|
|
6
|
+
import { jsonOutput } from "../lib/output.js";
|
|
6
7
|
const STATUS_ICON = {
|
|
7
8
|
healthy: chalk.green("✓"),
|
|
8
9
|
degraded: chalk.yellow("~"),
|
|
@@ -19,21 +20,25 @@ export const healthCommand = new Command("health")
|
|
|
19
20
|
.description("Check the health of all connected services")
|
|
20
21
|
.option("-v, --verbose", "Show latency and extra detail")
|
|
21
22
|
.option("-a, --all", "Check all known services, not just configured ones")
|
|
23
|
+
.option("-j, --json", "Output as JSON")
|
|
22
24
|
.action(async (opts) => {
|
|
23
25
|
const config = loadConfig();
|
|
24
|
-
const spinner = ora("Checking services...").start();
|
|
25
26
|
await loadPluginCheckers();
|
|
26
27
|
const serviceList = opts.all
|
|
27
28
|
? allCheckerNames()
|
|
28
29
|
: enabledServices(config);
|
|
29
30
|
if (serviceList.length === 0) {
|
|
30
|
-
|
|
31
|
+
if (opts.json) {
|
|
32
|
+
jsonOutput({ services: [], summary: "no services configured" });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
31
35
|
console.log();
|
|
32
36
|
console.log(chalk.yellow(" No services configured."));
|
|
33
37
|
console.log(chalk.dim(` Run ${chalk.white("stk init")} to set up your project, or ${chalk.white("stk health --all")} to check everything.`));
|
|
34
38
|
console.log();
|
|
35
39
|
return;
|
|
36
40
|
}
|
|
41
|
+
const spinner = opts.json ? null : ora("Checking services...").start();
|
|
37
42
|
const checks = serviceList.map((name) => {
|
|
38
43
|
const checker = getChecker(name);
|
|
39
44
|
if (!checker) {
|
|
@@ -46,7 +51,26 @@ export const healthCommand = new Command("health")
|
|
|
46
51
|
return checker();
|
|
47
52
|
});
|
|
48
53
|
const results = await Promise.all(checks);
|
|
49
|
-
spinner
|
|
54
|
+
spinner?.stop();
|
|
55
|
+
// JSON output
|
|
56
|
+
if (opts.json) {
|
|
57
|
+
const down = results.filter((r) => r.status === "down");
|
|
58
|
+
jsonOutput({
|
|
59
|
+
project: config.name,
|
|
60
|
+
services: results,
|
|
61
|
+
summary: {
|
|
62
|
+
healthy: results.filter((r) => r.status === "healthy").length,
|
|
63
|
+
down: down.length,
|
|
64
|
+
skipped: results.filter((r) => r.status === "skipped").length,
|
|
65
|
+
total: results.length,
|
|
66
|
+
},
|
|
67
|
+
ok: down.length === 0,
|
|
68
|
+
});
|
|
69
|
+
if (down.length > 0)
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// Human output
|
|
50
74
|
console.log();
|
|
51
75
|
console.log(chalk.bold(` ${config.name} — Service Health`));
|
|
52
76
|
console.log(chalk.dim(" ─────────────────────────────────────────"));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared output helper. When --json is passed, commands collect data
|
|
3
|
+
* and call jsonOutput() instead of console.log with chalk.
|
|
4
|
+
*/
|
|
5
|
+
export declare function setJsonMode(enabled: boolean): void;
|
|
6
|
+
export declare function isJsonMode(): boolean;
|
|
7
|
+
export declare function jsonOutput(data: unknown): void;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared output helper. When --json is passed, commands collect data
|
|
3
|
+
* and call jsonOutput() instead of console.log with chalk.
|
|
4
|
+
*/
|
|
5
|
+
let jsonMode = false;
|
|
6
|
+
export function setJsonMode(enabled) {
|
|
7
|
+
jsonMode = enabled;
|
|
8
|
+
}
|
|
9
|
+
export function isJsonMode() {
|
|
10
|
+
return jsonMode;
|
|
11
|
+
}
|
|
12
|
+
export function jsonOutput(data) {
|
|
13
|
+
console.log(JSON.stringify(data, null, 2));
|
|
14
|
+
}
|
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* stk MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Exposes your entire infrastructure as tools for Claude Code.
|
|
6
|
+
* Claude can check health, read logs, deploy, manage issues, and diagnose
|
|
7
|
+
* problems — all through structured tool calls.
|
|
8
|
+
*/
|
|
9
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { loadConfig, enabledServices } from "../lib/config.js";
|
|
13
|
+
import { getChecker, allCheckerNames, loadPluginCheckers } from "../services/registry.js";
|
|
14
|
+
import { execSync } from "child_process";
|
|
15
|
+
const server = new McpServer({
|
|
16
|
+
name: "stk",
|
|
17
|
+
version: "0.2.0",
|
|
18
|
+
});
|
|
19
|
+
// ──────────────────────────────────────────
|
|
20
|
+
// Tool: stk_health
|
|
21
|
+
// ──────────────────────────────────────────
|
|
22
|
+
server.tool("stk_health", "Check the health of all configured infrastructure services (databases, deploy providers, storage, billing). Returns structured results with status, latency, and details for each service.", {
|
|
23
|
+
all: z.boolean().optional().describe("Check all known services, not just configured ones"),
|
|
24
|
+
}, async ({ all }) => {
|
|
25
|
+
await loadPluginCheckers();
|
|
26
|
+
const config = loadConfig();
|
|
27
|
+
const serviceList = all ? allCheckerNames() : enabledServices(config);
|
|
28
|
+
const checks = serviceList.map(async (name) => {
|
|
29
|
+
const checker = getChecker(name);
|
|
30
|
+
if (!checker) {
|
|
31
|
+
return { name, status: "skipped", detail: `unknown service "${name}"` };
|
|
32
|
+
}
|
|
33
|
+
return checker();
|
|
34
|
+
});
|
|
35
|
+
const results = await Promise.all(checks);
|
|
36
|
+
const down = results.filter((r) => r.status === "down");
|
|
37
|
+
return {
|
|
38
|
+
content: [
|
|
39
|
+
{
|
|
40
|
+
type: "text",
|
|
41
|
+
text: JSON.stringify({
|
|
42
|
+
project: config.name,
|
|
43
|
+
services: results,
|
|
44
|
+
summary: {
|
|
45
|
+
healthy: results.filter((r) => r.status === "healthy").length,
|
|
46
|
+
down: down.length,
|
|
47
|
+
skipped: results.filter((r) => r.status === "skipped").length,
|
|
48
|
+
total: results.length,
|
|
49
|
+
},
|
|
50
|
+
ok: down.length === 0,
|
|
51
|
+
}, null, 2),
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
// ──────────────────────────────────────────
|
|
57
|
+
// Tool: stk_status
|
|
58
|
+
// ──────────────────────────────────────────
|
|
59
|
+
server.tool("stk_status", "Get a complete status overview: git state, service health, last deploy, and open issues — everything in one call.", {}, async () => {
|
|
60
|
+
const config = loadConfig();
|
|
61
|
+
const status = { project: config.name };
|
|
62
|
+
// Git
|
|
63
|
+
try {
|
|
64
|
+
status.git = {
|
|
65
|
+
branch: execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(),
|
|
66
|
+
dirty: execSync("git status --porcelain", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim().split("\n").filter(Boolean).length,
|
|
67
|
+
lastCommit: execSync('git log -1 --format="%s"', { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(),
|
|
68
|
+
lastCommitAge: execSync('git log -1 --format="%cr"', { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
status.git = null;
|
|
73
|
+
}
|
|
74
|
+
// Services
|
|
75
|
+
await loadPluginCheckers();
|
|
76
|
+
const serviceList = enabledServices(config);
|
|
77
|
+
if (serviceList.length > 0) {
|
|
78
|
+
const checks = serviceList.map(async (name) => {
|
|
79
|
+
const checker = getChecker(name);
|
|
80
|
+
if (!checker)
|
|
81
|
+
return { name, status: "skipped" };
|
|
82
|
+
return checker();
|
|
83
|
+
});
|
|
84
|
+
const results = await Promise.all(checks);
|
|
85
|
+
status.services = {
|
|
86
|
+
healthy: results.filter((r) => r.status === "healthy").length,
|
|
87
|
+
down: results.filter((r) => r.status === "down").map((r) => r.name),
|
|
88
|
+
skipped: results.filter((r) => r.status === "skipped").length,
|
|
89
|
+
total: results.length,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
status.services = { total: 0, note: "no services configured" };
|
|
94
|
+
}
|
|
95
|
+
// Deploy
|
|
96
|
+
if (process.env.VERCEL_TOKEN) {
|
|
97
|
+
try {
|
|
98
|
+
const res = await fetch("https://api.vercel.com/v6/deployments?limit=1", {
|
|
99
|
+
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
|
|
100
|
+
});
|
|
101
|
+
const data = (await res.json());
|
|
102
|
+
const dep = data.deployments?.[0];
|
|
103
|
+
if (dep) {
|
|
104
|
+
status.lastDeploy = {
|
|
105
|
+
provider: "vercel",
|
|
106
|
+
state: dep.readyState ?? dep.state,
|
|
107
|
+
url: dep.url,
|
|
108
|
+
created: dep.created,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch { /* skip */ }
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
// ──────────────────────────────────────────
|
|
119
|
+
// Tool: stk_doctor
|
|
120
|
+
// ──────────────────────────────────────────
|
|
121
|
+
server.tool("stk_doctor", "Diagnose infrastructure configuration issues. Checks for missing env vars, mismatched config, invalid URLs, and suggests fixes with documentation links.", {}, async () => {
|
|
122
|
+
const config = loadConfig();
|
|
123
|
+
const enabled = enabledServices(config);
|
|
124
|
+
const issues = [];
|
|
125
|
+
const ENV_REQS = {
|
|
126
|
+
railway: { required: ["RAILWAY_API_TOKEN"], optional: ["RAILWAY_PROJECT_ID", "RAILWAY_ENVIRONMENT_ID", "RAILWAY_SERVICE_ID"] },
|
|
127
|
+
vercel: { required: ["VERCEL_TOKEN"], optional: ["VERCEL_PROJECT_ID"] },
|
|
128
|
+
fly: { required: ["FLY_API_TOKEN"], optional: ["FLY_APP_NAME"] },
|
|
129
|
+
render: { required: ["RENDER_API_KEY"], optional: [] },
|
|
130
|
+
aws: { required: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"], optional: ["AWS_REGION"] },
|
|
131
|
+
database: { required: ["DATABASE_URL"], optional: [] },
|
|
132
|
+
mongodb: { required: ["MONGODB_URL"], optional: [] },
|
|
133
|
+
redis: { required: ["REDIS_URL"], optional: [] },
|
|
134
|
+
supabase: { required: ["SUPABASE_URL"], optional: ["SUPABASE_SERVICE_KEY"] },
|
|
135
|
+
r2: { required: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"], optional: [] },
|
|
136
|
+
stripe: { required: ["STRIPE_SECRET_KEY"], optional: [] },
|
|
137
|
+
};
|
|
138
|
+
for (const svc of enabled) {
|
|
139
|
+
const reqs = ENV_REQS[svc];
|
|
140
|
+
if (!reqs)
|
|
141
|
+
continue;
|
|
142
|
+
const missingReq = reqs.required.filter((v) => !process.env[v]);
|
|
143
|
+
const missingOpt = reqs.optional.filter((v) => !process.env[v]);
|
|
144
|
+
if (missingReq.length > 0) {
|
|
145
|
+
issues.push({ level: "error", service: svc, message: `Missing required: ${missingReq.join(", ")}` });
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
issues.push({ level: "ok", service: svc, message: "Configured correctly" });
|
|
149
|
+
}
|
|
150
|
+
if (missingOpt.length > 0) {
|
|
151
|
+
issues.push({ level: "warn", service: svc, message: `Missing optional: ${missingOpt.join(", ")}`, fix: "Needed for logs, env sync, deploy watching" });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
content: [{
|
|
156
|
+
type: "text",
|
|
157
|
+
text: JSON.stringify({
|
|
158
|
+
project: config.name,
|
|
159
|
+
issues,
|
|
160
|
+
summary: {
|
|
161
|
+
errors: issues.filter((i) => i.level === "error").length,
|
|
162
|
+
warnings: issues.filter((i) => i.level === "warn").length,
|
|
163
|
+
ok: issues.filter((i) => i.level === "ok").length,
|
|
164
|
+
},
|
|
165
|
+
}, null, 2),
|
|
166
|
+
}],
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
// ──────────────────────────────────────────
|
|
170
|
+
// Tool: stk_logs
|
|
171
|
+
// ──────────────────────────────────────────
|
|
172
|
+
server.tool("stk_logs", "Fetch recent production logs from Railway, Vercel, or other deploy providers. Useful for diagnosing errors and understanding runtime behavior.", {
|
|
173
|
+
provider: z.enum(["railway", "vercel"]).optional().describe("Which provider to fetch logs from (auto-detects if omitted)"),
|
|
174
|
+
lines: z.number().optional().default(30).describe("Number of log lines to fetch"),
|
|
175
|
+
}, async ({ provider, lines }) => {
|
|
176
|
+
// Railway logs
|
|
177
|
+
if ((provider === "railway" || !provider) && process.env.RAILWAY_API_TOKEN) {
|
|
178
|
+
const token = process.env.RAILWAY_API_TOKEN;
|
|
179
|
+
const projectId = process.env.RAILWAY_PROJECT_ID;
|
|
180
|
+
const serviceId = process.env.RAILWAY_SERVICE_ID;
|
|
181
|
+
if (!projectId) {
|
|
182
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "RAILWAY_PROJECT_ID not set" }) }] };
|
|
183
|
+
}
|
|
184
|
+
// Get latest deployment
|
|
185
|
+
const serviceFilter = serviceId ? `serviceId: "${serviceId}",` : "";
|
|
186
|
+
const depRes = await fetch("https://backboard.railway.com/graphql/v2", {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
189
|
+
body: JSON.stringify({
|
|
190
|
+
query: `{ deployments(first: 1, input: { projectId: "${projectId}", ${serviceFilter} }) { edges { node { id } } } }`,
|
|
191
|
+
}),
|
|
192
|
+
});
|
|
193
|
+
const depData = (await depRes.json());
|
|
194
|
+
const deploymentId = depData.data?.deployments?.edges?.[0]?.node?.id;
|
|
195
|
+
if (!deploymentId) {
|
|
196
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "No deployments found" }) }] };
|
|
197
|
+
}
|
|
198
|
+
const logRes = await fetch("https://backboard.railway.com/graphql/v2", {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
201
|
+
body: JSON.stringify({
|
|
202
|
+
query: `{ deploymentLogs(deploymentId: "${deploymentId}", limit: ${lines}) { timestamp message severity } }`,
|
|
203
|
+
}),
|
|
204
|
+
});
|
|
205
|
+
const logData = (await logRes.json());
|
|
206
|
+
const logs = logData.data?.deploymentLogs ?? [];
|
|
207
|
+
return {
|
|
208
|
+
content: [{ type: "text", text: JSON.stringify({ provider: "railway", deploymentId, logs }, null, 2) }],
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
// Vercel logs
|
|
212
|
+
if ((provider === "vercel" || !provider) && process.env.VERCEL_TOKEN) {
|
|
213
|
+
const token = process.env.VERCEL_TOKEN;
|
|
214
|
+
const depRes = await fetch("https://api.vercel.com/v6/deployments?limit=1", {
|
|
215
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
216
|
+
});
|
|
217
|
+
const depData = (await depRes.json());
|
|
218
|
+
const dep = depData.deployments?.[0];
|
|
219
|
+
if (!dep) {
|
|
220
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "No deployments found" }) }] };
|
|
221
|
+
}
|
|
222
|
+
const logRes = await fetch(`https://api.vercel.com/v2/deployments/${dep.uid}/events`, {
|
|
223
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
224
|
+
});
|
|
225
|
+
const events = (await logRes.json());
|
|
226
|
+
const logs = Array.isArray(events)
|
|
227
|
+
? events
|
|
228
|
+
.filter((e) => e.type === "stdout" || e.type === "stderr")
|
|
229
|
+
.slice(-lines)
|
|
230
|
+
.map((e) => ({
|
|
231
|
+
timestamp: new Date(e.created).toISOString(),
|
|
232
|
+
message: e.payload?.text ?? e.text ?? "",
|
|
233
|
+
severity: e.type === "stderr" ? "ERROR" : "INFO",
|
|
234
|
+
}))
|
|
235
|
+
: [];
|
|
236
|
+
return {
|
|
237
|
+
content: [{ type: "text", text: JSON.stringify({ provider: "vercel", deploymentUrl: dep.url, logs }, null, 2) }],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
content: [{ type: "text", text: JSON.stringify({ error: "No log provider available. Set RAILWAY_API_TOKEN or VERCEL_TOKEN." }) }],
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
// ──────────────────────────────────────────
|
|
245
|
+
// Tool: stk_todo_list
|
|
246
|
+
// ──────────────────────────────────────────
|
|
247
|
+
server.tool("stk_todo_list", "List open GitHub issues for this project. Helps understand what needs to be worked on.", {
|
|
248
|
+
label: z.string().optional().describe("Filter by label"),
|
|
249
|
+
limit: z.number().optional().default(15).describe("Max issues to return"),
|
|
250
|
+
}, async ({ label, limit }) => {
|
|
251
|
+
const config = loadConfig();
|
|
252
|
+
const repo = config.github?.repo ?? process.env.GITHUB_REPO ?? detectGitHubRepo();
|
|
253
|
+
const token = process.env.GITHUB_TOKEN;
|
|
254
|
+
if (!repo) {
|
|
255
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "Could not detect GitHub repo. Set GITHUB_REPO or add github.repo to stk.config.json" }) }] };
|
|
256
|
+
}
|
|
257
|
+
const params = new URLSearchParams({
|
|
258
|
+
state: "open",
|
|
259
|
+
per_page: String(limit),
|
|
260
|
+
sort: "updated",
|
|
261
|
+
direction: "desc",
|
|
262
|
+
});
|
|
263
|
+
if (label)
|
|
264
|
+
params.set("labels", label);
|
|
265
|
+
const headers = { Accept: "application/vnd.github+json" };
|
|
266
|
+
if (token)
|
|
267
|
+
headers.Authorization = `Bearer ${token}`;
|
|
268
|
+
const res = await fetch(`https://api.github.com/repos/${repo}/issues?${params}`, { headers });
|
|
269
|
+
if (!res.ok) {
|
|
270
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `GitHub API: ${res.status}` }) }] };
|
|
271
|
+
}
|
|
272
|
+
const issues = (await res.json());
|
|
273
|
+
const filtered = issues
|
|
274
|
+
.filter((i) => !i.pull_request)
|
|
275
|
+
.map((i) => ({
|
|
276
|
+
number: i.number,
|
|
277
|
+
title: i.title,
|
|
278
|
+
labels: i.labels.map((l) => l.name),
|
|
279
|
+
assignee: i.assignee?.login ?? null,
|
|
280
|
+
created: i.created_at,
|
|
281
|
+
url: i.html_url,
|
|
282
|
+
}));
|
|
283
|
+
return {
|
|
284
|
+
content: [{ type: "text", text: JSON.stringify({ repo, issues: filtered }, null, 2) }],
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
// ──────────────────────────────────────────
|
|
288
|
+
// Tool: stk_todo_add
|
|
289
|
+
// ──────────────────────────────────────────
|
|
290
|
+
server.tool("stk_todo_add", "Create a new GitHub issue for this project.", {
|
|
291
|
+
title: z.string().describe("Issue title"),
|
|
292
|
+
body: z.string().optional().describe("Issue body/description"),
|
|
293
|
+
labels: z.array(z.string()).optional().describe("Labels to add"),
|
|
294
|
+
}, async ({ title, body, labels }) => {
|
|
295
|
+
const config = loadConfig();
|
|
296
|
+
const repo = config.github?.repo ?? process.env.GITHUB_REPO ?? detectGitHubRepo();
|
|
297
|
+
const token = process.env.GITHUB_TOKEN;
|
|
298
|
+
if (!repo || !token) {
|
|
299
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "Need GITHUB_TOKEN and repo to create issues" }) }] };
|
|
300
|
+
}
|
|
301
|
+
const payload = { title };
|
|
302
|
+
if (body)
|
|
303
|
+
payload.body = body;
|
|
304
|
+
if (labels)
|
|
305
|
+
payload.labels = labels;
|
|
306
|
+
const res = await fetch(`https://api.github.com/repos/${repo}/issues`, {
|
|
307
|
+
method: "POST",
|
|
308
|
+
headers: {
|
|
309
|
+
Authorization: `Bearer ${token}`,
|
|
310
|
+
Accept: "application/vnd.github+json",
|
|
311
|
+
"Content-Type": "application/json",
|
|
312
|
+
},
|
|
313
|
+
body: JSON.stringify(payload),
|
|
314
|
+
});
|
|
315
|
+
if (!res.ok) {
|
|
316
|
+
const data = (await res.json());
|
|
317
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: data.message ?? `HTTP ${res.status}` }) }] };
|
|
318
|
+
}
|
|
319
|
+
const issue = (await res.json());
|
|
320
|
+
return {
|
|
321
|
+
content: [{ type: "text", text: JSON.stringify({ created: true, number: issue.number, url: issue.html_url }, null, 2) }],
|
|
322
|
+
};
|
|
323
|
+
});
|
|
324
|
+
// ──────────────────────────────────────────
|
|
325
|
+
// Tool: stk_deploy
|
|
326
|
+
// ──────────────────────────────────────────
|
|
327
|
+
server.tool("stk_deploy", "Push current branch to remote and trigger deploys. Use with caution — this pushes code to production.", {
|
|
328
|
+
skipPush: z.boolean().optional().describe("Skip git push, just report current deploy status"),
|
|
329
|
+
}, async ({ skipPush }) => {
|
|
330
|
+
const config = loadConfig();
|
|
331
|
+
const branch = config.deploy?.branch ?? "main";
|
|
332
|
+
if (!skipPush) {
|
|
333
|
+
try {
|
|
334
|
+
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
335
|
+
if (currentBranch !== branch) {
|
|
336
|
+
return {
|
|
337
|
+
content: [{ type: "text", text: JSON.stringify({ error: `On branch "${currentBranch}", not "${branch}". Switch branches first.` }) }],
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
execSync(`git push origin ${branch}`, { encoding: "utf-8", stdio: "pipe" });
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
return {
|
|
344
|
+
content: [{ type: "text", text: JSON.stringify({ error: `Git push failed: ${err.message}` }) }],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
content: [{
|
|
350
|
+
type: "text",
|
|
351
|
+
text: JSON.stringify({
|
|
352
|
+
pushed: !skipPush,
|
|
353
|
+
branch,
|
|
354
|
+
providers: config.deploy?.providers ?? [],
|
|
355
|
+
note: "Deploy triggered. Use stk_health to verify after a few minutes.",
|
|
356
|
+
}, null, 2),
|
|
357
|
+
}],
|
|
358
|
+
};
|
|
359
|
+
});
|
|
360
|
+
// ──────────────────────────────────────────
|
|
361
|
+
// Tool: stk_config
|
|
362
|
+
// ──────────────────────────────────────────
|
|
363
|
+
server.tool("stk_config", "Read the current stk configuration for this project. Shows which services are enabled, deploy settings, and project name.", {}, async () => {
|
|
364
|
+
const config = loadConfig();
|
|
365
|
+
return {
|
|
366
|
+
content: [{ type: "text", text: JSON.stringify(config, null, 2) }],
|
|
367
|
+
};
|
|
368
|
+
});
|
|
369
|
+
// ──────────────────────────────────────────
|
|
370
|
+
// Tool: stk_db
|
|
371
|
+
// ──────────────────────────────────────────
|
|
372
|
+
server.tool("stk_db", "Query your Supabase database directly. Run SELECT queries, check row counts, inspect table data — all from chat. Only read operations are allowed for safety.", {
|
|
373
|
+
query: z.string().optional().describe("SQL query to run (SELECT only for safety)"),
|
|
374
|
+
table: z.string().optional().describe("Shorthand: just provide a table name to SELECT * with a limit"),
|
|
375
|
+
limit: z.number().optional().describe("Max rows to return (default 20)"),
|
|
376
|
+
}, async ({ query, table, limit: rawLimit }) => {
|
|
377
|
+
const limit = rawLimit ?? 20;
|
|
378
|
+
const url = process.env.SUPABASE_URL;
|
|
379
|
+
const key = process.env.SUPABASE_SERVICE_KEY;
|
|
380
|
+
if (!url || !key) {
|
|
381
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "SUPABASE_URL or SUPABASE_SERVICE_KEY not set" }) }] };
|
|
382
|
+
}
|
|
383
|
+
// If table shorthand is used, build a simple query
|
|
384
|
+
let sql = query ?? "";
|
|
385
|
+
if (table && !query) {
|
|
386
|
+
sql = `SELECT * FROM ${table} ORDER BY created_at DESC LIMIT ${limit}`;
|
|
387
|
+
}
|
|
388
|
+
if (!sql && !table) {
|
|
389
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "Provide either a 'query' or 'table' parameter" }) }] };
|
|
390
|
+
}
|
|
391
|
+
// Safety: only allow read operations
|
|
392
|
+
const normalized = sql.trim().toUpperCase();
|
|
393
|
+
if (!normalized.startsWith("SELECT") && !normalized.startsWith("WITH")) {
|
|
394
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "Only SELECT/WITH queries are allowed for safety. Use Supabase dashboard for mutations." }) }] };
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
const res = await fetch(`${url}/rest/v1/rpc/`, {
|
|
398
|
+
method: "POST",
|
|
399
|
+
headers: {
|
|
400
|
+
apikey: key,
|
|
401
|
+
Authorization: `Bearer ${key}`,
|
|
402
|
+
"Content-Type": "application/json",
|
|
403
|
+
Prefer: "return=representation",
|
|
404
|
+
},
|
|
405
|
+
body: JSON.stringify({}),
|
|
406
|
+
});
|
|
407
|
+
// Use PostgREST query instead of RPC for better compatibility
|
|
408
|
+
// Parse table name from SQL for simple queries
|
|
409
|
+
const tableMatch = sql.match(/FROM\s+["']?(\w+)["']?/i);
|
|
410
|
+
const targetTable = table ?? tableMatch?.[1];
|
|
411
|
+
if (targetTable) {
|
|
412
|
+
const restRes = await fetch(`${url}/rest/v1/${targetTable}?select=*&limit=${limit}`, {
|
|
413
|
+
headers: {
|
|
414
|
+
apikey: key,
|
|
415
|
+
Authorization: `Bearer ${key}`,
|
|
416
|
+
"Content-Type": "application/json",
|
|
417
|
+
Prefer: "count=exact",
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
if (!restRes.ok) {
|
|
421
|
+
const errText = await restRes.text();
|
|
422
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `Query failed: ${errText}` }) }] };
|
|
423
|
+
}
|
|
424
|
+
const contentRange = restRes.headers.get("content-range");
|
|
425
|
+
const totalCount = contentRange ? contentRange.split("/")[1] : "unknown";
|
|
426
|
+
const data = await restRes.json();
|
|
427
|
+
return {
|
|
428
|
+
content: [{
|
|
429
|
+
type: "text",
|
|
430
|
+
text: JSON.stringify({
|
|
431
|
+
table: targetTable,
|
|
432
|
+
totalRows: totalCount,
|
|
433
|
+
returned: Array.isArray(data) ? data.length : 0,
|
|
434
|
+
data,
|
|
435
|
+
}, null, 2),
|
|
436
|
+
}],
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "Could not parse table name from query. Use the 'table' parameter instead." }) }] };
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
// ──────────────────────────────────────────
|
|
446
|
+
// Tool: stk_analytics
|
|
447
|
+
// ──────────────────────────────────────────
|
|
448
|
+
server.tool("stk_analytics", "Get live app analytics: total users, posts, payments, revenue, and recent activity. Pulls data from Supabase and Stripe in one call.", {}, async () => {
|
|
449
|
+
const results = {};
|
|
450
|
+
// Supabase stats
|
|
451
|
+
const url = process.env.SUPABASE_URL;
|
|
452
|
+
const key = process.env.SUPABASE_SERVICE_KEY;
|
|
453
|
+
if (url && key) {
|
|
454
|
+
const headers = {
|
|
455
|
+
apikey: key,
|
|
456
|
+
Authorization: `Bearer ${key}`,
|
|
457
|
+
Prefer: "count=exact",
|
|
458
|
+
};
|
|
459
|
+
// Get table counts in parallel
|
|
460
|
+
const [postsRes, usersRes, paymentsRes] = await Promise.all([
|
|
461
|
+
fetch(`${url}/rest/v1/posts?select=id&limit=0`, { headers }).catch(() => null),
|
|
462
|
+
fetch(`${url}/rest/v1/users?select=id&limit=0`, { headers }).catch(() => null),
|
|
463
|
+
fetch(`${url}/rest/v1/payments?select=id&limit=0`, { headers }).catch(() => null),
|
|
464
|
+
]);
|
|
465
|
+
const getCount = (res) => {
|
|
466
|
+
if (!res?.ok)
|
|
467
|
+
return null;
|
|
468
|
+
const range = res.headers.get("content-range");
|
|
469
|
+
return range ? parseInt(range.split("/")[1]) || 0 : null;
|
|
470
|
+
};
|
|
471
|
+
results.supabase = {
|
|
472
|
+
totalPosts: getCount(postsRes),
|
|
473
|
+
totalUsers: getCount(usersRes),
|
|
474
|
+
totalPayments: getCount(paymentsRes),
|
|
475
|
+
};
|
|
476
|
+
// Recent posts (last 24h)
|
|
477
|
+
const oneDayAgo = Math.floor(Date.now() / 1000) - 86400;
|
|
478
|
+
const recentRes = await fetch(`${url}/rest/v1/posts?select=id×tamp=gte.${oneDayAgo}&limit=0`, { headers: { ...headers, Prefer: "count=exact" } }).catch(() => null);
|
|
479
|
+
results.supabase.postsLast24h = getCount(recentRes);
|
|
480
|
+
// Recent users with paid posts
|
|
481
|
+
const paidUsersRes = await fetch(`${url}/rest/v1/users?select=id&paid_posts_available=gt.0&limit=0`, { headers: { ...headers, Prefer: "count=exact" } }).catch(() => null);
|
|
482
|
+
results.supabase.usersWithPaidPosts = getCount(paidUsersRes);
|
|
483
|
+
}
|
|
484
|
+
// Stripe stats
|
|
485
|
+
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
486
|
+
if (stripeKey) {
|
|
487
|
+
try {
|
|
488
|
+
// Balance
|
|
489
|
+
const balRes = await fetch("https://api.stripe.com/v1/balance", {
|
|
490
|
+
headers: { Authorization: `Bearer ${stripeKey}` },
|
|
491
|
+
});
|
|
492
|
+
const balData = await balRes.json();
|
|
493
|
+
// Recent charges
|
|
494
|
+
const chargesRes = await fetch("https://api.stripe.com/v1/charges?limit=100", {
|
|
495
|
+
headers: { Authorization: `Bearer ${stripeKey}` },
|
|
496
|
+
});
|
|
497
|
+
const chargesData = await chargesRes.json();
|
|
498
|
+
const charges = chargesData.data ?? [];
|
|
499
|
+
const totalRevenue = charges.reduce((sum, c) => sum + (c.status === "succeeded" ? c.amount : 0), 0);
|
|
500
|
+
results.stripe = {
|
|
501
|
+
balance: balData.available?.map((b) => `${(b.amount / 100).toFixed(2)} ${b.currency.toUpperCase()}`) ?? [],
|
|
502
|
+
totalCharges: charges.length,
|
|
503
|
+
successfulCharges: charges.filter((c) => c.status === "succeeded").length,
|
|
504
|
+
totalRevenue: `${(totalRevenue / 100).toFixed(2)}`,
|
|
505
|
+
mode: stripeKey.startsWith("sk_live") ? "live" : "test",
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
results.stripe = { error: err.message };
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return {
|
|
513
|
+
content: [{ type: "text", text: JSON.stringify({ analytics: results }, null, 2) }],
|
|
514
|
+
};
|
|
515
|
+
});
|
|
516
|
+
// ──────────────────────────────────────────
|
|
517
|
+
// Tool: stk_alerts
|
|
518
|
+
// ──────────────────────────────────────────
|
|
519
|
+
server.tool("stk_alerts", "Scan for problems across your entire stack: failed deploys, down services, error logs, Stripe failures, and database issues. Returns actionable alerts.", {}, async () => {
|
|
520
|
+
await loadPluginCheckers();
|
|
521
|
+
const config = loadConfig();
|
|
522
|
+
const alerts = [];
|
|
523
|
+
// 1. Check all service health
|
|
524
|
+
const serviceList = enabledServices(config);
|
|
525
|
+
const checks = serviceList.map(async (name) => {
|
|
526
|
+
const checker = getChecker(name);
|
|
527
|
+
if (!checker)
|
|
528
|
+
return null;
|
|
529
|
+
return checker();
|
|
530
|
+
});
|
|
531
|
+
const results = (await Promise.all(checks)).filter(Boolean);
|
|
532
|
+
for (const r of results) {
|
|
533
|
+
if (r.status === "down") {
|
|
534
|
+
alerts.push({ level: "critical", source: r.name, message: `Service is DOWN: ${r.detail ?? "unreachable"}` });
|
|
535
|
+
}
|
|
536
|
+
else if (r.status === "degraded") {
|
|
537
|
+
alerts.push({ level: "warning", source: r.name, message: `Service degraded: ${r.detail ?? "slow response"}` });
|
|
538
|
+
}
|
|
539
|
+
else if (r.latency && r.latency > 3000) {
|
|
540
|
+
alerts.push({ level: "warning", source: r.name, message: `High latency: ${r.latency}ms` });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// 2. Check Vercel for failed deploys
|
|
544
|
+
if (process.env.VERCEL_TOKEN) {
|
|
545
|
+
try {
|
|
546
|
+
const res = await fetch("https://api.vercel.com/v6/deployments?limit=5", {
|
|
547
|
+
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
|
|
548
|
+
});
|
|
549
|
+
const data = await res.json();
|
|
550
|
+
for (const dep of data.deployments ?? []) {
|
|
551
|
+
const state = dep.readyState ?? dep.state;
|
|
552
|
+
if (state === "ERROR" || state === "CANCELED") {
|
|
553
|
+
alerts.push({ level: "critical", source: "Vercel", message: `Deploy ${state}: ${dep.url ?? dep.uid}` });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
catch { /* skip */ }
|
|
558
|
+
}
|
|
559
|
+
// 3. Check Stripe for recent failures
|
|
560
|
+
if (process.env.STRIPE_SECRET_KEY) {
|
|
561
|
+
try {
|
|
562
|
+
const res = await fetch("https://api.stripe.com/v1/charges?limit=20", {
|
|
563
|
+
headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}` },
|
|
564
|
+
});
|
|
565
|
+
const data = await res.json();
|
|
566
|
+
const failed = (data.data ?? []).filter((c) => c.status === "failed");
|
|
567
|
+
if (failed.length > 0) {
|
|
568
|
+
alerts.push({ level: "warning", source: "Stripe", message: `${failed.length} failed charge(s) in recent transactions` });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
catch { /* skip */ }
|
|
572
|
+
}
|
|
573
|
+
// 4. Check for error logs in Vercel
|
|
574
|
+
if (process.env.VERCEL_TOKEN) {
|
|
575
|
+
try {
|
|
576
|
+
const depRes = await fetch("https://api.vercel.com/v6/deployments?limit=1", {
|
|
577
|
+
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
|
|
578
|
+
});
|
|
579
|
+
const depData = await depRes.json();
|
|
580
|
+
const dep = depData.deployments?.[0];
|
|
581
|
+
if (dep) {
|
|
582
|
+
const logRes = await fetch(`https://api.vercel.com/v2/deployments/${dep.uid}/events`, {
|
|
583
|
+
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
|
|
584
|
+
});
|
|
585
|
+
const events = await logRes.json();
|
|
586
|
+
if (Array.isArray(events)) {
|
|
587
|
+
const errors = events.filter((e) => e.type === "stderr");
|
|
588
|
+
if (errors.length > 5) {
|
|
589
|
+
alerts.push({ level: "warning", source: "Vercel Logs", message: `${errors.length} stderr entries in latest deploy` });
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
catch { /* skip */ }
|
|
595
|
+
}
|
|
596
|
+
if (alerts.length === 0) {
|
|
597
|
+
alerts.push({ level: "info", source: "stk", message: "All clear — no issues detected" });
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
content: [{
|
|
601
|
+
type: "text",
|
|
602
|
+
text: JSON.stringify({
|
|
603
|
+
project: config.name,
|
|
604
|
+
alerts,
|
|
605
|
+
summary: {
|
|
606
|
+
critical: alerts.filter((a) => a.level === "critical").length,
|
|
607
|
+
warnings: alerts.filter((a) => a.level === "warning").length,
|
|
608
|
+
ok: alerts.every((a) => a.level === "info"),
|
|
609
|
+
},
|
|
610
|
+
}, null, 2),
|
|
611
|
+
}],
|
|
612
|
+
};
|
|
613
|
+
});
|
|
614
|
+
// ──────────────────────────────────────────
|
|
615
|
+
// Tool: stk_rollback
|
|
616
|
+
// ──────────────────────────────────────────
|
|
617
|
+
server.tool("stk_rollback", "Rollback to a previous Vercel deployment. Lists recent deploys and can promote an older one to production.", {
|
|
618
|
+
deployId: z.string().optional().describe("Deployment ID to rollback to. If omitted, lists recent deployments to choose from."),
|
|
619
|
+
confirm: z.boolean().optional().default(false).describe("Must be true to actually execute the rollback"),
|
|
620
|
+
}, async ({ deployId, confirm }) => {
|
|
621
|
+
const token = process.env.VERCEL_TOKEN;
|
|
622
|
+
if (!token) {
|
|
623
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "VERCEL_TOKEN not set" }) }] };
|
|
624
|
+
}
|
|
625
|
+
// List recent deployments
|
|
626
|
+
const depRes = await fetch("https://api.vercel.com/v6/deployments?limit=10", {
|
|
627
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
628
|
+
});
|
|
629
|
+
const depData = await depRes.json();
|
|
630
|
+
const deployments = (depData.deployments ?? []).map((d) => ({
|
|
631
|
+
id: d.uid,
|
|
632
|
+
url: d.url,
|
|
633
|
+
state: d.readyState ?? d.state,
|
|
634
|
+
created: new Date(d.created).toISOString(),
|
|
635
|
+
target: d.target ?? "preview",
|
|
636
|
+
}));
|
|
637
|
+
if (!deployId) {
|
|
638
|
+
return {
|
|
639
|
+
content: [{
|
|
640
|
+
type: "text",
|
|
641
|
+
text: JSON.stringify({
|
|
642
|
+
message: "Recent deployments — provide a deployId to rollback",
|
|
643
|
+
deployments,
|
|
644
|
+
}, null, 2),
|
|
645
|
+
}],
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
if (!confirm) {
|
|
649
|
+
const target = deployments.find((d) => d.id === deployId);
|
|
650
|
+
return {
|
|
651
|
+
content: [{
|
|
652
|
+
type: "text",
|
|
653
|
+
text: JSON.stringify({
|
|
654
|
+
message: "Rollback requires confirmation. Call again with confirm: true",
|
|
655
|
+
target: target ?? deployId,
|
|
656
|
+
}, null, 2),
|
|
657
|
+
}],
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
// Execute rollback by promoting the old deployment
|
|
661
|
+
try {
|
|
662
|
+
// Get the deployment's project
|
|
663
|
+
const detailRes = await fetch(`https://api.vercel.com/v13/deployments/${deployId}`, {
|
|
664
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
665
|
+
});
|
|
666
|
+
const detail = await detailRes.json();
|
|
667
|
+
const projectId = detail.projectId;
|
|
668
|
+
if (!projectId) {
|
|
669
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "Could not determine project from deployment" }) }] };
|
|
670
|
+
}
|
|
671
|
+
// Create a new deployment based on the old one (redeploy)
|
|
672
|
+
const rollbackRes = await fetch(`https://api.vercel.com/v13/deployments`, {
|
|
673
|
+
method: "POST",
|
|
674
|
+
headers: {
|
|
675
|
+
Authorization: `Bearer ${token}`,
|
|
676
|
+
"Content-Type": "application/json",
|
|
677
|
+
},
|
|
678
|
+
body: JSON.stringify({
|
|
679
|
+
name: detail.name,
|
|
680
|
+
deploymentId: deployId,
|
|
681
|
+
target: "production",
|
|
682
|
+
}),
|
|
683
|
+
});
|
|
684
|
+
if (!rollbackRes.ok) {
|
|
685
|
+
const errData = await rollbackRes.json();
|
|
686
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: errData.error?.message ?? `HTTP ${rollbackRes.status}` }) }] };
|
|
687
|
+
}
|
|
688
|
+
const rollbackData = await rollbackRes.json();
|
|
689
|
+
return {
|
|
690
|
+
content: [{
|
|
691
|
+
type: "text",
|
|
692
|
+
text: JSON.stringify({
|
|
693
|
+
rolledBack: true,
|
|
694
|
+
newDeploymentId: rollbackData.id,
|
|
695
|
+
url: rollbackData.url,
|
|
696
|
+
note: "Rollback triggered. Use stk_health to verify.",
|
|
697
|
+
}, null, 2),
|
|
698
|
+
}],
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: err.message }) }] };
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
// ──────────────────────────────────────────
|
|
706
|
+
// Tool: stk_env_sync
|
|
707
|
+
// ──────────────────────────────────────────
|
|
708
|
+
server.tool("stk_env_sync", "Compare and sync environment variables between local .env and Vercel. Shows which vars are missing, extra, or mismatched.", {
|
|
709
|
+
action: z.enum(["diff", "pull", "push"]).optional().default("diff").describe("diff: compare local vs remote. pull: download remote to .env.pulled. push: upload local to Vercel."),
|
|
710
|
+
confirm: z.boolean().optional().default(false).describe("Required for push action"),
|
|
711
|
+
}, async ({ action, confirm }) => {
|
|
712
|
+
const token = process.env.VERCEL_TOKEN;
|
|
713
|
+
const projectId = process.env.VERCEL_PROJECT_ID;
|
|
714
|
+
// Read local .env
|
|
715
|
+
let localVars = {};
|
|
716
|
+
try {
|
|
717
|
+
const { readFileSync } = await import("fs");
|
|
718
|
+
const envContent = readFileSync(".env", "utf-8");
|
|
719
|
+
for (const line of envContent.split("\n")) {
|
|
720
|
+
const trimmed = line.trim();
|
|
721
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
722
|
+
continue;
|
|
723
|
+
const eqIdx = trimmed.indexOf("=");
|
|
724
|
+
if (eqIdx > 0) {
|
|
725
|
+
localVars[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
localVars = {};
|
|
731
|
+
}
|
|
732
|
+
if (!token) {
|
|
733
|
+
return {
|
|
734
|
+
content: [{
|
|
735
|
+
type: "text",
|
|
736
|
+
text: JSON.stringify({
|
|
737
|
+
localVars: Object.keys(localVars),
|
|
738
|
+
remote: "VERCEL_TOKEN not set — cannot fetch remote env vars",
|
|
739
|
+
}, null, 2),
|
|
740
|
+
}],
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
// Fetch Vercel env vars
|
|
744
|
+
let remoteVars = {};
|
|
745
|
+
const envUrl = projectId
|
|
746
|
+
? `https://api.vercel.com/v9/projects/${projectId}/env`
|
|
747
|
+
: null;
|
|
748
|
+
if (envUrl) {
|
|
749
|
+
try {
|
|
750
|
+
const res = await fetch(envUrl, {
|
|
751
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
752
|
+
});
|
|
753
|
+
const data = await res.json();
|
|
754
|
+
for (const env of data.envs ?? []) {
|
|
755
|
+
remoteVars[env.key] = env.value ?? "(encrypted)";
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
catch { /* skip */ }
|
|
759
|
+
}
|
|
760
|
+
const localKeys = new Set(Object.keys(localVars));
|
|
761
|
+
const remoteKeys = new Set(Object.keys(remoteVars));
|
|
762
|
+
const onlyLocal = [...localKeys].filter((k) => !remoteKeys.has(k));
|
|
763
|
+
const onlyRemote = [...remoteKeys].filter((k) => !localKeys.has(k));
|
|
764
|
+
const shared = [...localKeys].filter((k) => remoteKeys.has(k));
|
|
765
|
+
if (action === "diff" || !action) {
|
|
766
|
+
return {
|
|
767
|
+
content: [{
|
|
768
|
+
type: "text",
|
|
769
|
+
text: JSON.stringify({
|
|
770
|
+
localCount: localKeys.size,
|
|
771
|
+
remoteCount: remoteKeys.size,
|
|
772
|
+
onlyInLocal: onlyLocal,
|
|
773
|
+
onlyInRemote: onlyRemote,
|
|
774
|
+
inBoth: shared.length,
|
|
775
|
+
note: projectId ? undefined : "Set VERCEL_PROJECT_ID for remote env comparison",
|
|
776
|
+
}, null, 2),
|
|
777
|
+
}],
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
if (action === "pull") {
|
|
781
|
+
const { writeFileSync } = await import("fs");
|
|
782
|
+
const lines = Object.entries(remoteVars).map(([k, v]) => `${k}=${v}`);
|
|
783
|
+
writeFileSync(".env.pulled", lines.join("\n") + "\n");
|
|
784
|
+
return {
|
|
785
|
+
content: [{ type: "text", text: JSON.stringify({ pulled: true, file: ".env.pulled", count: lines.length }) }],
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
if (action === "push") {
|
|
789
|
+
if (!confirm) {
|
|
790
|
+
return {
|
|
791
|
+
content: [{ type: "text", text: JSON.stringify({ message: "Push requires confirm: true. This will overwrite remote env vars.", varsToUpload: onlyLocal.length + shared.length }) }],
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
if (!projectId || !envUrl) {
|
|
795
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "VERCEL_PROJECT_ID required for push" }) }] };
|
|
796
|
+
}
|
|
797
|
+
let uploaded = 0;
|
|
798
|
+
for (const [key, value] of Object.entries(localVars)) {
|
|
799
|
+
await fetch(envUrl, {
|
|
800
|
+
method: "POST",
|
|
801
|
+
headers: {
|
|
802
|
+
Authorization: `Bearer ${token}`,
|
|
803
|
+
"Content-Type": "application/json",
|
|
804
|
+
},
|
|
805
|
+
body: JSON.stringify({
|
|
806
|
+
key,
|
|
807
|
+
value,
|
|
808
|
+
type: "encrypted",
|
|
809
|
+
target: ["production", "preview", "development"],
|
|
810
|
+
}),
|
|
811
|
+
});
|
|
812
|
+
uploaded++;
|
|
813
|
+
}
|
|
814
|
+
return {
|
|
815
|
+
content: [{ type: "text", text: JSON.stringify({ pushed: true, uploaded }) }],
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "Unknown action" }) }] };
|
|
819
|
+
});
|
|
820
|
+
// ──────────────────────────────────────────
|
|
821
|
+
// Tool: stk_perf
|
|
822
|
+
// ──────────────────────────────────────────
|
|
823
|
+
server.tool("stk_perf", "Check performance across your stack: Supabase query latency, table sizes, Vercel deploy times, and API response times.", {
|
|
824
|
+
tables: z.array(z.string()).optional().describe("Specific Supabase tables to benchmark (defaults to all detected)"),
|
|
825
|
+
}, async ({ tables }) => {
|
|
826
|
+
const perf = {};
|
|
827
|
+
// Supabase performance
|
|
828
|
+
const url = process.env.SUPABASE_URL;
|
|
829
|
+
const key = process.env.SUPABASE_SERVICE_KEY;
|
|
830
|
+
if (url && key) {
|
|
831
|
+
const headers = {
|
|
832
|
+
apikey: key,
|
|
833
|
+
Authorization: `Bearer ${key}`,
|
|
834
|
+
Prefer: "count=exact",
|
|
835
|
+
};
|
|
836
|
+
// Auto-detect tables or use provided list
|
|
837
|
+
const tablesToCheck = tables ?? ["posts", "users", "payments"];
|
|
838
|
+
const tableStats = [];
|
|
839
|
+
for (const table of tablesToCheck) {
|
|
840
|
+
const start = Date.now();
|
|
841
|
+
try {
|
|
842
|
+
const res = await fetch(`${url}/rest/v1/${table}?select=id&limit=0`, {
|
|
843
|
+
headers,
|
|
844
|
+
});
|
|
845
|
+
const latency = Date.now() - start;
|
|
846
|
+
const range = res.headers.get("content-range");
|
|
847
|
+
const count = range ? parseInt(range.split("/")[1]) || 0 : null;
|
|
848
|
+
let status = "fast";
|
|
849
|
+
if (latency > 1000)
|
|
850
|
+
status = "slow";
|
|
851
|
+
else if (latency > 500)
|
|
852
|
+
status = "moderate";
|
|
853
|
+
tableStats.push({ table, rowCount: count, queryLatency: latency, status });
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
tableStats.push({ table, rowCount: null, queryLatency: Date.now() - start, status: "error" });
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
// API latency test
|
|
860
|
+
const apiStart = Date.now();
|
|
861
|
+
await fetch(`${url}/rest/v1/`, { headers }).catch(() => null);
|
|
862
|
+
const apiLatency = Date.now() - apiStart;
|
|
863
|
+
perf.supabase = {
|
|
864
|
+
apiLatency,
|
|
865
|
+
tables: tableStats,
|
|
866
|
+
recommendation: tableStats.some((t) => t.status === "slow")
|
|
867
|
+
? "Some queries are slow. Consider adding database indexes."
|
|
868
|
+
: tableStats.some((t) => (t.rowCount ?? 0) > 10000)
|
|
869
|
+
? "Large tables detected. Ensure you have indexes on frequently queried columns."
|
|
870
|
+
: "Performance looks good.",
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
// Vercel deploy performance
|
|
874
|
+
if (process.env.VERCEL_TOKEN) {
|
|
875
|
+
try {
|
|
876
|
+
const res = await fetch("https://api.vercel.com/v6/deployments?limit=5", {
|
|
877
|
+
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
|
|
878
|
+
});
|
|
879
|
+
const data = await res.json();
|
|
880
|
+
const deploys = (data.deployments ?? []).map((d) => {
|
|
881
|
+
const buildDuration = d.buildingAt && d.ready
|
|
882
|
+
? Math.round((d.ready - d.buildingAt) / 1000)
|
|
883
|
+
: null;
|
|
884
|
+
return {
|
|
885
|
+
id: d.uid,
|
|
886
|
+
state: d.readyState ?? d.state,
|
|
887
|
+
buildDuration: buildDuration ? `${buildDuration}s` : "unknown",
|
|
888
|
+
created: new Date(d.created).toISOString(),
|
|
889
|
+
};
|
|
890
|
+
});
|
|
891
|
+
perf.vercel = {
|
|
892
|
+
recentDeploys: deploys,
|
|
893
|
+
avgBuildTime: deploys.filter((d) => d.buildDuration !== "unknown").length > 0
|
|
894
|
+
? "see individual deploys"
|
|
895
|
+
: "no build data available",
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
catch { /* skip */ }
|
|
899
|
+
}
|
|
900
|
+
// Stripe API latency
|
|
901
|
+
if (process.env.STRIPE_SECRET_KEY) {
|
|
902
|
+
const start = Date.now();
|
|
903
|
+
await fetch("https://api.stripe.com/v1/balance", {
|
|
904
|
+
headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}` },
|
|
905
|
+
}).catch(() => null);
|
|
906
|
+
perf.stripe = { apiLatency: Date.now() - start };
|
|
907
|
+
}
|
|
908
|
+
return {
|
|
909
|
+
content: [{ type: "text", text: JSON.stringify({ performance: perf }, null, 2) }],
|
|
910
|
+
};
|
|
911
|
+
});
|
|
912
|
+
// ──────────────────────────────────────────
|
|
913
|
+
// Tool: stk_cost
|
|
914
|
+
// ──────────────────────────────────────────
|
|
915
|
+
server.tool("stk_cost", "Track costs across your stack: Stripe fees, Vercel usage, Supabase plan details. Get a unified view of what you're spending.", {}, async () => {
|
|
916
|
+
const costs = {};
|
|
917
|
+
// Stripe revenue & fees
|
|
918
|
+
if (process.env.STRIPE_SECRET_KEY) {
|
|
919
|
+
try {
|
|
920
|
+
const stripeKey = process.env.STRIPE_SECRET_KEY;
|
|
921
|
+
// Balance
|
|
922
|
+
const balRes = await fetch("https://api.stripe.com/v1/balance", {
|
|
923
|
+
headers: { Authorization: `Bearer ${stripeKey}` },
|
|
924
|
+
});
|
|
925
|
+
const balData = await balRes.json();
|
|
926
|
+
// Recent balance transactions for fee tracking
|
|
927
|
+
const txRes = await fetch("https://api.stripe.com/v1/balance_transactions?limit=100", {
|
|
928
|
+
headers: { Authorization: `Bearer ${stripeKey}` },
|
|
929
|
+
});
|
|
930
|
+
const txData = await txRes.json();
|
|
931
|
+
const transactions = txData.data ?? [];
|
|
932
|
+
const totalFees = transactions.reduce((sum, t) => sum + (t.fee || 0), 0);
|
|
933
|
+
const totalGross = transactions.reduce((sum, t) => sum + (t.amount > 0 ? t.amount : 0), 0);
|
|
934
|
+
const totalNet = transactions.reduce((sum, t) => sum + (t.net || 0), 0);
|
|
935
|
+
costs.stripe = {
|
|
936
|
+
mode: stripeKey.startsWith("sk_live") ? "live" : "test",
|
|
937
|
+
balance: balData.available?.map((b) => ({
|
|
938
|
+
amount: (b.amount / 100).toFixed(2),
|
|
939
|
+
currency: b.currency.toUpperCase(),
|
|
940
|
+
})) ?? [],
|
|
941
|
+
recentTransactions: transactions.length,
|
|
942
|
+
totalGross: (totalGross / 100).toFixed(2),
|
|
943
|
+
totalFees: (totalFees / 100).toFixed(2),
|
|
944
|
+
totalNet: (totalNet / 100).toFixed(2),
|
|
945
|
+
feePercentage: totalGross > 0 ? ((totalFees / totalGross) * 100).toFixed(1) + "%" : "N/A",
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
catch (err) {
|
|
949
|
+
costs.stripe = { error: err.message };
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
// Vercel usage
|
|
953
|
+
if (process.env.VERCEL_TOKEN) {
|
|
954
|
+
try {
|
|
955
|
+
// Get team/user info for billing context
|
|
956
|
+
const userRes = await fetch("https://api.vercel.com/v2/user", {
|
|
957
|
+
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
|
|
958
|
+
});
|
|
959
|
+
const userData = await userRes.json();
|
|
960
|
+
// Count deployments
|
|
961
|
+
const depRes = await fetch("https://api.vercel.com/v6/deployments?limit=100", {
|
|
962
|
+
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
|
|
963
|
+
});
|
|
964
|
+
const depData = await depRes.json();
|
|
965
|
+
const deployments = depData.deployments ?? [];
|
|
966
|
+
// Deployments this month
|
|
967
|
+
const monthStart = new Date();
|
|
968
|
+
monthStart.setDate(1);
|
|
969
|
+
monthStart.setHours(0, 0, 0, 0);
|
|
970
|
+
const thisMonth = deployments.filter((d) => new Date(d.created) >= monthStart);
|
|
971
|
+
costs.vercel = {
|
|
972
|
+
plan: userData.user?.billing?.plan ?? "unknown",
|
|
973
|
+
deploymentsThisMonth: thisMonth.length,
|
|
974
|
+
totalDeployments: deployments.length,
|
|
975
|
+
note: "Vercel free tier includes 100 deployments/day. Check vercel.com/dashboard for detailed billing.",
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
catch { /* skip */ }
|
|
979
|
+
}
|
|
980
|
+
// Supabase usage estimate
|
|
981
|
+
if (process.env.SUPABASE_URL && process.env.SUPABASE_SERVICE_KEY) {
|
|
982
|
+
const url = process.env.SUPABASE_URL;
|
|
983
|
+
const key = process.env.SUPABASE_SERVICE_KEY;
|
|
984
|
+
const headers = {
|
|
985
|
+
apikey: key,
|
|
986
|
+
Authorization: `Bearer ${key}`,
|
|
987
|
+
Prefer: "count=exact",
|
|
988
|
+
};
|
|
989
|
+
const tableCounts = {};
|
|
990
|
+
for (const table of ["posts", "users", "payments"]) {
|
|
991
|
+
try {
|
|
992
|
+
const res = await fetch(`${url}/rest/v1/${table}?select=id&limit=0`, { headers });
|
|
993
|
+
const range = res.headers.get("content-range");
|
|
994
|
+
tableCounts[table] = range ? parseInt(range.split("/")[1]) || 0 : null;
|
|
995
|
+
}
|
|
996
|
+
catch {
|
|
997
|
+
tableCounts[table] = null;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
const totalRows = Object.values(tableCounts).reduce((s, v) => s + (v ?? 0), 0);
|
|
1001
|
+
costs.supabase = {
|
|
1002
|
+
tables: tableCounts,
|
|
1003
|
+
totalRows,
|
|
1004
|
+
note: "Supabase free tier: 500MB database, 1GB storage, 50k monthly active users. Check supabase.com/dashboard for detailed usage.",
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
return {
|
|
1008
|
+
content: [{ type: "text", text: JSON.stringify({ costs }, null, 2) }],
|
|
1009
|
+
};
|
|
1010
|
+
});
|
|
1011
|
+
// Helper
|
|
1012
|
+
function detectGitHubRepo() {
|
|
1013
|
+
try {
|
|
1014
|
+
const url = execSync("git remote get-url origin", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
1015
|
+
const match = url.match(/github\.com[:/]([^/]+\/[^/.]+)/);
|
|
1016
|
+
return match?.[1] ?? null;
|
|
1017
|
+
}
|
|
1018
|
+
catch {
|
|
1019
|
+
return null;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
// Start
|
|
1023
|
+
async function main() {
|
|
1024
|
+
const transport = new StdioServerTransport();
|
|
1025
|
+
await server.connect(transport);
|
|
1026
|
+
}
|
|
1027
|
+
main().catch(console.error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prajwolkc/stk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "One CLI to deploy, monitor, and debug your entire stack. Health checks, deploy watching, env sync, logs, and GitHub issues — all from one command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"developer-tools"
|
|
27
27
|
],
|
|
28
28
|
"bin": {
|
|
29
|
-
"stk": "dist/index.js"
|
|
29
|
+
"stk": "dist/index.js",
|
|
30
|
+
"stk-mcp": "dist/mcp/server.js"
|
|
30
31
|
},
|
|
31
32
|
"files": [
|
|
32
33
|
"dist",
|
|
@@ -45,9 +46,11 @@
|
|
|
45
46
|
"prepublishOnly": "npm run build"
|
|
46
47
|
},
|
|
47
48
|
"dependencies": {
|
|
49
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
48
50
|
"chalk": "^5.4.1",
|
|
49
51
|
"commander": "^13.1.0",
|
|
50
|
-
"ora": "^8.2.0"
|
|
52
|
+
"ora": "^8.2.0",
|
|
53
|
+
"zod": "^4.3.6"
|
|
51
54
|
},
|
|
52
55
|
"devDependencies": {
|
|
53
56
|
"@types/node": "^22.13.0",
|