karajan-code 2.19.1 → 2.19.3

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
@@ -345,6 +345,10 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
345
345
 
346
346
  ## Recent releases
347
347
 
348
+ > **v2.19.3 released** — Patch. **HU Board now reads + writes plans from the canonical home dir** (KJC-BUG-0059). Reported by [@aitormf](https://github.com/aitormf): the top card showed `Directorio del proyecto — no detectado` even when the run had a valid `projectDir` and the coder was reading files from it. Root cause: five board call sites still hard-coded `~/.kj/plans/` as their plans root — leftover from the v2.19.0 home consolidation, which fixed `sync.js` but missed the rest. After the auto-migrator ran (or the user created new plans post-v2.19.0), plans landed under `~/.karajan/plans/<slug>/`; the board kept looking under `~/.kj/plans/<slug>/` and silently found nothing — so `GET /api/projects/:id/preflight` could not extract `projectDir` (the literal Aitor saw), `GET /api/projects/:id/plans-outcome` returned `plans: []` for every project, `DELETE /api/projects/:id` swept the wrong path leaving residue on disk, `DELETE /api/plans/:planId` failed silently, `plan-mutations.plansRoot` wrote new per-HU run logs to the legacy root splitting state across both, and `cleanup-zombies` never GC'd zombies under `~/.karajan/plans/`. PR #795 ships three new exports in `packages/hu-board/src/db.js`: `getHuBoardPlansDir()` (canonical, or `KJ_PLANS_DIR` override), `getHuBoardLegacyPlansDir()` (legacy, null when override set), `getHuBoardPlansDirs()` ordered `[canonical, legacy?]` for read callers. Single-write callers (`plan-mutations`) use the canonical root; read / delete / GC iterate both so users mid-migration with plans still under `~/.kj/` don't regress. 29 hu-board test files / 349 tests still green. **If your board shows "Directorio del proyecto — no detectado" on a project that clearly works in CLI, upgrade to v2.19.3.**
349
+ >
350
+ > **v2.19.2 released** — Patch. **SonarQube 401 now triggers automatic token re-bootstrap** instead of failing the run (KJC-BUG-0057). Until v2.19.1, when the Sonar token was missing / stale / revoked / pointed at a recreated Sonar instance, `kj run` / `kj audit` threw `SonarQube authentication failed (HTTP 401)` with the hint "Regenerate with `kj init`" — putting the user in the loop for plumbing Karajan can do itself. PR #793 wires `src/sonar/api.js::sonarFetchOnce` to invoke a new `src/sonar/token-recovery.js::recoverSonarToken()` on the first 401, which reuses `bootstrapSonarToken()` (already shipped in v2.10.2) — probes admin/admin, rotates the default password if still in place, revokes the existing `karajan-cli` token, generates a fresh GLOBAL_ANALYSIS_TOKEN, mutates `config.sonarqube.token` in place AND mirrors it to `~/.karajan/sonar-credentials.json` so future processes pick it up via the normal resolver chain instead of triggering recovery again. The original request retries once with the new token; the user never sees the 401 when recovery succeeds. Per-process latch ensures N endpoints 401-ing trigger ONE bootstrap, not N. Programmatic, **zero LLM involvement** — exactly the kind of plumbing Karajan should never delegate to the agent. Reported by [@aitormf](https://github.com/aitormf).
351
+ >
348
352
  > **v2.19.1 released** — Patch. **APPLICATION BLOCKER fix** for the HU Board: every fresh `npm install -g karajan-code` since the HU Board feature shipped was producing tarballs without the `packages/hu-board/` directory (the `files` array in `package.json` did not include it), and even when the user copied that directory manually, the board crashed at startup with `Cannot find package 'helmet' imported from .../packages/hu-board/src/server.js` because its five dependencies (`helmet`, `chokidar`, `better-sqlite3`, `express`, `express-rate-limit`) were declared in `packages/hu-board/package.json` but missing from the root `dependencies`. PR #791 adds `packages/hu-board/{src,public,package.json}` to `files`, adds the five deps at root at the exact same versions so `npm dedupe` collapses to one copy resolvable by upward traversal, and regenerates `package-lock.json`. Verified end-to-end: `npm pack --dry-run` now ships 12 board files; `node packages/hu-board/src/server.js` boots cleanly. Also internal: 38 direct `os.homedir()` callers routed through the unified resolver (PR #790, `KARAJAN_HOME` now redirects every component) and 5 inline constructions of `~/.karajan/hu-board-runs/` unified under one helper (PR #789). Reported by [@aitormf](https://github.com/aitormf). **If you tried v2.19.0 and `kj board start` failed, upgrade to v2.19.1.**
349
353
  >
350
354
  > **v2.19.0 released** — Minor. Consolidates the HOME-level state of Karajan into a single root: **`~/.karajan/`**. Previously `~/.kj/` held plans, hibernated standby state, run-registry entries and worktrees, while `~/.karajan/` held sessions, hu-stories, config and the rest — no ADR justified the split and four divergent `getKjHome()` implementations had drifted. Three internal PRs land the change without breaking anyone: PR #781 unifies the resolver and introduces **`KARAJAN_HOME`** as the canonical env var (with `KJ_HOME` honoured + one-shot deprecation warning); PR #782 ships an idempotent **auto-migrator** that runs once on the next `kj` invocation — tarball backup at `~/.karajan/backup/kj-pre-migration-<ISO>.tar.gz` BEFORE moving, marker file `~/.karajan/.kj-migrated.json` prevents re-runs, cross-device safe (`rename` → `cp + rm` on `EXDEV`); PR #783 flips every default to `~/.karajan/` and adds a `legacy-kj-home` check to `kj doctor`. The HU Board reads BOTH locations until the migrator fires, so users who start the board first never see "missing plans". Restore is one `tar -xzf ~/.karajan/backup/kj-pre-migration-<ts>.tar.gz -C ~` away. Safe upgrade from 2.18.x.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.19.1",
3
+ "version": "2.19.3",
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",
@@ -47,8 +47,12 @@ function isEphemeralId(id) {
47
47
  return EPHEMERAL_PREFIXES.some((p) => id.startsWith(p));
48
48
  }
49
49
 
50
- function plansRoot() {
51
- return process.env.KJ_PLANS_DIR || path.join(homedir(), '.kj', 'plans');
50
+ // KJC-BUG-0059 (v2.19.3): cleanup walks all known plan roots,
51
+ // canonical first then legacy. Pre-fix hard-coded `~/.kj/plans/`
52
+ // missed every plan post-v2.19.0 → zombies never garbage-collected.
53
+ import { getHuBoardPlansDirs } from './db.js';
54
+ function plansRoots() {
55
+ return getHuBoardPlansDirs();
52
56
  }
53
57
 
54
58
  function olderThanDays(isoStr, days) {
@@ -90,7 +94,7 @@ export function cleanupZombies(opts = {}) {
90
94
  const fsPaths = [
91
95
  ...storyIds.map((sid) => path.join(getKjHome(), 'hu-stories', sid)),
92
96
  ...sessionIds.map((sid) => path.join(getKjHome(), 'sessions', sid)),
93
- path.join(plansRoot(), proj.id),
97
+ ...plansRoots().map((root) => path.join(root, proj.id)),
94
98
  ];
95
99
  deleteProject(proj.id);
96
100
  addTombstone('project', proj.id, { source: 'auto-cleanup', fsPaths });
@@ -56,6 +56,51 @@ export function getHuBoardRunsDir() {
56
56
  return join(getKjHome(), 'hu-board-runs');
57
57
  }
58
58
 
59
+ /**
60
+ * Canonical path to the plans directory the board scans
61
+ * (`<karajan-home>/plans/`). Respects `KJ_PLANS_DIR` as an absolute
62
+ * override, mirroring sync.js. KJC-BUG-0059 (v2.19.3): preflight
63
+ * endpoint used a hard-coded `~/.kj/plans/` legacy fallback that
64
+ * survived the v2.19.0 consolidation, causing the board to report
65
+ * "directorio del proyecto: no detectado" even when the plan on disk
66
+ * had a valid `projectDir`.
67
+ *
68
+ * @returns {string}
69
+ */
70
+ export function getHuBoardPlansDir() {
71
+ return process.env.KJ_PLANS_DIR || join(getKjHome(), 'plans');
72
+ }
73
+
74
+ /**
75
+ * Legacy `~/.kj/plans/` directory — only returned when KJ_PLANS_DIR
76
+ * is unset (it would be the same path anyway when overridden).
77
+ * Callers iterate canonical + legacy so users who have not yet run
78
+ * the v2.19.0 auto-migrator still see their plans. KJC-BUG-0059.
79
+ *
80
+ * @returns {string|null}
81
+ */
82
+ export function getHuBoardLegacyPlansDir() {
83
+ if (process.env.KJ_PLANS_DIR) return null;
84
+ return join(process.env.HOME || '/root', '.kj', 'plans');
85
+ }
86
+
87
+ /**
88
+ * Ordered list of every plans root the board should scan: canonical
89
+ * first (`~/.karajan/plans/`), legacy second when applicable
90
+ * (`~/.kj/plans/`). Honours `KJ_PLANS_DIR` as a single override,
91
+ * matching `sync.js`'s fullScan / startWatcher precedence so the
92
+ * preflight, plans-outcome, delete-project and delete-plan endpoints
93
+ * see the same set of files the watcher sees. KJC-BUG-0059.
94
+ *
95
+ * @returns {string[]}
96
+ */
97
+ export function getHuBoardPlansDirs() {
98
+ const dirs = [getHuBoardPlansDir()];
99
+ const legacy = getHuBoardLegacyPlansDir();
100
+ if (legacy) dirs.push(legacy);
101
+ return dirs;
102
+ }
103
+
59
104
  // Bump when the schema gains a column / index / table that older
60
105
  // Karajan versions cannot understand. A DB written by a newer Karajan
61
106
  // refuses to open here so it does not get silently downgraded.
@@ -40,10 +40,13 @@ const KJ_CLI = join(REPO_ROOT, 'src', 'cli.js');
40
40
  * `KJ_PLANS_DIR` (tests) then `KJ_HOME/plans` (power users) then
41
41
  * `~/.kj/plans/` (default) — same precedence as `sync.js::fullScan`.
42
42
  */
43
+ // KJC-BUG-0059: write operations go to the canonical plans dir.
44
+ // Read operations should iterate getHuBoardPlansDirs() instead, to
45
+ // pick up plans the user has under the legacy ~/.kj/plans/ tree
46
+ // while the auto-migrator has not yet fired.
47
+ import { getHuBoardPlansDir } from './db.js';
43
48
  function plansRoot() {
44
- if (process.env.KJ_PLANS_DIR) return process.env.KJ_PLANS_DIR;
45
- if (process.env.KJ_HOME) return join(process.env.KJ_HOME, 'plans');
46
- return join(homedir(), '.kj', 'plans');
49
+ return getHuBoardPlansDir();
47
50
  }
48
51
 
49
52
  /**
@@ -30,6 +30,7 @@
30
30
  import { execSync } from 'node:child_process';
31
31
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
32
32
  import { join } from 'node:path';
33
+ import { getHuBoardPlansDirs } from './db.js';
33
34
 
34
35
  function runQuiet(cmd, opts = {}) {
35
36
  try {
@@ -221,13 +222,13 @@ function checkConfigYml() {
221
222
  }
222
223
 
223
224
  function checkPlans(projectId) {
224
- // Plans live at ~/.kj/plans/<projectId>/*.json (or
225
- // $KJ_HOME/plans/<projectId>/*.json under VITEST or custom config).
226
- // Mirror plan-mutations.js::plansRoot() so the path matches reality.
227
- const dir = process.env.KJ_HOME
228
- ? join(process.env.KJ_HOME, 'plans', projectId)
229
- : join(process.env.HOME || '', '.kj', 'plans', projectId);
230
- if (!existsSync(dir)) {
225
+ // KJC-BUG-0059 (v2.19.3): scan canonical + legacy roots, same as
226
+ // the preflight route. Pre-fix this hard-coded `~/.kj/plans/`
227
+ // missed every plan post-v2.19.0 → preflight reported "sin plans
228
+ // aún" even when the user had a valid plan on disk.
229
+ const dirs = getHuBoardPlansDirs().map((root) => join(root, projectId));
230
+ const dir = dirs.find((d) => existsSync(d));
231
+ if (!dir) {
231
232
  return {
232
233
  id: 'plans', label: 'Plans en disco', status: 'info',
233
234
  detail: 'sin plans aún',
@@ -15,6 +15,9 @@ import {
15
15
  deleteSession,
16
16
  getKjHome,
17
17
  getHuBoardRunsDir,
18
+ getHuBoardPlansDir,
19
+ getHuBoardLegacyPlansDir,
20
+ getHuBoardPlansDirs,
18
21
  getStoryRow,
19
22
  listPlanIdsForProject,
20
23
  updateStoryStatus,
@@ -192,22 +195,23 @@ router.get('/projects/:id/preflight', async (req, res) => {
192
195
  try {
193
196
  const projectId = req.params.id;
194
197
  let projectDir = null;
198
+ // KJC-BUG-0059 (v2.19.3): scan plans in BOTH the canonical
199
+ // ~/.karajan/plans/ AND the legacy ~/.kj/plans/ — the legacy
200
+ // fallback covers users who started the board before running any
201
+ // `kj` command on a fresh install (so the auto-migrator from
202
+ // v2.19.0 has not fired yet). Pre-fix, this endpoint hard-coded
203
+ // ~/.kj/plans/ as the default, missing every plan post-2.19.0
204
+ // → board card showed "directorio del proyecto: no detectado"
205
+ // even though the plan on disk had a valid projectDir.
195
206
  try {
196
- // Plans live at ~/.kj/plans/<slug>/ when KJ_HOME is unset, or
197
- // $KJ_HOME/plans/<slug>/ when set. db.js::getKjHome() returns
198
- // the .karajan root, NOT the .kj root — they are sister dirs.
199
- // Mirror plan-mutations.js::plansRoot() exactly.
200
- const plansBase = process.env.KJ_HOME
201
- ? path.join(process.env.KJ_HOME, 'plans')
202
- : path.join(process.env.HOME || '', '.kj', 'plans');
203
- const plansDir = path.join(plansBase, projectId);
204
- if (fs.existsSync(plansDir)) {
205
- const files = fs.readdirSync(plansDir).filter((f) => f.endsWith('.json'));
206
- for (const f of files) {
207
+ outer: for (const root of getHuBoardPlansDirs()) {
208
+ const plansDir = path.join(root, projectId);
209
+ if (!fs.existsSync(plansDir)) continue;
210
+ for (const f of fs.readdirSync(plansDir).filter((x) => x.endsWith('.json'))) {
207
211
  try {
208
212
  const plan = JSON.parse(fs.readFileSync(path.join(plansDir, f), 'utf8'));
209
- if (plan?.projectDir) { projectDir = plan.projectDir; break; }
210
- } catch { /* try next */ }
213
+ if (plan?.projectDir) { projectDir = plan.projectDir; break outer; }
214
+ } catch { /* try next file */ }
211
215
  }
212
216
  }
