@prajwolkc/stk 0.1.1 → 0.2.1
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 +112 -5
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +220 -0
- package/dist/commands/health.js +29 -4
- package/dist/commands/logs.js +202 -97
- package/dist/index.js +3 -1
- package/dist/lib/output.d.ts +7 -0
- package/dist/lib/output.js +14 -0
- package/dist/lib/plugins.d.ts +40 -0
- package/dist/lib/plugins.js +65 -0
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.js +385 -0
- package/dist/services/aws.js +39 -10
- package/dist/services/database.js +25 -6
- package/dist/services/registry.d.ts +1 -0
- package/dist/services/registry.js +16 -2
- package/package.json +10 -4
|
@@ -0,0 +1,385 @@
|
|
|
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
|
+
// Helper
|
|
370
|
+
function detectGitHubRepo() {
|
|
371
|
+
try {
|
|
372
|
+
const url = execSync("git remote get-url origin", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
373
|
+
const match = url.match(/github\.com[:/]([^/]+\/[^/.]+)/);
|
|
374
|
+
return match?.[1] ?? null;
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// Start
|
|
381
|
+
async function main() {
|
|
382
|
+
const transport = new StdioServerTransport();
|
|
383
|
+
await server.connect(transport);
|
|
384
|
+
}
|
|
385
|
+
main().catch(console.error);
|
package/dist/services/aws.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runCheck } from "./checker.js";
|
|
2
|
+
import { createHmac, createHash } from "crypto";
|
|
2
3
|
export async function checkAWS() {
|
|
3
4
|
const accessKey = process.env.AWS_ACCESS_KEY_ID;
|
|
4
5
|
const secretKey = process.env.AWS_SECRET_ACCESS_KEY;
|
|
@@ -11,22 +12,50 @@ export async function checkAWS() {
|
|
|
11
12
|
};
|
|
12
13
|
}
|
|
13
14
|
return runCheck("AWS", async () => {
|
|
14
|
-
//
|
|
15
|
+
// AWS STS GetCallerIdentity with Signature V4
|
|
16
|
+
const service = "sts";
|
|
15
17
|
const host = `sts.${region}.amazonaws.com`;
|
|
16
18
|
const body = "Action=GetCallerIdentity&Version=2011-06-15";
|
|
17
19
|
const now = new Date();
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
21
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
22
|
+
const bodyHash = sha256(body);
|
|
23
|
+
const canonicalHeaders = `content-type:application/x-www-form-urlencoded\nhost:${host}\nx-amz-date:${amzDate}\n`;
|
|
24
|
+
const signedHeaders = "content-type;host;x-amz-date";
|
|
25
|
+
const canonicalRequest = `POST\n/\n\n${canonicalHeaders}\n${signedHeaders}\n${bodyHash}`;
|
|
26
|
+
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
|
|
27
|
+
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${sha256(canonicalRequest)}`;
|
|
28
|
+
const signingKey = getSignatureKey(secretKey, dateStamp, region, service);
|
|
29
|
+
const signature = hmac(signingKey, stringToSign).toString("hex");
|
|
30
|
+
const authHeader = `AWS4-HMAC-SHA256 Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
20
31
|
const res = await fetch(`https://${host}/`, {
|
|
21
32
|
method: "POST",
|
|
22
|
-
headers: {
|
|
33
|
+
headers: {
|
|
34
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
35
|
+
"X-Amz-Date": amzDate,
|
|
36
|
+
Authorization: authHeader,
|
|
37
|
+
},
|
|
23
38
|
body,
|
|
24
39
|
});
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
40
|
+
if (!res.ok)
|
|
41
|
+
throw new Error(`HTTP ${res.status} — invalid credentials`);
|
|
42
|
+
const text = await res.text();
|
|
43
|
+
const accountMatch = text.match(/<Account>(.+?)<\/Account>/);
|
|
44
|
+
const detail = accountMatch
|
|
45
|
+
? `account ${accountMatch[1]} (${region})`
|
|
46
|
+
: `${region} — authenticated`;
|
|
47
|
+
return { detail };
|
|
31
48
|
});
|
|
32
49
|
}
|
|
50
|
+
function sha256(data) {
|
|
51
|
+
return createHash("sha256").update(data).digest("hex");
|
|
52
|
+
}
|
|
53
|
+
function hmac(key, data) {
|
|
54
|
+
return createHmac("sha256", key).update(data).digest();
|
|
55
|
+
}
|
|
56
|
+
function getSignatureKey(key, date, region, service) {
|
|
57
|
+
const kDate = hmac(`AWS4${key}`, date);
|
|
58
|
+
const kRegion = hmac(kDate, region);
|
|
59
|
+
const kService = hmac(kRegion, service);
|
|
60
|
+
return hmac(kService, "aws4_request");
|
|
61
|
+
}
|
|
@@ -5,16 +5,35 @@ export async function checkDatabase() {
|
|
|
5
5
|
return { name: "PostgreSQL", status: "skipped", detail: "DATABASE_URL not set" };
|
|
6
6
|
}
|
|
7
7
|
return runCheck("PostgreSQL", async () => {
|
|
8
|
-
// Parse host and port from DATABASE_URL to do a basic TCP connect check
|
|
9
|
-
// Full query check would require pg client — keeping deps minimal for now
|
|
10
8
|
const parsed = new URL(url);
|
|
11
9
|
const host = parsed.hostname;
|
|
12
10
|
const port = parseInt(parsed.port || "5432", 10);
|
|
11
|
+
const dbName = parsed.pathname.replace("/", "") || "unknown";
|
|
13
12
|
const { createConnection } = await import("net");
|
|
14
|
-
|
|
13
|
+
// Attempt a real PostgreSQL startup message handshake
|
|
14
|
+
const version = await new Promise((resolve, reject) => {
|
|
15
15
|
const socket = createConnection({ host, port, timeout: 5000 }, () => {
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
// Send PostgreSQL startup message (protocol v3.0)
|
|
17
|
+
const user = parsed.username || "postgres";
|
|
18
|
+
const params = `user\0${user}\0database\0${dbName}\0\0`;
|
|
19
|
+
const len = 4 + 4 + params.length;
|
|
20
|
+
const buf = Buffer.alloc(len);
|
|
21
|
+
buf.writeInt32BE(len, 0);
|
|
22
|
+
buf.writeInt32BE(196608, 4); // protocol 3.0
|
|
23
|
+
buf.write(params, 8);
|
|
24
|
+
socket.write(buf);
|
|
25
|
+
socket.once("data", (data) => {
|
|
26
|
+
socket.destroy();
|
|
27
|
+
const tag = String.fromCharCode(data[0]);
|
|
28
|
+
// 'R' = AuthenticationRequest (server recognized us as postgres)
|
|
29
|
+
// 'E' = Error (but server is responding — it's alive)
|
|
30
|
+
if (tag === "R" || tag === "E") {
|
|
31
|
+
resolve("protocol ok");
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
resolve("reachable");
|
|
35
|
+
}
|
|
36
|
+
});
|
|
18
37
|
});
|
|
19
38
|
socket.on("error", reject);
|
|
20
39
|
socket.on("timeout", () => {
|
|
@@ -22,6 +41,6 @@ export async function checkDatabase() {
|
|
|
22
41
|
reject(new Error("connection timeout"));
|
|
23
42
|
});
|
|
24
43
|
});
|
|
25
|
-
return { detail: `${host}:${port}
|
|
44
|
+
return { detail: `${host}:${port}/${dbName} — ${version}` };
|
|
26
45
|
});
|
|
27
46
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CheckResult } from "./checker.js";
|
|
2
2
|
export type HealthChecker = () => Promise<CheckResult>;
|
|
3
|
+
export declare function loadPluginCheckers(): Promise<void>;
|
|
3
4
|
export declare function getChecker(service: string): HealthChecker | null;
|
|
4
5
|
export declare function allCheckerNames(): string[];
|
|
@@ -29,9 +29,23 @@ const registry = {
|
|
|
29
29
|
r2: checkR2,
|
|
30
30
|
stripe: checkStripe,
|
|
31
31
|
};
|
|
32
|
+
let pluginCheckers = {};
|
|
33
|
+
let pluginsLoaded = false;
|
|
34
|
+
export async function loadPluginCheckers() {
|
|
35
|
+
if (pluginsLoaded)
|
|
36
|
+
return;
|
|
37
|
+
try {
|
|
38
|
+
const { getPluginCheckers } = await import("../lib/plugins.js");
|
|
39
|
+
pluginCheckers = await getPluginCheckers();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Plugins not available
|
|
43
|
+
}
|
|
44
|
+
pluginsLoaded = true;
|
|
45
|
+
}
|
|
32
46
|
export function getChecker(service) {
|
|
33
|
-
return registry[service] ?? null;
|
|
47
|
+
return registry[service] ?? pluginCheckers[service] ?? null;
|
|
34
48
|
}
|
|
35
49
|
export function allCheckerNames() {
|
|
36
|
-
return Object.keys(registry);
|
|
50
|
+
return [...Object.keys(registry), ...Object.keys(pluginCheckers)];
|
|
37
51
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prajwolkc/stk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
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",
|
|
@@ -40,16 +41,21 @@
|
|
|
40
41
|
"build": "tsc",
|
|
41
42
|
"dev": "tsx src/index.ts",
|
|
42
43
|
"start": "node dist/index.js",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"test:watch": "vitest",
|
|
43
46
|
"prepublishOnly": "npm run build"
|
|
44
47
|
},
|
|
45
48
|
"dependencies": {
|
|
49
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
46
50
|
"chalk": "^5.4.1",
|
|
47
51
|
"commander": "^13.1.0",
|
|
48
|
-
"ora": "^8.2.0"
|
|
52
|
+
"ora": "^8.2.0",
|
|
53
|
+
"zod": "^4.3.6"
|
|
49
54
|
},
|
|
50
55
|
"devDependencies": {
|
|
51
56
|
"@types/node": "^22.13.0",
|
|
52
57
|
"tsx": "^4.19.0",
|
|
53
|
-
"typescript": "^5.7.0"
|
|
58
|
+
"typescript": "^5.7.0",
|
|
59
|
+
"vitest": "^4.1.0"
|
|
54
60
|
}
|
|
55
61
|
}
|