kubeagent 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.
Files changed (57) hide show
  1. package/LICENSE +72 -0
  2. package/README.md +154 -0
  3. package/dist/auth.d.ts +23 -0
  4. package/dist/auth.js +162 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +447 -0
  7. package/dist/config.d.ts +50 -0
  8. package/dist/config.js +79 -0
  9. package/dist/debug.d.ts +10 -0
  10. package/dist/debug.js +18 -0
  11. package/dist/diagnoser/index.d.ts +17 -0
  12. package/dist/diagnoser/index.js +251 -0
  13. package/dist/diagnoser/tools.d.ts +119 -0
  14. package/dist/diagnoser/tools.js +108 -0
  15. package/dist/kb/loader.d.ts +1 -0
  16. package/dist/kb/loader.js +41 -0
  17. package/dist/kb/writer.d.ts +11 -0
  18. package/dist/kb/writer.js +36 -0
  19. package/dist/kubectl-config.d.ts +7 -0
  20. package/dist/kubectl-config.js +47 -0
  21. package/dist/kubectl.d.ts +13 -0
  22. package/dist/kubectl.js +57 -0
  23. package/dist/monitor/checks.d.ts +71 -0
  24. package/dist/monitor/checks.js +167 -0
  25. package/dist/monitor/index.d.ts +7 -0
  26. package/dist/monitor/index.js +126 -0
  27. package/dist/monitor/types.d.ts +11 -0
  28. package/dist/monitor/types.js +1 -0
  29. package/dist/notify/index.d.ts +5 -0
  30. package/dist/notify/index.js +40 -0
  31. package/dist/notify/setup.d.ts +4 -0
  32. package/dist/notify/setup.js +88 -0
  33. package/dist/notify/slack.d.ts +4 -0
  34. package/dist/notify/slack.js +76 -0
  35. package/dist/notify/telegram.d.ts +8 -0
  36. package/dist/notify/telegram.js +63 -0
  37. package/dist/notify/webhook.d.ts +3 -0
  38. package/dist/notify/webhook.js +49 -0
  39. package/dist/onboard/cluster-scan.d.ts +42 -0
  40. package/dist/onboard/cluster-scan.js +103 -0
  41. package/dist/onboard/code-scan.d.ts +9 -0
  42. package/dist/onboard/code-scan.js +114 -0
  43. package/dist/onboard/index.d.ts +1 -0
  44. package/dist/onboard/index.js +328 -0
  45. package/dist/onboard/interview.d.ts +12 -0
  46. package/dist/onboard/interview.js +71 -0
  47. package/dist/onboard/project-matcher.d.ts +25 -0
  48. package/dist/onboard/project-matcher.js +149 -0
  49. package/dist/orchestrator.d.ts +3 -0
  50. package/dist/orchestrator.js +222 -0
  51. package/dist/proxy-client.d.ts +15 -0
  52. package/dist/proxy-client.js +72 -0
  53. package/dist/render.d.ts +5 -0
  54. package/dist/render.js +143 -0
  55. package/dist/verifier.d.ts +9 -0
  56. package/dist/verifier.js +17 -0
  57. package/package.json +39 -0
