kickload-watcher-mcp 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.
@@ -0,0 +1,137 @@
1
+ import nodemailer from "nodemailer";
2
+ import { config } from "./config.js";
3
+
4
+ let transporter = null;
5
+
6
+ export function _getTransporter() { return transporter; }
7
+
8
+ /**
9
+ * Initialize email transporter.
10
+ * Does NOT verify — call verifyEmailService() for startup check.
11
+ */
12
+ export function initEmailSender() {
13
+ const p = config.email.provider;
14
+
15
+ if (p === "smtp") {
16
+ transporter = nodemailer.createTransport({
17
+ host: config.email.smtp.host,
18
+ port: config.email.smtp.port,
19
+ secure: config.email.smtp.port === 465,
20
+ auth: { user: config.email.smtp.user, pass: config.email.smtp.pass },
21
+ });
22
+ } else if (p === "sendgrid") {
23
+ transporter = nodemailer.createTransport({
24
+ host: "smtp.sendgrid.net", port: 587,
25
+ auth: { user: "apikey", pass: config.email.sendgrid.apiKey },
26
+ });
27
+ } else if (p === "ses") {
28
+ transporter = nodemailer.createTransport({
29
+ host: `email-smtp.${config.email.ses.region}.amazonaws.com`,
30
+ port: 587, secure: false,
31
+ auth: { user: config.email.ses.accessKey, pass: config.email.ses.secretKey },
32
+ });
33
+ } else {
34
+ throw new Error(`Unknown email provider: ${p}`);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * PROBLEM 2 — Email strict mode.
40
+ * Verifies the transporter can authenticate.
41
+ * Exits process if verification fails.
42
+ */
43
+ export async function verifyEmailService() {
44
+ if (!transporter) {
45
+ console.error("\n❌ Email transporter not initialized before verify()");
46
+ process.exit(1);
47
+ }
48
+
49
+ process.stdout.write(" Verifying email service... ");
50
+ try {
51
+ await transporter.verify();
52
+ console.log("✅ Email service verified");
53
+ } catch (err) {
54
+ console.log(""); // newline after the "..."
55
+ console.error("\n❌ Email verification failed — cannot start.");
56
+ console.error(` Provider : ${config.email.provider}`);
57
+ console.error(` User : ${config.email.smtp.user || "(none)"}`);
58
+ console.error(` Error : ${err.message}`);
59
+ console.error("\n Fix: check SMTP_USER / SMTP_PASS in .env");
60
+ console.error(" For Gmail, use an App Password: myaccount.google.com/apppasswords\n");
61
+ process.exit(1);
62
+ }
63
+ }
64
+
65
+ export async function sendResultEmail({ toEmail, endpoint, passed, summary, downloadUrl, pdfFilename, durationMs, testPrompt }) {
66
+ if (!transporter) throw new Error("Email sender not initialized");
67
+
68
+ const status = passed ? "✅ PASS" : "❌ FAIL";
69
+ const statusColor = passed ? "#16a34a" : "#dc2626";
70
+ const statusBg = passed ? "#f0fdf4" : "#fef2f2";
71
+ const statusBorder = passed ? "#86efac" : "#fca5a5";
72
+ const durationSec = durationMs ? Math.round(durationMs / 1000) : null;
73
+ const testedAt = new Date().toLocaleString("en-IN", { timeZone: "Asia/Kolkata", dateStyle: "medium", timeStyle: "short" });
74
+ const subject = `[KickLoad Watcher] ${status} — ${endpoint}`;
75
+
76
+ const rows = [
77
+ ["Endpoint", `<span style="font-family:monospace">${endpoint}</span>`],
78
+ ["Tested At", `${testedAt} IST`],
79
+ summary?.averageLatency != null ? ["Latency", `${summary.averageLatency}ms`] : null,
80
+ summary?.errorPercentage != null ? ["Error Rate", `${summary.errorPercentage}%`] : null,
81
+ summary?.throughput != null ? ["Throughput", `${summary.throughput?.toLocaleString()} req/s`] : null,
82
+ durationSec != null ? ["Duration", `${durationSec}s`] : null,
83
+ ].filter(Boolean).map(([label, value]) =>
84
+ `<tr style="border-bottom:1px solid #f3f4f6;">
85
+ <td style="padding:8px 16px;color:#6b7280;font-size:14px;">${label}</td>
86
+ <td style="padding:8px 16px;color:#111827;font-size:14px;font-weight:600;text-align:right;">${value}</td>
87
+ </tr>`
88
+ ).join("");
89
+
90
+ const downloadBtn = downloadUrl
91
+ ? `<a href="${downloadUrl}" style="display:inline-block;background:#f47c20;color:#fff;text-decoration:none;padding:12px 28px;border-radius:8px;font-weight:700;font-size:14px;">Download Report</a>
92
+ <p style="color:#9ca3af;font-size:12px;margin-top:8px;">${pdfFilename || ""} · expires in 24hrs</p>`
93
+ : `<p style="color:#9ca3af;font-size:13px;">Report available in KickLoad dashboard.</p>`;
94
+
95
+ const html = `<!DOCTYPE html><html><body style="margin:0;padding:0;background:#f9fafb;font-family:Inter,Arial,sans-serif;">
96
+ <table width="100%" cellpadding="0" cellspacing="0" style="padding:40px 20px;"><tr><td align="center">
97
+ <table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);">
98
+ <tr><td style="background:linear-gradient(135deg,#1e202a,#303952);padding:28px 32px;">
99
+ <h1 style="margin:0;color:#fff;font-size:22px;font-weight:800;">KickLoad Watcher</h1>
100
+ <p style="margin:4px 0 0;color:#9ca3af;font-size:13px;">OneQA — Automated API Testing</p>
101
+ </td></tr>
102
+ <tr><td style="padding:28px 32px 0;">
103
+ <div style="background:${statusBg};border:1px solid ${statusBorder};border-radius:10px;padding:16px 20px;">
104
+ <div style="font-size:20px;font-weight:800;color:${statusColor};">${status}</div>
105
+ <div style="font-size:13px;color:#6b7280;margin-top:2px;font-family:monospace;">${endpoint}</div>
106
+ </div>
107
+ </td></tr>
108
+ <tr><td style="padding:20px 32px 0;">
109
+ <table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
110
+ <tr style="background:#f9fafb;"><td colspan="2" style="padding:10px 16px;font-size:12px;font-weight:700;color:#9ca3af;text-transform:uppercase;border-bottom:1px solid #e5e7eb;">Test Details</td></tr>
111
+ ${rows}
112
+ </table>
113
+ </td></tr>
114
+ <tr><td style="padding:24px 32px;text-align:center;">${downloadBtn}</td></tr>
115
+ <tr><td style="padding:20px 32px;border-top:1px solid #f3f4f6;">
116
+ <p style="margin:0;color:#9ca3af;font-size:12px;text-align:center;">
117
+ KickLoad Watcher · <a href="https://kickload.neeyatai.com" style="color:#f47c20;text-decoration:none;">NeeyatAI</a>
118
+ </p>
119
+ </td></tr>
120
+ </table></td></tr></table></body></html>`;
121
+
122
+ const text =
123
+ `KickLoad Watcher — ${status}\nEndpoint: ${endpoint}\nTested: ${testedAt} IST\n` +
124
+ (summary?.averageLatency != null ? `Latency: ${summary.averageLatency}ms\n` : "") +
125
+ (summary?.errorPercentage != null ? `Errors: ${summary.errorPercentage}%\n` : "") +
126
+ (summary?.throughput != null ? `Throughput: ${summary.throughput} req/s\n` : "") +
127
+ (durationSec != null ? `Duration: ${durationSec}s\n` : "") +
128
+ (downloadUrl ? `\nReport: ${downloadUrl}\n` : "") +
129
+ `\n— KickLoad Watcher`;
130
+
131
+ const info = await transporter.sendMail({
132
+ from: `"${config.email.fromName}" <${config.email.fromAddress}>`,
133
+ to: toEmail, subject, html, text,
134
+ });
135
+
136
+ console.log(`📧 Email sent → ${toEmail} (${info.messageId})`);
137
+ }
package/env-check.js ADDED
@@ -0,0 +1,117 @@
1
+ import { execSync } from "child_process";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import readline from "readline";
5
+
6
+ const MIN_NODE_MAJOR = 18;
7
+
8
+ export async function runStartupChecks() {
9
+ checkNodeVersion();
10
+ await ensureEnvFile();
11
+ }
12
+
13
+ function checkNodeVersion() {
14
+ const major = parseInt(process.versions.node.split(".")[0]);
15
+ if (major < MIN_NODE_MAJOR) {
16
+ console.error(`\nNode.js ${MIN_NODE_MAJOR}+ required. You have ${process.versions.node}.`);
17
+ console.error(`Download: https://nodejs.org\n`);
18
+ process.exit(1);
19
+ }
20
+ }
21
+
22
+ export function checkNgrokInstalled() {
23
+ try {
24
+ execSync("ngrok version", { stdio: "pipe" });
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ export function printNgrokInstallGuide() {
32
+ console.error("\nngrok is not installed or not in PATH.");
33
+ console.error("Install it from: https://ngrok.com/download");
34
+ console.error("Then add your authtoken: ngrok config add-authtoken <token>");
35
+ console.error("Get your token at: https://dashboard.ngrok.com\n");
36
+ }
37
+
38
+ async function ensureEnvFile() {
39
+ const envPath = path.resolve(".env");
40
+
41
+ if (fs.existsSync(envPath)) return;
42
+
43
+ console.log("\nNo .env file found. Running first-time setup...\n");
44
+
45
+ const anthropicKey = await askMasked("Enter ANTHROPIC_API_KEY: ");
46
+
47
+ if (!anthropicKey) {
48
+ console.error("ANTHROPIC_API_KEY is required.");
49
+ process.exit(1);
50
+ }
51
+
52
+ const lines = [
53
+ `ANTHROPIC_API_KEY=${anthropicKey}`,
54
+ `KICKLOAD_API_TOKEN=`,
55
+ `KICKLOAD_BASE_URL=https://kickload.neeyatai.com/api`,
56
+ ``,
57
+ `NGROK_ENABLED=false`,
58
+ `NGROK_AUTHTOKEN=`,
59
+ ``,
60
+ `EMAIL_PROVIDER=smtp`,
61
+ `EMAIL_FROM_NAME=KickLoad Watcher`,
62
+ `EMAIL_FROM_ADDRESS=`,
63
+ `SMTP_HOST=smtp.gmail.com`,
64
+ `SMTP_PORT=465`,
65
+ `SMTP_USER=`,
66
+ `SMTP_PASS=`,
67
+ ``,
68
+ `WATCH_PATHS=.`,
69
+ `TRIGGER_MODE=claudecode`,
70
+ `DEFAULT_DEVELOPER_EMAIL=`,
71
+ `LOG_LEVEL=info`,
72
+ ];
73
+
74
+ fs.writeFileSync(envPath, lines.join("\n") + "\n", "utf-8");
75
+ console.log(".env created. Fill in KICKLOAD_API_TOKEN and email settings, then run again.\n");
76
+ process.exit(0);
77
+ }
78
+
79
+ function askMasked(prompt) {
80
+ return new Promise((resolve) => {
81
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
82
+ process.stdout.write(prompt);
83
+
84
+ if (process.stdin.isTTY) {
85
+ process.stdin.setRawMode(true);
86
+ process.stdin.resume();
87
+ process.stdin.setEncoding("utf8");
88
+ let value = "";
89
+
90
+ process.stdin.on("data", function handler(char) {
91
+ char = char.toString();
92
+ if (char === "\r" || char === "\n") {
93
+ process.stdin.setRawMode(false);
94
+ process.stdin.removeListener("data", handler);
95
+ process.stdout.write("\n");
96
+ rl.close();
97
+ resolve(value.trim());
98
+ } else if (char === "\u0003") {
99
+ process.stdout.write("\n");
100
+ process.exit(0);
101
+ } else if (char === "\u007F" || char === "\b") {
102
+ if (value.length > 0) {
103
+ value = value.slice(0, -1);
104
+ process.stdout.clearLine(0);
105
+ process.stdout.cursorTo(0);
106
+ process.stdout.write(prompt + "*".repeat(value.length));
107
+ }
108
+ } else {
109
+ value += char;
110
+ process.stdout.write("*");
111
+ }
112
+ });
113
+ } else {
114
+ rl.question("", (answer) => { rl.close(); resolve(answer.trim()); });
115
+ }
116
+ });
117
+ }
@@ -0,0 +1,230 @@
1
+ import chokidar from "chokidar";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { config } from "./config.js";
5
+ import { createLogger } from "./logger.js";
6
+
7
+ const logger = createLogger("file-watcher");
8
+ const DEBOUNCE_MS = 3000;
9
+
10
+ const apiFileCallbacks = [];
11
+ const debounceTimers = new Map();
12
+
13
+ export function onNewApiFile(callback) {
14
+ apiFileCallbacks.push(callback);
15
+ }
16
+
17
+ export function startFileWatcher() {
18
+ const watchPaths = config.watcher.watchPaths.filter(p => {
19
+ if (!fs.existsSync(p)) {
20
+ logger.warn(`Watch path does not exist: ${p}`);
21
+ return false;
22
+ }
23
+ return true;
24
+ });
25
+
26
+ if (watchPaths.length === 0) {
27
+ logger.warn("No valid watch paths. Set WATCH_PATHS in .env");
28
+ return null;
29
+ }
30
+
31
+ const watcher = chokidar.watch(watchPaths, {
32
+ ignored: buildIgnorePatterns(),
33
+ persistent: true,
34
+ ignoreInitial: true,
35
+ awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 },
36
+ });
37
+
38
+ watcher
39
+ .on("add", fp => scheduleCheck(fp, "created"))
40
+ .on("change", fp => scheduleCheck(fp, "modified"))
41
+ .on("error", err => logger.error(`Watcher error: ${err.message}`))
42
+ .on("ready", () => {
43
+ logger.info(`File watcher active on ${watchPaths.length} path(s):`);
44
+ watchPaths.forEach(p => logger.info(` ${p}`));
45
+ logger.info("Watching for backend API file changes...");
46
+ });
47
+
48
+ return watcher;
49
+ }
50
+
51
+ function scheduleCheck(filePath, eventType) {
52
+ // PROBLEM 3 — Smart filter: extension check first (cheap)
53
+ if (!isAllowedExtension(filePath)) return;
54
+ // Then path-based ignores
55
+ if (isIgnoredPath(filePath)) return;
56
+ // Then test file check
57
+ if (isTestFile(filePath)) return;
58
+
59
+ if (debounceTimers.has(filePath)) clearTimeout(debounceTimers.get(filePath));
60
+
61
+ const timer = setTimeout(() => {
62
+ debounceTimers.delete(filePath);
63
+ processStableFile(filePath, eventType);
64
+ }, DEBOUNCE_MS);
65
+
66
+ debounceTimers.set(filePath, timer);
67
+ }
68
+
69
+ function processStableFile(filePath, eventType) {
70
+ let content;
71
+ try {
72
+ content = fs.readFileSync(filePath, "utf-8");
73
+ } catch (err) {
74
+ if (err.code !== "ENOENT") logger.error(`Cannot read ${filePath}: ${err.message}`);
75
+ return;
76
+ }
77
+
78
+ // PROBLEM 3 — Reject frontend code
79
+ if (isFrontendCode(content)) {
80
+ logger.debug(`Skipping frontend file: ${path.basename(filePath)}`);
81
+ return;
82
+ }
83
+
84
+ // PROBLEM 3 — Must contain backend route patterns
85
+ if (!containsBackendRoutes(content)) {
86
+ logger.debug(`No API patterns in: ${path.basename(filePath)}`);
87
+ return;
88
+ }
89
+
90
+ // PROBLEM 3 — Extract endpoints; skip if none found
91
+ const endpoints = extractAllEndpoints(content);
92
+ if (endpoints.length === 0) {
93
+ logger.debug(`No extractable endpoints in: ${path.basename(filePath)}`);
94
+ return;
95
+ }
96
+
97
+ logger.info(`Backend API file detected: ${path.basename(filePath)} [${eventType}]`);
98
+ logger.info(`Endpoints found (${endpoints.length}): ${endpoints.join(", ")}`);
99
+
100
+ const event = {
101
+ filePath,
102
+ fileName: path.basename(filePath),
103
+ fileContent: content,
104
+ detectedEndpoints: endpoints,
105
+ timestamp: new Date().toISOString(),
106
+ eventType,
107
+ source: "file_watcher",
108
+ userId: process.env.DEFAULT_USER_ID || null,
109
+ };
110
+
111
+ for (const cb of apiFileCallbacks) {
112
+ cb(event).catch(err => logger.error(`Callback error: ${err.message}`));
113
+ }
114
+ }
115
+
116
+ // ── PROBLEM 3: Filter helpers ─────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Allow only backend-relevant file extensions.
120
+ */
121
+ function isAllowedExtension(filePath) {
122
+ const ALLOWED = new Set([".js", ".ts", ".py", ".go", ".java", ".rb", ".php", ".cs", ".rs"]);
123
+ return ALLOWED.has(path.extname(filePath).toLowerCase());
124
+ }
125
+
126
+ /**
127
+ * Ignore common non-backend directories.
128
+ */
129
+ function isIgnoredPath(filePath) {
130
+ const IGNORED = new Set([
131
+ "node_modules", "frontend", "client", "public", "static",
132
+ "assets", "dist", "build", "out", ".next", ".nuxt", ".vite",
133
+ "coverage", "__pycache__", ".git", "venv", ".venv", "env",
134
+ ]);
135
+ return filePath.replace(/\\/g, "/").split("/").some(s => IGNORED.has(s.toLowerCase()));
136
+ }
137
+
138
+ function containsBackendRoutes(content) {
139
+ if (isFrontendCode(content)) return false;
140
+ return BACKEND_ROUTE_PATTERNS.some(p => { p.lastIndex = 0; return p.test(content); });
141
+ }
142
+
143
+ function isFrontendCode(content) {
144
+ return [
145
+ /from\s+['"]react['"]/i, /from\s+['"]vue['"]/i, /from\s+['"]@angular/i,
146
+ /import\s+React/i, /createApp\s*\(/, /ReactDOM\.render/,
147
+ /useState\s*\(/, /useEffect\s*\(/, /JSX\.Element/,
148
+ /<\s*[A-Z][A-Za-z]+\s*\//, /export\s+default\s+function\s+[A-Z]/,
149
+ /className\s*=/, /getElementById\s*\(/, /querySelector\s*\(/,
150
+ /addEventListener\s*\(/, /axios\.(get|post|put|delete|patch)\s*\(/i,
151
+ /fetch\s*\(\s*['"`]\/api/i,
152
+ ].some(p => p.test(content));
153
+ }
154
+
155
+ const BACKEND_ROUTE_PATTERNS = [
156
+ /\bapp\.(get|post|put|patch|delete|use)\s*\(\s*['"`]/i,
157
+ /\brouter\.(get|post|put|patch|delete|use)\s*\(\s*['"`]/i,
158
+ /\bfastify\.(get|post|put|patch|delete)\s*\(\s*['"`]/i,
159
+ /\bserver\.(get|post|put|patch|delete)\s*\(\s*['"`]/i,
160
+ /@(app|bp|blueprint|router|api)\.(route|get|post|put|patch|delete)\s*\(\s*['"`]/i,
161
+ /add_url_rule\s*\(\s*['"`]/i,
162
+ /path\s*\(\s*['"`][^'"`]*['"`]\s*,\s*\w/,
163
+ /re_path\s*\(\s*['"`]/i,
164
+ /\br\.(GET|POST|PUT|PATCH|DELETE)\s*\(\s*['"`]/,
165
+ /\be\.(GET|POST|PUT|PATCH|DELETE)\s*\(\s*['"`]/,
166
+ /http\.HandleFunc\s*\(\s*['"`]/, /\.HandleFunc\s*\(\s*['"`]/,
167
+ /@(GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|RequestMapping)\s*\(/i,
168
+ /\bresources\s+:/,
169
+ /\bget\s+['"`][^'"]+['"`]\s*,\s*to:/, /\bpost\s+['"`][^'"]+['"`]\s*,\s*to:/,
170
+ /\[Http(Get|Post|Put|Delete|Patch)\]/i,
171
+ /MapGet\s*\(\s*['"`]/i, /MapPost\s*\(\s*['"`]/i,
172
+ ];
173
+
174
+ function extractAllEndpoints(content) {
175
+ const endpoints = new Set();
176
+ let m;
177
+
178
+ for (const pattern of [
179
+ /(?:app|router|fastify|server)\s*\.\s*(?:get|post|put|patch|delete|use)\s*\(\s*(['"`])(\/[^'"`\s,)]*)\1/gi,
180
+ /@(?:app|bp|blueprint|router|api)\s*\.\s*(?:route|get|post|put|patch|delete)\s*\(\s*(['"`])(\/[^'"`\s,)]*)\1/gi,
181
+ /(?:r|e|router)\s*\.\s*(?:GET|POST|PUT|PATCH|DELETE)\s*\(\s*(['"`])(\/[^'"`\s,)]*)\1/g,
182
+ /\.HandleFunc\s*\(\s*(['"`])(\/[^'"`\s,)]*)\1/g,
183
+ /@(?:GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|RequestMapping)\s*\(\s*(?:value\s*=\s*)?(['"`])(\/[^'"`\s,)]*)\1/gi,
184
+ /Map(?:Get|Post|Put|Delete|Patch)\s*\(\s*(['"`])(\/[^'"`\s,)]*)\1/gi,
185
+ ]) {
186
+ pattern.lastIndex = 0;
187
+ while ((m = pattern.exec(content)) !== null) {
188
+ const ep = m[2];
189
+ if (isValidEndpoint(ep)) endpoints.add(ep);
190
+ }
191
+ }
192
+
193
+ // Django path()
194
+ const djangoPattern = /(?:re_)?path\s*\(\s*r?(['"`])([^'"`]+)\1\s*,/gi;
195
+ djangoPattern.lastIndex = 0;
196
+ while ((m = djangoPattern.exec(content)) !== null) {
197
+ const raw = m[2].replace(/\^|\$|\?P<[^>]+>/g, "");
198
+ const ep = raw.startsWith("/") ? raw : `/${raw}`;
199
+ if (isValidEndpoint(ep)) endpoints.add(ep);
200
+ }
201
+
202
+ return [...endpoints];
203
+ }
204
+
205
+ function isValidEndpoint(ep) {
206
+ if (!ep || !ep.startsWith("/")) return false;
207
+ if (ep.length < 2 || ep.includes(" ")) return false;
208
+ if (ep === "/*" || ep === "/**") return false;
209
+ return true;
210
+ }
211
+
212
+ function isTestFile(filePath) {
213
+ const name = path.basename(filePath).toLowerCase();
214
+ return (
215
+ name.includes(".test.") || name.includes(".spec.") ||
216
+ name.startsWith("test_") || name.endsWith("_test.js") ||
217
+ /\/(tests?|__tests?__|spec)\//i.test(filePath)
218
+ );
219
+ }
220
+
221
+ function buildIgnorePatterns() {
222
+ return [
223
+ "**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**",
224
+ "**/out/**", "**/.next/**", "**/.nuxt/**", "**/.vite/**",
225
+ "**/coverage/**", "**/__pycache__/**", "**/venv/**", "**/.venv/**",
226
+ "**/public/**", "**/static/**", "**/assets/**",
227
+ "**/frontend/**", "**/client/**",
228
+ ...(config.watcher.ignore || []),
229
+ ];
230
+ }