karajan-code 3.12.2 → 3.13.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
@@ -190,7 +190,7 @@ None of these are required. Karajan auto-skips any scanner that isn't installed,
190
190
  | Tool | Scope | Install | Installable from `kj init`? | What you get |
191
191
  |------|-------|---------|-----------------------------|--------------|
192
192
  | **SonarQube** | Any stack | `docker compose -f ~/sonarqube/docker-compose.yml up -d` | ✅ Yes (wizard configures Docker container + token) | Code quality + security rules with line-precision in `kj audit` |
193
- | **OSV-Scanner** | Any stack | `go install github.com/google/osv-scanner@latest` | ❌ No (install manually) | Dependency CVE coverage broader than `npm audit` |
193
+ | **OSV-Scanner** | Any stack | `go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest` | ❌ No (install manually) | Dependency CVE coverage broader than `npm audit` |
194
194
  | **Semgrep** | Any stack | `pipx install semgrep` | ❌ No (install manually) | SAST: XSS, SQLi, taint flow, secrets — equivalent to `snyk code`, free for OSS |
195
195
  | **Lighthouse** | **Frontend only** | `npm install -g lighthouse` | ❌ No (install manually) | Core Web Vitals + opportunities for `kj webperf` (auto-feeds `kj audit`) |
196
196
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "3.12.2",
3
+ "version": "3.13.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",
@@ -19,7 +19,7 @@
19
19
  */
20
20
 
21
21
  import { execSync } from "node:child_process";
22
- import { cpSync, mkdirSync, writeFileSync, statSync } from "node:fs";
22
+ import { cpSync, mkdirSync, writeFileSync, statSync, readFileSync } from "node:fs";
23
23
  import path from "node:path";
24
24
  import { fileURLToPath } from "node:url";
25
25
 
