karajan-code 2.10.1 → 2.10.2
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 +3 -1
- package/package.json +1 -1
- package/src/commands/init.js +247 -57
- package/src/sonar/token-bootstrap.js +182 -0
package/README.md
CHANGED
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
> **v2.10.
|
|
26
|
+
> **v2.10.2 released** — Patch release. `kj init` wizard expanded from 9 prompts to a full setup: per-role provider selection (10 roles, "inherit / pick CLI / disable"), automatic SonarQube token generation via REST API (no more web UI walkthrough), git automation flags (`auto_commit/push/pr`) and HU Board security (bind host + port). +16 new tests. Safe upgrade from 2.10.1.
|
|
27
|
+
>
|
|
28
|
+
> **v2.10.1** — Patch release. One-line fix for a stdout contamination bug in `kj audit --agent-readiness --json` (the `[info]` banner was breaking downstream `jq` pipes), plus polish in the asciinema demo scripts. Safe upgrade from 2.10.0.
|
|
27
29
|
>
|
|
28
30
|
> **v2.10.0** — Agent-readiness release. Karajan is now the first orchestrator with a full agent-readability surface: an `llms.txt` index at the root, a `SKILL.md` per CLI command under `docs/agents/`, and a static auditor that scores any third-party repo against the same shape. Highlights: (1) **`kj audit --agent-readiness`** scores any repo 0–100 across seven checks (llms.txt, robots AI-bot allowlist, page token budgets ≤ 32 KB, heading hierarchy, agents/README, SKILL.md coverage). LLM-free, deterministic, JSON-able. Karajan-on-Karajan: **100/100**. (2) **Six new SKILL.md files** (`kj doctor / init / board / review / resume / clean`) under `docs/agents/`, all with the same `What it does · Inputs · Outputs · Side effects · Failure modes · Example` contract; CI guards every link in `llms.txt` resolves. (3) **Webperf quality gate inside the iteration loop** (`pipeline.perf.enabled`): PASS continues, FAIL pushes blocking-metric feedback to the coder, scanner-missing skips best-effort. (4) **HU Board hardening**: binds 127.0.0.1 by default, opt-in `--bind 0.0.0.0` enforces an auto-generated token, helmet headers, rate limiting at 300 req/min — "safe by default on a coffee-shop WiFi". (5) **a11y skills auto-route**: tasks mentioning `accessibility / WCAG / ARIA / screen reader / keyboard nav` automatically pull the `frontend-ui-engineering` skill. (6) **Asciinema demo scripts** under `docs/demos/` so the recordings re-record per release instead of rotting. 5 PRs merged (#605–#609 + #610), 4358/4358 tests passing. See [CHANGELOG.md](./CHANGELOG.md) for the full punch list.
|
|
29
31
|
|
package/package.json
CHANGED
package/src/commands/init.js
CHANGED
|
@@ -13,6 +13,200 @@ import { detectOsLocale, SUPPORTED_LANGUAGES } from "../utils/locale.js";
|
|
|
13
13
|
import { detectRtk } from "../utils/rtk-detect.js";
|
|
14
14
|
import { installRtk } from "../utils/rtk-install.js";
|
|
15
15
|
import { detectProjectStack } from "../utils/stack-detect.js";
|
|
16
|
+
import { bootstrapSonarToken } from "../sonar/token-bootstrap.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Roles asked individually after coder/reviewer. Each entry:
|
|
20
|
+
* - key: config.roles.<key>.provider
|
|
21
|
+
* - pipelineKey: config.pipeline.<key>.enabled (omit when not gated by a flag)
|
|
22
|
+
* - default: which role's CLI to inherit ("coder" | "reviewer")
|
|
23
|
+
* - allowDisable: true if "off" is a valid choice
|
|
24
|
+
*
|
|
25
|
+
* Order matters — keep the most-impactful first so the wizard never hides
|
|
26
|
+
* critical roles behind a long tail of opt-ins.
|
|
27
|
+
*/
|
|
28
|
+
const PER_ROLE_QUESTIONS = [
|
|
29
|
+
{ key: "planner", pipelineKey: "planner", default: "coder", allowDisable: true, label: "Planner (decomposes complex tasks into HUs)" },
|
|
30
|
+
{ key: "researcher", pipelineKey: "researcher", default: "coder", allowDisable: true, label: "Researcher (explores codebase before coding)" },
|
|
31
|
+
{ key: "architect", pipelineKey: "architect", default: "coder", allowDisable: true, label: "Architect (designs solution + contracts)" },
|
|
32
|
+
{ key: "tester", pipelineKey: "tester", default: "coder", allowDisable: false, label: "Tester (verifies tests + coverage)" },
|
|
33
|
+
{ key: "security", pipelineKey: "security", default: "coder", allowDisable: false, label: "Security (OWASP audit)" },
|
|
34
|
+
{ key: "solomon", pipelineKey: "solomon", default: "reviewer", allowDisable: false, label: "Solomon (judge AI for dilemmas)" },
|
|
35
|
+
{ key: "refactorer", pipelineKey: "refactorer", default: "coder", allowDisable: true, label: "Refactorer (clarity passes)" },
|
|
36
|
+
{ key: "impeccable", pipelineKey: "impeccable", default: "coder", allowDisable: true, label: "Impeccable (UI/UX audit — frontend only)" },
|
|
37
|
+
{ key: "perf", pipelineKey: "perf", default: "coder", allowDisable: true, label: "Perf (Core Web Vitals gate — frontend only)" },
|
|
38
|
+
{ key: "hu_reviewer", pipelineKey: "hu_reviewer", default: "reviewer", allowDisable: true, label: "HU Reviewer (mandatory user-story certification)" },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// ---- Sub-asks ----
|
|
42
|
+
|
|
43
|
+
async function askAgents(wizard, available, config, logger) {
|
|
44
|
+
if (available.length === 1) {
|
|
45
|
+
const only = available[0].name;
|
|
46
|
+
logger.info(`Only one agent available: ${only}. Using it for all roles.\n`);
|
|
47
|
+
config.coder = only;
|
|
48
|
+
config.reviewer = only;
|
|
49
|
+
config.roles.coder.provider = only;
|
|
50
|
+
config.roles.reviewer.provider = only;
|
|
51
|
+
return { coder: only, reviewer: only };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const agentOptions = available.map((a) => ({
|
|
55
|
+
label: `${a.name} (${a.version})`,
|
|
56
|
+
value: a.name,
|
|
57
|
+
available: true,
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
const coder = await wizard.select("Select default CODER agent:", agentOptions);
|
|
61
|
+
config.coder = coder;
|
|
62
|
+
config.roles.coder.provider = coder;
|
|
63
|
+
logger.info(` -> Coder: ${coder}`);
|
|
64
|
+
|
|
65
|
+
const reviewer = await wizard.select("Select default REVIEWER agent:", agentOptions);
|
|
66
|
+
config.reviewer = reviewer;
|
|
67
|
+
config.roles.reviewer.provider = reviewer;
|
|
68
|
+
logger.info(` -> Reviewer: ${reviewer}`);
|
|
69
|
+
|
|
70
|
+
return { coder, reviewer };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* For each non-coder/non-reviewer role, ask: use coder default | use reviewer
|
|
75
|
+
* default | pick a specific CLI | (when allowed) disable. The choice is
|
|
76
|
+
* persisted as `config.roles.<role>.provider` (null means "inherit from
|
|
77
|
+
* coder" at runtime). Pipeline-flag-gated roles also flip
|
|
78
|
+
* `config.pipeline.<role>.enabled`.
|
|
79
|
+
*
|
|
80
|
+
* `available.length === 1` short-circuits: every role uses the only CLI
|
|
81
|
+
* available, no point asking.
|
|
82
|
+
*/
|
|
83
|
+
async function askPerRoleProviders(wizard, available, config, { coder, reviewer }, logger) {
|
|
84
|
+
if (available.length <= 1) return;
|
|
85
|
+
|
|
86
|
+
logger.info("");
|
|
87
|
+
logger.info("Configure provider per role (Enter accepts the default):");
|
|
88
|
+
|
|
89
|
+
// Make sure containers exist for every role/pipeline key we touch —
|
|
90
|
+
// older configs may have a sparser shape than DEFAULTS.
|
|
91
|
+
config.roles = config.roles || {};
|
|
92
|
+
config.pipeline = config.pipeline || {};
|
|
93
|
+
|
|
94
|
+
for (const role of PER_ROLE_QUESTIONS) {
|
|
95
|
+
config.roles[role.key] = config.roles[role.key] || { provider: null, model: null };
|
|
96
|
+
if (role.pipelineKey) {
|
|
97
|
+
config.pipeline[role.pipelineKey] = config.pipeline[role.pipelineKey] || { enabled: false };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const defaultLabel = role.default === "coder" ? `coder (${coder})` : `reviewer (${reviewer})`;
|
|
101
|
+
const baseOptions = [
|
|
102
|
+
{ label: `Use ${defaultLabel} — default`, value: "__default__", available: true },
|
|
103
|
+
];
|
|
104
|
+
for (const a of available) {
|
|
105
|
+
if ((role.default === "coder" && a.name === coder) || (role.default === "reviewer" && a.name === reviewer)) continue;
|
|
106
|
+
baseOptions.push({ label: a.name, value: a.name, available: true });
|
|
107
|
+
}
|
|
108
|
+
if (role.allowDisable) {
|
|
109
|
+
baseOptions.push({ label: "Disable role", value: "__disabled__", available: true });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const choice = await wizard.select(` ${role.label}:`, baseOptions);
|
|
113
|
+
|
|
114
|
+
if (choice === "__disabled__") {
|
|
115
|
+
if (role.pipelineKey) config.pipeline[role.pipelineKey].enabled = false;
|
|
116
|
+
logger.info(` -> ${role.key}: disabled`);
|
|
117
|
+
} else if (choice === "__default__") {
|
|
118
|
+
// Inherit from coder/reviewer at runtime — leave provider null.
|
|
119
|
+
config.roles[role.key].provider = null;
|
|
120
|
+
if (role.pipelineKey) config.pipeline[role.pipelineKey].enabled = true;
|
|
121
|
+
logger.info(` -> ${role.key}: ${defaultLabel}`);
|
|
122
|
+
} else {
|
|
123
|
+
config.roles[role.key].provider = choice;
|
|
124
|
+
if (role.pipelineKey) config.pipeline[role.pipelineKey].enabled = true;
|
|
125
|
+
logger.info(` -> ${role.key}: ${choice}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function askMethodology(wizard, config, logger) {
|
|
131
|
+
const methodology = await wizard.select("Development methodology:", [
|
|
132
|
+
{ label: "TDD (test-driven development)", value: "tdd", available: true },
|
|
133
|
+
{ label: "Standard (no TDD enforcement)", value: "standard", available: true },
|
|
134
|
+
]);
|
|
135
|
+
config.development.methodology = methodology;
|
|
136
|
+
config.development.require_test_changes = methodology === "tdd";
|
|
137
|
+
logger.info(` -> Methodology: ${methodology}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function askLanguages(wizard, config, logger) {
|
|
141
|
+
const detectedLocale = detectOsLocale();
|
|
142
|
+
const allLangEntries = Object.entries(SUPPORTED_LANGUAGES).map(([code, name]) => ({
|
|
143
|
+
label: `${name} (${code})`,
|
|
144
|
+
value: code,
|
|
145
|
+
available: true,
|
|
146
|
+
}));
|
|
147
|
+
const languageOptions = [
|
|
148
|
+
...allLangEntries.filter((o) => o.value === detectedLocale),
|
|
149
|
+
...allLangEntries.filter((o) => o.value !== detectedLocale),
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
const pipelineLang = await wizard.select("Pipeline language:", languageOptions);
|
|
153
|
+
config.language = pipelineLang;
|
|
154
|
+
logger.info(` -> Pipeline language: ${pipelineLang}`);
|
|
155
|
+
|
|
156
|
+
const huLangOptions = [
|
|
157
|
+
...allLangEntries.filter((o) => o.value === pipelineLang),
|
|
158
|
+
...allLangEntries.filter((o) => o.value !== pipelineLang),
|
|
159
|
+
];
|
|
160
|
+
const huLang = await wizard.select("HU language (for user stories):", huLangOptions);
|
|
161
|
+
config.hu_language = huLang;
|
|
162
|
+
logger.info(` -> HU language: ${huLang}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function askGitAutomation(wizard, config, logger) {
|
|
166
|
+
config.git = config.git || {};
|
|
167
|
+
const autoCommit = await wizard.confirm("Auto-commit per HU? (recommended for `kj run` so commits show up in git log)", false);
|
|
168
|
+
config.git.auto_commit = autoCommit;
|
|
169
|
+
logger.info(` -> auto_commit: ${autoCommit}`);
|
|
170
|
+
|
|
171
|
+
const autoPush = await wizard.confirm("Auto-push to remote after each commit?", false);
|
|
172
|
+
config.git.auto_push = autoPush;
|
|
173
|
+
logger.info(` -> auto_push: ${autoPush}`);
|
|
174
|
+
|
|
175
|
+
const autoPr = await wizard.confirm("Auto-open a Pull Request when the run finishes?", false);
|
|
176
|
+
config.git.auto_pr = autoPr;
|
|
177
|
+
logger.info(` -> auto_pr: ${autoPr}`);
|
|
178
|
+
|
|
179
|
+
// Branch prefix only worth asking when auto_commit is on; otherwise leave default.
|
|
180
|
+
if (autoCommit) {
|
|
181
|
+
const prefix = await wizard.ask("Branch prefix (e.g. feat/, kj/, ai/) [feat/]: ");
|
|
182
|
+
config.git.branch_prefix = (prefix && prefix.trim()) || "feat/";
|
|
183
|
+
logger.info(` -> branch_prefix: ${config.git.branch_prefix}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function askBoardSecurity(wizard, config, logger) {
|
|
188
|
+
if (!config.hu_board?.enabled) return;
|
|
189
|
+
|
|
190
|
+
const bindChoice = await wizard.select(
|
|
191
|
+
"HU Board bind host:",
|
|
192
|
+
[
|
|
193
|
+
{ label: "127.0.0.1 (loopback only — recommended for personal laptops)", value: "127.0.0.1", available: true },
|
|
194
|
+
{ label: "0.0.0.0 (LAN exposure — token auth auto-enforced for non-loopback peers)", value: "0.0.0.0", available: true },
|
|
195
|
+
],
|
|
196
|
+
);
|
|
197
|
+
config.hu_board.bind = bindChoice;
|
|
198
|
+
logger.info(` -> HU Board bind: ${bindChoice}`);
|
|
199
|
+
|
|
200
|
+
if (bindChoice === "0.0.0.0") {
|
|
201
|
+
logger.info(" A secret token will be auto-generated on first start at ~/.karajan/hu-board/token (mode 0600).");
|
|
202
|
+
logger.info(" Open the URL printed by `kj board start` to grab it.");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const portInput = await wizard.ask("HU Board port [4000]: ");
|
|
206
|
+
const port = Number.parseInt(portInput, 10);
|
|
207
|
+
config.hu_board.port = Number.isFinite(port) && port > 0 && port < 65536 ? port : 4000;
|
|
208
|
+
logger.info(` -> HU Board port: ${config.hu_board.port}`);
|
|
209
|
+
}
|
|
16
210
|
|
|
17
211
|
async function runWizard(config, logger) {
|
|
18
212
|
const agents = await detectAvailableAgents();
|
|
@@ -41,30 +235,7 @@ async function runWizard(config, logger) {
|
|
|
41
235
|
|
|
42
236
|
const wizard = createWizard();
|
|
43
237
|
try {
|
|
44
|
-
|
|
45
|
-
const only = available[0].name;
|
|
46
|
-
logger.info(`Only one agent available: ${only}. Using it for all roles.\n`);
|
|
47
|
-
config.coder = only;
|
|
48
|
-
config.reviewer = only;
|
|
49
|
-
config.roles.coder.provider = only;
|
|
50
|
-
config.roles.reviewer.provider = only;
|
|
51
|
-
} else {
|
|
52
|
-
const agentOptions = available.map((a) => ({
|
|
53
|
-
label: `${a.name} (${a.version})`,
|
|
54
|
-
value: a.name,
|
|
55
|
-
available: true
|
|
56
|
-
}));
|
|
57
|
-
|
|
58
|
-
const coder = await wizard.select("Select default CODER agent:", agentOptions);
|
|
59
|
-
config.coder = coder;
|
|
60
|
-
config.roles.coder.provider = coder;
|
|
61
|
-
logger.info(` -> Coder: ${coder}`);
|
|
62
|
-
|
|
63
|
-
const reviewer = await wizard.select("Select default REVIEWER agent:", agentOptions);
|
|
64
|
-
config.reviewer = reviewer;
|
|
65
|
-
config.roles.reviewer.provider = reviewer;
|
|
66
|
-
logger.info(` -> Reviewer: ${reviewer}`);
|
|
67
|
-
}
|
|
238
|
+
const { coder, reviewer } = await askAgents(wizard, available, config, logger);
|
|
68
239
|
|
|
69
240
|
const enableTriage = await wizard.confirm("Enable triage (auto-classify task complexity)?", false);
|
|
70
241
|
config.pipeline.triage = config.pipeline.triage || {};
|
|
@@ -84,38 +255,19 @@ async function runWizard(config, logger) {
|
|
|
84
255
|
}
|
|
85
256
|
logger.info(` -> HU Board: ${enableHuBoard ? "enabled (auto-start on kj run)" : "disabled"}`);
|
|
86
257
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
]);
|
|
91
|
-
config.development.methodology = methodology;
|
|
92
|
-
config.development.require_test_changes = methodology === "tdd";
|
|
93
|
-
logger.info(` -> Methodology: ${methodology}`);
|
|
258
|
+
await askPerRoleProviders(wizard, available, config, { coder, reviewer }, logger);
|
|
259
|
+
await askMethodology(wizard, config, logger);
|
|
260
|
+
await askLanguages(wizard, config, logger);
|
|
94
261
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
value: code,
|
|
99
|
-
available: true
|
|
100
|
-
}));
|
|
101
|
-
// Put detected locale first so it becomes the default selection
|
|
102
|
-
const languageOptions = [
|
|
103
|
-
...allLangEntries.filter((o) => o.value === detectedLocale),
|
|
104
|
-
...allLangEntries.filter((o) => o.value !== detectedLocale)
|
|
105
|
-
];
|
|
106
|
-
|
|
107
|
-
const pipelineLang = await wizard.select("Pipeline language:", languageOptions);
|
|
108
|
-
config.language = pipelineLang;
|
|
109
|
-
logger.info(` -> Pipeline language: ${pipelineLang}`);
|
|
262
|
+
logger.info("");
|
|
263
|
+
logger.info("Git automation:");
|
|
264
|
+
await askGitAutomation(wizard, config, logger);
|
|
110
265
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const huLang = await wizard.select("HU language (for user stories):", huLangOptions);
|
|
117
|
-
config.hu_language = huLang;
|
|
118
|
-
logger.info(` -> HU language: ${huLang}`);
|
|
266
|
+
if (enableHuBoard) {
|
|
267
|
+
logger.info("");
|
|
268
|
+
logger.info("HU Board security:");
|
|
269
|
+
await askBoardSecurity(wizard, config, logger);
|
|
270
|
+
}
|
|
119
271
|
|
|
120
272
|
logger.info("");
|
|
121
273
|
} finally {
|
|
@@ -182,7 +334,7 @@ async function ensureCoderRules(coderRulesPath, logger) {
|
|
|
182
334
|
logger.info("Created .karajan/coder-rules.md");
|
|
183
335
|
}
|
|
184
336
|
|
|
185
|
-
async function setupSonarQube(config, logger) {
|
|
337
|
+
async function setupSonarQube(config, logger, { interactive } = {}) {
|
|
186
338
|
if (config.sonarqube?.enabled === false) {
|
|
187
339
|
logger.info("SonarQube disabled — skipping container setup.");
|
|
188
340
|
return;
|
|
@@ -212,8 +364,32 @@ async function setupSonarQube(config, logger) {
|
|
|
212
364
|
}
|
|
213
365
|
|
|
214
366
|
logger.info("SonarQube container started");
|
|
367
|
+
|
|
368
|
+
// KJC-TSK-0367: try to bootstrap the analysis token automatically. The
|
|
369
|
+
// bootstrapper logs in with admin/admin, rotates the password if it's
|
|
370
|
+
// still the default, and POSTs to /api/user_tokens/generate. On 401 or
|
|
371
|
+
// any other failure, we fall back to the manual flow that was the only
|
|
372
|
+
// option before this card.
|
|
373
|
+
if (interactive) {
|
|
374
|
+
logger.info("Bootstrapping SonarQube token automatically...");
|
|
375
|
+
const result = await bootstrapSonarToken({
|
|
376
|
+
host: config.sonarqube.host || "http://localhost:9000",
|
|
377
|
+
configPath: getConfigPath(),
|
|
378
|
+
});
|
|
379
|
+
if (result.ok) {
|
|
380
|
+
config.sonarqube.token = result.token;
|
|
381
|
+
logger.info(` -> Sonar token saved to ${result.savedTo}`);
|
|
382
|
+
if (result.passwordRotated) {
|
|
383
|
+
logger.info(` -> Sonar admin password rotated. New value at ${result.passwordSavedTo}`);
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
logger.warn(`Could not auto-generate Sonar token: ${result.reason}`);
|
|
388
|
+
logger.info("Falling back to manual flow:");
|
|
389
|
+
}
|
|
390
|
+
|
|
215
391
|
logger.info("");
|
|
216
|
-
logger.info("To configure the SonarQube token:");
|
|
392
|
+
logger.info("To configure the SonarQube token manually:");
|
|
217
393
|
logger.info(" 1. Open http://localhost:9000");
|
|
218
394
|
logger.info(" 2. Log in (default credentials: admin / admin)");
|
|
219
395
|
logger.info(" 3. Go to: My Account > Security > Generate Token");
|
|
@@ -402,9 +578,12 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
402
578
|
}
|
|
403
579
|
}
|
|
404
580
|
|
|
405
|
-
await setupSonarQube(config, logger);
|
|
581
|
+
await setupSonarQube(config, logger, { interactive });
|
|
406
582
|
await scaffoldCiGateway(config, flags, logger);
|
|
407
583
|
|
|
584
|
+
// Persist any changes setupSonarQube made (token, etc.) back to disk.
|
|
585
|
+
await writeConfig(configPath, config);
|
|
586
|
+
|
|
408
587
|
// Telemetry: anonymous install event (non-blocking)
|
|
409
588
|
const { sendTelemetryEvent } = await import("../utils/telemetry.js");
|
|
410
589
|
const { readFileSync } = await import("node:fs");
|
|
@@ -416,3 +595,14 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
416
595
|
sendTelemetryEvent("install", { version }, config).catch(() => {});
|
|
417
596
|
} catch { /* non-blocking */ }
|
|
418
597
|
}
|
|
598
|
+
|
|
599
|
+
// Internal exports for unit testing (KJC-TSK-0367)
|
|
600
|
+
export const __test__ = {
|
|
601
|
+
askAgents,
|
|
602
|
+
askPerRoleProviders,
|
|
603
|
+
askMethodology,
|
|
604
|
+
askLanguages,
|
|
605
|
+
askGitAutomation,
|
|
606
|
+
askBoardSecurity,
|
|
607
|
+
PER_ROLE_QUESTIONS,
|
|
608
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sonar token bootstrap (KJC-TSK-0367).
|
|
3
|
+
*
|
|
4
|
+
* On `kj init`, after the SonarQube container is up, try to generate the
|
|
5
|
+
* analysis token via the REST API instead of asking the user to walk
|
|
6
|
+
* through the web UI manually. The bootstrapper:
|
|
7
|
+
*
|
|
8
|
+
* 1. Probes the host with admin/admin (the container default).
|
|
9
|
+
* 2. If login succeeds AND the password is still default, it rotates
|
|
10
|
+
* the password to a freshly generated value and persists that to
|
|
11
|
+
* `~/.karajan/sonar.admin-password` (mode 0600). This avoids
|
|
12
|
+
* leaving the deployment with the well-known default credentials.
|
|
13
|
+
* 3. Generates a Global Analysis Token named `karajan-cli` via
|
|
14
|
+
* `POST /api/user_tokens/generate` and returns it.
|
|
15
|
+
* 4. Persists the token to `~/.karajan/sonar.token` (mode 0600). The
|
|
16
|
+
* caller writes it into `config.sonarqube.token` separately.
|
|
17
|
+
*
|
|
18
|
+
* On any HTTP error, we return `{ ok: false, reason }` and let the
|
|
19
|
+
* caller fall back to the manual-flow instructions that existed before
|
|
20
|
+
* this card. We never throw — the wizard must keep going.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { writeFileSync, chmodSync, mkdirSync } from "node:fs";
|
|
24
|
+
import { dirname } from "node:path";
|
|
25
|
+
import { randomBytes } from "node:crypto";
|
|
26
|
+
import { getKarajanHome } from "../utils/paths.js";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
const DEFAULT_USER = "admin";
|
|
30
|
+
const DEFAULT_PASS = "admin";
|
|
31
|
+
const TOKEN_NAME = "karajan-cli";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} host e.g. http://localhost:9000
|
|
35
|
+
* @param {string} pathname "/api/..."
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {"GET"|"POST"} [opts.method="GET"]
|
|
38
|
+
* @param {Record<string,string>} [opts.form] form-urlencoded body
|
|
39
|
+
* @param {string} opts.user
|
|
40
|
+
* @param {string} opts.pass
|
|
41
|
+
* @returns {Promise<{ status: number, body: any }>}
|
|
42
|
+
*/
|
|
43
|
+
async function call(host, pathname, opts) {
|
|
44
|
+
const url = host.replace(/\/$/, "") + pathname;
|
|
45
|
+
const headers = {
|
|
46
|
+
Authorization: "Basic " + Buffer.from(`${opts.user}:${opts.pass}`).toString("base64"),
|
|
47
|
+
};
|
|
48
|
+
let body;
|
|
49
|
+
if (opts.method === "POST" && opts.form) {
|
|
50
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
51
|
+
body = new URLSearchParams(opts.form).toString();
|
|
52
|
+
}
|
|
53
|
+
const res = await fetch(url, { method: opts.method || "GET", headers, body });
|
|
54
|
+
let parsed = null;
|
|
55
|
+
const text = await res.text();
|
|
56
|
+
if (text) {
|
|
57
|
+
try { parsed = JSON.parse(text); } catch { parsed = text; }
|
|
58
|
+
}
|
|
59
|
+
return { status: res.status, body: parsed };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Generate a strong (32-byte hex) random password. We never re-use the
|
|
64
|
+
* default `admin` password and never persist `admin` to disk.
|
|
65
|
+
*/
|
|
66
|
+
function generatePassword() {
|
|
67
|
+
return randomBytes(24).toString("base64url");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Persist a value at `~/.karajan/<filename>` with mode 0600.
|
|
72
|
+
*/
|
|
73
|
+
function persistSecret(filename, value) {
|
|
74
|
+
const home = getKarajanHome();
|
|
75
|
+
const target = path.join(home, filename);
|
|
76
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
77
|
+
writeFileSync(target, value, { mode: 0o600 });
|
|
78
|
+
try { chmodSync(target, 0o600); } catch { /* best-effort on Windows */ }
|
|
79
|
+
return target;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Bootstrap the Sonar analysis token. Idempotent: if the
|
|
84
|
+
* `karajan-cli` token already exists Sonar will refuse to create it
|
|
85
|
+
* again (HTTP 400 with `"errors":[{"msg":"A user token...exists"}]`).
|
|
86
|
+
* In that case we delete-and-recreate so kj init always returns a
|
|
87
|
+
* usable token.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} opts
|
|
90
|
+
* @param {string} opts.host Default http://localhost:9000
|
|
91
|
+
* @returns {Promise<{
|
|
92
|
+
* ok: boolean,
|
|
93
|
+
* token?: string,
|
|
94
|
+
* savedTo?: string,
|
|
95
|
+
* passwordRotated?: boolean,
|
|
96
|
+
* passwordSavedTo?: string,
|
|
97
|
+
* reason?: string,
|
|
98
|
+
* }>}
|
|
99
|
+
*/
|
|
100
|
+
export async function bootstrapSonarToken({ host = "http://localhost:9000" } = {}) {
|
|
101
|
+
let user = DEFAULT_USER;
|
|
102
|
+
let pass = DEFAULT_PASS;
|
|
103
|
+
let passwordRotated = false;
|
|
104
|
+
let passwordSavedTo = null;
|
|
105
|
+
|
|
106
|
+
// 1) Probe: does admin/admin still work?
|
|
107
|
+
const probe = await call(host, "/api/authentication/validate", { user, pass }).catch((err) => ({ status: 0, body: err.message }));
|
|
108
|
+
if (probe.status === 0) {
|
|
109
|
+
return { ok: false, reason: `network error reaching ${host}: ${probe.body}` };
|
|
110
|
+
}
|
|
111
|
+
if (probe.status === 401 || (probe.body && probe.body.valid === false)) {
|
|
112
|
+
return { ok: false, reason: "admin/admin login failed (password may already be customized — set sonarqube.token manually)" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 2) Rotate the default password if it's still in place. This stops
|
|
116
|
+
// the well-known credentials from being a stable attack surface
|
|
117
|
+
// on this user's machine.
|
|
118
|
+
const newPass = generatePassword();
|
|
119
|
+
const change = await call(host, "/api/users/change_password", {
|
|
120
|
+
method: "POST",
|
|
121
|
+
user,
|
|
122
|
+
pass,
|
|
123
|
+
form: { login: user, previousPassword: pass, password: newPass },
|
|
124
|
+
}).catch((err) => ({ status: 0, body: err.message }));
|
|
125
|
+
|
|
126
|
+
if (change.status === 204 || change.status === 200) {
|
|
127
|
+
pass = newPass;
|
|
128
|
+
passwordRotated = true;
|
|
129
|
+
passwordSavedTo = persistSecret("sonar.admin-password", pass);
|
|
130
|
+
} else if (change.status === 400 && Array.isArray(change.body?.errors)
|
|
131
|
+
&& change.body.errors.some(e => /default/i.test(e.msg))) {
|
|
132
|
+
// Sonar complained the password matches a default — rare. Try with
|
|
133
|
+
// a longer one.
|
|
134
|
+
const longerPass = generatePassword() + generatePassword();
|
|
135
|
+
const retry = await call(host, "/api/users/change_password", {
|
|
136
|
+
method: "POST",
|
|
137
|
+
user,
|
|
138
|
+
pass,
|
|
139
|
+
form: { login: user, previousPassword: pass, password: longerPass },
|
|
140
|
+
}).catch(() => ({ status: 0 }));
|
|
141
|
+
if (retry.status === 204 || retry.status === 200) {
|
|
142
|
+
pass = longerPass;
|
|
143
|
+
passwordRotated = true;
|
|
144
|
+
passwordSavedTo = persistSecret("sonar.admin-password", pass);
|
|
145
|
+
}
|
|
146
|
+
} else if (change.status !== 0 && change.status !== 401) {
|
|
147
|
+
// Non-fatal: probably the admin already changed the password. We
|
|
148
|
+
// continue using whatever credentials worked at the probe step.
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 3) Token already exists? Revoke it so we can re-create cleanly.
|
|
152
|
+
await call(host, "/api/user_tokens/revoke", {
|
|
153
|
+
method: "POST",
|
|
154
|
+
user,
|
|
155
|
+
pass,
|
|
156
|
+
form: { name: TOKEN_NAME },
|
|
157
|
+
}).catch(() => { /* token may not exist — that's fine */ });
|
|
158
|
+
|
|
159
|
+
// 4) Generate the analysis token.
|
|
160
|
+
const gen = await call(host, "/api/user_tokens/generate", {
|
|
161
|
+
method: "POST",
|
|
162
|
+
user,
|
|
163
|
+
pass,
|
|
164
|
+
form: { name: TOKEN_NAME, type: "GLOBAL_ANALYSIS_TOKEN" },
|
|
165
|
+
}).catch((err) => ({ status: 0, body: err.message }));
|
|
166
|
+
|
|
167
|
+
if (gen.status !== 200 || !gen.body?.token) {
|
|
168
|
+
const detail = gen.body?.errors?.[0]?.msg || gen.body || `HTTP ${gen.status}`;
|
|
169
|
+
return { ok: false, reason: `token generation failed: ${detail}`, passwordRotated, passwordSavedTo };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const token = gen.body.token;
|
|
173
|
+
const savedTo = persistSecret("sonar.token", token);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
token,
|
|
178
|
+
savedTo,
|
|
179
|
+
passwordRotated,
|
|
180
|
+
passwordSavedTo: passwordRotated ? passwordSavedTo : null,
|
|
181
|
+
};
|
|
182
|
+
}
|