213
217
  } catch { /* projectDir stays null → preflight will report it */ }
@@ -229,17 +233,17 @@ router.get('/projects/:id/preflight', async (req, res) => {
229
233
  router.get('/projects/:id/plans-outcome', (req, res) => {
230
234
  try {
231
235
  const projectId = req.params.id;
232
- const plansBase = process.env.KJ_HOME
233
- ? path.join(process.env.KJ_HOME, 'plans')
234
- : path.join(process.env.HOME || '', '.kj', 'plans');
235
- const plansDir = path.join(plansBase, projectId);
236
- if (!fs.existsSync(plansDir)) return res.json({ projectId, plans: [] });
236
+ // KJC-BUG-0059 (v2.19.3): scan canonical + legacy roots so users
237
+ // who have not yet run the auto-migrator still see their plans.
237
238
  const out = [];
238
- for (const f of fs.readdirSync(plansDir).filter(n => n.endsWith('.json'))) {
239
- try {
240
- const plan = JSON.parse(fs.readFileSync(path.join(plansDir, f), 'utf8'));
241
- if (plan?.version !== 2) continue;
242
- out.push({
239
+ for (const root of getHuBoardPlansDirs()) {
240
+ const plansDir = path.join(root, projectId);
241
+ if (!fs.existsSync(plansDir)) continue;
242
+ for (const f of fs.readdirSync(plansDir).filter(n => n.endsWith('.json'))) {
243
+ try {
244
+ const plan = JSON.parse(fs.readFileSync(path.join(plansDir, f), 'utf8'));
245
+ if (plan?.version !== 2) continue;
246
+ out.push({
243
247
  planId: plan.planId,
244
248
  name: plan.name || (plan.task ? plan.task.slice(0, 80) : plan.planId),
245
249
  status: plan.status || 'draft',
@@ -248,6 +252,7 @@ router.get('/projects/:id/plans-outcome', (req, res) => {
248
252
  huCount: Array.isArray(plan.hus) ? plan.hus.length : 0,
249
253
  });
250
254
  } catch { /* skip unreadable plan */ }
255
+ }
251
256
  }
252
257
  res.json({ projectId, plans: out });
253
258
  } catch (err) {
@@ -356,12 +361,13 @@ router.delete('/projects/:id', (req, res) => {
356
361
  // ~/.kj/plans relative to KJ_HOME's parent, which broke when the
357
362
  // two homes were configured separately and left orphan plan dirs
358
363
  // on disk after a 🗑️ delete.
359
- const plansRoot = process.env.KJ_PLANS_DIR
360
- || path.join(os.homedir(), '.kj', 'plans');
364
+ // KJC-BUG-0059 (v2.19.3): when removing a deleted project's
365
+ // residual plan dirs, sweep BOTH the canonical and legacy roots
366
+ // so the cleanup is consistent for users mid-migration.
361
367
  const fsPaths = [
362
368
  ...storyIds.map((sid) => path.join(huStoriesDir(), sid)),
363
369
  ...sessionIds.map((sid) => path.join(getKjHome(), 'sessions', sid)),
364
- path.join(plansRoot, id),
370
+ ...getHuBoardPlansDirs().map((root) => path.join(root, id)),
365
371
  ];
366
372
 
367
373
  const existed = deleteProject(id);
@@ -464,13 +470,18 @@ router.delete('/sessions/:id', (req, res) => {
464
470
  router.delete('/plans/:planId', (req, res) => {
465
471
  try {
466
472
  const planId = req.params.planId;
467
- // Locate the plan file by scanning the plans dir for filename match.
468
- const plansRoot = process.env.KJ_PLANS_DIR || path.join(getKjHome(), '..', '.kj', 'plans');
473
+ // Locate the plan file by scanning both canonical and legacy plans
474
+ // roots — KJC-BUG-0059 (v2.19.3). Pre-fix this hard-coded
475
+ // `getKjHome()/../.kj/plans` which silently misses plans saved by
476
+ // post-2.19.0 callers under `~/.karajan/plans/`.
469
477
  let foundPath = null;
470
478
  try {
471
- for (const projSlug of fs.readdirSync(plansRoot)) {
472
- const candidate = path.join(plansRoot, projSlug, `plan-${planId}.json`);
473
- if (fs.existsSync(candidate)) { foundPath = candidate; break; }
479
+ outer: for (const plansRoot of getHuBoardPlansDirs()) {
480
+ if (!fs.existsSync(plansRoot)) continue;
481
+ for (const projSlug of fs.readdirSync(plansRoot)) {
482
+ const candidate = path.join(plansRoot, projSlug, `plan-${planId}.json`);
483
+ if (fs.existsSync(candidate)) { foundPath = candidate; break outer; }
484
+ }
474
485
  }
475
486
  } catch { /* plansRoot missing */ }
476
487
  addTombstone('plan', planId, { source: 'api', fsPaths: foundPath ? [foundPath] : [] });
package/src/sonar/api.js CHANGED
@@ -3,6 +3,7 @@ import { withRetry } from "../utils/retry.js";
3
3
  import { resolveSonarProjectKey } from "./project-key.js";
4
4
  import { resolveSonarToken } from "./config-resolver.js";
5
5
  import { filterFalsePositives } from "./issue-filter.js";
6
+ import { recoverSonarToken } from "./token-recovery.js";
6
7
 
7
8
  class SonarApiError extends Error {
8
9
  constructor(message, { url, httpStatus, hint } = {}) {
@@ -25,7 +26,7 @@ function parseHttpResponse(stdout) {
25
26
  return { httpCode, body };
26
27
  }
27
28
 
28
- async function sonarFetchOnce(config, urlPath) {
29
+ async function sonarFetchOnce(config, urlPath, { _retriedAfterRecovery = false, logger = null } = {}) {
29
30
  const token = tokenFromConfig(config);
30
31
  const url = `${config.sonarqube.host}${urlPath}`;
31
32
  const res = await runCommand("curl", ["-s", "-w", "\n%{http_code}", "-u", `${token}:`, url]);
@@ -42,9 +43,22 @@ async function sonarFetchOnce(config, urlPath) {
42
43
  const { httpCode, body } = parseHttpResponse(res.stdout);
43
44
 
44
45
  if (httpCode === 401) {
46
+ // KJC-BUG-0057 (v2.19.2): try to bootstrap a fresh token via the Sonar
47
+ // REST API and retry ONCE. If recovery still fails — or this was already
48
+ // the retry — surface the actionable error like before.
49
+ if (!_retriedAfterRecovery) {
50
+ const recovery = await recoverSonarToken(config, logger);
51
+ if (recovery.ok) {
52
+ return sonarFetchOnce(config, urlPath, { _retriedAfterRecovery: true, logger });
53
+ }
54
+ throw new SonarApiError(
55
+ `SonarQube authentication failed (HTTP 401) and auto-recovery failed: ${recovery.reason}. Regenerate the token manually with 'kj init' or save admin user/password in ~/.karajan/sonar-credentials.json.`,
56
+ { url, httpStatus: 401, hint: "Run 'kj init' to regenerate the SonarQube token, or save admin credentials in ~/.karajan/sonar-credentials.json so Karajan can re-bootstrap automatically." }
57
+ );
58
+ }
45
59
  throw new SonarApiError(
46
- `SonarQube authentication failed (HTTP 401). Token may be invalid or expired. Regenerate with 'kj init'.`,
47
- { url, httpStatus: 401, hint: "Run 'kj init' to regenerate the SonarQube token." }
60
+ `SonarQube authentication failed (HTTP 401) even after auto-recovery. The newly generated token is also being rejected — the Sonar instance may be in an inconsistent state.`,
61
+ { url, httpStatus: 401, hint: "Inspect the Sonar UI directly; consider 'kj sonar restart'." }
48
62
  );
49
63
  }
50
64
 
@@ -0,0 +1,67 @@
1
+ // Sonar 401 auto-recovery — KJC-BUG-0057 / v2.19.2.
2
+ //
3
+ // When the Sonar API returns 401 (token missing / invalid / expired / revoked)
4
+ // and we still have admin/admin (or a user/pass we saved at `kj init` time),
5
+ // regenerate the analysis token VIA THE REST API and persist it. The caller
6
+ // retries the original request once with the new token.
7
+ //
8
+ // Programmatic, no LLM involvement. The whole point of the user feedback was
9
+ // that this is plumbing, not a decision: Karajan has the credentials to
10
+ // re-bootstrap and should do it without asking.
11
+
12
+ import { bootstrapSonarToken } from "./token-bootstrap.js";
13
+ import { saveSonarToken } from "./credentials.js";
14
+
15
+ // Per-process latch: a single Sonar run that 401s on every endpoint must
16
+ // only trigger ONE bootstrap attempt — not one per endpoint. The latch
17
+ // resets when the process exits.
18
+ let _recoveryAttempted = false;
19
+
20
+ /**
21
+ * @internal Reset the latch between tests (mirrors __resetKjHomeWarningForTests).
22
+ */
23
+ export function __resetSonarRecoveryLatchForTests() {
24
+ _recoveryAttempted = false;
25
+ }
26
+
27
+ /**
28
+ * Attempt to re-bootstrap the Sonar token after a 401. Mutates `config`
29
+ * in place when successful so the immediate retry picks up the new token.
30
+ *
31
+ * @param {object} config Karajan config (will be mutated on success).
32
+ * @param {object} [logger]
33
+ * @returns {Promise<{ ok: boolean, reason?: string, newToken?: string, savedTo?: string, passwordRotated?: boolean }>}
34
+ */
35
+ export async function recoverSonarToken(config, logger = null) {
36
+ if (_recoveryAttempted) {
37
+ return { ok: false, reason: "recovery already attempted in this process" };
38
+ }
39
+ _recoveryAttempted = true;
40
+
41
+ const host = config?.sonarqube?.host || "http://localhost:9000";
42
+ logger?.warn?.(`[sonar-recovery] 401 from ${host} — attempting token re-bootstrap`);
43
+
44
+ const result = await bootstrapSonarToken({ host });
45
+
46
+ if (!result.ok) {
47
+ logger?.warn?.(`[sonar-recovery] bootstrap failed: ${result.reason}`);
48
+ return { ok: false, reason: result.reason };
49
+ }
50
+
51
+ // Mutate the in-memory config so the caller's retry sees the new token.
52
+ config.sonarqube = config.sonarqube || {};
53
+ config.sonarqube.token = result.token;
54
+
55
+ // Also persist to ~/.karajan/sonar-credentials.json so future processes
56
+ // pick it up via the normal config-resolver chain instead of triggering
57
+ // recovery again. token-bootstrap.js already wrote sonar.token; this
58
+ // mirrors it into the structured credentials file the resolver reads.
59
+ try {
60
+ await saveSonarToken(result.token);
61
+ } catch (err) {
62
+ logger?.warn?.(`[sonar-recovery] persist to sonar-credentials.json failed (non-blocking): ${err.message}`);
63
+ }
64
+
65
+ logger?.info?.(`[sonar-recovery] new token persisted to ${result.savedTo}${result.passwordRotated ? " (admin password rotated)" : ""} — retrying original request`);
66
+ return { ok: true, newToken: result.token, savedTo: result.savedTo, passwordRotated: result.passwordRotated };
67
+ }