@@ -59,10 +59,32 @@ async function main() {
59
59
 
60
60
  // ── Step 2: Generate SEA blob ───────────────────────────────────
61
61
  console.log("[2/5] Generating SEA preparation blob...");
62
+ // Templates travel as SEA assets (KJC-BUG-0104): the binary ships no
63
+ // templates/ directory, so kj init / role prompts / onboard resolved
64
+ // paths like $HOME/templates/skills and hit ENOENT. The runtime
65
+ // (src/utils/templates-root.js) reads the index asset and extracts
66
+ // the files to ~/.karajan/embedded-templates/<version>/ on first use.
67
+ const { collectTemplateFiles } = await import("./esbuild-sea.config.mjs");
68
+ const pkgVersion = JSON.parse(
69
+ readFileSync(path.join(ROOT, "package.json"), "utf8"),
70
+ ).version;
71
+ const templateFiles = collectTemplateFiles();
72
+ writeFileSync(
73
+ path.join(DIST, "templates-index.json"),
74
+ JSON.stringify({ version: pkgVersion, files: templateFiles }),
75
+ );
76
+ // Absolute paths: node resolves asset paths against the CWD (not the
77
+ // config file), so anything relative is ambiguous.
78
+ const assets = { "templates-index.json": path.join(DIST, "templates-index.json") };
79
+ for (const rel of templateFiles) {
80
+ assets[`templates/${rel}`] = path.join(ROOT, "templates", rel);
81
+ }
82
+ console.log(` -> ${templateFiles.length} template assets embedded.`);
62
83
  const seaConfig = {
63
84
  main: "dist/kj-bundle.cjs",
64
85
  output: "dist/sea-prep.blob",
65
86
  disableExperimentalSEAWarning: true,
87
+ assets,
66
88
  };
67
89
  writeFileSync(
68
90
  path.join(DIST, "sea-config.json"),
@@ -10,7 +10,7 @@
10
10
 
11
11
  import path from "node:path";
12
12
  import fs from "node:fs/promises";
13
- import { readFileSync } from "node:fs";
13
+ import { readFileSync, readdirSync } from "node:fs";
14
14
  import { fileURLToPath } from "node:url";
15
15
 
16
16
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -213,6 +213,27 @@ const auditHistoryStubPlugin = {
213
213
  };
214
214
 
215
215
  /** @type {import('esbuild').BuildOptions} */
216
+ /**
217
+ * List every built-in template file, as paths relative to templates/
218
+ * (KJC-BUG-0104). The SEA binary ships them as SEA assets — build-sea.mjs
219
+ * turns this list into the sea-config `assets` map plus an index the
220
+ * runtime (src/utils/templates-root.js) uses to extract them on first use.
221
+ * Exported (instead of living in build-sea.mjs, which runs main() on
222
+ * import) so tests can exercise it.
223
+ */
224
+ export function collectTemplateFiles(rootDir = path.join(ROOT, "templates")) {
225
+ const files = [];
226
+ const walk = (dir) => {
227
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
228
+ const p = path.join(dir, entry.name);
229
+ if (entry.isDirectory()) walk(p);
230
+ else files.push(path.relative(rootDir, p));
231
+ }
232
+ };
233
+ walk(rootDir);
234
+ return files.sort();
235
+ }
236
+
216
237
  export const seaBuildOptions = {
217
238
  entryPoints: ["src/cli.js"],
218
239
  outfile: "dist/kj-bundle.cjs",
@@ -48,7 +48,7 @@ export async function collectOsvFindings(projectDir, logger = null) {
48
48
  // err.code 1 is NORMAL — we still want the stdout. Real failures are
49
49
  // ENOENT (binary missing) or stdout empty.
50
50
  if (err.code === "ENOENT") {
51
- if (logger?.warn) logger.warn("osv-scanner not found in PATH — install via 'go install github.com/google/osv-scanner@latest'");
51
+ if (logger?.warn) logger.warn("osv-scanner not found in PATH — install via 'go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest'");
52
52
  return { available: false, reason: "osv-scanner not installed" };
53
53
  }
54
54
  if (err.killed) {
@@ -7,6 +7,7 @@ import { ollamaUp, waitForOllamaReady, normalizeOllamaConfig } from "../rag/olla
7
7
  import { checkOllamaCapability, pullOllamaModel } from "../rag/ollama-capability.js";
8
8
  import { exists, ensureDir } from "../utils/fs.js";
9
9
  import { getKarajanHome } from "../utils/paths.js";
10
+ import { getTemplatesRoot } from "../utils/templates-root.js";
10
11
  import { detectAvailableAgents } from "../utils/agent-detect.js";
11
12
  import { createWizard, isTTY } from "../utils/wizard.js";
12
13
  import { runCommand } from "../utils/process.js";
@@ -441,7 +442,7 @@ async function ensureReviewRules(reviewRulesPath, logger) {
441
442
 
442
443
  async function ensureCoderRules(coderRulesPath, logger) {
443
444
  if (await exists(coderRulesPath)) return;
444
- const templatePath = path.resolve(import.meta.dirname, "../../templates/coder-rules.md");
445
+ const templatePath = path.join(getTemplatesRoot(), "coder-rules.md");
445
446
  let content;
446
447
  try {
447
448
  content = await fs.readFile(templatePath, "utf8");
@@ -537,7 +538,7 @@ async function scaffoldCiGateway(config, flags, logger) {
537
538
  const workflowDir = path.join(projectDir, ".github", "workflows");
538
539
  await ensureDir(workflowDir);
539
540
 
540
- const templatesDir = path.resolve(import.meta.dirname, "../../templates/workflows");
541
+ const templatesDir = path.join(getTemplatesRoot(), "workflows");
541
542
  const workflows = ["kj-ci-gateway.yml", "automerge.yml", "houston-override.yml"];
542
543
 
543
544
  for (const wf of workflows) {
@@ -611,7 +612,7 @@ async function bootstrapOllama({ flags, config, logger, interactive }) {
611
612
  async function installSkills(logger, interactive) {
612
613
  const projectDir = process.cwd();
613
614
  const commandsDir = path.join(projectDir, ".claude", "commands");
614
- const skillsTemplateDir = path.resolve(import.meta.dirname, "../../templates/skills");
615
+ const skillsTemplateDir = path.join(getTemplatesRoot(), "skills");
615
616
 
616
617
  let doInstall = true;
617
618
  if (interactive) {
@@ -6,6 +6,7 @@ import { collectAll } from "../onboarder/collectors/index.js";
6
6
  import { OnboarderRole } from "../roles/onboarder-role.js";
7
7
  import { resolveRole } from "../config.js";
8
8
  import { getKarajanHome } from "../utils/paths.js";
9
+ import { getTemplatesRoot } from "../utils/templates-root.js";
9
10
 
10
11
  export function briefPath(projectDir) {
11
12
  const slug = path.basename(projectDir).replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase() || "project";
@@ -28,7 +29,7 @@ export async function onboardCommand({ config, logger, flags = {} }) {
28
29
  return { path: out, bundle, brief: null };
29
30
  }
30
31
  const roleCfg = resolveRole(config, "onboarder") || resolveRole(config, "architect");
31
- const templatePath = new URL("../../templates/roles/onboarder.md", import.meta.url).pathname;
32
+ const templatePath = path.join(getTemplatesRoot(), "roles", "onboarder.md");
32
33
  const instructions = existsSync(templatePath) ? await readFile(templatePath, "utf8") : "";
33
34
  const role = new OnboarderRole({ instructions, config, ...roleCfg });
34
35
  // KJC-BUG-0061 Bug B: BaseRole.run() refuses to execute unless init()
@@ -12,6 +12,7 @@ import { existsSync, readFileSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
 
14
14
  import { findManagedBlock } from "../utils/managed-markers.js";
15
+ import { findAlternative } from "./alternatives.js";
15
16
  import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
16
17
  import { detectStackRoots } from "./stack-roots.js";
17
18
 
@@ -98,6 +99,13 @@ function readArtifactContent(searchDir, foundAt) {
98
99
 
99
100
  /** Map a classified state to a recommendation + human rationale. */
100
101
  function recommend(state) {
102
+ if (state.status === "SATISFIED_BY_ALTERNATIVE") {
103
+ return {
104
+ recommendation: "keep",
105
+ rationale: `covered by ${state.foundAt} (${state.alternativeTool}) — kj won't add a second linter/formatter`,
106
+ mergeable: false,
107
+ };
108
+ }
101
109
  if (state.status === "MISSING") return { recommendation: "install", rationale: `kj would add ${state.file}`, mergeable: true };
102
110
  if (state.status === "USER_OWNED") {
103
111
  const n = state.improvements?.length ?? 0;
@@ -109,11 +117,20 @@ function recommend(state) {
109
117
  return { recommendation: "update", rationale: `kj v${state.currentVersion} available (you have v${state.managedVersion})`, mergeable: true };
110
118
  }
111
119
 
112
- /** Classify a single artifact found (or not) under `searchDir`. */
113
- export function classifyArtifact(searchDir, artifact) {
120
+ /**
121
+ * Classify a single artifact found (or not) under `searchDir`. `altDirs`
122
+ * widens the cross-tool alternative lookup (a root-level biome.json covers a
123
+ * monorepo's language roots); the user's OWN config of the same tool still
124
+ * wins over an alternative.
125
+ */
126
+ export function classifyArtifact(searchDir, artifact, altDirs = [searchDir]) {
114
127
  const foundAt = findArtifactFile(searchDir, artifact);
115
128
  const base = { foundAt, managedVersion: null, upToDate: null, improvements: [] };
116
129
  let state = { ...base, status: "MISSING" };
130
+ if (!foundAt) {
131
+ const alt = findAlternative(altDirs, artifact.id);
132
+ if (alt) state = { ...base, status: "SATISFIED_BY_ALTERNATIVE", foundAt: alt.foundAt, alternativeTool: alt.tool };
133
+ }
117
134
  if (foundAt) {
118
135
  const content = readArtifactContent(searchDir, foundAt);
119
136
  if (artifact.blockId && content.includes(`kj:managed:${artifact.blockId}`)) {
@@ -139,8 +156,10 @@ export function compareHarden({ projectDir = process.cwd(), profile = "standard"
139
156
  for (const { dir, language } of detectStackRoots(projectDir, { only, exclude })) {
140
157
  const searchDir = dir === "." ? projectDir : join(projectDir, dir);
141
158
  for (const cfg of CONFIGS_BY_LANGUAGE[language] ?? []) {
142
- const res = classifyArtifact(searchDir, toArtifact(cfg));
143
- artifacts.push({ dir, ...res, foundAt: res.foundAt && dir !== "." ? join(dir, res.foundAt) : res.foundAt });
159
+ const res = classifyArtifact(searchDir, toArtifact(cfg), [searchDir, projectDir]);
160
+ // Alternative matches may live at the repo root — keep their path unprefixed.
161
+ const prefix = res.foundAt && dir !== "." && res.status !== "SATISFIED_BY_ALTERNATIVE";
162
+ artifacts.push({ dir, ...res, foundAt: prefix ? join(dir, res.foundAt) : res.foundAt });
144
163
  }
145
164
  }
146
165
  return { artifacts };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Cross-tool alternatives for `kj harden` (KJC-TSK-0614).
3
+ *
4
+ * A user's Biome config replaces BOTH eslint and prettier — seeding kj's
5
+ * configs next to biome.json installs a second, conflicting linter/formatter
6
+ * that fights the user's own hooks. The advisory's EQUIVALENTS map only
7
+ * covers same-tool filename variants; this map covers whole-tool
8
+ * substitutions, and the three surfaces (advisory, config-engine, check)
9
+ * consult it before treating an artifact as missing.
10
+ *
11
+ * Detection is config-file based on purpose: deterministic, and a false
12
+ * negative just means kj proposes its default (the pre-0614 behaviour),
13
+ * never that it overwrites the user's tool.
14
+ */
15
+
16
+ import { existsSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ const ALTERNATIVES = {
20
+ eslint: [{ tool: "biome", files: ["biome.json", "biome.jsonc"] }],
21
+ prettier: [{ tool: "biome", files: ["biome.json", "biome.jsonc"] }],
22
+ };
23
+
24
+ /**
25
+ * First alternative tool that satisfies `artifactId`, searching each dir in
26
+ * order (a language root first, then the repo root — a root-level biome.json
27
+ * covers the whole monorepo). Null when none applies.
28
+ *
29
+ * @param {string[]} searchDirs
30
+ * @param {string} artifactId - "eslint" | "prettier" | ...
31
+ * @returns {{ tool: string, foundAt: string }|null}
32
+ */
33
+ export function findAlternative(searchDirs, artifactId) {
34
+ for (const alt of ALTERNATIVES[artifactId] ?? []) {
35
+ for (const dir of searchDirs) {
36
+ for (const rel of alt.files) {
37
+ if (existsSync(join(dir, rel))) return { tool: alt.tool, foundAt: rel };
38
+ }
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Artifact id for a config-template entry — same convention as the
46
+ * advisory's toArtifact: prettier is the only marker-less JSON config.
47
+ */
48
+ export function artifactIdForConfig(cfg) {
49
+ return cfg.blockId ?? "prettier";
50
+ }
@@ -11,6 +11,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
 
13
13
  import { runCommand } from "../utils/process.js";
14
+ import { artifactIdForConfig, findAlternative } from "./alternatives.js";
14
15
  import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
15
16
  import { PROFILE_HOOKS } from "./hook-templates.js";
16
17
  import { detectStackRoots } from "./stack-roots.js";
@@ -53,8 +54,18 @@ export async function checkHarden({ projectDir = process.cwd(), profile = "stand
53
54
  checks.push(presence(projectDir, cfg.file, `config:${cfg.file}`, "missing — run kj harden"));
54
55
  }
55
56
  for (const { dir, language } of roots) {
57
+ const rootDir = dir === "." ? projectDir : join(projectDir, dir);
56
58
  for (const cfg of CONFIGS_BY_LANGUAGE[language] ?? []) {
57
59
  const rel = dir === "." ? cfg.file : join(dir, cfg.file);
60
+ // A config covered by the user's own tool (biome.json ⇒ eslint+prettier)
61
+ // is not drift — kj must not demand a second linter/formatter (KJC-TSK-0614).
62
+ if (!existsSync(join(projectDir, rel))) {
63
+ const alt = findAlternative([rootDir, projectDir], artifactIdForConfig(cfg));
64
+ if (alt) {
65
+ checks.push({ id: `config:${rel}`, ok: true, detail: `covered by ${alt.foundAt} (${alt.tool})` });
66
+ continue;
67
+ }
68
+ }
58
69
  checks.push(presence(projectDir, rel, `config:${rel}`, `missing for ${language} — run kj harden`));
59
70
  }
60
71
  }
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
 
11
11
  import { upsertManagedBlock } from "../utils/managed-markers.js";
12
+ import { artifactIdForConfig, findAlternative } from "./alternatives.js";
12
13
  import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
13
14
 
14
15
  const BLOCK_VERSION = 1;
@@ -18,13 +19,26 @@ function writeFile(target, content) {
18
19
  writeFileSync(target, content);
19
20
  }
20
21
 
21
- /** Seed a list of config entries into `targetDir`, labelling each by `prefix`. */
22
- function seedInto(targetDir, configs, dryRun, prefix, results) {
22
+ /**
23
+ * Seed a list of config entries into `targetDir`, labelling each by `prefix`.
24
+ * `altDirs` widens the cross-tool alternative lookup (KJC-TSK-0614): a config
25
+ * covered by the user's own tool (biome.json ⇒ eslint+prettier) is never
26
+ * seeded — kj must not install a second, conflicting linter/formatter.
27
+ */
28
+ function seedInto(targetDir, configs, dryRun, prefix, results, altDirs = [targetDir]) {
23
29
  for (const cfg of configs) {
24
30
  const target = join(targetDir, cfg.file);
25
31
  const exists = existsSync(target);
26
32
  const file = prefix === "." ? cfg.file : join(prefix, cfg.file);
27
33
 
34
+ if (!exists) {
35
+ const alt = findAlternative(altDirs, artifactIdForConfig(cfg));
36
+ if (alt) {
37
+ results.push({ file, action: "covered", by: alt.foundAt });
38
+ continue;
39
+ }
40
+ }
41
+
28
42
  if (cfg.json) {
29
43
  const action = exists ? "skipped" : "inserted";
30
44
  if (!dryRun && !exists) writeFile(target, `${cfg.body}\n`);
@@ -69,7 +83,9 @@ export function installConfigsForRoots({ projectDir = process.cwd(), roots = [],
69
83
  const results = [];
70
84
  seedInto(projectDir, UNIVERSAL_CONFIGS, dryRun, ".", results);
71
85
  for (const { dir, language } of roots) {
72
- seedInto(join(projectDir, dir), CONFIGS_BY_LANGUAGE[language] ?? [], dryRun, dir, results);
86
+ const rootDir = join(projectDir, dir);
87
+ // A root-level biome.json covers every language root of the monorepo.
88
+ seedInto(rootDir, CONFIGS_BY_LANGUAGE[language] ?? [], dryRun, dir, results, [rootDir, projectDir]);
73
89
  }
74
90
  return { dryRun, configs: results };
75
91
  }
@@ -10,6 +10,7 @@ import { buildCoderPrompt } from "../prompts/coder.js";
10
10
  import { buildReviewerPrompt } from "../prompts/reviewer.js";
11
11
  import { resolveRole } from "../config.js";
12
12
  import { emitProgress, makeEvent } from "../utils/events.js";
13
+ import { getTemplatesRoot } from "../utils/templates-root.js";
13
14
  import { BudgetTracker, extractUsageMetrics } from "../utils/budget.js";
14
15
  import { computeKjComparison } from "../budget/comparison.js";
15
16
  import { resolveRoleMdPath, loadFirstExisting } from "../roles/base-role.js";
@@ -76,7 +77,7 @@ export async function autoInit(projectDir, logger) {
76
77
  logger.info("No .karajan/ found — auto-initializing project scaffolding");
77
78
  await ensureDir(karajanDir);
78
79
 
79
- const templatesDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..", "templates");
80
+ const templatesDir = getTemplatesRoot();
80
81
 
81
82
  const filesToCopy = [
82
83
  { src: "coder-rules.md", dest: "coder-rules.md" },
@@ -26,8 +26,8 @@
26
26
 
27
27
  import fs from "node:fs/promises";
28
28
  import path from "node:path";
29
- import { fileURLToPath } from "node:url";
30
29
  import { getKarajanHome } from "../utils/paths.js";
30
+ import { getTemplatesRoot } from "../utils/templates-root.js";
31
31
  import { normalizeProvider } from "../utils/provider-env.js";
32
32
 
33
33
  /**
@@ -50,14 +50,7 @@ export function resolveRolePromptPaths(role, provider, projectDir) {
50
50
  if (projectDir) homes.push(path.join(projectDir, ".karajan", "roles"));
51
51
  homes.push(path.join(getKarajanHome(), "roles"));
52
52
 
53
- const builtInRoot = path.resolve(
54
- path.dirname(fileURLToPath(import.meta.url)),
55
- "..",
56
- "..",
57
- "templates",
58
- "roles"
59
- );
60
- homes.push(builtInRoot);
53
+ homes.push(path.join(getTemplatesRoot(), "roles"));
61
54
 
62
55
  const perProvider = [];
63
56
  const defaults = [];
@@ -97,7 +97,11 @@ const INSTALL_CANDIDATES = {
97
97
  { manager: "pip", command: "pip install semgrep" },
98
98
  ],
99
99
  "osv-scanner": [
100
- { manager: "go", command: "go install github.com/google/osv-scanner@latest" },
100
+ // The module root has no main package — the binary lives in the cmd/
101
+ // subpackage, and since v2 the module path carries the /v2 suffix.
102
+ // `go install github.com/google/osv-scanner@latest` resolves the module
103
+ // (v1.x) but fails with "does not contain package" (KJC-BUG-0105).
104
+ { manager: "go", command: "go install github.com/google/osv-scanner/v2/cmd/osv-scanner@latest" },
101
105
  { manager: "brew", command: "brew install osv-scanner" },
102
106
  ],
103
107
  lighthouse: [
@@ -0,0 +1,61 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { isSea, getAsset } from "node:sea";
5
+ import { getKarajanHome } from "./paths.js";
6
+
7
+ // npm install / source tree: the real templates/ directory next to src/.
8
+ const SOURCE_ROOT = path.resolve(
9
+ path.dirname(fileURLToPath(import.meta.url)),
10
+ "..",
11
+ "..",
12
+ "templates",
13
+ );
14
+
15
+ const INDEX_ASSET = "templates-index.json";
16
+
17
+ /**
18
+ * Root directory of the built-in templates (KJC-BUG-0104).
19
+ *
20
+ * The SEA binary ships only the JS bundle — templates/ never travels as
21
+ * files, and `import.meta.dirname`-relative resolution lands on the
22
+ * binary's own directory (e.g. `$HOME/templates/skills` → ENOENT). In SEA
23
+ * builds the templates travel as SEA assets (see scripts/build-sea.mjs);
24
+ * they are extracted once per version into ~/.karajan/embedded-templates/
25
+ * and served from there, so every fs-based consumer keeps working.
26
+ */
27
+ export function getTemplatesRoot() {
28
+ if (!isSeaRuntime()) return SOURCE_ROOT;
29
+ const index = JSON.parse(getAsset(INDEX_ASSET, "utf8"));
30
+ const destRoot = path.join(getKarajanHome(), "embedded-templates", index.version);
31
+ return extractEmbeddedTemplates({
32
+ destRoot,
33
+ files: index.files,
34
+ readAsset: (rel) => getAsset(`templates/${rel}`, "utf8"),
35
+ });
36
+ }
37
+
38
+ function isSeaRuntime() {
39
+ try {
40
+ return isSea();
41
+ } catch {
42
+ // isSea() unexpectedly threw ⇒ treat as npm / source tree, never block.
43
+ return false;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Extract the embedded template files into destRoot (idempotent: a
49
+ * .complete marker skips re-extraction). Exported for unit testing.
50
+ */
51
+ export function extractEmbeddedTemplates({ destRoot, files, readAsset }) {
52
+ const marker = path.join(destRoot, ".complete");
53
+ if (fs.existsSync(marker)) return destRoot;
54
+ for (const rel of files) {
55
+ const dest = path.join(destRoot, rel);
56
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
57
+ fs.writeFileSync(dest, readAsset(rel));
58
+ }
59
+ fs.writeFileSync(marker, "");
60
+ return destRoot;
61
+ }
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs/promises";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
  import { isSea } from "node:sea";
4
5
  import { getKarajanHome } from "./paths.js";
@@ -104,30 +105,46 @@ export async function printUpdateNotice(currentVersion) {
104
105
  }
105
106
 
106
107
  /**
107
- * Run the npm-channel self-update (`kj update`). Captures npm's output instead
108
- * of streaming it: a successful update shows only the progress + result line,
109
- * so npm's deprecation / allow-scripts / funding noise — build plumbing, not
110
- * actionable for whoever runs the command — never reaches the user. On failure
111
- * the captured stdout/stderr IS surfaced, so real errors (native build,
112
- * permissions) stay diagnosable — never a silent failure.
108
+ * Run the self-update (`kj update`), channel-aware (KJC-BUG-0106).
109
+ *
110
+ * npm channel: `npm install -g` with CAPTURED output — success shows only
111
+ * progress + result; npm's deprecation / allow-scripts / funding noise never
112
+ * reaches the user. On failure the captured output IS surfaced.
113
+ *
114
+ * sea channel (standalone binary): npm-installing would update a copy the
115
+ * PATH never resolves — the running binary stays old while `kj update`
116
+ * reports success. Instead the binary installer is re-run (downloaded to a
117
+ * temp file first, never piped from the network straight into a shell).
118
+ *
119
+ * Both channels end with a PATH probe: `kj --version` must report the new
120
+ * version, otherwise a stale copy shadows the update and the command fails
121
+ * LOUDLY naming the likely cause — never a silent "updated" that isn't.
113
122
  *
114
123
  * @param {Object} opts
115
124
  * @param {string} opts.currentVersion - version of the running kj
116
125
  * @param {(cmd: string, args: string[]) => Promise<{stdout: string, stderr: string}>} [opts.exec] - injectable runner (defaults to execa)
117
126
  * @param {Console} [opts.logger]
118
- * @returns {Promise<{ ok: boolean, alreadyLatest?: boolean, latest?: string }>}
127
+ * @param {"npm"|"sea"} [opts.channel] - injectable channel (defaults to detectInstallChannel())
128
+ * @param {typeof fetch} [opts.fetchFn] - injectable fetch (registry lookup + installer download)
129
+ * @returns {Promise<{ ok: boolean, alreadyLatest?: boolean, latest?: string, manual?: boolean }>}
119
130
  */
120
- export async function performSelfUpdate({ currentVersion, exec, logger = console } = {}) {
131
+ export async function performSelfUpdate({ currentVersion, exec, logger = console, channel, fetchFn = fetch } = {}) {
121
132
  const run = exec || (async (cmd, args) => (await import("execa")).execa(cmd, args));
133
+ const ch = channel || detectInstallChannel();
122
134
  logger.log(`Current version: ${currentVersion}`);
123
135
  logger.log("Checking for updates...");
124
136
 
125
137
  let latest;
126
138
  try {
127
- const { stdout } = await run("npm", ["view", PACKAGE_NAME, "version"]);
128
- latest = stdout.trim();
139
+ // Registry over HTTP, not `npm view` — standalone-binary machines may
140
+ // not have npm at all.
141
+ const res = await fetchFn(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
142
+ headers: { Accept: "application/json" },
143
+ });
144
+ if (!res.ok) throw new Error(`registry responded ${res.status}`);
145
+ latest = (await res.json()).version;
129
146
  } catch (err) {
130
- logger.error(`Update failed: ${err.shortMessage || err.message}`);
147
+ logger.error(`Update failed: could not check the registry (${err.message})`);
131
148
  return { ok: false };
132
149
  }
133
150
 
@@ -136,18 +153,64 @@ export async function performSelfUpdate({ currentVersion, exec, logger = console
136
153
  return { ok: true, alreadyLatest: true, latest };
137
154
  }
138
155
 
156
+ if (ch === "sea" && process.platform === "win32") {
157
+ // Executing a downloaded .ps1 from inside the binary is not supported
158
+ // yet — hand the user the exact command instead of half-updating.
159
+ logger.log(`Update available: ${currentVersion} → ${latest} (standalone binary).`);
160
+ logger.log(`${updateInstruction({ channel: "sea" })}`);
161
+ return { ok: true, latest, manual: true };
162
+ }
163
+
139
164
  logger.log(`Updating ${currentVersion} → ${latest}... (this can take a few minutes)`);
140
165
  try {
141
- // No stdio:inherit — capture and drop npm's warnings on the success path.
142
- await run("npm", ["install", "-g", `${PACKAGE_NAME}@latest`]);
143
- logger.log(`Updated to ${latest}. Restart Claude to pick up the new MCP server.`);
144
- return { ok: true, latest };
166
+ if (ch === "sea") {
167
+ // Re-run the binary installer: download to a temp file, then execute —
168
+ // never pipe the network straight into a shell.
169
+ const res = await fetchFn(INSTALL_SH_URL);
170
+ if (!res.ok) throw new Error(`installer download failed (${res.status})`);
171
+ const tmpScript = path.join(os.tmpdir(), `kj-install-${process.pid}.sh`);
172
+ await fs.writeFile(tmpScript, await res.text(), { mode: 0o700 });
173
+ try {
174
+ await run("sh", [tmpScript]);
175
+ } finally {
176
+ await fs.rm(tmpScript, { force: true });
177
+ }
178
+ } else {
179
+ // No stdio:inherit — capture and drop npm's warnings on the success path.
180
+ await run("npm", ["install", "-g", `${PACKAGE_NAME}@latest`]);
181
+ }
145
182
  } catch (err) {
146
183
  if (err.stdout) logger.error(err.stdout);
147
184
  if (err.stderr) logger.error(err.stderr);
148
185
  logger.error(`Update failed: ${err.shortMessage || err.message}`);
149
186
  return { ok: false };
150
187
  }
188
+
189
+ // The install succeeded — but is the kj the USER runs actually the new one?
190
+ const onPath = await verifyKjOnPath({ run, latest, logger });
191
+ if (!onPath) return { ok: false };
192
+ logger.log(`Updated to ${latest}. Restart Claude to pick up the new MCP server.`);
193
+ return { ok: true, latest };
194
+ }
195
+
196
+ /**
197
+ * Probe `kj --version` through the PATH and confirm it reports `latest`.
198
+ * A mismatch means a stale copy shadows the freshly installed one (e.g. a
199
+ * standalone binary in ~/.local/bin in front of the npm global prefix).
200
+ */
201
+ async function verifyKjOnPath({ run, latest, logger }) {
202
+ let reported;
203
+ try {
204
+ const { stdout } = await run("kj", ["--version"]);
205
+ reported = String(stdout).trim();
206
+ } catch (err) {
207
+ logger.error(`Update installed ${latest}, but running \`kj --version\` failed: ${err.shortMessage || err.message}`);
208
+ return false;
209
+ }
210
+ if (reported.includes(latest)) return true;
211
+ logger.error(`Update installed ${latest}, but the \`kj\` on your PATH still reports ${reported}.`);
212
+ logger.error("A stale copy is shadowing the update. Check `which -a kj` and remove the old one, or update through the channel that owns the first entry.");
213
+ return false;
151
214
  }
152
215
 
153
216
  /** Simple semver compare: returns >0 if a > b, <0 if a < b, 0 if equal */