@prajwolkc/stk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/dist/commands/deploy.d.ts +2 -0
- package/dist/commands/deploy.js +152 -0
- package/dist/commands/env.d.ts +2 -0
- package/dist/commands/env.js +136 -0
- package/dist/commands/health.d.ts +2 -0
- package/dist/commands/health.js +77 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +111 -0
- package/dist/commands/logs.d.ts +2 -0
- package/dist/commands/logs.js +151 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +130 -0
- package/dist/commands/todo.d.ts +2 -0
- package/dist/commands/todo.js +187 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +22 -0
- package/dist/lib/config.d.ts +36 -0
- package/dist/lib/config.js +102 -0
- package/dist/services/aws.d.ts +2 -0
- package/dist/services/aws.js +32 -0
- package/dist/services/checker.d.ts +10 -0
- package/dist/services/checker.js +20 -0
- package/dist/services/database.d.ts +2 -0
- package/dist/services/database.js +27 -0
- package/dist/services/fly.d.ts +2 -0
- package/dist/services/fly.js +17 -0
- package/dist/services/mongodb.d.ts +2 -0
- package/dist/services/mongodb.js +25 -0
- package/dist/services/r2.d.ts +2 -0
- package/dist/services/r2.js +20 -0
- package/dist/services/railway.d.ts +2 -0
- package/dist/services/railway.js +26 -0
- package/dist/services/redis.d.ts +2 -0
- package/dist/services/redis.js +34 -0
- package/dist/services/registry.d.ts +4 -0
- package/dist/services/registry.js +37 -0
- package/dist/services/render.d.ts +2 -0
- package/dist/services/render.js +19 -0
- package/dist/services/stripe.d.ts +2 -0
- package/dist/services/stripe.js +21 -0
- package/dist/services/supabase.d.ts +2 -0
- package/dist/services/supabase.js +24 -0
- package/dist/services/vercel.d.ts +2 -0
- package/dist/services/vercel.js +35 -0
- package/dist/templates/index.d.ts +8 -0
- package/dist/templates/index.js +105 -0
- package/package.json +55 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
export const logsCommand = new Command("logs")
|
|
5
|
+
.description("Tail Railway runtime logs locally")
|
|
6
|
+
.option("-n, --lines <count>", "number of recent lines to show", "50")
|
|
7
|
+
.option("-f, --follow", "keep streaming new logs")
|
|
8
|
+
.action(async (opts) => {
|
|
9
|
+
const token = process.env.RAILWAY_API_TOKEN;
|
|
10
|
+
if (!token) {
|
|
11
|
+
console.log(chalk.red(" RAILWAY_API_TOKEN not set"));
|
|
12
|
+
process.exitCode = 1;
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
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();
|
|
21
|
+
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
|
+
}
|
|
29
|
+
}
|
|
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"));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
spinner.fail(err.message);
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
await fetchAndPrintLogs(token, deploymentId, parseInt(opts.lines, 10));
|
|
41
|
+
if (opts.follow) {
|
|
42
|
+
await followLogs(token, deploymentId);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
async function getLatestDeploymentId(token, projectId, serviceId) {
|
|
46
|
+
if (!projectId)
|
|
47
|
+
return null;
|
|
48
|
+
const serviceFilter = serviceId
|
|
49
|
+
? `serviceId: "${serviceId}",`
|
|
50
|
+
: "";
|
|
51
|
+
const res = await fetch("https://backboard.railway.com/graphql/v2", {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: {
|
|
54
|
+
Authorization: `Bearer ${token}`,
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
},
|
|
57
|
+
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
|
+
}`,
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
73
|
+
const data = (await res.json());
|
|
74
|
+
return data.data?.deployments?.edges?.[0]?.node?.id ?? null;
|
|
75
|
+
}
|
|
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",
|
|
82
|
+
},
|
|
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
|
+
}
|
|
102
|
+
}
|
|
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", {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: {
|
|
112
|
+
Authorization: `Bearer ${token}`,
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify({
|
|
116
|
+
query: `{
|
|
117
|
+
deploymentLogs(deploymentId: "${deploymentId}", limit: 20, filter: "${lastTimestamp}") {
|
|
118
|
+
timestamp
|
|
119
|
+
message
|
|
120
|
+
severity
|
|
121
|
+
}
|
|
122
|
+
}`,
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
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
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function printLogLine(log) {
|
|
140
|
+
const time = chalk.dim(new Date(log.timestamp).toLocaleTimeString());
|
|
141
|
+
const severity = log.severity?.toUpperCase() ?? "INFO";
|
|
142
|
+
const sevColor = severity === "ERROR"
|
|
143
|
+
? chalk.red
|
|
144
|
+
: severity === "WARN"
|
|
145
|
+
? chalk.yellow
|
|
146
|
+
: chalk.dim;
|
|
147
|
+
console.log(` ${time} ${sevColor(severity.padEnd(5))} ${log.message}`);
|
|
148
|
+
}
|
|
149
|
+
function sleep(ms) {
|
|
150
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
151
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { execSync } from "child_process";
|
|
5
|
+
import { loadConfig, enabledServices } from "../lib/config.js";
|
|
6
|
+
import { getChecker } from "../services/registry.js";
|
|
7
|
+
export const statusCommand = new Command("status")
|
|
8
|
+
.description("One-line summary of your entire stack")
|
|
9
|
+
.action(async () => {
|
|
10
|
+
const config = loadConfig();
|
|
11
|
+
const spinner = ora("Checking everything...").start();
|
|
12
|
+
const sections = [];
|
|
13
|
+
// 1. Git status
|
|
14
|
+
try {
|
|
15
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
16
|
+
encoding: "utf-8",
|
|
17
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
18
|
+
}).trim();
|
|
19
|
+
const status = execSync("git status --porcelain", {
|
|
20
|
+
encoding: "utf-8",
|
|
21
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
22
|
+
}).trim();
|
|
23
|
+
const dirty = status.split("\n").filter(Boolean).length;
|
|
24
|
+
const lastCommit = execSync('git log -1 --format="%s" 2>/dev/null', {
|
|
25
|
+
encoding: "utf-8",
|
|
26
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
27
|
+
}).trim();
|
|
28
|
+
const commitAge = execSync('git log -1 --format="%cr" 2>/dev/null', {
|
|
29
|
+
encoding: "utf-8",
|
|
30
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
31
|
+
}).trim();
|
|
32
|
+
const branchStr = chalk.cyan(branch);
|
|
33
|
+
const dirtyStr = dirty > 0 ? chalk.yellow(` ${dirty} changed`) : chalk.green(" clean");
|
|
34
|
+
sections.push(` ${chalk.bold("Git")} ${branchStr}${dirtyStr} ${chalk.dim(`"${lastCommit}" ${commitAge}`)}`);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
sections.push(` ${chalk.bold("Git")} ${chalk.dim("not a git repo")}`);
|
|
38
|
+
}
|
|
39
|
+
// 2. Service health (parallel)
|
|
40
|
+
const serviceList = enabledServices(config);
|
|
41
|
+
let healthLine;
|
|
42
|
+
if (serviceList.length === 0) {
|
|
43
|
+
healthLine = chalk.dim("no services configured");
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
const checks = serviceList.map((name) => {
|
|
47
|
+
const checker = getChecker(name);
|
|
48
|
+
if (!checker) {
|
|
49
|
+
return Promise.resolve({
|
|
50
|
+
name,
|
|
51
|
+
status: "skipped",
|
|
52
|
+
detail: "unknown",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return checker();
|
|
56
|
+
});
|
|
57
|
+
const results = await Promise.all(checks);
|
|
58
|
+
const up = results.filter((r) => r.status === "healthy").length;
|
|
59
|
+
const down = results.filter((r) => r.status === "down");
|
|
60
|
+
const skipped = results.filter((r) => r.status === "skipped").length;
|
|
61
|
+
if (down.length > 0) {
|
|
62
|
+
const downNames = down.map((d) => d.name).join(", ");
|
|
63
|
+
healthLine = `${chalk.green(`${up} up`)} ${chalk.red(`${down.length} down`)} (${chalk.red(downNames)})`;
|
|
64
|
+
}
|
|
65
|
+
else if (up > 0) {
|
|
66
|
+
healthLine = chalk.green(`${up}/${serviceList.length} healthy`);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
healthLine = chalk.yellow(`${skipped} skipped (check env vars)`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
sections.push(` ${chalk.bold("Services")} ${healthLine}`);
|
|
73
|
+
// 3. Last deploy info (quick check for deploy providers)
|
|
74
|
+
const deployProviders = config.deploy?.providers ?? [];
|
|
75
|
+
if (deployProviders.includes("vercel") && process.env.VERCEL_TOKEN) {
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch("https://api.vercel.com/v6/deployments?limit=1", {
|
|
78
|
+
headers: { Authorization: `Bearer ${process.env.VERCEL_TOKEN}` },
|
|
79
|
+
});
|
|
80
|
+
const data = (await res.json());
|
|
81
|
+
const dep = data.deployments?.[0];
|
|
82
|
+
if (dep) {
|
|
83
|
+
const state = dep.readyState ?? dep.state;
|
|
84
|
+
const ago = timeSince(new Date(dep.created));
|
|
85
|
+
const stateColor = state === "READY" ? chalk.green : state === "ERROR" ? chalk.red : chalk.yellow;
|
|
86
|
+
sections.push(` ${chalk.bold("Deploy")} Vercel: ${stateColor(state.toLowerCase())} ${chalk.dim(`${ago} ago`)}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch { /* skip */ }
|
|
90
|
+
}
|
|
91
|
+
// 4. Open issues count
|
|
92
|
+
const ghRepo = config.github?.repo ?? process.env.GITHUB_REPO;
|
|
93
|
+
if (ghRepo) {
|
|
94
|
+
try {
|
|
95
|
+
const headers = {
|
|
96
|
+
Accept: "application/vnd.github+json",
|
|
97
|
+
};
|
|
98
|
+
if (process.env.GITHUB_TOKEN) {
|
|
99
|
+
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
|
100
|
+
}
|
|
101
|
+
const res = await fetch(`https://api.github.com/repos/${ghRepo}`, { headers });
|
|
102
|
+
if (res.ok) {
|
|
103
|
+
const repo = (await res.json());
|
|
104
|
+
sections.push(` ${chalk.bold("Issues")} ${chalk.white(repo.open_issues_count)} open`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch { /* skip */ }
|
|
108
|
+
}
|
|
109
|
+
spinner.stop();
|
|
110
|
+
console.log();
|
|
111
|
+
console.log(chalk.bold(` ${config.name} — Status`));
|
|
112
|
+
console.log(chalk.dim(" ─────────────────────────────────────────"));
|
|
113
|
+
for (const section of sections) {
|
|
114
|
+
console.log(section);
|
|
115
|
+
}
|
|
116
|
+
console.log();
|
|
117
|
+
});
|
|
118
|
+
function timeSince(date) {
|
|
119
|
+
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
120
|
+
if (seconds < 60)
|
|
121
|
+
return `${seconds}s`;
|
|
122
|
+
const minutes = Math.floor(seconds / 60);
|
|
123
|
+
if (minutes < 60)
|
|
124
|
+
return `${minutes}m`;
|
|
125
|
+
const hours = Math.floor(minutes / 60);
|
|
126
|
+
if (hours < 24)
|
|
127
|
+
return `${hours}h`;
|
|
128
|
+
const days = Math.floor(hours / 24);
|
|
129
|
+
return `${days}d`;
|
|
130
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { loadConfig } from "../lib/config.js";
|
|
5
|
+
export const todoCommand = new Command("todo")
|
|
6
|
+
.description("Manage project todos from GitHub Issues");
|
|
7
|
+
todoCommand
|
|
8
|
+
.command("list")
|
|
9
|
+
.alias("ls")
|
|
10
|
+
.description("List open issues")
|
|
11
|
+
.option("-l, --label <label>", "filter by label")
|
|
12
|
+
.option("-a, --assignee <user>", "filter by assignee (use @me for yourself)")
|
|
13
|
+
.option("-n, --limit <count>", "max issues to show", "15")
|
|
14
|
+
.action(async (opts) => {
|
|
15
|
+
const { repo, token } = getGitHubConfig();
|
|
16
|
+
if (!repo)
|
|
17
|
+
return;
|
|
18
|
+
const spinner = ora("Fetching issues...").start();
|
|
19
|
+
try {
|
|
20
|
+
const params = new URLSearchParams({
|
|
21
|
+
state: "open",
|
|
22
|
+
per_page: opts.limit,
|
|
23
|
+
sort: "updated",
|
|
24
|
+
direction: "desc",
|
|
25
|
+
});
|
|
26
|
+
if (opts.label)
|
|
27
|
+
params.set("labels", opts.label);
|
|
28
|
+
if (opts.assignee)
|
|
29
|
+
params.set("assignee", opts.assignee === "@me" ? "" : opts.assignee);
|
|
30
|
+
const res = await fetch(`https://api.github.com/repos/${repo}/issues?${params}`, { headers: ghHeaders(token) });
|
|
31
|
+
if (!res.ok)
|
|
32
|
+
throw new Error(`HTTP ${res.status}`);
|
|
33
|
+
const issues = (await res.json());
|
|
34
|
+
spinner.stop();
|
|
35
|
+
// Filter out pull requests
|
|
36
|
+
const filtered = issues.filter((i) => !i.pull_request);
|
|
37
|
+
if (filtered.length === 0) {
|
|
38
|
+
console.log(chalk.dim("\n No open issues found.\n"));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
console.log();
|
|
42
|
+
console.log(chalk.bold(` ${repo} — Open Issues`));
|
|
43
|
+
console.log(chalk.dim(" ─────────────────────────────────────────"));
|
|
44
|
+
for (const issue of filtered) {
|
|
45
|
+
const num = chalk.dim(`#${issue.number}`);
|
|
46
|
+
const labels = issue.labels
|
|
47
|
+
.map((l) => chalk.hex(l.color ?? "888888")(l.name))
|
|
48
|
+
.join(" ");
|
|
49
|
+
const assignee = issue.assignee
|
|
50
|
+
? chalk.dim(` @${issue.assignee.login}`)
|
|
51
|
+
: "";
|
|
52
|
+
const age = timeSince(new Date(issue.created_at));
|
|
53
|
+
console.log(` ${num} ${issue.title} ${labels}${assignee} ${chalk.dim(age)}`);
|
|
54
|
+
}
|
|
55
|
+
console.log();
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
spinner.fail(err.message);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
todoCommand
|
|
62
|
+
.command("add <title>")
|
|
63
|
+
.description("Create a new GitHub issue")
|
|
64
|
+
.option("-b, --body <body>", "issue body")
|
|
65
|
+
.option("-l, --label <labels>", "comma-separated labels")
|
|
66
|
+
.option("-a, --assignee <user>", "assign to user")
|
|
67
|
+
.action(async (title, opts) => {
|
|
68
|
+
const { repo, token } = getGitHubConfig();
|
|
69
|
+
if (!repo || !token) {
|
|
70
|
+
if (!token)
|
|
71
|
+
console.log(chalk.red(" GITHUB_TOKEN required to create issues"));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const spinner = ora("Creating issue...").start();
|
|
75
|
+
try {
|
|
76
|
+
const body = { title };
|
|
77
|
+
if (opts.body)
|
|
78
|
+
body.body = opts.body;
|
|
79
|
+
if (opts.label)
|
|
80
|
+
body.labels = opts.label.split(",").map((s) => s.trim());
|
|
81
|
+
if (opts.assignee)
|
|
82
|
+
body.assignees = [opts.assignee];
|
|
83
|
+
const res = await fetch(`https://api.github.com/repos/${repo}/issues`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { ...ghHeaders(token), "Content-Type": "application/json" },
|
|
86
|
+
body: JSON.stringify(body),
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
const data = (await res.json());
|
|
90
|
+
throw new Error(data.message ?? `HTTP ${res.status}`);
|
|
91
|
+
}
|
|
92
|
+
const issue = (await res.json());
|
|
93
|
+
spinner.succeed(`Created ${chalk.bold(`#${issue.number}`)} — ${issue.html_url}`);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
spinner.fail(err.message);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
todoCommand
|
|
100
|
+
.command("close <number>")
|
|
101
|
+
.description("Close an issue by number")
|
|
102
|
+
.option("-c, --comment <text>", "add a closing comment")
|
|
103
|
+
.action(async (number, opts) => {
|
|
104
|
+
const { repo, token } = getGitHubConfig();
|
|
105
|
+
if (!repo || !token) {
|
|
106
|
+
if (!token)
|
|
107
|
+
console.log(chalk.red(" GITHUB_TOKEN required to close issues"));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const spinner = ora(`Closing #${number}...`).start();
|
|
111
|
+
try {
|
|
112
|
+
if (opts.comment) {
|
|
113
|
+
await fetch(`https://api.github.com/repos/${repo}/issues/${number}/comments`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { ...ghHeaders(token), "Content-Type": "application/json" },
|
|
116
|
+
body: JSON.stringify({ body: opts.comment }),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const res = await fetch(`https://api.github.com/repos/${repo}/issues/${number}`, {
|
|
120
|
+
method: "PATCH",
|
|
121
|
+
headers: { ...ghHeaders(token), "Content-Type": "application/json" },
|
|
122
|
+
body: JSON.stringify({ state: "closed" }),
|
|
123
|
+
});
|
|
124
|
+
if (!res.ok)
|
|
125
|
+
throw new Error(`HTTP ${res.status}`);
|
|
126
|
+
spinner.succeed(`Closed #${number}`);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
spinner.fail(err.message);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
function getGitHubConfig() {
|
|
133
|
+
const token = process.env.GITHUB_TOKEN ?? null;
|
|
134
|
+
const config = loadConfig();
|
|
135
|
+
const repo = config.github?.repo ??
|
|
136
|
+
process.env.GITHUB_REPO ??
|
|
137
|
+
detectGitHubRepo();
|
|
138
|
+
if (!repo) {
|
|
139
|
+
console.log(chalk.red(" Could not detect GitHub repo."));
|
|
140
|
+
console.log(chalk.dim(' Set GITHUB_REPO=owner/repo or add "github.repo" to stk.config.json'));
|
|
141
|
+
return { repo: null, token };
|
|
142
|
+
}
|
|
143
|
+
return { repo, token };
|
|
144
|
+
}
|
|
145
|
+
function detectGitHubRepo() {
|
|
146
|
+
try {
|
|
147
|
+
const { execSync } = require("child_process");
|
|
148
|
+
const url = execSync("git remote get-url origin", {
|
|
149
|
+
encoding: "utf-8",
|
|
150
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
151
|
+
}).trim();
|
|
152
|
+
// Handle SSH: git@github.com:owner/repo.git
|
|
153
|
+
const sshMatch = url.match(/github\.com[:/]([^/]+\/[^/.]+)/);
|
|
154
|
+
if (sshMatch)
|
|
155
|
+
return sshMatch[1];
|
|
156
|
+
// Handle HTTPS: https://github.com/owner/repo.git
|
|
157
|
+
const httpsMatch = url.match(/github\.com\/([^/]+\/[^/.]+)/);
|
|
158
|
+
if (httpsMatch)
|
|
159
|
+
return httpsMatch[1];
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// not a git repo or no remote
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
function ghHeaders(token) {
|
|
167
|
+
const headers = {
|
|
168
|
+
Accept: "application/vnd.github+json",
|
|
169
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
170
|
+
};
|
|
171
|
+
if (token)
|
|
172
|
+
headers.Authorization = `Bearer ${token}`;
|
|
173
|
+
return headers;
|
|
174
|
+
}
|
|
175
|
+
function timeSince(date) {
|
|
176
|
+
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
177
|
+
if (seconds < 60)
|
|
178
|
+
return `${seconds}s`;
|
|
179
|
+
const minutes = Math.floor(seconds / 60);
|
|
180
|
+
if (minutes < 60)
|
|
181
|
+
return `${minutes}m`;
|
|
182
|
+
const hours = Math.floor(minutes / 60);
|
|
183
|
+
if (hours < 24)
|
|
184
|
+
return `${hours}h`;
|
|
185
|
+
const days = Math.floor(hours / 24);
|
|
186
|
+
return `${days}d`;
|
|
187
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { initCommand } from "./commands/init.js";
|
|
4
|
+
import { statusCommand } from "./commands/status.js";
|
|
5
|
+
import { healthCommand } from "./commands/health.js";
|
|
6
|
+
import { deployCommand } from "./commands/deploy.js";
|
|
7
|
+
import { envCommand } from "./commands/env.js";
|
|
8
|
+
import { logsCommand } from "./commands/logs.js";
|
|
9
|
+
import { todoCommand } from "./commands/todo.js";
|
|
10
|
+
const program = new Command();
|
|
11
|
+
program
|
|
12
|
+
.name("stk")
|
|
13
|
+
.description("One CLI to deploy, monitor, and debug your entire stack.")
|
|
14
|
+
.version("0.1.0");
|
|
15
|
+
program.addCommand(initCommand);
|
|
16
|
+
program.addCommand(statusCommand);
|
|
17
|
+
program.addCommand(healthCommand);
|
|
18
|
+
program.addCommand(deployCommand);
|
|
19
|
+
program.addCommand(envCommand);
|
|
20
|
+
program.addCommand(logsCommand);
|
|
21
|
+
program.addCommand(todoCommand);
|
|
22
|
+
program.parse();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export declare const CONFIG_FILE = "stk.config.json";
|
|
2
|
+
export interface ServiceConfig {
|
|
3
|
+
enabled?: boolean;
|
|
4
|
+
/** Override the default env var name for the token/url */
|
|
5
|
+
tokenEnv?: string;
|
|
6
|
+
/** Service-specific settings */
|
|
7
|
+
projectId?: string;
|
|
8
|
+
environmentId?: string;
|
|
9
|
+
serviceId?: string;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
export interface StkConfig {
|
|
13
|
+
name: string;
|
|
14
|
+
services: Record<string, boolean | ServiceConfig>;
|
|
15
|
+
deploy?: {
|
|
16
|
+
branch?: string;
|
|
17
|
+
providers?: string[];
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** All services stk knows about */
|
|
21
|
+
export declare const KNOWN_SERVICES: readonly ["railway", "vercel", "fly", "render", "aws", "database", "mongodb", "redis", "supabase", "r2", "stripe"];
|
|
22
|
+
export type KnownService = (typeof KNOWN_SERVICES)[number];
|
|
23
|
+
/**
|
|
24
|
+
* Load config from stk.config.json in cwd or any parent directory.
|
|
25
|
+
* Falls back to auto-detecting enabled services from env vars.
|
|
26
|
+
*/
|
|
27
|
+
export declare function loadConfig(): StkConfig;
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the service config for a given service name.
|
|
30
|
+
* Returns null if the service is not enabled.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveService(config: StkConfig, service: string): ServiceConfig | null;
|
|
33
|
+
/**
|
|
34
|
+
* Get list of enabled service names from config.
|
|
35
|
+
*/
|
|
36
|
+
export declare function enabledServices(config: StkConfig): string[];
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
export const CONFIG_FILE = "stk.config.json";
|
|
4
|
+
const DEFAULTS = {
|
|
5
|
+
name: "my-app",
|
|
6
|
+
services: {},
|
|
7
|
+
};
|
|
8
|
+
/** All services stk knows about */
|
|
9
|
+
export const KNOWN_SERVICES = [
|
|
10
|
+
"railway",
|
|
11
|
+
"vercel",
|
|
12
|
+
"fly",
|
|
13
|
+
"render",
|
|
14
|
+
"aws",
|
|
15
|
+
"database",
|
|
16
|
+
"mongodb",
|
|
17
|
+
"redis",
|
|
18
|
+
"supabase",
|
|
19
|
+
"r2",
|
|
20
|
+
"stripe",
|
|
21
|
+
];
|
|
22
|
+
/**
|
|
23
|
+
* Load config from stk.config.json in cwd or any parent directory.
|
|
24
|
+
* Falls back to auto-detecting enabled services from env vars.
|
|
25
|
+
*/
|
|
26
|
+
export function loadConfig() {
|
|
27
|
+
const configPath = findConfigFile();
|
|
28
|
+
if (configPath) {
|
|
29
|
+
try {
|
|
30
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
31
|
+
const parsed = JSON.parse(raw);
|
|
32
|
+
return { ...DEFAULTS, ...parsed };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Invalid JSON — fall through to auto-detect
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Auto-detect: enable services whose env vars are present
|
|
39
|
+
return {
|
|
40
|
+
...DEFAULTS,
|
|
41
|
+
services: autoDetectServices(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the service config for a given service name.
|
|
46
|
+
* Returns null if the service is not enabled.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveService(config, service) {
|
|
49
|
+
const entry = config.services[service];
|
|
50
|
+
if (!entry)
|
|
51
|
+
return null;
|
|
52
|
+
if (entry === true)
|
|
53
|
+
return { enabled: true };
|
|
54
|
+
if (typeof entry === "object" && entry.enabled !== false)
|
|
55
|
+
return entry;
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Get list of enabled service names from config.
|
|
60
|
+
*/
|
|
61
|
+
export function enabledServices(config) {
|
|
62
|
+
return Object.entries(config.services)
|
|
63
|
+
.filter(([, v]) => v === true || (typeof v === "object" && v.enabled !== false))
|
|
64
|
+
.map(([k]) => k);
|
|
65
|
+
}
|
|
66
|
+
/** Walk up from cwd looking for stk.config.json */
|
|
67
|
+
function findConfigFile() {
|
|
68
|
+
let dir = process.cwd();
|
|
69
|
+
while (true) {
|
|
70
|
+
const candidate = resolve(dir, CONFIG_FILE);
|
|
71
|
+
if (existsSync(candidate))
|
|
72
|
+
return candidate;
|
|
73
|
+
const parent = resolve(dir, "..");
|
|
74
|
+
if (parent === dir)
|
|
75
|
+
break;
|
|
76
|
+
dir = parent;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
/** Map of service → env var that indicates it's available */
|
|
81
|
+
const SERVICE_ENV_MAP = {
|
|
82
|
+
railway: ["RAILWAY_API_TOKEN"],
|
|
83
|
+
vercel: ["VERCEL_TOKEN"],
|
|
84
|
+
fly: ["FLY_API_TOKEN"],
|
|
85
|
+
render: ["RENDER_API_KEY"],
|
|
86
|
+
aws: ["AWS_ACCESS_KEY_ID"],
|
|
87
|
+
database: ["DATABASE_URL"],
|
|
88
|
+
mongodb: ["MONGODB_URL", "MONGO_URL"],
|
|
89
|
+
redis: ["REDIS_URL"],
|
|
90
|
+
supabase: ["SUPABASE_URL"],
|
|
91
|
+
r2: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN"],
|
|
92
|
+
stripe: ["STRIPE_SECRET_KEY"],
|
|
93
|
+
};
|
|
94
|
+
function autoDetectServices() {
|
|
95
|
+
const services = {};
|
|
96
|
+
for (const [name, envVars] of Object.entries(SERVICE_ENV_MAP)) {
|
|
97
|
+
if (envVars.some((v) => process.env[v])) {
|
|
98
|
+
services[name] = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return services;
|
|
102
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { runCheck } from "./checker.js";
|
|
2
|
+
export async function checkAWS() {
|
|
3
|
+
const accessKey = process.env.AWS_ACCESS_KEY_ID;
|
|
4
|
+
const secretKey = process.env.AWS_SECRET_ACCESS_KEY;
|
|
5
|
+
const region = process.env.AWS_REGION ?? "us-east-1";
|
|
6
|
+
if (!accessKey || !secretKey) {
|
|
7
|
+
return {
|
|
8
|
+
name: "AWS",
|
|
9
|
+
status: "skipped",
|
|
10
|
+
detail: "AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY not set",
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
return runCheck("AWS", async () => {
|
|
14
|
+
// Use STS GetCallerIdentity — the simplest AWS API call that works with any credentials
|
|
15
|
+
const host = `sts.${region}.amazonaws.com`;
|
|
16
|
+
const body = "Action=GetCallerIdentity&Version=2011-06-15";
|
|
17
|
+
const now = new Date();
|
|
18
|
+
// AWS Signature V4 is complex — for a lightweight check we hit the endpoint
|
|
19
|
+
// and verify we get a response (even 403 means AWS is reachable)
|
|
20
|
+
const res = await fetch(`https://${host}/`, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
23
|
+
body,
|
|
24
|
+
});
|
|
25
|
+
// 403 = creds not signed but AWS is reachable
|
|
26
|
+
// 200 = would need full SigV4, which we skip for simplicity
|
|
27
|
+
if (res.status === 403 || res.ok) {
|
|
28
|
+
return { detail: `${region} — endpoint reachable` };
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`HTTP ${res.status}`);
|
|
31
|
+
});
|
|
32
|
+
}
|