karajan-code 1.2.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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +441 -0
  3. package/docs/karajan-code-logo-small.png +0 -0
  4. package/package.json +60 -0
  5. package/scripts/install.js +898 -0
  6. package/scripts/install.sh +7 -0
  7. package/scripts/postinstall.js +117 -0
  8. package/scripts/setup-multi-instance.sh +150 -0
  9. package/src/activity-log.js +59 -0
  10. package/src/agents/aider-agent.js +25 -0
  11. package/src/agents/availability.js +32 -0
  12. package/src/agents/base-agent.js +27 -0
  13. package/src/agents/claude-agent.js +24 -0
  14. package/src/agents/codex-agent.js +27 -0
  15. package/src/agents/gemini-agent.js +25 -0
  16. package/src/agents/index.js +19 -0
  17. package/src/agents/resolve-bin.js +60 -0
  18. package/src/cli.js +200 -0
  19. package/src/commands/code.js +32 -0
  20. package/src/commands/config.js +74 -0
  21. package/src/commands/doctor.js +155 -0
  22. package/src/commands/init.js +181 -0
  23. package/src/commands/plan.js +67 -0
  24. package/src/commands/report.js +340 -0
  25. package/src/commands/resume.js +39 -0
  26. package/src/commands/review.js +26 -0
  27. package/src/commands/roles.js +117 -0
  28. package/src/commands/run.js +91 -0
  29. package/src/commands/scan.js +18 -0
  30. package/src/commands/sonar.js +53 -0
  31. package/src/config.js +322 -0
  32. package/src/git/automation.js +100 -0
  33. package/src/mcp/progress.js +69 -0
  34. package/src/mcp/run-kj.js +87 -0
  35. package/src/mcp/server-handlers.js +259 -0
  36. package/src/mcp/server.js +37 -0
  37. package/src/mcp/tool-arg-normalizers.js +16 -0
  38. package/src/mcp/tools.js +184 -0
  39. package/src/orchestrator.js +1277 -0
  40. package/src/planning-game/adapter.js +105 -0
  41. package/src/planning-game/client.js +81 -0
  42. package/src/prompts/coder.js +60 -0
  43. package/src/prompts/planner.js +26 -0
  44. package/src/prompts/reviewer.js +45 -0
  45. package/src/repeat-detector.js +77 -0
  46. package/src/review/diff-generator.js +22 -0
  47. package/src/review/parser.js +93 -0
  48. package/src/review/profiles.js +66 -0
  49. package/src/review/schema.js +31 -0
  50. package/src/review/tdd-policy.js +57 -0
  51. package/src/roles/base-role.js +127 -0
  52. package/src/roles/coder-role.js +60 -0
  53. package/src/roles/commiter-role.js +94 -0
  54. package/src/roles/index.js +12 -0
  55. package/src/roles/planner-role.js +81 -0
  56. package/src/roles/refactorer-role.js +66 -0
  57. package/src/roles/researcher-role.js +134 -0
  58. package/src/roles/reviewer-role.js +132 -0
  59. package/src/roles/security-role.js +128 -0
  60. package/src/roles/solomon-role.js +199 -0
  61. package/src/roles/sonar-role.js +65 -0
  62. package/src/roles/tester-role.js +114 -0
  63. package/src/roles/triage-role.js +128 -0
  64. package/src/session-store.js +80 -0
  65. package/src/sonar/api.js +78 -0
  66. package/src/sonar/enforcer.js +19 -0
  67. package/src/sonar/manager.js +163 -0
  68. package/src/sonar/project-key.js +83 -0
  69. package/src/sonar/scanner.js +267 -0
  70. package/src/utils/agent-detect.js +32 -0
  71. package/src/utils/budget.js +123 -0
  72. package/src/utils/display.js +346 -0
  73. package/src/utils/events.js +23 -0
  74. package/src/utils/fs.js +19 -0
  75. package/src/utils/git.js +101 -0
  76. package/src/utils/logger.js +86 -0
  77. package/src/utils/paths.js +18 -0
  78. package/src/utils/pricing.js +28 -0
  79. package/src/utils/process.js +67 -0
  80. package/src/utils/wizard.js +41 -0
  81. package/templates/coder-rules.md +24 -0
  82. package/templates/docker-compose.sonar.yml +60 -0
  83. package/templates/kj.config.yml +82 -0
  84. package/templates/review-rules.md +11 -0
  85. package/templates/roles/coder.md +42 -0
  86. package/templates/roles/commiter.md +44 -0
  87. package/templates/roles/planner.md +45 -0
  88. package/templates/roles/refactorer.md +39 -0
  89. package/templates/roles/researcher.md +37 -0
  90. package/templates/roles/reviewer-paranoid.md +38 -0
  91. package/templates/roles/reviewer-relaxed.md +34 -0
  92. package/templates/roles/reviewer-strict.md +37 -0
  93. package/templates/roles/reviewer.md +55 -0
  94. package/templates/roles/security.md +54 -0
  95. package/templates/roles/solomon.md +106 -0
  96. package/templates/roles/sonar.md +49 -0
  97. package/templates/roles/tester.md +41 -0
  98. package/templates/roles/triage.md +25 -0
