karajan-code 1.34.3 → 1.35.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/README.md CHANGED
@@ -116,7 +116,7 @@ hu-reviewer? → triage → discover? → architect? → planner? → coder →
116
116
  | **Claude** | `claude` | `npm install -g @anthropic-ai/claude-code` |
117
117
  | **Codex** | `codex` | `npm install -g @openai/codex` |
118
118
  | **Gemini** | `gemini` | See [Gemini CLI docs](https://github.com/google-gemini/gemini-cli) |
119
- | **Aider** | `aider` | `pip install aider-chat` |
119
+ | **Aider** | `aider` | `pipx install aider-chat` (or `pip3 install aider-chat`) |
120
120
  | **OpenCode** | `opencode` | See [OpenCode docs](https://github.com/nicepkg/opencode) |
121
121
 
122
122
  Mix and match. Use Claude as coder and Codex as reviewer. Karajan auto-detects installed agents during `kj init`.
package/docs/README.es.md CHANGED
@@ -124,7 +124,7 @@ Guias completas: [`docs/multi-instance.md`](multi-instance.md) | [`docs/install-
124
124
  | **Claude** | `claude` | `npm install -g @anthropic-ai/claude-code` |
125
125
  | **Codex** | `codex` | `npm install -g @openai/codex` |
126
126
  | **Gemini** | `gemini` | Ver [Gemini CLI docs](https://github.com/google-gemini/gemini-cli) |
127
- | **Aider** | `aider` | `pip install aider-chat` |
127
+ | **Aider** | `aider` | `pipx install aider-chat` (o `pip3 install aider-chat`) |
128
128
 
129
129
  `kj init` auto-detecta los agentes instalados. Si solo hay uno disponible, se asigna a todos los roles automaticamente.
130
130
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "1.34.3",
3
+ "version": "1.35.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Project bootstrap — mandatory prerequisite gate.
3
+ *
4
+ * Before any KJ tool that executes agents (kj_run, kj_code, kj_review, etc.),
5
+ * this module validates that ALL environment prerequisites are met.
6
+ *
7
+ * Philosophy: NEVER degrade gracefully. If something is missing, STOP and
8
+ * tell the user exactly what to fix. No silent fallbacks, no auto-disabling.
9
+ *
10
+ * Results are cached in `.kj-ready.json` per project (TTL-based) so that
11
+ * slow checks (SonarQube reachability, agent detection) don't repeat on
12
+ * every invocation.
13
+ */
14
+
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+ import { readFileSync } from "node:fs";
18
+ import { fileURLToPath } from "node:url";
19
+ import { ensureGitRepo } from "./utils/git.js";
20
+ import { runCommand } from "./utils/process.js";
21
+ import { checkBinary } from "./utils/agent-detect.js";
22
+ import { exists } from "./utils/fs.js";
23
+ import { getConfigPath, resolveRole } from "./config.js";
24
+ import { isSonarReachable, sonarUp } from "./sonar/manager.js";
25
+
26
+ const BOOTSTRAP_VERSION = 1;
27
+ const BOOTSTRAP_TTL_HOURS = 24;
28
+ const BOOTSTRAP_FILENAME = ".kj-ready.json";
29
+
30
+ function getPackageVersion() {
31
+ const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../package.json");
32
+ return JSON.parse(readFileSync(pkgPath, "utf8")).version;
33
+ }
34
+
35
+ // ── Individual checks ────────────────────────────────────────────────
36
+
37
+ async function checkGitRepo() {
38
+ let ok = false;
39
+ try {
40
+ ok = await ensureGitRepo();
41
+ } catch {
42
+ ok = false;
43
+ }
44
+ return {
45
+ name: "gitRepo",
46
+ ok,
47
+ detail: ok ? "Inside a git repository" : "Not a git repository",
48
+ fix: "Run 'git init' in your project directory."
49
+ };
50
+ }
51
+
52
+ async function checkGitRemote() {
53
+ try {
54
+ const res = await runCommand("git", ["remote", "get-url", "origin"]);
55
+ if (res.exitCode === 0 && res.stdout.trim()) {
56
+ return { name: "gitRemote", ok: true, detail: res.stdout.trim(), fix: null };
57
+ }
58
+ } catch { /* fall through */ }
59
+ return {
60
+ name: "gitRemote",
61
+ ok: false,
62
+ detail: "origin remote not configured",
63
+ fix: "Run 'git remote add origin <your-repo-url>'."
64
+ };
65
+ }
66
+
67
+ async function checkConfigExists() {
68
+ const configPath = getConfigPath();
69
+ const configOk = await exists(configPath);
70
+ return {
71
+ name: "config",
72
+ ok: configOk,
73
+ detail: configOk ? configPath : "Config file not found",
74
+ fix: configOk ? null : "Run 'kj_init' to create your Karajan config file."
75
+ };
76
+ }
77
+
78
+ async function checkCoreBinaries() {
79
+ const missing = [];
80
+ for (const bin of ["node", "npm", "git"]) {
81
+ const result = await checkBinary(bin);
82
+ if (!result.ok) {
83
+ missing.push(bin);
84
+ }
85
+ }
86
+ if (missing.length > 0) {
87
+ return {
88
+ name: "coreBinaries",
89
+ ok: false,
90
+ detail: `Missing: ${missing.join(", ")}`,
91
+ fix: `Install missing binaries: ${missing.join(", ")}.`
92
+ };
93
+ }
94
+ return { name: "coreBinaries", ok: true, detail: "node, npm, git available", fix: null };
95
+ }
96
+
97
+ async function checkConfiguredAgents(config) {
98
+ const { provider } = resolveRole(config, "coder");
99
+ if (!provider) {
100
+ return {
101
+ name: "agents",
102
+ ok: false,
103
+ detail: "No coder provider configured",
104
+ fix: "Run 'kj_init' or set a coder provider in kj.config.yml."
105
+ };
106
+ }
107
+ const result = await checkBinary(provider);
108
+ if (!result.ok) {
109
+ return {
110
+ name: "agents",
111
+ ok: false,
112
+ detail: `Coder agent "${provider}" not found`,
113
+ fix: `Install "${provider}" CLI. Run 'kj_doctor' for installation instructions.`
114
+ };
115
+ }
116
+ return { name: "agents", ok: true, detail: `coder: ${provider}`, fix: null };
117
+ }
118
+
119
+ async function checkSonarQubeReady(config) {
120
+ if (config.sonarqube?.enabled === false) {
121
+ return { name: "sonarqube", ok: true, detail: "Disabled in config", fix: null };
122
+ }
123
+
124
+ const host = config.sonarqube?.host || "http://localhost:9000";
125
+
126
+ // First attempt
127
+ let reachable = await isSonarReachable(host);
128
+ if (reachable) {
129
+ return { name: "sonarqube", ok: true, detail: `Reachable at ${host}`, fix: null };
130
+ }
131
+
132
+ // Auto-remediation: try to start
133
+ try {
134
+ await sonarUp(host);
135
+ reachable = await isSonarReachable(host);
136
+ if (reachable) {
137
+ return { name: "sonarqube", ok: true, detail: `Started and reachable at ${host}`, fix: null };
138
+ }
139
+ } catch { /* fall through */ }
140
+
141
+ return {
142
+ name: "sonarqube",
143
+ ok: false,
144
+ detail: `Not reachable at ${host}`,
145
+ fix: `Start SonarQube: 'docker start karajan-sonarqube', or disable it: set sonarqube.enabled: false in kj.config.yml, or pass --no-sonar.`
146
+ };
147
+ }
148
+
149
+ // ── Bootstrap file management ────────────────────────────────────────
150
+
151
+ function bootstrapPath(projectDir) {
152
+ return path.join(projectDir, BOOTSTRAP_FILENAME);
153
+ }
154
+
155
+ async function readBootstrapFile(projectDir) {
156
+ try {
157
+ const raw = await fs.readFile(bootstrapPath(projectDir), "utf8");
158
+ return JSON.parse(raw);
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ function isBootstrapValid(bootstrap, projectDir) {
165
+ if (!bootstrap || bootstrap.version !== BOOTSTRAP_VERSION) return false;
166
+ if (bootstrap.karajanVersion !== getPackageVersion()) return false;
167
+ if (bootstrap.projectDir !== projectDir) return false;
168
+ const age = Date.now() - new Date(bootstrap.createdAt).getTime();
169
+ if (age > BOOTSTRAP_TTL_HOURS * 3600 * 1000) return false;
170
+ return true;
171
+ }
172
+
173
+ async function writeBootstrapFile(projectDir, checks) {
174
+ const data = {
175
+ version: BOOTSTRAP_VERSION,
176
+ karajanVersion: getPackageVersion(),
177
+ createdAt: new Date().toISOString(),
178
+ projectDir,
179
+ checks: Object.fromEntries(checks.map(c => [c.name, { ok: c.ok, detail: c.detail }]))
180
+ };
181
+ await fs.writeFile(bootstrapPath(projectDir), JSON.stringify(data, null, 2) + "\n", "utf8");
182
+ }
183
+
184
+ function formatBootstrapFailure(failures) {
185
+ const lines = failures.map(f =>
186
+ ` FAIL ${f.name}: ${f.detail}\n Fix: ${f.fix}`
187
+ );
188
+ return [
189
+ "BOOTSTRAP FAILED — Environment not ready for Karajan Code.\n",
190
+ "The following prerequisite(s) are not met:\n",
191
+ ...lines,
192
+ "",
193
+ "Run 'kj_doctor' for a complete environment diagnostic.",
194
+ "Do NOT work around these issues — fix them properly."
195
+ ].join("\n");
196
+ }
197
+
198
+ // ── Public API ───────────────────────────────────────────────────────
199
+
200
+ /**
201
+ * Ensure the project environment is ready for KJ execution.
202
+ * Reads cached `.kj-ready.json` if valid; otherwise runs all checks.
203
+ * Throws Error with actionable message if any prerequisite fails.
204
+ */
205
+ export async function ensureBootstrap(projectDir, config) {
206
+ const cached = await readBootstrapFile(projectDir);
207
+ if (isBootstrapValid(cached, projectDir)) {
208
+ return; // Environment already validated
209
+ }
210
+
211
+ const checks = await Promise.all([
212
+ checkGitRepo(),
213
+ checkGitRemote(),
214
+ checkConfigExists(),
215
+ checkCoreBinaries(),
216
+ checkConfiguredAgents(config),
217
+ checkSonarQubeReady(config)
218
+ ]);
219
+
220
+ const failures = checks.filter(c => !c.ok);
221
+ if (failures.length > 0) {
222
+ throw new Error(formatBootstrapFailure(failures));
223
+ }
224
+
225
+ await writeBootstrapFile(projectDir, checks);
226
+ }
227
+
228
+ /**
229
+ * Delete `.kj-ready.json` to force re-validation on next run.
230
+ */
231
+ export async function invalidateBootstrap(projectDir) {
232
+ try {
233
+ await fs.unlink(bootstrapPath(projectDir));
234
+ } catch { /* file may not exist */ }
235
+ }
@@ -8,6 +8,7 @@ import { isSonarReachable } from "../sonar/manager.js";
8
8
  import { resolveRoleMdPath, loadFirstExisting } from "../roles/base-role.js";
9
9
  import { ensureGitRepo } from "../utils/git.js";
10
10
  import { checkBinary, KNOWN_AGENTS } from "../utils/agent-detect.js";
11
+ import { getInstallCommand } from "../utils/os-detect.js";
11
12
 
12
13
  function getPackageVersion() {
13
14
  const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../package.json");
@@ -205,7 +206,7 @@ async function checkBecariaInfra(config) {
205
206
  }
206
207
 
207
208
  async function checkRtk() {
208
- const NOT_FOUND_DETAIL = "Not found — install for 60-90% token savings: brew install rtk";
209
+ const NOT_FOUND_DETAIL = `Not found — install for 60-90% token savings: ${getInstallCommand("rtk")}`;
209
210
  let detail = NOT_FOUND_DETAIL;
210
211
  try {
211
212
  const res = await runCommand("rtk", ["--version"]);
@@ -8,6 +8,7 @@ import { getKarajanHome } from "../utils/paths.js";
8
8
  import { detectAvailableAgents } from "../utils/agent-detect.js";
9
9
  import { createWizard, isTTY } from "../utils/wizard.js";
10
10
  import { runCommand } from "../utils/process.js";
11
+ import { getInstallCommand } from "../utils/os-detect.js";
11
12
 
12
13
  async function runWizard(config, logger) {
13
14
  const agents = await detectAvailableAgents();
@@ -292,7 +293,7 @@ export async function initCommand({ logger, flags = {} }) {
292
293
  if (!hasRtk) {
293
294
  logger.info("");
294
295
  logger.info("RTK (Rust Token Killer) can reduce token usage by 60-90%.");
295
- logger.info(" Install: brew install rtk && rtk init --global");
296
+ logger.info(` Install: ${getInstallCommand("rtk")}`);
296
297
  }
297
298
 
298
299
  await setupSonarQube(config, logger);
package/src/config.js CHANGED
@@ -77,7 +77,7 @@ const DEFAULTS = {
77
77
  },
78
78
  token: null,
79
79
  project_key: null,
80
- admin_user: "admin",
80
+ admin_user: null,
81
81
  admin_password: null,
82
82
  coverage: {
83
83
  enabled: false,
@@ -24,6 +24,7 @@ import { resolveReviewProfile } from "../review/profiles.js";
24
24
  import { createRunLog, readRunLog } from "../utils/run-log.js";
25
25
  import { currentBranch } from "../utils/git.js";
26
26
  import { isPreflightAcked, ackPreflight, getSessionOverrides } from "./preflight.js";
27
+ import { ensureBootstrap } from "../bootstrap.js";
27
28
 
28
29
  /**
29
30
  * Resolve the user's project directory.
@@ -118,6 +119,11 @@ const ERROR_CLASSIFIERS = [
118
119
  test: (lower) => lower.includes("not a git repository"),
119
120
  category: "git_error",
120
121
  suggestion: "Current directory is not a git repository. Navigate to your project root or initialize git with 'git init'."
122
+ },
123
+ {
124
+ test: (lower) => lower.includes("bootstrap failed"),
125
+ category: "bootstrap_error",
126
+ suggestion: "Environment prerequisites not met. Run kj_doctor for diagnostics, then fix the issues listed. Do NOT work around these — fix them properly."
121
127
  }
122
128
  ];
123
129
 
@@ -149,6 +155,16 @@ export async function assertNotOnBaseBranch(config) {
149
155
  }
150
156
  }
151
157
 
158
+ /**
159
+ * Run bootstrap gate: validate all environment prerequisites before execution.
160
+ * Throws if any prerequisite fails.
161
+ */
162
+ async function runBootstrapGate(server, a) {
163
+ const projectDir = await resolveProjectDir(server, a.projectDir);
164
+ const { config } = await loadConfig(projectDir);
165
+ await ensureBootstrap(projectDir, config);
166
+ }
167
+
152
168
  export function enrichedFailPayload(error, toolName) {
153
169
  const msg = error?.message || String(error);
154
170
  const { category, suggestion } = classifyError(error);
@@ -191,7 +207,7 @@ export function buildAskQuestion(server) {
191
207
 
192
208
  const MAX_AUTO_RESUMES = 2;
193
209
  const NON_RECOVERABLE_CATEGORIES = new Set([
194
- "config_error", "auth_error", "agent_missing", "branch_error", "git_error"
210
+ "config_error", "auth_error", "agent_missing", "branch_error", "git_error", "bootstrap_error"
195
211
  ]);
196
212
 
197
213
  async function attemptAutoResume({ err, config, logger, emitter, askQuestion, runLog }) {
@@ -783,6 +799,7 @@ async function handleResume(a, server, extra) {
783
799
  if (!a.sessionId) {
784
800
  return failPayload("Missing required field: sessionId");
785
801
  }
802
+ await runBootstrapGate(server, a);
786
803
  if (a.answer) {
787
804
  const validation = validateResumeAnswer(a.answer);
788
805
  if (!validation.valid) {
@@ -804,6 +821,7 @@ async function handleRun(a, server, extra) {
804
821
  return failPayload(`Invalid taskType "${a.taskType}". Valid values: ${[...validTypes].join(", ")}`);
805
822
  }
806
823
  }
824
+ await runBootstrapGate(server, a);
807
825
  if (!isPreflightAcked()) {
808
826
  // Auto-acknowledge with defaults for autonomous operation
809
827
  ackPreflight({});
@@ -818,6 +836,7 @@ async function handleCode(a, server, extra) {
818
836
  if (!a.task) {
819
837
  return failPayload("Missing required field: task");
820
838
  }
839
+ await runBootstrapGate(server, a);
821
840
  if (!isPreflightAcked()) {
822
841
  // Auto-acknowledge with defaults for autonomous operation
823
842
  ackPreflight({});
@@ -832,6 +851,7 @@ async function handleReview(a, server, extra) {
832
851
  if (!a.task) {
833
852
  return failPayload("Missing required field: task");
834
853
  }
854
+ await runBootstrapGate(server, a);
835
855
  return handleReviewDirect(a, server, extra);
836
856
  }
837
857
 
@@ -839,6 +859,7 @@ async function handlePlan(a, server, extra) {
839
859
  if (!a.task) {
840
860
  return failPayload("Missing required field: task");
841
861
  }
862
+ await runBootstrapGate(server, a);
842
863
  return handlePlanDirect(a, server, extra);
843
864
  }
844
865
 
@@ -850,6 +871,7 @@ async function handleDiscover(a, server, extra) {
850
871
  if (a.mode && !validModes.has(a.mode)) {
851
872
  return failPayload(`Invalid mode "${a.mode}". Valid values: ${[...validModes].join(", ")}`);
852
873
  }
874
+ await runBootstrapGate(server, a);
853
875
  return handleDiscoverDirect(a, server, extra);
854
876
  }
855
877
 
@@ -857,6 +879,7 @@ async function handleTriage(a, server, extra) {
857
879
  if (!a.task) {
858
880
  return failPayload("Missing required field: task");
859
881
  }
882
+ await runBootstrapGate(server, a);
860
883
  return handleTriageDirect(a, server, extra);
861
884
  }
862
885
 
@@ -864,6 +887,7 @@ async function handleResearcher(a, server, extra) {
864
887
  if (!a.task) {
865
888
  return failPayload("Missing required field: task");
866
889
  }
890
+ await runBootstrapGate(server, a);
867
891
  return handleResearcherDirect(a, server, extra);
868
892
  }
869
893
 
@@ -871,10 +895,12 @@ async function handleArchitect(a, server, extra) {
871
895
  if (!a.task) {
872
896
  return failPayload("Missing required field: task");
873
897
  }
898
+ await runBootstrapGate(server, a);
874
899
  return handleArchitectDirect(a, server, extra);
875
900
  }
876
901
 
877
902
  async function handleAudit(a, server, extra) {
903
+ await runBootstrapGate(server, a);
878
904
  return handleAuditDirect(a, server, extra);
879
905
  }
880
906
 
@@ -892,6 +918,11 @@ async function handleBoard(a) {
892
918
  }
893
919
  }
894
920
 
921
+ async function handleScan(a, server) {
922
+ await runBootstrapGate(server, a);
923
+ return runKjCommand({ command: "scan", options: a });
924
+ }
925
+
895
926
  /* ── Handler dispatch map ─────────────────────────────────────────── */
896
927
 
897
928
  const toolHandlers = {
@@ -901,7 +932,7 @@ const toolHandlers = {
901
932
  kj_agents: (a) => handleAgents(a),
902
933
  kj_preflight: (a) => handlePreflight(a),
903
934
  kj_config: (a) => runKjCommand({ command: "config", commandArgs: a.json ? ["--json"] : [], options: a }),
904
- kj_scan: (a) => runKjCommand({ command: "scan", options: a }),
935
+ kj_scan: (a, server) => handleScan(a, server),
905
936
  kj_roles: (a) => handleRoles(a),
906
937
  kj_report: (a) => runKjCommand({ command: "report", commandArgs: buildReportArgs(a), options: a }),
907
938
  kj_resume: (a, server, extra) => handleResume(a, server, extra),
@@ -2,16 +2,18 @@
2
2
  * Preflight environment checks for kj_run.
3
3
  *
4
4
  * Runs AFTER policy resolution (so we know which stages are active)
5
- * and BEFORE session iteration loop (so we fail fast or degrade gracefully).
5
+ * and BEFORE session iteration loop (so we fail fast).
6
6
  *
7
- * Design: always returns ok:true (graceful degradation, never hard-fail).
8
- * Disabled stages are auto-disabled via configOverrides instead of blocking.
7
+ * Design: SonarQube checks are BLOCKING when enabled — if SonarQube is
8
+ * configured but not available, the pipeline STOPS with a clear error.
9
+ * Security agent checks remain graceful (warning, auto-disable).
9
10
  */
10
11
 
11
12
  import { checkBinary } from "../utils/agent-detect.js";
12
13
  import { isSonarReachable, sonarUp } from "../sonar/manager.js";
13
14
  import { runCommand } from "../utils/process.js";
14
15
  import { emitProgress, makeEvent } from "../utils/events.js";
16
+ import { loadSonarCredentials } from "../sonar/credentials.js";
15
17
 
16
18
  function normalizeApiHost(rawHost) {
17
19
  return String(rawHost || "http://localhost:9000").replace(/host\.docker\.internal/g, "localhost");
@@ -75,14 +77,19 @@ async function checkSonarAuth(config) {
75
77
  }
76
78
  }
77
79
 
78
- // Try admin credentials to generate a token
79
- const adminUser = process.env.KJ_SONAR_ADMIN_USER || config.sonarqube?.admin_user || "admin";
80
+ // Try admin credentials: env vars config → ~/.karajan/sonar-credentials.json (NO default admin/admin)
81
+ const fileCreds = await loadSonarCredentials() || {};
82
+ const adminUser = process.env.KJ_SONAR_ADMIN_USER || config.sonarqube?.admin_user || fileCreds.user;
80
83
  const candidates = [
81
84
  process.env.KJ_SONAR_ADMIN_PASSWORD,
82
85
  config.sonarqube?.admin_password,
83
- "admin"
86
+ fileCreds.password
84
87
  ].filter(Boolean);
85
88
 
89
+ if (!adminUser || candidates.length === 0) {
90
+ return { name: "sonar-auth", ok: false, detail: "No Sonar token or admin credentials configured. Set KJ_SONAR_TOKEN, configure sonarqube.token in kj.config.yml, or save credentials in ~/.karajan/sonar-credentials.json." };
91
+ }
92
+
86
93
  for (const password of [...new Set(candidates)]) {
87
94
  const validateRes = await runCommand("curl", [
88
95
  "-sS", "-u", `${adminUser}:${password}`,
@@ -128,6 +135,10 @@ async function checkSecurityAgent(config) {
128
135
  /**
129
136
  * Run preflight environment checks.
130
137
  *
138
+ * SonarQube checks are BLOCKING: if SonarQube is enabled but not available,
139
+ * ok will be false and errors[] will contain actionable fix instructions.
140
+ * Security agent checks remain graceful (auto-disable via configOverrides).
141
+ *
131
142
  * @param {object} opts
132
143
  * @param {object} opts.config - Karajan config
133
144
  * @param {object} opts.logger - Logger instance
@@ -135,7 +146,7 @@ async function checkSecurityAgent(config) {
135
146
  * @param {object} opts.eventBase - Base event data
136
147
  * @param {object} opts.resolvedPolicies - Output from applyPolicies()
137
148
  * @param {boolean} opts.securityEnabled - Whether security stage is enabled
138
- * @returns {{ ok: boolean, checks: object[], remediations: string[], configOverrides: object, warnings: string[] }}
149
+ * @returns {{ ok: boolean, checks: object[], remediations: string[], configOverrides: object, warnings: string[], errors: object[] }}
139
150
  */
140
151
  export async function runPreflightChecks({ config, logger, emitter, eventBase, resolvedPolicies, securityEnabled }) {
141
152
  const sonarEnabled = Boolean(config.sonarqube?.enabled) && resolvedPolicies.sonar !== false;
@@ -148,6 +159,7 @@ export async function runPreflightChecks({ config, logger, emitter, eventBase, r
148
159
  remediations: [],
149
160
  configOverrides: {},
150
161
  warnings: [],
162
+ errors: [],
151
163
  };
152
164
 
153
165
  // Short-circuit: nothing to check
@@ -171,20 +183,24 @@ export async function runPreflightChecks({ config, logger, emitter, eventBase, r
171
183
  result.checks.push(dockerCheck);
172
184
 
173
185
  emitProgress(emitter, makeEvent("preflight:check", { ...eventBase, stage: "preflight" }, {
174
- status: dockerCheck.ok ? "ok" : "warn",
186
+ status: dockerCheck.ok ? "ok" : "fail",
175
187
  message: `Docker: ${dockerCheck.detail}`,
176
188
  detail: dockerCheck
177
189
  }));
178
190
 
179
191
  if (!dockerCheck.ok) {
180
- result.configOverrides.sonarDisabled = true;
181
- result.warnings.push("Docker not available — SonarQube auto-disabled");
182
- logger.warn("Preflight: Docker not found, disabling SonarQube");
192
+ result.ok = false;
193
+ result.errors.push({
194
+ check: "docker",
195
+ message: "Docker not available but SonarQube is enabled.",
196
+ fix: "Start Docker, or disable SonarQube: set sonarqube.enabled: false in kj.config.yml, or pass --no-sonar."
197
+ });
198
+ logger.error("Preflight: Docker not found — SonarQube requires Docker");
183
199
 
184
200
  // Skip remaining sonar checks, continue to security
185
201
  if (!securityEnabled) {
186
202
  emitProgress(emitter, makeEvent("preflight:end", { ...eventBase, stage: "preflight" }, {
187
- status: "warn", message: "Preflight completed with warnings", detail: result
203
+ status: "fail", message: "Preflight FAILED environment not ready", detail: result
188
204
  }));
189
205
  return result;
190
206
  }
@@ -192,7 +208,7 @@ export async function runPreflightChecks({ config, logger, emitter, eventBase, r
192
208
  }
193
209
 
194
210
  // --- 2. SonarQube reachable ---
195
- if (sonarEnabled && !result.configOverrides.sonarDisabled) {
211
+ if (sonarEnabled && result.ok) {
196
212
  const reachableCheck = await checkSonarReachable(sonarHost);
197
213
  result.checks.push(reachableCheck);
198
214
 
@@ -201,25 +217,29 @@ export async function runPreflightChecks({ config, logger, emitter, eventBase, r
201
217
  }
202
218
 
203
219
  emitProgress(emitter, makeEvent("preflight:check", { ...eventBase, stage: "preflight" }, {
204
- status: reachableCheck.ok ? "ok" : "warn",
220
+ status: reachableCheck.ok ? "ok" : "fail",
205
221
  message: `SonarQube reachability: ${reachableCheck.detail}`,
206
222
  detail: reachableCheck
207
223
  }));
208
224
 
209
225
  if (!reachableCheck.ok) {
210
- result.configOverrides.sonarDisabled = true;
211
- result.warnings.push("SonarQube not reachable — auto-disabled");
212
- logger.warn("Preflight: SonarQube not reachable after remediation, disabling");
226
+ result.ok = false;
227
+ result.errors.push({
228
+ check: "sonar-reachable",
229
+ message: `SonarQube not reachable at ${sonarHost}.`,
230
+ fix: "Start SonarQube: 'docker start karajan-sonarqube', or disable it: set sonarqube.enabled: false in kj.config.yml, or pass --no-sonar."
231
+ });
232
+ logger.error("Preflight: SonarQube not reachable after remediation attempt");
213
233
  }
214
234
  }
215
235
 
216
236
  // --- 3. SonarQube auth/token ---
217
- if (sonarEnabled && !result.configOverrides.sonarDisabled) {
237
+ if (sonarEnabled && result.ok) {
218
238
  const authCheck = await checkSonarAuth(config);
219
239
  result.checks.push(authCheck);
220
240
 
221
241
  emitProgress(emitter, makeEvent("preflight:check", { ...eventBase, stage: "preflight" }, {
222
- status: authCheck.ok ? "ok" : "warn",
242
+ status: authCheck.ok ? "ok" : "fail",
223
243
  message: `SonarQube auth: ${authCheck.detail}`,
224
244
  detail: { name: authCheck.name, ok: authCheck.ok, detail: authCheck.detail }
225
245
  }));
@@ -229,13 +249,17 @@ export async function runPreflightChecks({ config, logger, emitter, eventBase, r
229
249
  result.remediations.push("Sonar token resolved and cached in KJ_SONAR_TOKEN");
230
250
  logger.info("Preflight: Sonar token resolved and cached");
231
251
  } else if (!authCheck.ok) {
232
- result.configOverrides.sonarDisabled = true;
233
- result.warnings.push("SonarQube auth failed — auto-disabled");
234
- logger.warn("Preflight: Sonar auth failed, disabling SonarQube");
252
+ result.ok = false;
253
+ result.errors.push({
254
+ check: "sonar-auth",
255
+ message: "SonarQube authentication failed.",
256
+ fix: "Regenerate the SonarQube token and update it via kj_init or in kj.config.yml under sonarqube.token."
257
+ });
258
+ logger.error("Preflight: Sonar auth failed");
235
259
  }
236
260
  }
237
261
 
238
- // --- 4. Security agent ---
262
+ // --- 4. Security agent (graceful — only warning, not blocking) ---
239
263
  if (securityEnabled) {
240
264
  const secCheck = await checkSecurityAgent(config);
241
265
  result.checks.push(secCheck);
@@ -253,12 +277,15 @@ export async function runPreflightChecks({ config, logger, emitter, eventBase, r
253
277
  }
254
278
  }
255
279
 
280
+ const hasErrors = result.errors.length > 0;
256
281
  const hasWarnings = result.warnings.length > 0;
257
282
  emitProgress(emitter, makeEvent("preflight:end", { ...eventBase, stage: "preflight" }, {
258
- status: hasWarnings ? "warn" : "ok",
259
- message: hasWarnings
260
- ? `Preflight completed with ${result.warnings.length} warning(s)`
261
- : "Preflight passed — all checks OK",
283
+ status: hasErrors ? "fail" : hasWarnings ? "warn" : "ok",
284
+ message: hasErrors
285
+ ? `Preflight FAILED ${result.errors.length} blocking issue(s)`
286
+ : hasWarnings
287
+ ? `Preflight completed with ${result.warnings.length} warning(s)`
288
+ : "Preflight passed — all checks OK",
262
289
  detail: result
263
290
  }));
264
291
 
@@ -886,9 +886,16 @@ async function runPreLoopStages({ config, logger, emitter, eventBase, session, f
886
886
  session.preflight = preflightResult;
887
887
  await saveSession(session);
888
888
 
889
- if (preflightResult.configOverrides.sonarDisabled) {
890
- updatedConfig = { ...updatedConfig, sonarqube: { ...updatedConfig.sonarqube, enabled: false } };
889
+ // Hard fail if blocking checks failed (SonarQube enabled but not available)
890
+ if (!preflightResult.ok) {
891
+ const errorLines = (preflightResult.errors || [])
892
+ .map(e => ` - ${e.message}\n Fix: ${e.fix}`)
893
+ .join("\n");
894
+ throw new Error(
895
+ `Preflight FAILED — environment changed during session. Fix the issues and retry:\n${errorLines}`
896
+ );
891
897
  }
898
+
892
899
  if (preflightResult.configOverrides.securityDisabled) {
893
900
  pipelineFlags.securityEnabled = false;
894
901
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Load SonarQube admin credentials from ~/.karajan/sonar-credentials.json.
3
+ *
4
+ * File format:
5
+ * {
6
+ * "user": "admin",
7
+ * "password": "your-password"
8
+ * }
9
+ *
10
+ * Returns { user, password } or { user: null, password: null } if file missing.
11
+ */
12
+
13
+ import fs from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { getKarajanHome } from "../utils/paths.js";
16
+
17
+ const CREDENTIALS_FILENAME = "sonar-credentials.json";
18
+
19
+ export async function loadSonarCredentials() {
20
+ try {
21
+ const filePath = path.join(getKarajanHome(), CREDENTIALS_FILENAME);
22
+ const raw = await fs.readFile(filePath, "utf8");
23
+ const data = JSON.parse(raw);
24
+ return {
25
+ user: data.user || null,
26
+ password: data.password || null
27
+ };
28
+ } catch {
29
+ return { user: null, password: null };
30
+ }
31
+ }
32
+
33
+ export function credentialsPath() {
34
+ return path.join(getKarajanHome(), CREDENTIALS_FILENAME);
35
+ }
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { runCommand } from "../utils/process.js";
5
5
  import { sonarUp } from "./manager.js";
6
6
  import { resolveSonarProjectKey } from "./project-key.js";
7
+ import { loadSonarCredentials } from "./credentials.js";
7
8
 
8
9
  export function buildScannerOpts(projectKey, scanner = {}) {
9
10
  const opts = [`-Dsonar.projectKey=${projectKey}`];
@@ -136,18 +137,18 @@ async function resolveSonarToken(config, apiHost) {
136
137
  const explicitToken = process.env.KJ_SONAR_TOKEN || process.env.SONAR_TOKEN || config.sonarqube.token;
137
138
  if (explicitToken) return explicitToken;
138
139
 
139
- const adminUser = process.env.KJ_SONAR_ADMIN_USER || config.sonarqube.admin_user || "admin";
140
+ // Resolve admin credentials from: env vars config ~/.karajan/sonar-credentials.json
141
+ const fileCreds = await loadSonarCredentials() || {};
142
+ const adminUser = process.env.KJ_SONAR_ADMIN_USER || config.sonarqube.admin_user || fileCreds.user;
140
143
  const candidates = [
141
144
  process.env.KJ_SONAR_ADMIN_PASSWORD,
142
145
  config.sonarqube.admin_password,
143
- "admin"
146
+ fileCreds.password
144
147
  ].filter(Boolean);
145
148
 
149
+ if (!adminUser || candidates.length === 0) return null;
150
+
146
151
  for (const password of new Set(candidates)) {
147
- if (password === "admin") {
148
- // eslint-disable-next-line no-console
149
- console.warn("[karajan] WARNING: Using default admin/admin credentials for SonarQube. Set KJ_SONAR_TOKEN for production use.");
150
- }
151
152
  const valid = await validateAdminCredentials(apiHost, adminUser, password);
152
153
  if (!valid) continue;
153
154
  const token = await generateUserToken(apiHost, adminUser, password);
@@ -224,7 +225,7 @@ export async function runSonarScan(config, projectKey = null) {
224
225
  ok: false,
225
226
  stdout: "",
226
227
  stderr:
227
- "Unable to resolve Sonar token. Tried configured token/password and fallback admin/admin.",
228
+ "Unable to resolve Sonar token. Set KJ_SONAR_TOKEN env var, configure sonarqube.token in kj.config.yml, or save credentials in ~/.karajan/sonar-credentials.json.",
228
229
  exitCode: 1
229
230
  };
230
231
  }
@@ -1,12 +1,13 @@
1
1
  import { runCommand } from "./process.js";
2
2
  import { resolveBin } from "../agents/resolve-bin.js";
3
+ import { getInstallCommand } from "./os-detect.js";
3
4
 
4
5
  const KNOWN_AGENTS = [
5
- { name: "claude", install: "npm install -g @anthropic-ai/claude-code" },
6
- { name: "codex", install: "npm install -g @openai/codex" },
7
- { name: "gemini", install: "npm install -g @google/gemini-cli (or check https://geminicli.com/docs/get-started/installation/)" },
8
- { name: "aider", install: "pip install aider-chat" },
9
- { name: "opencode", install: "curl -fsSL https://opencode.ai/install | bash (or see https://opencode.ai)" }
6
+ { name: "claude", install: getInstallCommand("claude") },
7
+ { name: "codex", install: getInstallCommand("codex") },
8
+ { name: "gemini", install: getInstallCommand("gemini") },
9
+ { name: "aider", install: getInstallCommand("aider") },
10
+ { name: "opencode", install: getInstallCommand("opencode") }
10
11
  ];
11
12
 
12
13
  export async function checkBinary(name, versionArg = "--version") {
@@ -0,0 +1,45 @@
1
+ import os from "node:os";
2
+
3
+ /**
4
+ * Detect platform and return OS-appropriate install commands.
5
+ */
6
+ export function getPlatform() {
7
+ const platform = os.platform();
8
+ return platform === "darwin" ? "macos" : "linux";
9
+ }
10
+
11
+ const INSTALL_COMMANDS = {
12
+ rtk: {
13
+ macos: "brew install rtk && rtk init --global",
14
+ linux: "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh && rtk init --global"
15
+ },
16
+ claude: {
17
+ macos: "npm install -g @anthropic-ai/claude-code",
18
+ linux: "npm install -g @anthropic-ai/claude-code"
19
+ },
20
+ codex: {
21
+ macos: "npm install -g @openai/codex",
22
+ linux: "npm install -g @openai/codex"
23
+ },
24
+ gemini: {
25
+ macos: "npm install -g @google/gemini-cli",
26
+ linux: "npm install -g @google/gemini-cli"
27
+ },
28
+ aider: {
29
+ macos: "pipx install aider-chat",
30
+ linux: "pipx install aider-chat || pip3 install aider-chat"
31
+ },
32
+ opencode: {
33
+ macos: "curl -fsSL https://opencode.ai/install | bash",
34
+ linux: "curl -fsSL https://opencode.ai/install | bash"
35
+ },
36
+ docker: {
37
+ macos: "brew install --cask docker",
38
+ linux: "sudo apt install docker.io docker-compose-v2 (or see https://docs.docker.com/engine/install/)"
39
+ }
40
+ };
41
+
42
+ export function getInstallCommand(tool) {
43
+ const platform = getPlatform();
44
+ return INSTALL_COMMANDS[tool]?.[platform] || INSTALL_COMMANDS[tool]?.linux || `Install ${tool} manually`;
45
+ }