@@ -0,0 +1,328 @@
1
+ import chalk from "chalk";
2
+ import ora from "ora";
3
+ import { scanCluster, formatClusterMarkdown } from "./cluster-scan.js";
4
+ import { detectTechStack, formatProjectMarkdown } from "./code-scan.js";
5
+ import { scanProjectDirectory, matchProjectsToWorkloads, bestMatches } from "./project-matcher.js";
6
+ import { runInterview } from "./interview.js";
7
+ import { writeClusterKb, writeProjectKb, ensureKbDir } from "../kb/writer.js";
8
+ import { saveConfig, loadConfig, configDir, ALL_ACTIONS, DEFAULT_SAFE_ACTIONS } from "../config.js";
9
+ import { interactiveAddChannel } from "../notify/setup.js";
10
+ import { pickContext } from "../kubectl-config.js";
11
+ import { join } from "node:path";
12
+ import { homedir } from "node:os";
13
+ import readline from "node:readline";
14
+ function expandPath(p) {
15
+ return p.startsWith("~/") ? homedir() + p.slice(1) : p;
16
+ }
17
+ async function ask(question) {
18
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
19
+ return new Promise((resolve) => {
20
+ rl.question(chalk.cyan(`${question} `), (answer) => {
21
+ rl.close();
22
+ resolve(answer.trim());
23
+ });
24
+ });
25
+ }
26
+ const ACTION_DESCRIPTIONS = {
27
+ restart_pod: "Delete a pod (recreated automatically if owned by a Deployment/DaemonSet)",
28
+ rollout_restart: "Rolling restart of a Deployment",
29
+ scale_deployment: "Scale a Deployment to a different replica count",
30
+ set_resources: "Change CPU/memory requests and limits on a Deployment",
31
+ };
32
+ async function configureSafeActions(current) {
33
+ console.log(chalk.bold("\nAuto-approved actions (no confirmation required):\n"));
34
+ ALL_ACTIONS.forEach((action, i) => {
35
+ const enabled = current.includes(action);
36
+ const status = enabled ? chalk.green("[enabled] ") : chalk.dim("[disabled]");
37
+ console.log(` ${chalk.cyan(String(i + 1))}. ${status} ${action}`);
38
+ console.log(chalk.dim(` ${ACTION_DESCRIPTIONS[action]}`));
39
+ });
40
+ console.log(chalk.dim(`\nEnter numbers to toggle (e.g. "1 3"), or press Enter to keep current settings:`));
41
+ const answer = await ask("Toggle actions:");
42
+ if (!answer)
43
+ return current;
44
+ const toggled = new Set(current);
45
+ for (const token of answer.split(/[\s,]+/)) {
46
+ const idx = parseInt(token, 10) - 1;
47
+ if (isNaN(idx) || idx < 0 || idx >= ALL_ACTIONS.length)
48
+ continue;
49
+ const action = ALL_ACTIONS[idx];
50
+ if (toggled.has(action))
51
+ toggled.delete(action);
52
+ else
53
+ toggled.add(action);
54
+ }
55
+ const result = ALL_ACTIONS.filter((a) => toggled.has(a));
56
+ console.log(chalk.dim(`Safe actions set to: ${result.length > 0 ? result.join(", ") : "(none — all require approval)"}`));
57
+ return result;
58
+ }
59
+ export async function onboard() {
60
+ console.log(chalk.bold("\nKubeAgent Onboarding\n"));
61
+ // Step 1: Pick cluster context from list
62
+ const context = await pickContext();
63
+ const kubectlOpts = context ? { context } : {};
64
+ const contextName = context || "default";
65
+ // Step 2: Scan cluster
66
+ const spinner = ora("Scanning cluster...").start();
67
+ let clusterInfo;
68
+ try {
69
+ clusterInfo = await scanCluster(kubectlOpts);
70
+ spinner.succeed(`Found ${clusterInfo.nodes.length} nodes, ${clusterInfo.deployments.length} deployments, ${clusterInfo.namespaces.length} namespaces`);
71
+ }
72
+ catch (err) {
73
+ spinner.fail(`Cluster scan failed: ${err.message}`);
74
+ return;
75
+ }
76
+ // Step 3: Discover projects — scan a directory or enter paths manually
77
+ const HIGH_CONFIDENCE = 60;
78
+ const codePaths = [];
79
+ const projectMappings = [];
80
+ // Check for existing mappings for this context
81
+ const existingConfig = loadConfig();
82
+ const existingCluster = existingConfig.clusters.find((c) => c.context === contextName);
83
+ const existingMappings = existingCluster?.projects ?? [];
84
+ if (existingMappings.length > 0) {
85
+ console.log(chalk.bold(`\nExisting project mappings for "${contextName}":\n`));
86
+ for (const m of existingMappings) {
87
+ console.log(` ${chalk.green("✓")} ${m.name} → ${m.kind}/${m.namespace}/${m.deployment}`);
88
+ }
89
+ const rescan = await ask("\nRe-scan projects? [y/N]");
90
+ if (rescan.toLowerCase() !== "y") {
91
+ // Carry forward existing mappings
92
+ codePaths.push(...existingMappings.map((m) => ({ name: m.name, path: m.dir })));
93
+ projectMappings.push(...existingMappings);
94
+ // Skip to interview
95
+ const projects = codePaths.map(({ name, path }) => ({ name, path, stack: detectTechStack(path) }));
96
+ const notes = await runInterview({ cluster: clusterInfo, projects });
97
+ const kbDir = join(configDir(), "clusters", contextName);
98
+ ensureKbDir(kbDir);
99
+ writeClusterKb(kbDir, formatClusterMarkdown(clusterInfo));
100
+ for (const p of projects) {
101
+ const mapping = projectMappings.find((m) => m.name === p.name);
102
+ const mappingNote = mapping ? `**Deployment:** ${mapping.deployment} (${mapping.namespace})\n**Kind:** ${mapping.kind}` : undefined;
103
+ writeProjectKb(kbDir, p.name, formatProjectMarkdown(p.name, p.path, p.stack, mappingNote));
104
+ }
105
+ if (notes.size > 0) {
106
+ writeProjectKb(kbDir, "_notes", `# Onboarding Notes\n\n${Array.from(notes.entries()).map(([q, a]) => `**${q}**\n${a}`).join("\n\n")}\n`);
107
+ }
108
+ const currentSafeActions = existingConfig.remediation?.safe_actions ?? DEFAULT_SAFE_ACTIONS;
109
+ const safeActions = await configureSafeActions(currentSafeActions);
110
+ const existingChannels = existingConfig.notifications?.channels ?? [];
111
+ const newChannels = [...existingChannels];
112
+ console.log(chalk.bold("\nNotifications\n"));
113
+ if (existingChannels.length > 0) {
114
+ console.log(chalk.dim(` ${existingChannels.length} channel(s) already configured.`));
115
+ }
116
+ const addNotify = await ask("Set up a notification channel (Slack / Telegram)? [y/N]");
117
+ if (addNotify.toLowerCase() === "y") {
118
+ let adding = true;
119
+ while (adding) {
120
+ const channel = await interactiveAddChannel();
121
+ if (channel)
122
+ newChannels.push(channel);
123
+ const another = await ask("Add another channel? [y/N]");
124
+ adding = another.toLowerCase() === "y";
125
+ }
126
+ }
127
+ else {
128
+ console.log(chalk.dim(" Skipped. Run: kubeagent notify add to set up later."));
129
+ }
130
+ saveConfig({
131
+ ...existingConfig,
132
+ remediation: { ...existingConfig.remediation, safe_actions: safeActions },
133
+ notifications: { ...existingConfig.notifications, channels: newChannels },
134
+ clusters: existingConfig.clusters.map((c) => c.context === contextName ? { ...c, codepaths: codePaths.map((p) => p.path) } : c),
135
+ });
136
+ console.log(chalk.green(`\nKnowledge base written to ${kbDir}`));
137
+ console.log(chalk.green(`Config saved to ${configDir()}/config.yaml`));
138
+ console.log(chalk.dim("\nRun `kubeagent watch` to start monitoring."));
139
+ return;
140
+ }
141
+ console.log();
142
+ }
143
+ console.log(chalk.dim("How would you like to add your code repositories?"));
144
+ console.log(` ${chalk.cyan("1")}. Scan a parent directory (auto-detect projects)`);
145
+ console.log(` ${chalk.cyan("2")}. Enter paths manually`);
146
+ console.log(` ${chalk.cyan("3")}. Skip\n`);
147
+ const modeAnswer = await ask("Choice [1]:");
148
+ const mode = modeAnswer === "2" ? "manual" : modeAnswer === "3" ? "skip" : "scan";
149
+ if (mode === "scan") {
150
+ const parentDirRaw = await ask("Parent directory to scan:");
151
+ const parentDir = expandPath(parentDirRaw);
152
+ if (parentDir) {
153
+ const scanSpinner = ora("Scanning project directories...").start();
154
+ const candidates = scanProjectDirectory(parentDir);
155
+ scanSpinner.succeed(`Found ${candidates.length} project(s)`);
156
+ if (candidates.length > 0) {
157
+ const suggestions = matchProjectsToWorkloads(candidates, clusterInfo.deployments, clusterInfo.statefulsets);
158
+ const matches = bestMatches(suggestions, 30);
159
+ // Group by project
160
+ const grouped = new Map();
161
+ for (const m of matches) {
162
+ if (!grouped.has(m.project.dir))
163
+ grouped.set(m.project.dir, []);
164
+ grouped.get(m.project.dir).push(m);
165
+ }
166
+ console.log(chalk.bold("\nProject → deployment suggestions:\n"));
167
+ for (const [, ms] of grouped) {
168
+ const best = ms[0];
169
+ const isHigh = best.score >= HIGH_CONFIDENCE;
170
+ const confidence = isHigh ? chalk.green("high") : best.score >= 40 ? chalk.yellow("medium") : chalk.red("low");
171
+ console.log(` ${chalk.bold(best.project.name + "/")} → ${best.workload.namespace}/${best.workload.name} [${confidence}]`);
172
+ console.log(chalk.dim(` ${best.reasons[0]}`));
173
+ if (isHigh) {
174
+ // Auto-accept high confidence
175
+ codePaths.push({ name: best.project.name, path: best.project.dir });
176
+ projectMappings.push({
177
+ name: best.project.name,
178
+ dir: best.project.dir,
179
+ deployment: best.workload.name,
180
+ namespace: best.workload.namespace,
181
+ kind: best.workload.kind,
182
+ });
183
+ console.log(chalk.dim(" Auto-accepted.\n"));
184
+ }
185
+ else {
186
+ // Ask for medium/low
187
+ const ans = await ask(" Accept this mapping? [y/N]");
188
+ if (ans.toLowerCase() === "y") {
189
+ codePaths.push({ name: best.project.name, path: best.project.dir });
190
+ projectMappings.push({
191
+ name: best.project.name,
192
+ dir: best.project.dir,
193
+ deployment: best.workload.name,
194
+ namespace: best.workload.namespace,
195
+ kind: best.workload.kind,
196
+ });
197
+ }
198
+ console.log();
199
+ }
200
+ }
201
+ // Projects with no match — offer to pick a deployment manually
202
+ const matchedDirs = new Set(matches.map((m) => m.project.dir));
203
+ const unmatched = candidates.filter((c) => !matchedDirs.has(c.dir));
204
+ if (unmatched.length > 0) {
205
+ const allWorkloads = [
206
+ ...clusterInfo.deployments.map((d) => ({ ...d, kind: "Deployment" })),
207
+ ...clusterInfo.statefulsets.map((s) => ({ ...s, kind: "StatefulSet" })),
208
+ ];
209
+ console.log(chalk.dim("\nProjects without a cluster match:"));
210
+ for (const p of unmatched) {
211
+ console.log(`\n ${chalk.bold(p.name + "/")} (${p.stack.language}/${p.stack.framework})`);
212
+ const ans = await ask(" Add to knowledge base? [y/N]");
213
+ if (ans.toLowerCase() !== "y")
214
+ continue;
215
+ codePaths.push({ name: p.name, path: p.dir });
216
+ // Let user pick a deployment
217
+ console.log(chalk.dim("\n Pick a deployment (or press Enter to skip mapping):\n"));
218
+ allWorkloads.forEach((w, i) => {
219
+ console.log(` ${chalk.cyan(String(i + 1))}. ${w.kind}/${w.namespace}/${w.name}`);
220
+ });
221
+ const pick = await ask(`\n Deployment number [skip]:`);
222
+ const idx = parseInt(pick, 10) - 1;
223
+ if (!isNaN(idx) && idx >= 0 && idx < allWorkloads.length) {
224
+ const w = allWorkloads[idx];
225
+ projectMappings.push({ name: p.name, dir: p.dir, deployment: w.name, namespace: w.namespace, kind: w.kind });
226
+ console.log(chalk.dim(` Mapped: ${p.name} → ${w.name}`));
227
+ }
228
+ }
229
+ console.log();
230
+ }
231
+ }
232
+ }
233
+ }
234
+ else if (mode === "manual") {
235
+ console.log(chalk.dim("\nEnter one path per line. Empty line to finish.\n"));
236
+ while (true) {
237
+ const path = await ask("Code path (empty to finish):");
238
+ if (!path)
239
+ break;
240
+ const name = await ask(`Project name for ${path}:`);
241
+ codePaths.push({ name: name || path.split("/").pop() || "project", path });
242
+ }
243
+ }
244
+ // Step 4: Scan codebases
245
+ const projects = codePaths.map(({ name, path }) => ({
246
+ name,
247
+ path,
248
+ stack: detectTechStack(path),
249
+ }));
250
+ if (projects.length > 0) {
251
+ console.log(chalk.bold("\nDetected tech stacks:"));
252
+ for (const p of projects) {
253
+ console.log(chalk.green(` ${p.name}: ${p.stack.language}/${p.stack.framework}`));
254
+ }
255
+ }
256
+ // Step 5: Interview
257
+ const notes = await runInterview({ cluster: clusterInfo, projects });
258
+ // Step 6: Write knowledge base
259
+ const kbDir = join(configDir(), "clusters", contextName);
260
+ ensureKbDir(kbDir);
261
+ const clusterMd = formatClusterMarkdown(clusterInfo);
262
+ writeClusterKb(kbDir, clusterMd);
263
+ for (const p of projects) {
264
+ const interviewNotes = Array.from(notes.entries())
265
+ .filter(([q]) => q.toLowerCase().includes(p.name.toLowerCase()))
266
+ .map(([q, a]) => `**${q}**\n${a}`)
267
+ .join("\n\n");
268
+ const mapping = projectMappings.find((m) => m.name === p.name);
269
+ const mappingNote = mapping
270
+ ? `**Deployment:** ${mapping.deployment} (${mapping.namespace})\n**Kind:** ${mapping.kind}`
271
+ : undefined;
272
+ const extraNotes = [mappingNote, interviewNotes].filter(Boolean).join("\n\n") || undefined;
273
+ const projectMd = formatProjectMarkdown(p.name, p.path, p.stack, extraNotes);
274
+ writeProjectKb(kbDir, p.name, projectMd);
275
+ }
276
+ // Save general notes
277
+ if (notes.size > 0) {
278
+ const generalNotes = Array.from(notes.entries())
279
+ .map(([q, a]) => `**${q}**\n${a}`)
280
+ .join("\n\n");
281
+ writeProjectKb(kbDir, "_notes", `# Onboarding Notes\n\n${generalNotes}\n`);
282
+ }
283
+ // Step 6b: Configure safe actions
284
+ const currentSafeActions = existingConfig.remediation?.safe_actions ?? DEFAULT_SAFE_ACTIONS;
285
+ const safeActions = await configureSafeActions(currentSafeActions);
286
+ // Step 6c: Notification channels
287
+ const existingChannels = existingConfig.notifications?.channels ?? [];
288
+ const newChannels = [...existingChannels];
289
+ console.log(chalk.bold("\nNotifications\n"));
290
+ if (existingChannels.length > 0) {
291
+ console.log(chalk.dim(` ${existingChannels.length} channel(s) already configured.`));
292
+ }
293
+ const addNotify = await ask("Set up a notification channel (Slack / Telegram)? [y/N]");
294
+ if (addNotify.toLowerCase() === "y") {
295
+ let adding = true;
296
+ while (adding) {
297
+ const channel = await interactiveAddChannel();
298
+ if (channel)
299
+ newChannels.push(channel);
300
+ const another = await ask("Add another channel? [y/N]");
301
+ adding = another.toLowerCase() === "y";
302
+ }
303
+ }
304
+ else {
305
+ console.log(chalk.dim(" Skipped. Run: kubeagent notify add to set up later."));
306
+ }
307
+ // Step 7: Update config
308
+ const config = loadConfig();
309
+ config.remediation = { ...config.remediation, safe_actions: safeActions };
310
+ config.notifications = { ...config.notifications, channels: newChannels };
311
+ const clusterConfig = {
312
+ context: contextName,
313
+ interval: 60,
314
+ codepaths: codePaths.map((p) => p.path),
315
+ projects: projectMappings.length > 0 ? projectMappings : undefined,
316
+ };
317
+ const existingIdx = config.clusters.findIndex((c) => c.context === contextName);
318
+ if (existingIdx >= 0) {
319
+ config.clusters[existingIdx] = clusterConfig;
320
+ }
321
+ else {
322
+ config.clusters.push(clusterConfig);
323
+ }
324
+ saveConfig(config);
325
+ console.log(chalk.green(`\nKnowledge base written to ${kbDir}`));
326
+ console.log(chalk.green(`Config saved to ${configDir()}/config.yaml`));
327
+ console.log(chalk.dim("\nRun `kubeagent watch` to start monitoring."));
328
+ }
@@ -0,0 +1,12 @@
1
+ import type { ClusterInfo } from "./cluster-scan.js";
2
+ import type { TechStack } from "./code-scan.js";
3
+ interface InterviewContext {
4
+ cluster: ClusterInfo;
5
+ projects: Array<{
6
+ name: string;
7
+ path: string;
8
+ stack: TechStack;
9
+ }>;
10
+ }
11
+ export declare function runInterview(context: InterviewContext): Promise<Map<string, string>>;
12
+ export {};
@@ -0,0 +1,71 @@
1
+ import readline from "node:readline";
2
+ import chalk from "chalk";
3
+ import { loadAuth } from "../auth.js";
4
+ import { proxyRequest } from "../proxy-client.js";
5
+ async function ask(question) {
6
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
7
+ return new Promise((resolve) => {
8
+ rl.question(chalk.cyan(`\n${question}\n> `), (answer) => {
9
+ rl.close();
10
+ resolve(answer.trim());
11
+ });
12
+ });
13
+ }
14
+ async function createMessage(params) {
15
+ const auth = loadAuth();
16
+ if (auth?.apiKey) {
17
+ const result = await proxyRequest(auth, params);
18
+ return result;
19
+ }
20
+ throw new Error("no_auth");
21
+ }
22
+ export async function runInterview(context) {
23
+ const notes = new Map();
24
+ const contextSummary = [
25
+ `Cluster: ${context.cluster.context}`,
26
+ `Nodes: ${context.cluster.nodes.length}`,
27
+ `Namespaces: ${context.cluster.namespaces.join(", ")}`,
28
+ `Deployments: ${context.cluster.deployments.map((d) => d.name).join(", ")}`,
29
+ `Projects: ${context.projects.map((p) => `${p.name} (${p.stack.language}/${p.stack.framework})`).join(", ")}`,
30
+ ].join("\n");
31
+ let response;
32
+ try {
33
+ response = await createMessage({
34
+ model: "claude-haiku-4-5-20251001",
35
+ max_tokens: 2000,
36
+ messages: [
37
+ {
38
+ role: "user",
39
+ content: `Given this Kubernetes cluster setup, generate 5-8 short clarifying questions to fill gaps in the knowledge base. Questions should cover: service purposes, constraints, critical vs best-effort services, known quirks, notification preferences. Return one question per line, no numbering.\n\n${contextSummary}`,
40
+ },
41
+ ],
42
+ });
43
+ }
44
+ catch (err) {
45
+ const msg = err.message;
46
+ if (msg === "no_auth") {
47
+ console.log(chalk.yellow("Skipping AI interview — run 'kubeagent login' to enable."));
48
+ }
49
+ else {
50
+ console.log(chalk.yellow(`Skipping AI interview — ${msg}`));
51
+ }
52
+ return notes;
53
+ }
54
+ const questionsText = response.content
55
+ .filter((b) => b.type === "text")
56
+ .map((b) => b.text)
57
+ .join("\n");
58
+ const questions = questionsText
59
+ .split("\n")
60
+ .map((q) => q.trim())
61
+ .filter((q) => q.length > 0 && q.endsWith("?"));
62
+ console.log(chalk.green("\nI'll ask a few questions to fill gaps in the knowledge base."));
63
+ console.log(chalk.dim("Press Enter to skip any question.\n"));
64
+ for (const question of questions) {
65
+ const answer = await ask(question);
66
+ if (answer) {
67
+ notes.set(question, answer);
68
+ }
69
+ }
70
+ return notes;
71
+ }
@@ -0,0 +1,25 @@
1
+ import { type TechStack } from "./code-scan.js";
2
+ import type { DeploymentInfo, StatefulSetInfo } from "./cluster-scan.js";
3
+ export interface ProjectCandidate {
4
+ dir: string;
5
+ name: string;
6
+ stack: TechStack;
7
+ packageName?: string;
8
+ }
9
+ export interface WorkloadRef {
10
+ name: string;
11
+ namespace: string;
12
+ image: string;
13
+ kind: "Deployment" | "StatefulSet";
14
+ }
15
+ export interface MatchSuggestion {
16
+ project: ProjectCandidate;
17
+ workload: WorkloadRef;
18
+ score: number;
19
+ reasons: string[];
20
+ }
21
+ /** Scan a parent directory and return all subdirectories that look like code projects. */
22
+ export declare function scanProjectDirectory(parentDir: string): ProjectCandidate[];
23
+ export declare function matchProjectsToWorkloads(projects: ProjectCandidate[], deployments: DeploymentInfo[], statefulsets: StatefulSetInfo[]): MatchSuggestion[];
24
+ /** For each project, return only its best-matching workload (if score ≥ threshold). */
25
+ export declare function bestMatches(suggestions: MatchSuggestion[], threshold?: number): MatchSuggestion[];
@@ -0,0 +1,149 @@
1
+ import { readdirSync, existsSync, readFileSync, statSync } from "node:fs";
2
+ import { join, basename } from "node:path";
3
+ import { detectTechStack } from "./code-scan.js";
4
+ // ── Helpers ─────────────────────────────────────────────────────────────────
5
+ /** Strip common deployment suffixes to get the base name. */
6
+ function normalize(s) {
7
+ return s
8
+ .toLowerCase()
9
+ .replace(/[-_](web|api|worker|job|frontend|dashboard|backend|service|app|server|worker\d*)$/, "")
10
+ .replace(/[-_.]/g, "");
11
+ }
12
+ /** Extract the last path segment from an image, minus the tag. */
13
+ function imageBasename(image) {
14
+ const withoutTag = image.split(":")[0];
15
+ return (withoutTag.split("/").pop() ?? withoutTag).toLowerCase();
16
+ }
17
+ /** Read the declared project name from manifest files. */
18
+ function extractPackageName(dir) {
19
+ const pkgPath = join(dir, "package.json");
20
+ if (existsSync(pkgPath)) {
21
+ try {
22
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
23
+ return pkg.name?.toLowerCase();
24
+ }
25
+ catch { /* ignore */ }
26
+ }
27
+ const goModPath = join(dir, "go.mod");
28
+ if (existsSync(goModPath)) {
29
+ const match = readFileSync(goModPath, "utf-8").match(/^module\s+(\S+)/m);
30
+ if (match)
31
+ return match[1].split("/").pop()?.toLowerCase();
32
+ }
33
+ const composerPath = join(dir, "composer.json");
34
+ if (existsSync(composerPath)) {
35
+ try {
36
+ const c = JSON.parse(readFileSync(composerPath, "utf-8"));
37
+ return c.name?.split("/").pop()?.toLowerCase();
38
+ }
39
+ catch { /* ignore */ }
40
+ }
41
+ return undefined;
42
+ }
43
+ // ── Scanning ─────────────────────────────────────────────────────────────────
44
+ /** Scan a parent directory and return all subdirectories that look like code projects. */
45
+ export function scanProjectDirectory(parentDir) {
46
+ if (!existsSync(parentDir))
47
+ return [];
48
+ return readdirSync(parentDir)
49
+ .filter((entry) => {
50
+ try {
51
+ return statSync(join(parentDir, entry)).isDirectory() && !entry.startsWith(".");
52
+ }
53
+ catch {
54
+ return false;
55
+ }
56
+ })
57
+ .filter((entry) => {
58
+ const d = join(parentDir, entry);
59
+ // Must have at least one project manifest
60
+ return (existsSync(join(d, "package.json")) ||
61
+ existsSync(join(d, "go.mod")) ||
62
+ existsSync(join(d, "composer.json")) ||
63
+ existsSync(join(d, "Dockerfile")) ||
64
+ existsSync(join(d, "pyproject.toml")) ||
65
+ existsSync(join(d, "Cargo.toml")));
66
+ })
67
+ .map((entry) => {
68
+ const dir = join(parentDir, entry);
69
+ return {
70
+ dir,
71
+ name: basename(dir),
72
+ stack: detectTechStack(dir),
73
+ packageName: extractPackageName(dir),
74
+ };
75
+ });
76
+ }
77
+ // ── Scoring ──────────────────────────────────────────────────────────────────
78
+ function scoreMatch(project, workload) {
79
+ const reasons = [];
80
+ let score = 0;
81
+ const projName = normalize(project.name);
82
+ const pkgName = project.packageName ? normalize(project.packageName) : undefined;
83
+ const wlName = normalize(workload.name);
84
+ const imgBase = normalize(imageBasename(workload.image));
85
+ // Exact match on deployment name
86
+ if (projName === wlName) {
87
+ score += 60;
88
+ reasons.push(`Directory name matches deployment name exactly ("${project.name}" = "${workload.name}")`);
89
+ }
90
+ else if (wlName.includes(projName) || projName.includes(wlName)) {
91
+ score += 40;
92
+ reasons.push(`Directory name "${project.name}" is contained in deployment name "${workload.name}"`);
93
+ }
94
+ // Package name vs deployment name
95
+ if (pkgName) {
96
+ if (pkgName === wlName) {
97
+ score += 50;
98
+ reasons.push(`Package name "${project.packageName}" matches deployment name exactly`);
99
+ }
100
+ else if (wlName.includes(pkgName) || pkgName.includes(wlName)) {
101
+ score += 30;
102
+ reasons.push(`Package name "${project.packageName}" is contained in deployment name "${workload.name}"`);
103
+ }
104
+ }
105
+ // Image basename vs directory/package name
106
+ if (imgBase === projName || imgBase === pkgName) {
107
+ score += 40;
108
+ reasons.push(`Image basename "${imageBasename(workload.image)}" matches project name`);
109
+ }
110
+ else if (projName.includes(imgBase) || imgBase.includes(projName)) {
111
+ score += 20;
112
+ reasons.push(`Image basename "${imageBasename(workload.image)}" overlaps with project name "${project.name}"`);
113
+ }
114
+ // Cap at 100
115
+ return { score: Math.min(score, 100), reasons };
116
+ }
117
+ // ── Matching ─────────────────────────────────────────────────────────────────
118
+ export function matchProjectsToWorkloads(projects, deployments, statefulsets) {
119
+ const workloads = [
120
+ ...deployments.map((d) => ({ ...d, kind: "Deployment" })),
121
+ ...statefulsets.map((s) => ({ ...s, kind: "StatefulSet" })),
122
+ ];
123
+ const suggestions = [];
124
+ for (const project of projects) {
125
+ for (const workload of workloads) {
126
+ const { score, reasons } = scoreMatch(project, workload);
127
+ if (score >= 20) {
128
+ suggestions.push({ project, workload, score, reasons });
129
+ }
130
+ }
131
+ }
132
+ // Sort: highest score first
133
+ return suggestions.sort((a, b) => b.score - a.score);
134
+ }
135
+ /** For each project, return only its best-matching workload (if score ≥ threshold). */
136
+ export function bestMatches(suggestions, threshold = 30) {
137
+ const seen = new Set();
138
+ const best = [];
139
+ for (const s of suggestions) {
140
+ if (s.score < threshold)
141
+ continue;
142
+ const key = s.project.dir;
143
+ if (!seen.has(key)) {
144
+ seen.add(key);
145
+ best.push(s);
146
+ }
147
+ }
148
+ return best;
149
+ }
@@ -0,0 +1,3 @@
1
+ import type { Issue } from "./monitor/types.js";
2
+ import type { KubeAgentConfig } from "./config.js";
3
+ export declare function handleIssues(issues: Issue[], config: KubeAgentConfig, clusterContext?: string, noInteractive?: boolean): Promise<void>;