@@ -0,0 +1,80 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { ensureDir, exists } from "./utils/fs.js";
4
+ import { getSessionRoot } from "./utils/paths.js";
5
+
6
+ const SESSION_ROOT = getSessionRoot();
7
+
8
+ export function newSessionId() {
9
+ const now = new Date();
10
+ const stamp = now.toISOString().replace(/[:.]/g, "-");
11
+ return `s_${stamp}`;
12
+ }
13
+
14
+ export async function createSession(initial = {}) {
15
+ const id = initial.id || newSessionId();
16
+ const dir = path.join(SESSION_ROOT, id);
17
+ await ensureDir(dir);
18
+ const data = {
19
+ id,
20
+ created_at: new Date().toISOString(),
21
+ updated_at: new Date().toISOString(),
22
+ status: "running",
23
+ checkpoints: [],
24
+ ...initial
25
+ };
26
+ await saveSession(data);
27
+ return data;
28
+ }
29
+
30
+ export async function saveSession(session) {
31
+ const dir = path.join(SESSION_ROOT, session.id);
32
+ await ensureDir(dir);
33
+ session.updated_at = new Date().toISOString();
34
+ await fs.writeFile(path.join(dir, "session.json"), JSON.stringify(session, null, 2), "utf8");
35
+ }
36
+
37
+ export async function loadSession(sessionId) {
38
+ const file = path.join(SESSION_ROOT, sessionId, "session.json");
39
+ if (!(await exists(file))) {
40
+ throw new Error(`Session not found: ${sessionId}`);
41
+ }
42
+ const raw = await fs.readFile(file, "utf8");
43
+ return JSON.parse(raw);
44
+ }
45
+
46
+ export async function addCheckpoint(session, checkpoint) {
47
+ session.checkpoints.push({ at: new Date().toISOString(), ...checkpoint });
48
+ await saveSession(session);
49
+ }
50
+
51
+ export async function markSessionStatus(session, status) {
52
+ session.status = status;
53
+ await saveSession(session);
54
+ }
55
+
56
+ export async function pauseSession(session, { question, context: pauseContext }) {
57
+ session.status = "paused";
58
+ session.paused_state = {
59
+ question,
60
+ context: pauseContext,
61
+ paused_at: new Date().toISOString()
62
+ };
63
+ await saveSession(session);
64
+ }
65
+
66
+ export async function resumeSessionWithAnswer(sessionId, answer) {
67
+ const session = await loadSession(sessionId);
68
+ if (session.status !== "paused") {
69
+ throw new Error(`Session ${sessionId} is not paused (status: ${session.status})`);
70
+ }
71
+ const pausedState = session.paused_state;
72
+ if (!pausedState) {
73
+ throw new Error(`Session ${sessionId} has no paused state`);
74
+ }
75
+ session.paused_state.answer = answer;
76
+ session.paused_state.resumed_at = new Date().toISOString();
77
+ session.status = "running";
78
+ await saveSession(session);
79
+ return session;
80
+ }
@@ -0,0 +1,78 @@
1
+ import { runCommand } from "../utils/process.js";
2
+ import { resolveSonarProjectKey } from "./project-key.js";
3
+
4
+ export class SonarApiError extends Error {
5
+ constructor(message, { url, httpStatus, hint } = {}) {
6
+ super(message);
7
+ this.name = "SonarApiError";
8
+ this.url = url;
9
+ this.httpStatus = httpStatus;
10
+ this.hint = hint;
11
+ }
12
+ }
13
+
14
+ function tokenFromConfig(config) {
15
+ return process.env.KJ_SONAR_TOKEN || config.sonarqube.token || "";
16
+ }
17
+
18
+ function parseHttpResponse(stdout) {
19
+ const lines = stdout.split("\n");
20
+ const httpCode = parseInt(lines.pop(), 10) || 0;
21
+ const body = lines.join("\n");
22
+ return { httpCode, body };
23
+ }
24
+
25
+ async function sonarFetch(config, urlPath) {
26
+ const token = tokenFromConfig(config);
27
+ const url = `${config.sonarqube.host}${urlPath}`;
28
+ const res = await runCommand("curl", ["-s", "-w", "\n%{http_code}", "-u", `${token}:`, url]);
29
+
30
+ if (res.exitCode !== 0) {
31
+ throw new SonarApiError(
32
+ `SonarQube is not reachable at ${config.sonarqube.host}. Check that SonarQube is running ('kj sonar start').`,
33
+ { url, hint: "Run 'kj sonar start' or verify Docker is running." }
34
+ );
35
+ }
36
+
37
+ const { httpCode, body } = parseHttpResponse(res.stdout);
38
+
39
+ if (httpCode === 401) {
40
+ throw new SonarApiError(
41
+ `SonarQube authentication failed (HTTP 401). Token may be invalid or expired. Regenerate with 'kj init'.`,
42
+ { url, httpStatus: 401, hint: "Run 'kj init' to regenerate the SonarQube token." }
43
+ );
44
+ }
45
+
46
+ if (httpCode >= 400) {
47
+ throw new SonarApiError(
48
+ `SonarQube API returned HTTP ${httpCode} for ${url}.`,
49
+ { url, httpStatus: httpCode }
50
+ );
51
+ }
52
+
53
+ return body;
54
+ }
55
+
56
+ export async function getQualityGateStatus(config, projectKey = null) {
57
+ const effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey });
58
+ const body = await sonarFetch(config, `/api/qualitygates/project_status?projectKey=${effectiveProjectKey}`);
59
+
60
+ try {
61
+ const parsed = JSON.parse(body);
62
+ return { ok: true, status: parsed.projectStatus?.status || "ERROR", raw: parsed };
63
+ } catch {
64
+ return { ok: false, status: "ERROR", raw: body };
65
+ }
66
+ }
67
+
68
+ export async function getOpenIssues(config, projectKey = null) {
69
+ const effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey });
70
+ const body = await sonarFetch(config, `/api/issues/search?projectKeys=${effectiveProjectKey}&statuses=OPEN`);
71
+
72
+ try {
73
+ const parsed = JSON.parse(body);
74
+ return { total: parsed.total || 0, issues: parsed.issues || [], raw: parsed };
75
+ } catch {
76
+ return { total: 0, issues: [], raw: body };
77
+ }
78
+ }
@@ -0,0 +1,19 @@
1
+ export function shouldBlockByProfile({ gateStatus, profile = "pragmatic" }) {
2
+ if (profile === "paranoid") {
3
+ return gateStatus !== "OK";
4
+ }
5
+
6
+ return gateStatus === "ERROR";
7
+ }
8
+
9
+ export function summarizeIssues(issues) {
10
+ const bySeverity = issues.reduce((acc, issue) => {
11
+ const severity = issue.severity || "UNKNOWN";
12
+ acc[severity] = (acc[severity] || 0) + 1;
13
+ return acc;
14
+ }, {});
15
+
16
+ return Object.entries(bySeverity)
17
+ .map(([severity, count]) => `${severity}: ${count}`)
18
+ .join(", ");
19
+ }
@@ -0,0 +1,163 @@
1
+ import fs from "node:fs/promises";
2
+ import { ensureDir } from "../utils/fs.js";
3
+ import { runCommand } from "../utils/process.js";
4
+ import { getKarajanHome, getSonarComposePath } from "../utils/paths.js";
5
+ import { loadConfig } from "../config.js";
6
+
7
+ const KARAJAN_HOME = getKarajanHome();
8
+ const COMPOSE_PATH = getSonarComposePath();
9
+
10
+ function normalizeSonarConfig(sonarqube = {}) {
11
+ const timeouts = sonarqube.timeouts || {};
12
+ return {
13
+ host: sonarqube.host || "http://localhost:9000",
14
+ external: sonarqube.external === true,
15
+ containerName: sonarqube.container_name || "karajan-sonarqube",
16
+ network: sonarqube.network || "karajan_sonar_net",
17
+ volumes: {
18
+ data: sonarqube?.volumes?.data || "karajan_sonar_data",
19
+ logs: sonarqube?.volumes?.logs || "karajan_sonar_logs",
20
+ extensions: sonarqube?.volumes?.extensions || "karajan_sonar_extensions"
21
+ },
22
+ timeouts: {
23
+ healthcheckSeconds: Number(timeouts.healthcheck_seconds) > 0 ? Number(timeouts.healthcheck_seconds) : 5,
24
+ composeUpMs: Number(timeouts.compose_up_ms) > 0 ? Number(timeouts.compose_up_ms) : 5 * 60 * 1000,
25
+ composeControlMs: Number(timeouts.compose_control_ms) > 0 ? Number(timeouts.compose_control_ms) : 2 * 60 * 1000,
26
+ logsMs: Number(timeouts.logs_ms) > 0 ? Number(timeouts.logs_ms) : 30 * 1000
27
+ }
28
+ };
29
+ }
30
+
31
+ function buildComposeTemplate(sonarConfig) {
32
+ return `services:
33
+ sonarqube:
34
+ image: sonarqube:community
35
+ container_name: ${sonarConfig.containerName}
36
+ ports:
37
+ - "9000:9000"
38
+ volumes:
39
+ - ${sonarConfig.volumes.data}:/opt/sonarqube/data
40
+ - ${sonarConfig.volumes.logs}:/opt/sonarqube/logs
41
+ - ${sonarConfig.volumes.extensions}:/opt/sonarqube/extensions
42
+ environment:
43
+ - SONAR_ES_BOOTSTRAP_CHECKS_DISABLE=true
44
+ networks:
45
+ - ${sonarConfig.network}
46
+ restart: unless-stopped
47
+
48
+ volumes:
49
+ ${sonarConfig.volumes.data}:
50
+ ${sonarConfig.volumes.logs}:
51
+ ${sonarConfig.volumes.extensions}:
52
+
53
+ networks:
54
+ ${sonarConfig.network}:
55
+ name: ${sonarConfig.network}
56
+ `;
57
+ }
58
+
59
+ export async function ensureComposeFile(sonarqube = null) {
60
+ const sonarConfig = sonarqube ? normalizeSonarConfig(sonarqube) : normalizeSonarConfig((await loadConfig()).config.sonarqube);
61
+ const composeTemplate = buildComposeTemplate(sonarConfig);
62
+ await ensureDir(KARAJAN_HOME);
63
+ await fs.writeFile(COMPOSE_PATH, composeTemplate, "utf8");
64
+ return COMPOSE_PATH;
65
+ }
66
+
67
+ export async function isSonarReachable(host, healthcheckSeconds = 5) {
68
+ const timeout = Number(healthcheckSeconds) > 0 ? String(Number(healthcheckSeconds)) : "5";
69
+ const res = await runCommand("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", timeout, `${host}/api/system/status`]);
70
+ return res.exitCode === 0 && res.stdout.trim().startsWith("2");
71
+ }
72
+
73
+ export async function sonarUp(hostOverride = null) {
74
+ const { config } = await loadConfig();
75
+ const sonarConfig = normalizeSonarConfig(config.sonarqube);
76
+ const host = hostOverride || sonarConfig.host;
77
+
78
+ if (await isSonarReachable(host, sonarConfig.timeouts.healthcheckSeconds)) {
79
+ return { exitCode: 0, stdout: `SonarQube already reachable at ${host}, skipping container start.`, stderr: "" };
80
+ }
81
+
82
+ if (sonarConfig.external) {
83
+ return {
84
+ exitCode: 1,
85
+ stdout: "",
86
+ stderr: `Configured external SonarQube is not reachable at ${host}.`
87
+ };
88
+ }
89
+
90
+ const compose = await ensureComposeFile(config.sonarqube);
91
+ return runCommand("docker", ["compose", "-f", compose, "up", "-d"], { timeout: sonarConfig.timeouts.composeUpMs });
92
+ }
93
+
94
+ export async function sonarDown() {
95
+ const { config } = await loadConfig();
96
+ const sonarConfig = normalizeSonarConfig(config.sonarqube);
97
+ if (sonarConfig.external) {
98
+ return { exitCode: 0, stdout: "sonarqube.external=true, skipping Docker stop.", stderr: "" };
99
+ }
100
+ const compose = await ensureComposeFile(config.sonarqube);
101
+ return runCommand("docker", ["compose", "-f", compose, "stop"], { timeout: sonarConfig.timeouts.composeControlMs });
102
+ }
103
+
104
+ export async function sonarStatus() {
105
+ const { config } = await loadConfig();
106
+ const sonarConfig = normalizeSonarConfig(config.sonarqube);
107
+ const host = sonarConfig.host;
108
+
109
+ if (sonarConfig.external) {
110
+ if (await isSonarReachable(host, sonarConfig.timeouts.healthcheckSeconds)) {
111
+ return { exitCode: 0, stdout: `external SonarQube running at ${host}`, stderr: "" };
112
+ }
113
+ return { exitCode: 1, stdout: "", stderr: `external SonarQube is not reachable at ${host}` };
114
+ }
115
+
116
+ const containerRes = await runCommand("docker", ["ps", "--filter", `name=${sonarConfig.containerName}`, "--format", "{{.Status}}"]);
117
+ if (containerRes.stdout?.trim()) return containerRes;
118
+
119
+ if (await isSonarReachable(host, sonarConfig.timeouts.healthcheckSeconds)) {
120
+ return { exitCode: 0, stdout: `external SonarQube running at ${host}`, stderr: "" };
121
+ }
122
+
123
+ return containerRes;
124
+ }
125
+
126
+ export async function sonarLogs() {
127
+ const { config } = await loadConfig();
128
+ const sonarConfig = normalizeSonarConfig(config.sonarqube);
129
+ if (sonarConfig.external) {
130
+ return { exitCode: 1, stdout: "", stderr: "sonarqube.external=true, Docker logs are not available." };
131
+ }
132
+ return runCommand("docker", ["logs", "--tail", "100", sonarConfig.containerName], { timeout: sonarConfig.timeouts.logsMs });
133
+ }
134
+
135
+ const MIN_MAP_COUNT = 262144;
136
+
137
+ export async function checkVmMaxMapCount(platform) {
138
+ if (platform === "darwin" || platform === "win32") {
139
+ return { ok: true, reason: "vm.max_map_count check not required on this platform" };
140
+ }
141
+
142
+ const res = await runCommand("sysctl", ["vm.max_map_count"]);
143
+ if (res.exitCode !== 0) {
144
+ return {
145
+ ok: false,
146
+ reason: "Could not read vm.max_map_count",
147
+ fix: `sudo sysctl -w vm.max_map_count=${MIN_MAP_COUNT}`
148
+ };
149
+ }
150
+
151
+ const match = res.stdout.match(/=\s*(\d+)/);
152
+ const current = match ? Number(match[1]) : 0;
153
+
154
+ if (current >= MIN_MAP_COUNT) {
155
+ return { ok: true, reason: `vm.max_map_count = ${current}` };
156
+ }
157
+
158
+ return {
159
+ ok: false,
160
+ reason: `vm.max_map_count = ${current} (needs >= ${MIN_MAP_COUNT})`,
161
+ fix: `sudo sysctl -w vm.max_map_count=${MIN_MAP_COUNT} && echo "vm.max_map_count=${MIN_MAP_COUNT}" | sudo tee -a /etc/sysctl.conf`
162
+ };
163
+ }
@@ -0,0 +1,83 @@
1
+ import crypto from "node:crypto";
2
+ import { runCommand } from "../utils/process.js";
3
+
4
+ function slug(value) {
5
+ return String(value || "")
6
+ .toLowerCase()
7
+ .replace(/[^a-z0-9._:-]+/g, "-")
8
+ .replace(/-{2,}/g, "-")
9
+ .replace(/^-+|-+$/g, "");
10
+ }
11
+
12
+ export function normalizeProjectKey(value) {
13
+ const out = slug(value);
14
+ if (!out) return "kj-default";
15
+ return /[a-z]/.test(out) ? out : `kj-${out}`;
16
+ }
17
+
18
+ function digest(input) {
19
+ return crypto.createHash("sha1").update(String(input)).digest("hex").slice(0, 12);
20
+ }
21
+
22
+ function parseScpLikeRemote(remoteUrl) {
23
+ // Example: git@github.com:owner/repo.git
24
+ const match = String(remoteUrl || "").trim().match(/^(?:[^@]+@)?([^:]+):(.+)$/);
25
+ if (!match) return null;
26
+ return { host: match[1], path: match[2] };
27
+ }
28
+
29
+ function parseUrlLikeRemote(remoteUrl) {
30
+ try {
31
+ const parsed = new URL(String(remoteUrl || "").trim());
32
+ return { host: parsed.hostname, path: parsed.pathname.replace(/^\/+/, "") };
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function canonicalRepoId(remoteUrl) {
39
+ const raw = String(remoteUrl || "").trim();
40
+ if (!raw) return null;
41
+
42
+ const parsed = raw.includes("://") ? parseUrlLikeRemote(raw) : (parseScpLikeRemote(raw) || parseUrlLikeRemote(raw));
43
+ if (!parsed) return null;
44
+
45
+ const host = String(parsed.host || "").trim().toLowerCase();
46
+ const cleanPath = String(parsed.path || "")
47
+ .trim()
48
+ .replace(/^\/+|\/+$/g, "")
49
+ .replace(/\.git$/i, "")
50
+ .toLowerCase();
51
+ const segments = cleanPath.split("/").filter(Boolean);
52
+ if (!host || segments.length < 2) return null;
53
+
54
+ // Keep full repository path (owner/subgroups/repo) to avoid collisions in nested groups.
55
+ return `${host}/${segments.join("/")}`;
56
+ }
57
+
58
+ export async function resolveSonarProjectKey(config, options = {}) {
59
+ const explicit = String(
60
+ options.projectKey || process.env.KJ_SONAR_PROJECT_KEY || config?.sonarqube?.project_key || ""
61
+ ).trim();
62
+ if (explicit) {
63
+ return normalizeProjectKey(explicit);
64
+ }
65
+
66
+ const remote = await runCommand("git", ["config", "--get", "remote.origin.url"]);
67
+ const remoteUrl = String(remote.stdout || "").trim();
68
+ if (remote.exitCode !== 0 || !remoteUrl) {
69
+ throw new Error(
70
+ "Missing git remote.origin.url. Configure remote origin or set sonarqube.project_key explicitly."
71
+ );
72
+ }
73
+
74
+ const repoId = canonicalRepoId(remoteUrl);
75
+ if (!repoId) {
76
+ throw new Error(
77
+ "Unable to parse git remote.origin.url. Use a valid SSH/HTTPS remote or set sonarqube.project_key explicitly."
78
+ );
79
+ }
80
+
81
+ const repo = slug(repoId.split("/").pop());
82
+ return normalizeProjectKey(`kj-${repo}-${digest(repoId)}`);
83
+ }
@@ -0,0 +1,267 @@
1
+ import fs from "node:fs";
2
+ import { runCommand } from "../utils/process.js";
3
+ import { sonarUp } from "./manager.js";
4
+ import { resolveSonarProjectKey } from "./project-key.js";
5
+
6
+ export function buildScannerOpts(projectKey, scanner = {}) {
7
+ const opts = [`-Dsonar.projectKey=${projectKey}`];
8
+ if (scanner.sources) opts.push(`-Dsonar.sources=${scanner.sources}`);
9
+ if (scanner.exclusions) opts.push(`-Dsonar.exclusions=${scanner.exclusions}`);
10
+ if (scanner.test_inclusions) opts.push(`-Dsonar.test.inclusions=${scanner.test_inclusions}`);
11
+ if (scanner.coverage_exclusions) opts.push(`-Dsonar.coverage.exclusions=${scanner.coverage_exclusions}`);
12
+ if (scanner.javascript_lcov_report_paths) {
13
+ opts.push(`-Dsonar.javascript.lcov.reportPaths=${scanner.javascript_lcov_report_paths}`);
14
+ }
15
+ const rules = scanner.disabled_rules || [];
16
+ rules.forEach((rule, i) => {
17
+ opts.push(`-Dsonar.issue.ignore.multicriteria=e${i + 1}`);
18
+ opts.push(`-Dsonar.issue.ignore.multicriteria.e${i + 1}.ruleKey=${rule}`);
19
+ opts.push(`-Dsonar.issue.ignore.multicriteria.e${i + 1}.resourceKey=**/*`);
20
+ });
21
+ return opts.join(" ");
22
+ }
23
+
24
+ function normalizeScannerConfig(scanner = {}) {
25
+ const out = { ...scanner };
26
+ if (typeof out.sources === "string" && out.sources.trim()) {
27
+ const existing = out.sources
28
+ .split(",")
29
+ .map((s) => s.trim())
30
+ .filter(Boolean)
31
+ .filter((entry) => fs.existsSync(entry));
32
+
33
+ if (existing.length > 0) {
34
+ out.sources = existing.join(",");
35
+ }
36
+ }
37
+ return out;
38
+ }
39
+
40
+ function parseJsonSafe(text) {
41
+ try {
42
+ return JSON.parse(text);
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function normalizeApiHost(rawHost) {
49
+ return String(rawHost || "http://localhost:9000").replace(/host\.docker\.internal/g, "localhost");
50
+ }
51
+
52
+ async function validateAdminCredentials(host, user, password) {
53
+ const res = await runCommand("curl", [
54
+ "-sS",
55
+ "-u",
56
+ `${user}:${password}`,
57
+ `${host}/api/authentication/validate`
58
+ ]);
59
+ if (res.exitCode !== 0) return false;
60
+ const parsed = parseJsonSafe(res.stdout);
61
+ return Boolean(parsed?.valid);
62
+ }
63
+
64
+ async function generateUserToken(host, user, password) {
65
+ const tokenName = `karajan-${Date.now()}`;
66
+ const res = await runCommand("curl", [
67
+ "-sS",
68
+ "-u",
69
+ `${user}:${password}`,
70
+ "-X",
71
+ "POST",
72
+ "--data-urlencode",
73
+ `name=${tokenName}`,
74
+ `${host}/api/user_tokens/generate`
75
+ ]);
76
+ if (res.exitCode !== 0) return null;
77
+ const parsed = parseJsonSafe(res.stdout);
78
+ return parsed?.token || null;
79
+ }
80
+
81
+ function coverageConfig(config) {
82
+ return config?.sonarqube?.coverage || {};
83
+ }
84
+
85
+ async function maybeRunCoverage(config) {
86
+ const coverage = coverageConfig(config);
87
+ if (!coverage.enabled) {
88
+ return { ok: true, scannerPatch: {} };
89
+ }
90
+
91
+ const lcovPath = String(coverage.lcov_report_path || "").trim();
92
+ const blockOnFailure = coverage.block_on_failure !== false;
93
+
94
+ // Allow "consume existing lcov only" mode without running any coverage command.
95
+ if (!String(coverage.command || "").trim()) {
96
+ if (!lcovPath) {
97
+ return {
98
+ ok: false,
99
+ exitCode: 1,
100
+ stdout: "",
101
+ stderr:
102
+ "Sonar coverage is enabled but neither coverage.command nor coverage.lcov_report_path is configured."
103
+ };
104
+ }
105
+ if (!fs.existsSync(lcovPath)) {
106
+ if (blockOnFailure) {
107
+ return {
108
+ ok: false,
109
+ exitCode: 1,
110
+ stdout: "",
111
+ stderr: `Configured lcov report path does not exist: ${lcovPath}`
112
+ };
113
+ }
114
+ return { ok: true, scannerPatch: {} };
115
+ }
116
+ return {
117
+ ok: true,
118
+ scannerPatch: {
119
+ javascript_lcov_report_paths: lcovPath
120
+ }
121
+ };
122
+ }
123
+
124
+ const command = String(coverage.command || "").trim();
125
+ const timeout = Number(coverage.timeout_ms) > 0 ? Number(coverage.timeout_ms) : 5 * 60 * 1000;
126
+ const run = await runCommand("bash", ["-lc", command], { timeout });
127
+
128
+ if (run.exitCode !== 0) {
129
+ if (blockOnFailure) {
130
+ return {
131
+ ok: false,
132
+ exitCode: run.exitCode,
133
+ stdout: run.stdout || "",
134
+ stderr: run.stderr || "Coverage command failed"
135
+ };
136
+ }
137
+ return { ok: true, scannerPatch: {} };
138
+ }
139
+
140
+ if (!lcovPath) {
141
+ return { ok: true, scannerPatch: {} };
142
+ }
143
+
144
+ if (!fs.existsSync(lcovPath)) {
145
+ if (blockOnFailure) {
146
+ return {
147
+ ok: false,
148
+ exitCode: 1,
149
+ stdout: run.stdout || "",
150
+ stderr: `Configured lcov report path does not exist: ${lcovPath}`
151
+ };
152
+ }
153
+ return { ok: true, scannerPatch: {} };
154
+ }
155
+
156
+ return {
157
+ ok: true,
158
+ scannerPatch: {
159
+ javascript_lcov_report_paths: lcovPath
160
+ }
161
+ };
162
+ }
163
+
164
+ async function resolveSonarToken(config, apiHost) {
165
+ const explicitToken = process.env.KJ_SONAR_TOKEN || process.env.SONAR_TOKEN || config.sonarqube.token;
166
+ if (explicitToken) return explicitToken;
167
+
168
+ const adminUser = process.env.KJ_SONAR_ADMIN_USER || config.sonarqube.admin_user || "admin";
169
+ const candidates = [
170
+ process.env.KJ_SONAR_ADMIN_PASSWORD,
171
+ config.sonarqube.admin_password,
172
+ "admin"
173
+ ].filter(Boolean);
174
+
175
+ for (const password of [...new Set(candidates)]) {
176
+ const valid = await validateAdminCredentials(apiHost, adminUser, password);
177
+ if (!valid) continue;
178
+ const token = await generateUserToken(apiHost, adminUser, password);
179
+ if (token) return token;
180
+ }
181
+
182
+ return null;
183
+ }
184
+
185
+ export async function runSonarScan(config, projectKey = null) {
186
+ let effectiveProjectKey;
187
+ try {
188
+ effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey });
189
+ } catch (error) {
190
+ return {
191
+ ok: false,
192
+ projectKey: null,
193
+ stdout: "",
194
+ stderr: error?.message || String(error),
195
+ exitCode: 1
196
+ };
197
+ }
198
+ const sonarConfig = config?.sonarqube || {};
199
+ const rawHost = sonarConfig.host || "http://localhost:9000";
200
+ const isExternalSonar = sonarConfig.external === true;
201
+ const scannerTimeout = Number(sonarConfig?.timeouts?.scanner_ms) > 0
202
+ ? Number(sonarConfig.timeouts.scanner_ms)
203
+ : 15 * 60 * 1000;
204
+ const sonarNetwork = sonarConfig.network || "karajan_sonar_net";
205
+ const apiHost = normalizeApiHost(rawHost);
206
+ const isLocalHost = /localhost|127\.0\.0\.1/.test(rawHost);
207
+ const host = isLocalHost ? rawHost.replace(/localhost|127\.0\.0\.1/g, "host.docker.internal") : rawHost;
208
+
209
+ const start = await sonarUp(rawHost);
210
+ if (start.exitCode !== 0) {
211
+ return {
212
+ ok: false,
213
+ stdout: start.stdout || "",
214
+ stderr: start.stderr || "Failed to start SonarQube service",
215
+ exitCode: start.exitCode
216
+ };
217
+ }
218
+ const token = await resolveSonarToken(config, apiHost);
219
+ if (!token) {
220
+ return {
221
+ ok: false,
222
+ stdout: "",
223
+ stderr:
224
+ "Unable to resolve Sonar token. Tried configured token/password and fallback admin/admin.",
225
+ exitCode: 1
226
+ };
227
+ }
228
+ process.env.KJ_SONAR_TOKEN = token;
229
+ const coverage = await maybeRunCoverage(config);
230
+ if (!coverage.ok) {
231
+ return {
232
+ ok: false,
233
+ stdout: coverage.stdout || "",
234
+ stderr: coverage.stderr || "Failed to generate coverage report for SonarQube",
235
+ exitCode: coverage.exitCode || 1
236
+ };
237
+ }
238
+ const scannerConfig = normalizeScannerConfig({
239
+ ...sonarConfig.scanner,
240
+ ...coverage.scannerPatch
241
+ });
242
+
243
+ const args = [
244
+ "run",
245
+ "--rm",
246
+ "-v",
247
+ `${process.cwd()}:/usr/src`,
248
+ ...(isLocalHost ? ["--add-host", "host.docker.internal:host-gateway"] : []),
249
+ ...(!isLocalHost && !isExternalSonar ? ["--network", sonarNetwork] : []),
250
+ "-e",
251
+ `SONAR_HOST_URL=${host}`,
252
+ "-e",
253
+ `SONAR_TOKEN=${token || ""}`,
254
+ "-e",
255
+ `SONAR_SCANNER_OPTS=${buildScannerOpts(effectiveProjectKey, scannerConfig)}`,
256
+ "sonarsource/sonar-scanner-cli"
257
+ ];
258
+
259
+ const result = await runCommand("docker", args, { timeout: scannerTimeout });
260
+ return {
261
+ ok: result.exitCode === 0,
262
+ projectKey: effectiveProjectKey,
263
+ stdout: result.stdout,
264
+ stderr: result.stderr,
265
+ exitCode: result.exitCode
266
+ };
267
+ }