@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.
@@ -1,142 +1,246 @@
1
1
  import { Command } from "commander";
2
2
  import chalk from "chalk";
3
3
  import ora from "ora";
4
+ import { loadConfig, enabledServices } from "../lib/config.js";
4
5
  export const logsCommand = new Command("logs")
5
- .description("Tail Railway runtime logs locally")
6
+ .description("Tail logs from your deploy providers")
6
7
  .option("-n, --lines <count>", "number of recent lines to show", "50")
7
8
  .option("-f, --follow", "keep streaming new logs")
9
+ .option("-p, --provider <name>", "specific provider (railway, vercel, fly, render)")
8
10
  .action(async (opts) => {
9
- const token = process.env.RAILWAY_API_TOKEN;
10
- if (!token) {
11
- console.log(chalk.red(" RAILWAY_API_TOKEN not set"));
11
+ const config = loadConfig();
12
+ const enabled = enabledServices(config);
13
+ const limit = parseInt(opts.lines, 10);
14
+ // Build list of available log providers
15
+ const providers = [];
16
+ if ((opts.provider === "railway" || (!opts.provider && enabled.includes("railway"))) &&
17
+ process.env.RAILWAY_API_TOKEN) {
18
+ providers.push(railwayProvider());
19
+ }
20
+ if ((opts.provider === "vercel" || (!opts.provider && enabled.includes("vercel"))) &&
21
+ process.env.VERCEL_TOKEN) {
22
+ providers.push(vercelProvider());
23
+ }
24
+ if ((opts.provider === "fly" || (!opts.provider && enabled.includes("fly"))) &&
25
+ process.env.FLY_API_TOKEN) {
26
+ providers.push(flyProvider());
27
+ }
28
+ if ((opts.provider === "render" || (!opts.provider && enabled.includes("render"))) &&
29
+ process.env.RENDER_API_KEY) {
30
+ providers.push(renderProvider());
31
+ }
32
+ if (providers.length === 0) {
33
+ console.log(chalk.red("\n No log providers available."));
34
+ console.log(chalk.dim(" Configure railway, vercel, fly, or render in stk.config.json and set their tokens.\n"));
12
35
  process.exitCode = 1;
13
36
  return;
14
37
  }
15
- const projectId = process.env.RAILWAY_PROJECT_ID;
16
- const serviceId = process.env.RAILWAY_SERVICE_ID;
17
- const deploymentId = process.env.RAILWAY_DEPLOYMENT_ID;
18
- if (!deploymentId) {
19
- // Try to get latest deployment
20
- const spinner = ora("Fetching latest deployment...").start();
38
+ // Fetch logs from all providers
39
+ for (const provider of providers) {
40
+ const spinner = ora(`Fetching ${provider.name} logs...`).start();
21
41
  try {
22
- const latestDeploymentId = await getLatestDeploymentId(token, projectId, serviceId);
23
- if (latestDeploymentId) {
24
- spinner.succeed(`Found deployment ${chalk.dim(latestDeploymentId.slice(0, 8))}`);
25
- await fetchAndPrintLogs(token, latestDeploymentId, parseInt(opts.lines, 10));
26
- if (opts.follow) {
27
- await followLogs(token, latestDeploymentId);
28
- }
42
+ const logs = await provider.fetch(limit);
43
+ spinner.stop();
44
+ if (logs.length === 0) {
45
+ console.log(chalk.dim(`\n ${provider.name}: no logs found\n`));
46
+ continue;
29
47
  }
30
- else {
31
- spinner.fail("No deployments found");
32
- console.log(chalk.dim(" Set RAILWAY_PROJECT_ID and RAILWAY_SERVICE_ID, or RAILWAY_DEPLOYMENT_ID"));
48
+ console.log();
49
+ console.log(chalk.bold(` ${provider.name} Logs`));
50
+ console.log(chalk.dim(" ─────────────────────────────────────────"));
51
+ for (const log of logs) {
52
+ printLogLine(log, provider.name);
33
53
  }
34
54
  }
35
55
  catch (err) {
36
- spinner.fail(err.message);
56
+ spinner.fail(`${provider.name}: ${err.message}`);
37
57
  }
38
- return;
39
58
  }
40
- await fetchAndPrintLogs(token, deploymentId, parseInt(opts.lines, 10));
41
- if (opts.follow) {
42
- await followLogs(token, deploymentId);
59
+ // Follow mode stream from first available provider
60
+ if (opts.follow && providers[0]) {
61
+ console.log(chalk.dim("\n --- streaming (Ctrl+C to stop) ---\n"));
62
+ const provider = providers[0];
63
+ if (provider.follow) {
64
+ await provider.follow((log) => printLogLine(log, provider.name));
65
+ }
66
+ else {
67
+ console.log(chalk.dim(` ${provider.name} does not support streaming`));
68
+ }
43
69
  }
70
+ console.log();
44
71
  });
45
- async function getLatestDeploymentId(token, projectId, serviceId) {
72
+ // --- Railway ---
73
+ function railwayProvider() {
74
+ const token = process.env.RAILWAY_API_TOKEN;
75
+ const projectId = process.env.RAILWAY_PROJECT_ID;
76
+ const serviceId = process.env.RAILWAY_SERVICE_ID;
77
+ return {
78
+ name: "Railway",
79
+ async fetch(limit) {
80
+ const deploymentId = await getLatestRailwayDeployment(token, projectId, serviceId);
81
+ if (!deploymentId)
82
+ return [];
83
+ const res = await fetch("https://backboard.railway.com/graphql/v2", {
84
+ method: "POST",
85
+ headers: {
86
+ Authorization: `Bearer ${token}`,
87
+ "Content-Type": "application/json",
88
+ },
89
+ body: JSON.stringify({
90
+ query: `{ deploymentLogs(deploymentId: "${deploymentId}", limit: ${limit}) { timestamp message severity } }`,
91
+ }),
92
+ });
93
+ const data = (await res.json());
94
+ return data.data?.deploymentLogs ?? [];
95
+ },
96
+ async follow(onLog) {
97
+ const deploymentId = await getLatestRailwayDeployment(token, projectId, serviceId);
98
+ if (!deploymentId)
99
+ return;
100
+ let lastTimestamp = new Date().toISOString();
101
+ while (true) {
102
+ await sleep(3000);
103
+ try {
104
+ const res = await fetch("https://backboard.railway.com/graphql/v2", {
105
+ method: "POST",
106
+ headers: {
107
+ Authorization: `Bearer ${token}`,
108
+ "Content-Type": "application/json",
109
+ },
110
+ body: JSON.stringify({
111
+ query: `{ deploymentLogs(deploymentId: "${deploymentId}", limit: 20) { timestamp message severity } }`,
112
+ }),
113
+ });
114
+ const data = (await res.json());
115
+ for (const log of data.data?.deploymentLogs ?? []) {
116
+ if (log.timestamp > lastTimestamp) {
117
+ onLog(log);
118
+ lastTimestamp = log.timestamp;
119
+ }
120
+ }
121
+ }
122
+ catch { /* retry */ }
123
+ }
124
+ },
125
+ };
126
+ }
127
+ async function getLatestRailwayDeployment(token, projectId, serviceId) {
46
128
  if (!projectId)
47
129
  return null;
48
- const serviceFilter = serviceId
49
- ? `serviceId: "${serviceId}",`
50
- : "";
130
+ const serviceFilter = serviceId ? `serviceId: "${serviceId}",` : "";
51
131
  const res = await fetch("https://backboard.railway.com/graphql/v2", {
52
132
  method: "POST",
53
- headers: {
54
- Authorization: `Bearer ${token}`,
55
- "Content-Type": "application/json",
56
- },
133
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
57
134
  body: JSON.stringify({
58
- query: `{
59
- deployments(
60
- first: 1,
61
- input: {
62
- projectId: "${projectId}",
63
- ${serviceFilter}
64
- }
65
- ) {
66
- edges {
67
- node { id status }
68
- }
69
- }
70
- }`,
135
+ query: `{ deployments(first: 1, input: { projectId: "${projectId}", ${serviceFilter} }) { edges { node { id } } } }`,
71
136
  }),
72
137
  });
73
138
  const data = (await res.json());
74
139
  return data.data?.deployments?.edges?.[0]?.node?.id ?? null;
75
140
  }
76
- async function fetchAndPrintLogs(token, deploymentId, limit) {
77
- const res = await fetch("https://backboard.railway.com/graphql/v2", {
78
- method: "POST",
79
- headers: {
80
- Authorization: `Bearer ${token}`,
81
- "Content-Type": "application/json",
141
+ // --- Vercel ---
142
+ function vercelProvider() {
143
+ const token = process.env.VERCEL_TOKEN;
144
+ return {
145
+ name: "Vercel",
146
+ async fetch(limit) {
147
+ // Get latest deployment, then its build logs
148
+ const depRes = await fetch("https://api.vercel.com/v6/deployments?limit=1", {
149
+ headers: { Authorization: `Bearer ${token}` },
150
+ });
151
+ const depData = (await depRes.json());
152
+ const dep = depData.deployments?.[0];
153
+ if (!dep)
154
+ return [];
155
+ const logRes = await fetch(`https://api.vercel.com/v2/deployments/${dep.uid}/events`, { headers: { Authorization: `Bearer ${token}` } });
156
+ const events = (await logRes.json());
157
+ if (!Array.isArray(events))
158
+ return [];
159
+ return events
160
+ .filter((e) => e.type === "stdout" || e.type === "stderr")
161
+ .slice(-limit)
162
+ .map((e) => ({
163
+ timestamp: new Date(e.created).toISOString(),
164
+ message: e.payload?.text ?? e.text ?? "",
165
+ severity: e.type === "stderr" ? "ERROR" : "INFO",
166
+ }));
82
167
  },
83
- body: JSON.stringify({
84
- query: `{
85
- deploymentLogs(deploymentId: "${deploymentId}", limit: ${limit}) {
86
- timestamp
87
- message
88
- severity
89
- }
90
- }`,
91
- }),
92
- });
93
- const data = (await res.json());
94
- const logs = data.data?.deploymentLogs ?? [];
95
- if (logs.length === 0) {
96
- console.log(chalk.dim(" No logs found"));
97
- return;
98
- }
99
- for (const log of logs) {
100
- printLogLine(log);
101
- }
168
+ };
102
169
  }
103
- async function followLogs(token, deploymentId) {
104
- console.log(chalk.dim("\n --- streaming (Ctrl+C to stop) ---\n"));
105
- let lastTimestamp = new Date().toISOString();
106
- while (true) {
107
- await sleep(3000);
108
- try {
109
- const res = await fetch("https://backboard.railway.com/graphql/v2", {
170
+ // --- Fly.io ---
171
+ function flyProvider() {
172
+ const token = process.env.FLY_API_TOKEN;
173
+ const app = process.env.FLY_APP_NAME;
174
+ return {
175
+ name: "Fly.io",
176
+ async fetch(limit) {
177
+ if (!app) {
178
+ throw new Error("FLY_APP_NAME not set");
179
+ }
180
+ const res = await fetch("https://api.fly.io/graphql", {
110
181
  method: "POST",
111
182
  headers: {
112
183
  Authorization: `Bearer ${token}`,
113
184
  "Content-Type": "application/json",
114
185
  },
115
186
  body: JSON.stringify({
116
- query: `{
117
- deploymentLogs(deploymentId: "${deploymentId}", limit: 20, filter: "${lastTimestamp}") {
118
- timestamp
119
- message
120
- severity
187
+ query: `query {
188
+ app(name: "${app}") {
189
+ currentRelease { status createdAt }
121
190
  }
122
191
  }`,
123
192
  }),
124
193
  });
125
194
  const data = (await res.json());
126
- const logs = data.data?.deploymentLogs ?? [];
127
- for (const log of logs) {
128
- if (log.timestamp > lastTimestamp) {
129
- printLogLine(log);
130
- lastTimestamp = log.timestamp;
131
- }
132
- }
133
- }
134
- catch {
135
- // retry silently
136
- }
137
- }
195
+ const release = data.data?.app?.currentRelease;
196
+ if (!release)
197
+ return [];
198
+ // Fly doesn't have a simple log fetch API via GraphQL
199
+ // Return release info as a log line
200
+ return [
201
+ {
202
+ timestamp: release.createdAt,
203
+ message: `Current release: ${release.status}`,
204
+ severity: "INFO",
205
+ },
206
+ ];
207
+ },
208
+ };
209
+ }
210
+ // --- Render ---
211
+ function renderProvider() {
212
+ const token = process.env.RENDER_API_KEY;
213
+ return {
214
+ name: "Render",
215
+ async fetch(logLimit) {
216
+ // Get first service
217
+ const svcRes = await fetch("https://api.render.com/v1/services?limit=1", {
218
+ headers: { Authorization: `Bearer ${token}` },
219
+ });
220
+ const services = (await svcRes.json());
221
+ const svc = services[0]?.service;
222
+ if (!svc)
223
+ return [];
224
+ // Get deploys for the service
225
+ const depRes = await fetch(`https://api.render.com/v1/services/${svc.id}/deploys?limit=1`, { headers: { Authorization: `Bearer ${token}` } });
226
+ const deploys = (await depRes.json());
227
+ const deploy = deploys[0]?.deploy;
228
+ if (!deploy)
229
+ return [];
230
+ // Get logs for the deploy
231
+ const logRes = await fetch(`https://api.render.com/v1/services/${svc.id}/deploys/${deploy.id}/logs`, { headers: { Authorization: `Bearer ${token}` } });
232
+ const logs = (await logRes.json());
233
+ if (!Array.isArray(logs))
234
+ return [];
235
+ return logs.slice(-logLimit).map((l) => ({
236
+ timestamp: l.timestamp ?? new Date().toISOString(),
237
+ message: l.message ?? l.text ?? String(l),
238
+ severity: "INFO",
239
+ }));
240
+ },
241
+ };
138
242
  }
139
- function printLogLine(log) {
243
+ function printLogLine(log, provider) {
140
244
  const time = chalk.dim(new Date(log.timestamp).toLocaleTimeString());
141
245
  const severity = log.severity?.toUpperCase() ?? "INFO";
142
246
  const sevColor = severity === "ERROR"
@@ -144,7 +248,8 @@ function printLogLine(log) {
144
248
  : severity === "WARN"
145
249
  ? chalk.yellow
146
250
  : chalk.dim;
147
- console.log(` ${time} ${sevColor(severity.padEnd(5))} ${log.message}`);
251
+ const tag = chalk.dim(`[${provider.toLowerCase()}]`);
252
+ console.log(` ${time} ${tag} ${sevColor(severity.padEnd(5))} ${log.message}`);
148
253
  }
149
254
  function sleep(ms) {
150
255
  return new Promise((resolve) => setTimeout(resolve, ms));
package/dist/index.js CHANGED
@@ -7,11 +7,12 @@ import { deployCommand } from "./commands/deploy.js";
7
7
  import { envCommand } from "./commands/env.js";
8
8
  import { logsCommand } from "./commands/logs.js";
9
9
  import { todoCommand } from "./commands/todo.js";
10
+ import { doctorCommand } from "./commands/doctor.js";
10
11
  const program = new Command();
11
12
  program
12
13
  .name("stk")
13
14
  .description("One CLI to deploy, monitor, and debug your entire stack.")
14
- .version("0.1.0");
15
+ .version("0.2.0");
15
16
  program.addCommand(initCommand);
16
17
  program.addCommand(statusCommand);
17
18
  program.addCommand(healthCommand);
@@ -19,4 +20,5 @@ program.addCommand(deployCommand);
19
20
  program.addCommand(envCommand);
20
21
  program.addCommand(logsCommand);
21
22
  program.addCommand(todoCommand);
23
+ program.addCommand(doctorCommand);
22
24
  program.parse();
@@ -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,40 @@
1
+ import type { CheckResult } from "../services/checker.js";
2
+ export interface StkPlugin {
3
+ name: string;
4
+ version?: string;
5
+ services?: Record<string, PluginService>;
6
+ }
7
+ export interface PluginService {
8
+ name: string;
9
+ envVars: string[];
10
+ healthCheck: () => Promise<CheckResult>;
11
+ }
12
+ /**
13
+ * Load plugins from .stk/plugins/ directory.
14
+ *
15
+ * Each plugin is a .js or .mjs file that exports a StkPlugin:
16
+ *
17
+ * ```js
18
+ * // .stk/plugins/my-service.mjs
19
+ * export default {
20
+ * name: "my-plugin",
21
+ * services: {
22
+ * myservice: {
23
+ * name: "My Service",
24
+ * envVars: ["MY_SERVICE_TOKEN"],
25
+ * healthCheck: async () => {
26
+ * const token = process.env.MY_SERVICE_TOKEN;
27
+ * if (!token) return { name: "My Service", status: "skipped", detail: "MY_SERVICE_TOKEN not set" };
28
+ * // ... check logic
29
+ * return { name: "My Service", status: "healthy", detail: "connected" };
30
+ * }
31
+ * }
32
+ * }
33
+ * };
34
+ * ```
35
+ */
36
+ export declare function loadPlugins(): Promise<StkPlugin[]>;
37
+ /**
38
+ * Collect all plugin health checkers into a flat record.
39
+ */
40
+ export declare function getPluginCheckers(): Promise<Record<string, () => Promise<CheckResult>>>;
@@ -0,0 +1,65 @@
1
+ import { existsSync, readdirSync } from "fs";
2
+ import { resolve, join } from "path";
3
+ import { pathToFileURL } from "url";
4
+ const PLUGIN_DIR = ".stk/plugins";
5
+ /**
6
+ * Load plugins from .stk/plugins/ directory.
7
+ *
8
+ * Each plugin is a .js or .mjs file that exports a StkPlugin:
9
+ *
10
+ * ```js
11
+ * // .stk/plugins/my-service.mjs
12
+ * export default {
13
+ * name: "my-plugin",
14
+ * services: {
15
+ * myservice: {
16
+ * name: "My Service",
17
+ * envVars: ["MY_SERVICE_TOKEN"],
18
+ * healthCheck: async () => {
19
+ * const token = process.env.MY_SERVICE_TOKEN;
20
+ * if (!token) return { name: "My Service", status: "skipped", detail: "MY_SERVICE_TOKEN not set" };
21
+ * // ... check logic
22
+ * return { name: "My Service", status: "healthy", detail: "connected" };
23
+ * }
24
+ * }
25
+ * }
26
+ * };
27
+ * ```
28
+ */
29
+ export async function loadPlugins() {
30
+ const pluginDir = resolve(process.cwd(), PLUGIN_DIR);
31
+ if (!existsSync(pluginDir))
32
+ return [];
33
+ const plugins = [];
34
+ const files = readdirSync(pluginDir).filter((f) => f.endsWith(".js") || f.endsWith(".mjs"));
35
+ for (const file of files) {
36
+ try {
37
+ const filePath = join(pluginDir, file);
38
+ const fileUrl = pathToFileURL(filePath).href;
39
+ const mod = await import(fileUrl);
40
+ const plugin = mod.default ?? mod;
41
+ if (plugin.name && plugin.services) {
42
+ plugins.push(plugin);
43
+ }
44
+ }
45
+ catch {
46
+ // Skip invalid plugins silently
47
+ }
48
+ }
49
+ return plugins;
50
+ }
51
+ /**
52
+ * Collect all plugin health checkers into a flat record.
53
+ */
54
+ export async function getPluginCheckers() {
55
+ const plugins = await loadPlugins();
56
+ const checkers = {};
57
+ for (const plugin of plugins) {
58
+ if (!plugin.services)
59
+ continue;
60
+ for (const [key, svc] of Object.entries(plugin.services)) {
61
+ checkers[key] = svc.healthCheck;
62
+ }
63
+ }
64
+ return checkers;
65
+ }
@@ -0,0 +1,9 @@
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
+ export {};