karajan-code 1.57.0 → 1.57.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 +1 -2
- package/docs/README.es.md +1 -2
- package/package.json +1 -1
- package/scripts/ai-attribution-guard.yml +77 -0
- package/scripts/esbuild-sea.config.mjs +83 -15
- package/scripts/install-guard-all-repos.sh +85 -0
- package/scripts/protect-all-repos.sh +88 -0
- package/src/bootstrap.js +8 -4
- package/src/commands/init.js +30 -0
- package/src/config.js +19 -6
- package/src/orchestrator/preflight-checks.js +16 -5
- package/src/utils/process.js +4 -0
package/README.md
CHANGED
package/docs/README.es.md
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# AI Attribution Guard — GitHub Action
|
|
2
|
+
#
|
|
3
|
+
# Copy this file to .github/workflows/ai-attribution-guard.yml in any repo.
|
|
4
|
+
# Blocks PRs that contain AI attribution comments or Co-Authored-By lines.
|
|
5
|
+
# No dependencies, no Node.js required. Pure bash + grep.
|
|
6
|
+
|
|
7
|
+
name: AI Attribution Guard
|
|
8
|
+
|
|
9
|
+
on:
|
|
10
|
+
pull_request:
|
|
11
|
+
types: [opened, synchronize, reopened]
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
scan:
|
|
15
|
+
name: Scan for AI attribution
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
with:
|
|
20
|
+
fetch-depth: 0
|
|
21
|
+
|
|
22
|
+
- name: Check PR diff for AI attribution
|
|
23
|
+
run: |
|
|
24
|
+
DIFF=$(git diff origin/${{ github.base_ref }}...HEAD \
|
|
25
|
+
-- ':!*.png' ':!*.jpg' ':!*.svg' ':!*.ico' \
|
|
26
|
+
':!*.lock' ':!package-lock.json' ':!yarn.lock' \
|
|
27
|
+
':!*.min.js' ':!*.min.css' \
|
|
28
|
+
':!.githooks/*' \
|
|
29
|
+
| grep '^+' \
|
|
30
|
+
| grep -v '^+++' \
|
|
31
|
+
|| true)
|
|
32
|
+
|
|
33
|
+
[ -z "$DIFF" ] && echo "No diff to scan" && exit 0
|
|
34
|
+
|
|
35
|
+
# Attribution patterns (not functional references like provider: "claude")
|
|
36
|
+
ATTR='(Generated|Created|Written|Made|Built|Authored|Assisted|Powered|Produced) (by|with|using|via) .*(Claude|Anthropic|OpenAI|ChatGPT|GPT-[0-9]|Copilot|Gemini|Cursor|Windsurf|Codeium)'
|
|
37
|
+
COAUTH='Co-Authored-By:.*\b(Claude|Anthropic|OpenAI|ChatGPT|Copilot|Gemini|Cursor|Windsurf|Codeium)\b'
|
|
38
|
+
COMMENT='(//|#|/\*|\*|<!--|--)\s*.*(Claude|Anthropic|OpenAI|ChatGPT|Copilot|Gemini).*(generated|assisted|helped|created|wrote|built|made)'
|
|
39
|
+
|
|
40
|
+
FOUND=""
|
|
41
|
+
for PAT in "$ATTR" "$COAUTH" "$COMMENT"; do
|
|
42
|
+
MATCH=$(echo "$DIFF" | grep -i -E "$PAT" || true)
|
|
43
|
+
[ -n "$MATCH" ] && FOUND="${FOUND}${MATCH}\n"
|
|
44
|
+
done
|
|
45
|
+
|
|
46
|
+
if [ -n "$FOUND" ]; then
|
|
47
|
+
echo "::error::AI attribution detected in PR diff"
|
|
48
|
+
echo -e "$FOUND"
|
|
49
|
+
echo ""
|
|
50
|
+
echo "Remove AI attribution comments/lines and push again."
|
|
51
|
+
exit 1
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
echo "AI attribution guard: clean"
|
|
55
|
+
|
|
56
|
+
- name: Check commit messages
|
|
57
|
+
run: |
|
|
58
|
+
COMMITS=$(git log --format='%s%n%b' origin/${{ github.base_ref }}...HEAD)
|
|
59
|
+
|
|
60
|
+
COAUTH='Co-Authored-By:.*\b(Claude|Anthropic|OpenAI|ChatGPT|Copilot|Gemini|Cursor|Windsurf|Codeium)\b'
|
|
61
|
+
ATTR='(Generated|Created|Written|Built|Authored) (by|with|using) .*(Claude|Anthropic|OpenAI|ChatGPT|Copilot|Gemini|Cursor|Windsurf|Codeium)'
|
|
62
|
+
|
|
63
|
+
FOUND=""
|
|
64
|
+
for PAT in "$COAUTH" "$ATTR"; do
|
|
65
|
+
MATCH=$(echo "$COMMITS" | grep -i -E "$PAT" || true)
|
|
66
|
+
[ -n "$MATCH" ] && FOUND="${FOUND}${MATCH}\n"
|
|
67
|
+
done
|
|
68
|
+
|
|
69
|
+
if [ -n "$FOUND" ]; then
|
|
70
|
+
echo "::error::AI attribution detected in commit messages"
|
|
71
|
+
echo -e "$FOUND"
|
|
72
|
+
echo ""
|
|
73
|
+
echo "Amend commits to remove AI attribution and force-push."
|
|
74
|
+
exit 1
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
echo "Commit messages: clean"
|
|
@@ -2,9 +2,90 @@
|
|
|
2
2
|
* esbuild configuration for Node.js SEA (Single Executable Application) bundle.
|
|
3
3
|
*
|
|
4
4
|
* Bundles the entire CLI into a single CJS file suitable for SEA injection.
|
|
5
|
-
*
|
|
5
|
+
* A plugin handles:
|
|
6
|
+
* 1. import.meta.url / import.meta.dirname -> CJS equivalents
|
|
7
|
+
* 2. Top-level await in cli.js -> async IIFE wrapper
|
|
8
|
+
* 3. Runtime package.json reads -> inlined version string
|
|
6
9
|
*/
|
|
7
10
|
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import fs from "node:fs/promises";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
const PKG_VERSION = JSON.parse(readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
|
|
18
|
+
|
|
19
|
+
const seaTransformPlugin = {
|
|
20
|
+
name: "sea-transform",
|
|
21
|
+
setup(build) {
|
|
22
|
+
build.onLoad({ filter: /\.m?js$/ }, async (args) => {
|
|
23
|
+
let contents = await fs.readFile(args.path, "utf8");
|
|
24
|
+
let modified = false;
|
|
25
|
+
|
|
26
|
+
// Replace import.meta.dirname with __dirname
|
|
27
|
+
if (contents.includes("import.meta.dirname")) {
|
|
28
|
+
contents = contents.replaceAll("import.meta.dirname", "__dirname");
|
|
29
|
+
modified = true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Replace import.meta.url with CJS equivalent
|
|
33
|
+
if (contents.includes("import.meta.url")) {
|
|
34
|
+
contents = contents.replaceAll(
|
|
35
|
+
"import.meta.url",
|
|
36
|
+
'require("node:url").pathToFileURL(__filename).href'
|
|
37
|
+
);
|
|
38
|
+
modified = true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Inline version: replace any pattern that reads ../../package.json or ../package.json
|
|
42
|
+
// to extract the version, with the hardcoded version string.
|
|
43
|
+
// Covers cli.js, display.js, doctor.js, bootstrap.js, init.js, server.js
|
|
44
|
+
if (contents.includes("package.json") && contents.match(/["']\.\.\/.*package\.json/)) {
|
|
45
|
+
// Pattern: const X = path.resolve(..., "../../package.json");
|
|
46
|
+
// const Y = JSON.parse(readFileSync(X, "utf8")).version;
|
|
47
|
+
// Replace both lines with: const Y = "1.57.0";
|
|
48
|
+
contents = contents.replace(
|
|
49
|
+
/const (\w+) = (?:path\.resolve|resolve)\(.*?["']\.\.\/.*?package\.json["']\);\s*\n\s*(?:const|return) (\w+) = JSON\.parse\(.*?readFileSync\(\1.*?\).*?\.version;/g,
|
|
50
|
+
`const $2 = ${JSON.stringify(PKG_VERSION)};`
|
|
51
|
+
);
|
|
52
|
+
// Single-line pattern: return JSON.parse(readFileSync(pkgPath...)).version
|
|
53
|
+
contents = contents.replace(
|
|
54
|
+
/const (\w+) = (?:path\.resolve|resolve)\(.*?["']\.\.\/.*?package\.json["']\);\s*\n\s*return JSON\.parse\(.*?readFileSync\(\1.*?\).*?\.version;/g,
|
|
55
|
+
`return ${JSON.stringify(PKG_VERSION)};`
|
|
56
|
+
);
|
|
57
|
+
// Pattern in cli.js: const PKG_PATH = ...; const PKG_VERSION = ...;
|
|
58
|
+
contents = contents.replace(
|
|
59
|
+
/const PKG_PATH = .*?package\.json.*?;\s*\nconst PKG_VERSION = .*?\.version;/,
|
|
60
|
+
`const PKG_VERSION = ${JSON.stringify(PKG_VERSION)};`
|
|
61
|
+
);
|
|
62
|
+
// Pattern in display.js: const DISPLAY_PKG_PATH = ...; const DISPLAY_VERSION = ...;
|
|
63
|
+
contents = contents.replace(
|
|
64
|
+
/const DISPLAY_PKG_PATH = .*?package\.json.*?;\s*\nconst DISPLAY_VERSION = .*?\.version;/,
|
|
65
|
+
`const DISPLAY_VERSION = ${JSON.stringify(PKG_VERSION)};`
|
|
66
|
+
);
|
|
67
|
+
// Pattern in server.js: const PKG_PATH = ...; (used later to read version)
|
|
68
|
+
contents = contents.replace(
|
|
69
|
+
/const PKG_PATH = .*?package\.json.*?;\s*\nconst PKG = JSON\.parse\(readFileSync\(PKG_PATH.*?\)\);/,
|
|
70
|
+
`const PKG = { version: ${JSON.stringify(PKG_VERSION)}, name: "karajan-code" };`
|
|
71
|
+
);
|
|
72
|
+
modified = true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Wrap top-level await in cli.js inside an async IIFE
|
|
76
|
+
if (args.path.endsWith("src/cli.js") || args.path.endsWith("src\\cli.js")) {
|
|
77
|
+
contents = contents.replace(
|
|
78
|
+
/^(try\s*\{[\s\S]*?await\s+program\.parseAsync\(\)[\s\S]*?^\})\s*$/m,
|
|
79
|
+
"(async () => {\n$1\n})();"
|
|
80
|
+
);
|
|
81
|
+
modified = true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return modified ? { contents, loader: "js" } : undefined;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
8
89
|
/** @type {import('esbuild').BuildOptions} */
|
|
9
90
|
export const seaBuildOptions = {
|
|
10
91
|
entryPoints: ["src/cli.js"],
|
|
@@ -14,20 +95,7 @@ export const seaBuildOptions = {
|
|
|
14
95
|
target: "node20",
|
|
15
96
|
bundle: true,
|
|
16
97
|
minify: false,
|
|
17
|
-
// Bundle everything — no externals. The binary must be fully self-contained.
|
|
18
98
|
external: [],
|
|
19
|
-
|
|
20
|
-
banner: {
|
|
21
|
-
js: [
|
|
22
|
-
"/* Karajan Code — SEA bundle */",
|
|
23
|
-
"/* eslint-disable */",
|
|
24
|
-
'const __importMetaUrl = require("node:url").pathToFileURL(__filename).href;',
|
|
25
|
-
].join("\n"),
|
|
26
|
-
},
|
|
27
|
-
// Replace import.meta.url references with the CJS-compatible shim variable.
|
|
28
|
-
define: {
|
|
29
|
-
"import.meta.url": "__importMetaUrl",
|
|
30
|
-
},
|
|
31
|
-
// Log level for debugging build issues.
|
|
99
|
+
plugins: [seaTransformPlugin],
|
|
32
100
|
logLevel: "info",
|
|
33
101
|
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#
|
|
3
|
+
# Installs the AI Attribution Guard workflow in all repos of a GitHub user.
|
|
4
|
+
# Requires: gh CLI authenticated with admin/push permissions.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# ./scripts/install-guard-all-repos.sh # dry-run
|
|
8
|
+
# ./scripts/install-guard-all-repos.sh --apply # install in all repos
|
|
9
|
+
#
|
|
10
|
+
# What it does:
|
|
11
|
+
# - Creates .github/workflows/ai-attribution-guard.yml in each repo
|
|
12
|
+
# - Skips repos that already have the file
|
|
13
|
+
# - Skips archived repos
|
|
14
|
+
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
|
|
17
|
+
OWNER="${GH_OWNER:-manufosela}"
|
|
18
|
+
APPLY=false
|
|
19
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
20
|
+
WORKFLOW_FILE="$SCRIPT_DIR/ai-attribution-guard.yml"
|
|
21
|
+
|
|
22
|
+
if [[ "${1:-}" == "--apply" ]]; then
|
|
23
|
+
APPLY=true
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
if [[ ! -f "$WORKFLOW_FILE" ]]; then
|
|
27
|
+
echo "ERROR: $WORKFLOW_FILE not found"
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
CONTENT=$(base64 -w 0 "$WORKFLOW_FILE")
|
|
32
|
+
|
|
33
|
+
echo "=== Install AI Attribution Guard in all repos of $OWNER ==="
|
|
34
|
+
echo ""
|
|
35
|
+
|
|
36
|
+
if [[ "$APPLY" == false ]]; then
|
|
37
|
+
echo " DRY RUN mode. Use --apply to actually install."
|
|
38
|
+
echo ""
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
REPOS=$(gh repo list "$OWNER" --limit 500 --json name,isArchived \
|
|
42
|
+
--jq '.[] | select(.isArchived == false) | .name')
|
|
43
|
+
|
|
44
|
+
TOTAL=0
|
|
45
|
+
INSTALLED=0
|
|
46
|
+
SKIPPED=0
|
|
47
|
+
FAILED=0
|
|
48
|
+
|
|
49
|
+
while read -r REPO; do
|
|
50
|
+
[ -z "$REPO" ] && continue
|
|
51
|
+
TOTAL=$((TOTAL + 1))
|
|
52
|
+
|
|
53
|
+
# Check if workflow already exists
|
|
54
|
+
EXISTS=$(gh api "repos/$OWNER/$REPO/contents/.github/workflows/ai-attribution-guard.yml" --jq '.sha' 2>/dev/null || echo "")
|
|
55
|
+
|
|
56
|
+
if [[ -n "$EXISTS" ]]; then
|
|
57
|
+
echo " SKIP $REPO — workflow already exists"
|
|
58
|
+
SKIPPED=$((SKIPPED + 1))
|
|
59
|
+
continue
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
if [[ "$APPLY" == false ]]; then
|
|
63
|
+
echo " WOULD $REPO"
|
|
64
|
+
continue
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
if gh api "repos/$OWNER/$REPO/contents/.github/workflows/ai-attribution-guard.yml" -X PUT \
|
|
68
|
+
-f message="chore: add AI attribution guard workflow" \
|
|
69
|
+
-f content="$CONTENT" \
|
|
70
|
+
--jq '.commit.sha' 2>/dev/null; then
|
|
71
|
+
echo " OK $REPO"
|
|
72
|
+
INSTALLED=$((INSTALLED + 1))
|
|
73
|
+
else
|
|
74
|
+
echo " FAIL $REPO — check permissions"
|
|
75
|
+
FAILED=$((FAILED + 1))
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
done <<< "$REPOS"
|
|
79
|
+
|
|
80
|
+
echo ""
|
|
81
|
+
echo "=== Summary ==="
|
|
82
|
+
echo " Total: $TOTAL"
|
|
83
|
+
echo " Installed: $INSTALLED"
|
|
84
|
+
echo " Skipped: $SKIPPED"
|
|
85
|
+
echo " Failed: $FAILED"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#
|
|
3
|
+
# Configures branch protection on all repos of a GitHub user.
|
|
4
|
+
# Requires: gh CLI authenticated with admin permissions.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# ./scripts/protect-all-repos.sh # dry-run (shows what it would do)
|
|
8
|
+
# ./scripts/protect-all-repos.sh --apply # apply protection rules
|
|
9
|
+
#
|
|
10
|
+
# What it does:
|
|
11
|
+
# - Require PRs before merging (no direct push to default branch)
|
|
12
|
+
# - Block force push
|
|
13
|
+
# - Block branch deletion
|
|
14
|
+
#
|
|
15
|
+
# Skips repos where branch protection is already configured.
|
|
16
|
+
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
OWNER="${GH_OWNER:-manufosela}"
|
|
20
|
+
APPLY=false
|
|
21
|
+
|
|
22
|
+
if [[ "${1:-}" == "--apply" ]]; then
|
|
23
|
+
APPLY=true
|
|
24
|
+
fi
|
|
25
|
+
|
|
26
|
+
echo "=== Branch Protection for all repos of $OWNER ==="
|
|
27
|
+
echo ""
|
|
28
|
+
|
|
29
|
+
if [[ "$APPLY" == false ]]; then
|
|
30
|
+
echo " DRY RUN mode. Use --apply to actually configure."
|
|
31
|
+
echo ""
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
REPOS=$(gh repo list "$OWNER" --limit 500 --json name,defaultBranchRef,isArchived \
|
|
35
|
+
--jq '.[] | select(.isArchived == false) | "\(.name)\t\(.defaultBranchRef.name // "main")"')
|
|
36
|
+
|
|
37
|
+
TOTAL=0
|
|
38
|
+
PROTECTED=0
|
|
39
|
+
SKIPPED=0
|
|
40
|
+
FAILED=0
|
|
41
|
+
|
|
42
|
+
while IFS=$'\t' read -r REPO BRANCH; do
|
|
43
|
+
TOTAL=$((TOTAL + 1))
|
|
44
|
+
|
|
45
|
+
# Check if already protected
|
|
46
|
+
STATUS=$(gh api "repos/$OWNER/$REPO/branches/$BRANCH/protection" --jq '.required_pull_request_reviews.required_approving_review_count // 0' 2>/dev/null || echo "none")
|
|
47
|
+
|
|
48
|
+
if [[ "$STATUS" != "none" ]]; then
|
|
49
|
+
echo " SKIP $REPO ($BRANCH) — already protected"
|
|
50
|
+
SKIPPED=$((SKIPPED + 1))
|
|
51
|
+
continue
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
if [[ "$APPLY" == false ]]; then
|
|
55
|
+
echo " WOULD $REPO ($BRANCH)"
|
|
56
|
+
continue
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# Apply protection
|
|
60
|
+
if gh api "repos/$OWNER/$REPO/branches/$BRANCH/protection" -X PUT \
|
|
61
|
+
--input - <<EOF 2>/dev/null; then
|
|
62
|
+
{
|
|
63
|
+
"required_pull_request_reviews": {
|
|
64
|
+
"required_approving_review_count": 0,
|
|
65
|
+
"dismiss_stale_reviews": false
|
|
66
|
+
},
|
|
67
|
+
"enforce_admins": false,
|
|
68
|
+
"required_status_checks": null,
|
|
69
|
+
"restrictions": null,
|
|
70
|
+
"allow_force_pushes": false,
|
|
71
|
+
"allow_deletions": false
|
|
72
|
+
}
|
|
73
|
+
EOF
|
|
74
|
+
echo " OK $REPO ($BRANCH)"
|
|
75
|
+
PROTECTED=$((PROTECTED + 1))
|
|
76
|
+
else
|
|
77
|
+
echo " FAIL $REPO ($BRANCH) — check permissions"
|
|
78
|
+
FAILED=$((FAILED + 1))
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
done <<< "$REPOS"
|
|
82
|
+
|
|
83
|
+
echo ""
|
|
84
|
+
echo "=== Summary ==="
|
|
85
|
+
echo " Total: $TOTAL"
|
|
86
|
+
echo " Protected: $PROTECTED"
|
|
87
|
+
echo " Skipped: $SKIPPED"
|
|
88
|
+
echo " Failed: $FAILED"
|
package/src/bootstrap.js
CHANGED
|
@@ -130,12 +130,16 @@ async function checkSonarQubeReady(config) {
|
|
|
130
130
|
return { name: "sonarqube", ok: true, detail: `Reachable at ${host}`, fix: null };
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
// Auto-remediation:
|
|
133
|
+
// Auto-remediation: start and wait up to 60s for SonarQube to be ready
|
|
134
134
|
try {
|
|
135
135
|
await sonarUp(host);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
const deadline = Date.now() + 60000;
|
|
137
|
+
while (Date.now() < deadline) {
|
|
138
|
+
reachable = await isSonarReachable(host);
|
|
139
|
+
if (reachable) {
|
|
140
|
+
return { name: "sonarqube", ok: true, detail: `Started and reachable at ${host}`, fix: null };
|
|
141
|
+
}
|
|
142
|
+
await new Promise(r => setTimeout(r, 5000));
|
|
139
143
|
}
|
|
140
144
|
} catch { /* fall through */ }
|
|
141
145
|
|
package/src/commands/init.js
CHANGED
|
@@ -305,6 +305,35 @@ async function installSkills(logger, interactive) {
|
|
|
305
305
|
}
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
const GITIGNORE_ENTRIES = [
|
|
309
|
+
{ pattern: ".kj/", comment: "Karajan runtime (logs, worktrees)" },
|
|
310
|
+
{ pattern: ".agent/", comment: "Agent skills (OpenSkills)" },
|
|
311
|
+
{ pattern: ".scannerwork/", comment: "SonarQube scanner temp" },
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
async function ensureGitignoreEntries(projectDir, logger) {
|
|
315
|
+
const gitignorePath = path.join(projectDir, ".gitignore");
|
|
316
|
+
let content;
|
|
317
|
+
try {
|
|
318
|
+
content = await fs.readFile(gitignorePath, "utf8");
|
|
319
|
+
} catch {
|
|
320
|
+
// No .gitignore yet — will create one
|
|
321
|
+
}
|
|
322
|
+
if (!content) content = "";
|
|
323
|
+
|
|
324
|
+
const missing = GITIGNORE_ENTRIES.filter(e => !content.includes(e.pattern));
|
|
325
|
+
if (missing.length === 0) return;
|
|
326
|
+
|
|
327
|
+
const block = [
|
|
328
|
+
"",
|
|
329
|
+
"# Karajan Code",
|
|
330
|
+
...missing.map(e => e.pattern)
|
|
331
|
+
].join("\n") + "\n";
|
|
332
|
+
|
|
333
|
+
await fs.appendFile(gitignorePath, block, "utf8");
|
|
334
|
+
logger.info(`Added to .gitignore: ${missing.map(e => e.pattern).join(", ")}`);
|
|
335
|
+
}
|
|
336
|
+
|
|
308
337
|
export async function initCommand({ logger, flags = {} }) {
|
|
309
338
|
const karajanHome = getKarajanHome();
|
|
310
339
|
await ensureDir(karajanHome);
|
|
@@ -329,6 +358,7 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
329
358
|
await handleConfigSetup({ config, configExists, interactive, configPath, logger });
|
|
330
359
|
await ensureReviewRules(reviewRulesPath, logger);
|
|
331
360
|
await ensureCoderRules(coderRulesPath, logger);
|
|
361
|
+
await ensureGitignoreEntries(process.cwd(), logger);
|
|
332
362
|
await installSkills(logger, interactive);
|
|
333
363
|
|
|
334
364
|
// Auto-detect project stack and preconfigure
|
package/src/config.js
CHANGED
|
@@ -221,7 +221,7 @@ export async function loadProjectConfig(projectDir = process.cwd()) {
|
|
|
221
221
|
return null;
|
|
222
222
|
}
|
|
223
223
|
const raw = await fs.readFile(projectConfigPath, "utf8");
|
|
224
|
-
return yaml.load(raw) || {};
|
|
224
|
+
return yaml.load(raw, { json: true }) || {};
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
async function loadProjectPricingOverrides(projectDir = process.cwd()) {
|
|
@@ -231,7 +231,7 @@ async function loadProjectPricingOverrides(projectDir = process.cwd()) {
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
const raw = await fs.readFile(projectConfigPath, "utf8");
|
|
234
|
-
const parsed = yaml.load(raw) || {};
|
|
234
|
+
const parsed = yaml.load(raw, { json: true }) || {};
|
|
235
235
|
const pricing = parsed?.budget?.pricing;
|
|
236
236
|
if (!pricing || typeof pricing !== "object") {
|
|
237
237
|
return null;
|
|
@@ -249,7 +249,7 @@ export async function loadConfig(projectDir) {
|
|
|
249
249
|
const globalExists = await exists(configPath);
|
|
250
250
|
if (globalExists) {
|
|
251
251
|
const raw = await fs.readFile(configPath, "utf8");
|
|
252
|
-
globalConfig = yaml.load(raw) || {};
|
|
252
|
+
globalConfig = yaml.load(raw, { json: true }) || {};
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
// Load project config (.karajan/kj.config.yml)
|
|
@@ -469,6 +469,13 @@ const CODER_INHERITED_ROLES = new Set([
|
|
|
469
469
|
|
|
470
470
|
function resolveProvider(roleConfig, role, roles, legacyCoder, legacyReviewer) {
|
|
471
471
|
if (roleConfig.provider) return roleConfig.provider;
|
|
472
|
+
|
|
473
|
+
// If model has "provider/model" format (e.g. "gemini/pro"), extract the provider
|
|
474
|
+
if (roleConfig.model && roleConfig.model.includes("/")) {
|
|
475
|
+
const inferredProvider = roleConfig.model.split("/")[0].toLowerCase();
|
|
476
|
+
if (AGENT_MODEL_SIGNATURES[inferredProvider]) return inferredProvider;
|
|
477
|
+
}
|
|
478
|
+
|
|
472
479
|
if (role === "coder") return legacyCoder;
|
|
473
480
|
if (role === "reviewer") return legacyReviewer;
|
|
474
481
|
if (CODER_INHERITED_ROLES.has(role)) return roles.coder?.provider || legacyCoder;
|
|
@@ -476,7 +483,13 @@ function resolveProvider(roleConfig, role, roles, legacyCoder, legacyReviewer) {
|
|
|
476
483
|
}
|
|
477
484
|
|
|
478
485
|
function resolveModel(roleConfig, role, config) {
|
|
479
|
-
if (roleConfig.model)
|
|
486
|
+
if (roleConfig.model) {
|
|
487
|
+
// Strip "provider/" prefix from models like "gemini/pro" → "pro"
|
|
488
|
+
const model = roleConfig.model.includes("/")
|
|
489
|
+
? roleConfig.model.split("/").slice(1).join("/")
|
|
490
|
+
: roleConfig.model;
|
|
491
|
+
return { model, inherited: false };
|
|
492
|
+
}
|
|
480
493
|
if (role === "coder") return { model: config?.coder_options?.model ?? null, inherited: false };
|
|
481
494
|
if (role === "reviewer") return { model: config?.reviewer_options?.model ?? null, inherited: false };
|
|
482
495
|
if (CODER_INHERITED_ROLES.has(role)) {
|
|
@@ -495,8 +508,8 @@ export function resolveRole(config, role) {
|
|
|
495
508
|
const provider = resolveProvider(roleConfig, role, roles, legacyCoder, legacyReviewer);
|
|
496
509
|
let { model, inherited: modelIsInherited } = resolveModel(roleConfig, role, config);
|
|
497
510
|
|
|
498
|
-
// Drop
|
|
499
|
-
if (
|
|
511
|
+
// Drop model if incompatible with the resolved provider (inherited or explicit)
|
|
512
|
+
if (provider && model && !isModelCompatible(provider, model)) {
|
|
500
513
|
model = null;
|
|
501
514
|
}
|
|
502
515
|
|
|
@@ -38,19 +38,30 @@ async function checkDocker() {
|
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
const SONAR_STARTUP_POLL_MS = 5000;
|
|
42
|
+
const SONAR_STARTUP_MAX_WAIT_MS = 60000;
|
|
43
|
+
|
|
44
|
+
async function waitForSonar(host, maxWaitMs = SONAR_STARTUP_MAX_WAIT_MS) {
|
|
45
|
+
const deadline = Date.now() + maxWaitMs;
|
|
46
|
+
while (Date.now() < deadline) {
|
|
47
|
+
if (await isSonarReachable(host)) return true;
|
|
48
|
+
await new Promise(r => setTimeout(r, SONAR_STARTUP_POLL_MS));
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
async function checkSonarReachable(host) {
|
|
42
54
|
const reachable = await isSonarReachable(host);
|
|
43
55
|
if (reachable) {
|
|
44
56
|
return { name: "sonar-reachable", ok: true, detail: `SonarQube reachable at ${host}`, remediated: false };
|
|
45
57
|
}
|
|
46
58
|
|
|
47
|
-
// Auto-remediation:
|
|
59
|
+
// Auto-remediation: start SonarQube and wait for it to be ready
|
|
48
60
|
try {
|
|
49
61
|
const upResult = await sonarUp(host);
|
|
50
62
|
if (upResult.exitCode === 0) {
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
if (reachableAfter) {
|
|
63
|
+
// SonarQube needs 20-40s to start. Poll until ready.
|
|
64
|
+
if (await waitForSonar(host)) {
|
|
54
65
|
return { name: "sonar-reachable", ok: true, detail: `SonarQube started and reachable at ${host}`, remediated: true };
|
|
55
66
|
}
|
|
56
67
|
}
|
|
@@ -58,7 +69,7 @@ async function checkSonarReachable(host) {
|
|
|
58
69
|
// sonarUp failed, fall through
|
|
59
70
|
}
|
|
60
71
|
|
|
61
|
-
return { name: "sonar-reachable", ok: false, detail: `SonarQube not reachable at ${host} (auto-start failed)` };
|
|
72
|
+
return { name: "sonar-reachable", ok: false, detail: `SonarQube not reachable at ${host} (auto-start failed after ${SONAR_STARTUP_MAX_WAIT_MS / 1000}s)` };
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
async function checkSonarAuth(config) {
|
package/src/utils/process.js
CHANGED
|
@@ -76,6 +76,10 @@ async function awaitWithTimeout(subprocess, timeout, getStdout) {
|
|
|
76
76
|
|
|
77
77
|
export async function runCommand(command, args = [], options = {}) {
|
|
78
78
|
const { timeout, onOutput, silenceTimeoutMs, partialOutputFlushMs, ...rest } = options;
|
|
79
|
+
// Always detach stdin: KJ subprocesses never need interactive input.
|
|
80
|
+
// Without this, a subprocess prompting for input (sonar credentials,
|
|
81
|
+
// agent approval, npm prompts) hangs forever since there is no TTY.
|
|
82
|
+
if (!rest.stdin) rest.stdin = "ignore";
|
|
79
83
|
const subprocess = execa(command, args, { reject: false, ...rest });
|
|
80
84
|
|
|
81
85
|
let stdoutAccum = "";
|