karajan-code 3.5.0 → 3.6.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 +1 -1
- package/package.json +13 -1
- package/packages/ai-trash/README.md +37 -0
- package/packages/ai-trash/bin/kj-trash.js +10 -0
- package/packages/ai-trash/package.json +43 -0
- package/packages/ai-trash/src/cli.js +198 -0
- package/packages/ai-trash/src/destructive-parser.js +74 -0
- package/packages/ai-trash/src/git-snapshot.js +128 -0
- package/packages/ai-trash/src/hook.js +137 -0
- package/packages/ai-trash/src/index.js +14 -0
- package/packages/ai-trash/src/install.js +52 -0
- package/packages/ai-trash/src/logger.js +60 -0
- package/packages/ai-trash/src/manifest.js +91 -0
- package/packages/ai-trash/src/permissions.js +26 -0
- package/packages/ai-trash/src/snapshot.js +66 -0
- package/packages/ai-trash/src/ulid.js +66 -0
- package/scripts/verify-pack.mjs +14 -1
- package/src/checks/ai-trash.js +1 -1
- package/src/cli/register-meta.js +8 -0
- package/src/cli.js +15 -1
- package/src/commands/harden.js +57 -2
- package/src/commands/init.js +77 -45
- package/src/harden/advisory.js +168 -0
- package/src/harden/config-templates.js +5 -0
- package/src/harden/hook-commands.js +5 -0
- package/src/harden/interactive.js +76 -0
- package/src/harden/stack-roots.js +35 -4
- package/src/harden/workflow-templates.js +8 -0
- package/src/utils/squeezr-detect.js +4 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// @karajan/ai-trash — public entry (skeleton).
|
|
2
|
+
// Real surface (snapshot, restore, list, manifest) lands in KJC-TSK-0388
|
|
3
|
+
// commits 2-6 and KJC-TSK-0389 (git ops). For now we expose a single
|
|
4
|
+
// `version` export so consumers / tests can confirm the package loads.
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
|
|
10
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
|
|
12
|
+
|
|
13
|
+
export const version = pkg.version;
|
|
14
|
+
export const name = pkg.name;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Claude Code settings patcher (KJC-TSK-0390 commit 3). Idempotently
|
|
2
|
+
// adds a PreToolUse hook entry that pipes Bash commands through
|
|
3
|
+
// `kj-trash hook`. Preserves any unrelated keys and existing hooks so
|
|
4
|
+
// the user's other settings are untouched.
|
|
5
|
+
|
|
6
|
+
import { promises as fs } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
|
|
11
|
+
export const HOOK_COMMAND = "kj-trash hook";
|
|
12
|
+
const MATCHER = "Bash";
|
|
13
|
+
|
|
14
|
+
async function readSettings(path) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(await fs.readFile(path, "utf8"));
|
|
17
|
+
} catch (err) {
|
|
18
|
+
if (err.code === "ENOENT") return {};
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hasHookCommand(matcherEntry, command) {
|
|
24
|
+
return (matcherEntry.hooks ?? []).some((h) => h.type === "command" && h.command === command);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function patchSettings(settings, command = HOOK_COMMAND, matcher = MATCHER) {
|
|
28
|
+
const next = { ...settings };
|
|
29
|
+
next.hooks = { ...(next.hooks ?? {}) };
|
|
30
|
+
const preToolUse = Array.isArray(next.hooks.PreToolUse) ? next.hooks.PreToolUse.slice() : [];
|
|
31
|
+
let entry = preToolUse.find((e) => e.matcher === matcher);
|
|
32
|
+
let mutated = false;
|
|
33
|
+
if (!entry) {
|
|
34
|
+
entry = { matcher, hooks: [{ type: "command", command }] };
|
|
35
|
+
preToolUse.push(entry);
|
|
36
|
+
mutated = true;
|
|
37
|
+
} else if (!hasHookCommand(entry, command)) {
|
|
38
|
+
entry.hooks = [...(entry.hooks ?? []), { type: "command", command }];
|
|
39
|
+
mutated = true;
|
|
40
|
+
}
|
|
41
|
+
next.hooks.PreToolUse = preToolUse;
|
|
42
|
+
return { settings: next, mutated };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function installClaudeCodeHook({ path = DEFAULT_SETTINGS_PATH, command = HOOK_COMMAND } = {}) {
|
|
46
|
+
const settings = await readSettings(path);
|
|
47
|
+
const { settings: next, mutated } = patchSettings(settings, command);
|
|
48
|
+
if (!mutated) return { path, mutated: false };
|
|
49
|
+
await fs.mkdir(dirname(path), { recursive: true });
|
|
50
|
+
await fs.writeFile(path, JSON.stringify(next, null, 2) + "\n", { encoding: "utf8" });
|
|
51
|
+
return { path, mutated: true };
|
|
52
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Append-only JSONL audit log for the trash store. Events are written one
|
|
2
|
+
// JSON object per line so the file can be `tail -f`-d and parsed
|
|
3
|
+
// line-by-line without holding the whole log in memory.
|
|
4
|
+
|
|
5
|
+
import { promises as fs } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { lockdownFile } from "./permissions.js";
|
|
9
|
+
|
|
10
|
+
const HOME = homedir();
|
|
11
|
+
|
|
12
|
+
function redactPath(p) {
|
|
13
|
+
if (typeof p !== "string" || !p) return p;
|
|
14
|
+
if (p === HOME) return "~";
|
|
15
|
+
if (p.startsWith(`${HOME}/`)) return `~/${p.slice(HOME.length + 1)}`;
|
|
16
|
+
return p;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function redactPayload(payload) {
|
|
20
|
+
if (!payload || typeof payload !== "object") return payload;
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
23
|
+
if (typeof v === "string" && (k.includes("Path") || k === "cwd")) {
|
|
24
|
+
out[k] = redactPath(v);
|
|
25
|
+
} else {
|
|
26
|
+
out[k] = v;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function appendLogEntry(rootDir, event, payload = {}) {
|
|
33
|
+
if (!event) throw new Error("ai-trash: log event required");
|
|
34
|
+
const file = join(rootDir, "log.jsonl");
|
|
35
|
+
const line =
|
|
36
|
+
JSON.stringify({
|
|
37
|
+
ts: new Date().toISOString(),
|
|
38
|
+
event,
|
|
39
|
+
...redactPayload(payload),
|
|
40
|
+
}) + "\n";
|
|
41
|
+
await fs.mkdir(rootDir, { recursive: true });
|
|
42
|
+
await fs.appendFile(file, line, { encoding: "utf8", mode: 0o600 });
|
|
43
|
+
await lockdownFile(file);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readLog(rootDir) {
|
|
47
|
+
const file = join(rootDir, "log.jsonl");
|
|
48
|
+
try {
|
|
49
|
+
const raw = await fs.readFile(file, "utf8");
|
|
50
|
+
return raw
|
|
51
|
+
.split("\n")
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.map((l) => JSON.parse(l));
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err.code === "ENOENT") return [];
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export { redactPath };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { ulid } from "./ulid.js";
|
|
4
|
+
|
|
5
|
+
export const SCHEMA_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
export async function loadManifest(rootDir) {
|
|
8
|
+
const file = join(rootDir, "manifest.json");
|
|
9
|
+
try {
|
|
10
|
+
const raw = await fs.readFile(file, "utf8");
|
|
11
|
+
const parsed = JSON.parse(raw);
|
|
12
|
+
if (parsed?.schemaVersion !== SCHEMA_VERSION) {
|
|
13
|
+
throw new Error(`ai-trash: unsupported manifest schemaVersion ${parsed?.schemaVersion}`);
|
|
14
|
+
}
|
|
15
|
+
if (!Array.isArray(parsed.entries)) parsed.entries = [];
|
|
16
|
+
return parsed;
|
|
17
|
+
} catch (err) {
|
|
18
|
+
if (err.code === "ENOENT") return { schemaVersion: SCHEMA_VERSION, entries: [] };
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function saveManifest(rootDir, manifest) {
|
|
24
|
+
const file = join(rootDir, "manifest.json");
|
|
25
|
+
await fs.mkdir(dirname(file), { recursive: true });
|
|
26
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
27
|
+
await fs.writeFile(tmp, JSON.stringify(manifest, null, 2), {
|
|
28
|
+
encoding: "utf8",
|
|
29
|
+
mode: 0o600,
|
|
30
|
+
});
|
|
31
|
+
await fs.rename(tmp, file);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function addEntry(manifest, partial) {
|
|
35
|
+
if (!partial?.sourcePath) throw new Error("ai-trash: sourcePath required");
|
|
36
|
+
if (typeof partial.sizeBytes !== "number" || partial.sizeBytes < 0) {
|
|
37
|
+
throw new Error("ai-trash: sizeBytes must be a non-negative number");
|
|
38
|
+
}
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const entry = {
|
|
41
|
+
id: ulid(now),
|
|
42
|
+
createdAt: now,
|
|
43
|
+
sourcePath: partial.sourcePath,
|
|
44
|
+
snapshotPath: partial.snapshotPath ?? null,
|
|
45
|
+
sizeBytes: partial.sizeBytes,
|
|
46
|
+
command: partial.command ?? null,
|
|
47
|
+
origin: partial.origin ?? "unknown",
|
|
48
|
+
};
|
|
49
|
+
manifest.entries.push(entry);
|
|
50
|
+
return entry;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function listEntries(manifest) {
|
|
54
|
+
return [...manifest.entries].sort((a, b) => a.id.localeCompare(b.id));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function removeEntry(manifest, id) {
|
|
58
|
+
const idx = manifest.entries.findIndex((e) => e.id === id);
|
|
59
|
+
if (idx < 0) return null;
|
|
60
|
+
return manifest.entries.splice(idx, 1)[0];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function expireBefore(manifest, cutoffMs) {
|
|
64
|
+
const expired = [];
|
|
65
|
+
manifest.entries = manifest.entries.filter((e) => {
|
|
66
|
+
if (e.createdAt < cutoffMs) {
|
|
67
|
+
expired.push(e);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
72
|
+
return expired;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function enforceLruQuota(manifest, maxBytes) {
|
|
76
|
+
if (typeof maxBytes !== "number" || maxBytes < 0) {
|
|
77
|
+
throw new Error("ai-trash: maxBytes must be a non-negative number");
|
|
78
|
+
}
|
|
79
|
+
const sorted = listEntries(manifest);
|
|
80
|
+
const total = sorted.reduce((sum, e) => sum + e.sizeBytes, 0);
|
|
81
|
+
const evicted = [];
|
|
82
|
+
let running = total;
|
|
83
|
+
for (const entry of sorted) {
|
|
84
|
+
if (running <= maxBytes) break;
|
|
85
|
+
evicted.push(entry);
|
|
86
|
+
running -= entry.sizeBytes;
|
|
87
|
+
}
|
|
88
|
+
const evictedIds = new Set(evicted.map((e) => e.id));
|
|
89
|
+
manifest.entries = manifest.entries.filter((e) => !evictedIds.has(e.id));
|
|
90
|
+
return evicted;
|
|
91
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const DIR_MODE = 0o700;
|
|
4
|
+
const FILE_MODE = 0o600;
|
|
5
|
+
|
|
6
|
+
export async function ensureSecureDir(path) {
|
|
7
|
+
await fs.mkdir(path, { recursive: true, mode: DIR_MODE });
|
|
8
|
+
await fs.chmod(path, DIR_MODE);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function lockdownFile(path) {
|
|
12
|
+
await fs.chmod(path, FILE_MODE);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function assertOwnedByCurrentUser(path) {
|
|
16
|
+
if (process.platform === "win32") return;
|
|
17
|
+
const stats = await fs.stat(path);
|
|
18
|
+
if (typeof process.getuid !== "function") return;
|
|
19
|
+
if (stats.uid !== process.getuid()) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`ai-trash: ${path} is owned by uid ${stats.uid}, expected ${process.getuid()}`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { DIR_MODE, FILE_MODE };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { ulid } from "./ulid.js";
|
|
4
|
+
import { ensureSecureDir, lockdownFile } from "./permissions.js";
|
|
5
|
+
import { appendLogEntry } from "./logger.js";
|
|
6
|
+
|
|
7
|
+
async function statOrNull(path) {
|
|
8
|
+
try {
|
|
9
|
+
return await fs.lstat(path);
|
|
10
|
+
} catch (err) {
|
|
11
|
+
if (err.code === "ENOENT") return null;
|
|
12
|
+
throw err;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function snapshotFile(rootDir, sourcePath, options = {}) {
|
|
17
|
+
const stat = await statOrNull(sourcePath);
|
|
18
|
+
if (!stat) throw new Error(`ai-trash: source not found: ${sourcePath}`);
|
|
19
|
+
if (stat.isDirectory()) {
|
|
20
|
+
throw new Error("ai-trash: directory snapshots not implemented in MVP");
|
|
21
|
+
}
|
|
22
|
+
const id = ulid();
|
|
23
|
+
const storeDir = join(rootDir, "store", id);
|
|
24
|
+
await ensureSecureDir(storeDir);
|
|
25
|
+
const snapshotPath = join(storeDir, basename(sourcePath));
|
|
26
|
+
await fs.copyFile(sourcePath, snapshotPath);
|
|
27
|
+
await lockdownFile(snapshotPath);
|
|
28
|
+
const entry = {
|
|
29
|
+
id,
|
|
30
|
+
createdAt: Date.now(),
|
|
31
|
+
sourcePath,
|
|
32
|
+
snapshotPath,
|
|
33
|
+
sizeBytes: stat.size,
|
|
34
|
+
command: options.command ?? null,
|
|
35
|
+
origin: options.origin ?? "unknown",
|
|
36
|
+
};
|
|
37
|
+
await appendLogEntry(rootDir, "snapshot.create", {
|
|
38
|
+
id,
|
|
39
|
+
sourcePath,
|
|
40
|
+
snapshotPath,
|
|
41
|
+
sizeBytes: stat.size,
|
|
42
|
+
});
|
|
43
|
+
return entry;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function restoreSnapshot(rootDir, entry, targetPath) {
|
|
47
|
+
const target = targetPath ?? entry.sourcePath;
|
|
48
|
+
const exists = await statOrNull(target);
|
|
49
|
+
if (exists) throw new Error(`ai-trash: refusing to overwrite ${target}`);
|
|
50
|
+
await fs.mkdir(join(target, ".."), { recursive: true });
|
|
51
|
+
await fs.copyFile(entry.snapshotPath, target);
|
|
52
|
+
await appendLogEntry(rootDir, "snapshot.restore", {
|
|
53
|
+
id: entry.id,
|
|
54
|
+
snapshotPath: entry.snapshotPath,
|
|
55
|
+
targetPath: target,
|
|
56
|
+
});
|
|
57
|
+
return target;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function purgeSnapshot(rootDir, entry) {
|
|
61
|
+
await fs.rm(join(rootDir, "store", entry.id), { recursive: true, force: true });
|
|
62
|
+
await appendLogEntry(rootDir, "snapshot.purge", {
|
|
63
|
+
id: entry.id,
|
|
64
|
+
sizeBytes: entry.sizeBytes,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Tiny ULID generator (Crockford base32, 26 chars, monotonic within ms).
|
|
2
|
+
// We keep this self-contained to avoid pulling a runtime dep into the
|
|
3
|
+
// safety-net package — fewer transitive deps = fewer supply-chain surfaces
|
|
4
|
+
// that an attacker could pivot through to reach the trash store.
|
|
5
|
+
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
9
|
+
|
|
10
|
+
let lastMs = 0;
|
|
11
|
+
let lastRandom = new Uint8Array(10);
|
|
12
|
+
|
|
13
|
+
function encodeTime(ms) {
|
|
14
|
+
let out = "";
|
|
15
|
+
for (let i = 9; i >= 0; i--) {
|
|
16
|
+
out = ENCODING[ms % 32] + out;
|
|
17
|
+
ms = Math.floor(ms / 32);
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function encodeRandom(bytes) {
|
|
23
|
+
let out = "";
|
|
24
|
+
for (let i = 0; i < 16; i++) {
|
|
25
|
+
const byteIndex = Math.floor((i * 5) / 8);
|
|
26
|
+
const bitOffset = (i * 5) % 8;
|
|
27
|
+
const hi = bytes[byteIndex] ?? 0;
|
|
28
|
+
const lo = bytes[byteIndex + 1] ?? 0;
|
|
29
|
+
const value = ((hi << 8) | lo) >> (11 - bitOffset);
|
|
30
|
+
out += ENCODING[value & 31];
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function bumpRandom(buf) {
|
|
36
|
+
for (let i = buf.length - 1; i >= 0; i--) {
|
|
37
|
+
if (buf[i] === 0xff) {
|
|
38
|
+
buf[i] = 0;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
buf[i] += 1;
|
|
42
|
+
return buf;
|
|
43
|
+
}
|
|
44
|
+
throw new Error("ulid: random overflow within the same millisecond");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ulid(nowMs = Date.now()) {
|
|
48
|
+
if (nowMs === lastMs) {
|
|
49
|
+
lastRandom = bumpRandom(lastRandom);
|
|
50
|
+
} else {
|
|
51
|
+
lastMs = nowMs;
|
|
52
|
+
lastRandom = new Uint8Array(randomBytes(10));
|
|
53
|
+
}
|
|
54
|
+
return encodeTime(nowMs) + encodeRandom(lastRandom);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function decodeTimeFromUlid(id) {
|
|
58
|
+
const slice = id.slice(0, 10);
|
|
59
|
+
let ms = 0;
|
|
60
|
+
for (const ch of slice) {
|
|
61
|
+
const v = ENCODING.indexOf(ch);
|
|
62
|
+
if (v < 0) throw new Error(`ulid: invalid char ${ch}`);
|
|
63
|
+
ms = ms * 32 + v;
|
|
64
|
+
}
|
|
65
|
+
return ms;
|
|
66
|
+
}
|
package/scripts/verify-pack.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* this from prepublishOnly does not recurse.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { execFileSync } from "node:child_process";
|
|
21
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
22
22
|
import fs from "node:fs";
|
|
23
23
|
import os from "node:os";
|
|
24
24
|
import path from "node:path";
|
|
@@ -101,6 +101,19 @@ try {
|
|
|
101
101
|
}
|
|
102
102
|
console.log("verify-pack: sqlite-vec + @karajan/core resolve ✓");
|
|
103
103
|
|
|
104
|
+
// 4. kj-trash (ai-trash safety binary) must ship + link onto PATH and boot
|
|
105
|
+
// without a module-resolution crash. It was silently absent from the tarball
|
|
106
|
+
// until KJC-BUG-0089 — every npm user got "kj-trash not found".
|
|
107
|
+
const trashBin = path.join(tmpDir, "node_modules", ".bin", "kj-trash");
|
|
108
|
+
if (!fs.existsSync(trashBin)) fail(`kj-trash binary missing after install: ${trashBin}`);
|
|
109
|
+
const trash = spawnSync(trashBin, ["--help"], { encoding: "utf8", env: childEnv });
|
|
110
|
+
// The CLI exits 2 on --help (usage); only a real crash (module not found, or
|
|
111
|
+
// an unexpected non-0/2 exit) is a failure.
|
|
112
|
+
if (![0, 2].includes(trash.status) || /ERR_MODULE_NOT_FOUND|Cannot find/.test(trash.stderr || "")) {
|
|
113
|
+
fail("`kj-trash --help` crashed on a clean install", trash.stderr || `exit ${trash.status}`);
|
|
114
|
+
}
|
|
115
|
+
console.log("verify-pack: kj-trash ships + boots ✓");
|
|
116
|
+
|
|
104
117
|
console.log(`\n✓ verify-pack: karajan-code@${expectedVersion} installs clean and runs.`);
|
|
105
118
|
} finally {
|
|
106
119
|
if (tgzPath && fs.existsSync(tgzPath)) fs.rmSync(tgzPath, { force: true });
|
package/src/checks/ai-trash.js
CHANGED
|
@@ -23,7 +23,7 @@ function createAiTrashCheck() {
|
|
|
23
23
|
ok: false,
|
|
24
24
|
severity: "warn",
|
|
25
25
|
detail: "kj-trash not found — destructive ops unprotected",
|
|
26
|
-
fix: "
|
|
26
|
+
fix: "Install karajan-code globally (npm i -g karajan-code) so kj-trash is on PATH, then run `kj-trash install --claude-code`",
|
|
27
27
|
};
|
|
28
28
|
},
|
|
29
29
|
};
|
package/src/cli/register-meta.js
CHANGED
|
@@ -406,6 +406,10 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
406
406
|
.option("--no-config", "Skip lint/format/commit config files (hooks only)")
|
|
407
407
|
.option("--no-ci", "Skip CI quality workflows")
|
|
408
408
|
.option("--no-guidelines", "Skip AI-agent guideline files (AGENTS.md/CLAUDE.md)")
|
|
409
|
+
.option("--only <dirs...>", "Harden only these language roots (e.g. frontend backend)")
|
|
410
|
+
.option("--exclude <globs...>", "Skip language roots matching these globs (e.g. wrappers)")
|
|
411
|
+
.option("--report", "Read-only: show what kj would add/improve, change nothing")
|
|
412
|
+
.option("--interactive", "Adopt the kj standard piece by piece (default keeps yours)")
|
|
409
413
|
.option("--dry-run", "Show what would change without writing")
|
|
410
414
|
.option("--json", "Emit machine-readable JSON")
|
|
411
415
|
.action(async (flags) => {
|
|
@@ -415,6 +419,10 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
415
419
|
config: flags.config !== false,
|
|
416
420
|
ci: flags.ci !== false,
|
|
417
421
|
guidelines: flags.guidelines !== false,
|
|
422
|
+
only: flags.only ?? [],
|
|
423
|
+
exclude: flags.exclude ?? [],
|
|
424
|
+
report: Boolean(flags.report),
|
|
425
|
+
interactive: Boolean(flags.interactive),
|
|
418
426
|
dryRun: Boolean(flags.dryRun),
|
|
419
427
|
json: Boolean(flags.json),
|
|
420
428
|
logger,
|
package/src/cli.js
CHANGED
|
@@ -41,6 +41,21 @@ program
|
|
|
41
41
|
.allowUnknownOption(true)
|
|
42
42
|
.allowExcessArguments(true);
|
|
43
43
|
|
|
44
|
+
// KJC-TSK-0571: the command list is long (30+). Orient a newcomer to the few
|
|
45
|
+
// commands they actually need; the rest are advanced/internal.
|
|
46
|
+
program.addHelpText(
|
|
47
|
+
"after",
|
|
48
|
+
`
|
|
49
|
+
Getting started (the basics — start here):
|
|
50
|
+
kj init Set up Karajan in this project (run once)
|
|
51
|
+
kj run "<task>" Build or fix something end-to-end (coder + review)
|
|
52
|
+
kj plan "<task>" Break a task into user stories first, then run them
|
|
53
|
+
kj doctor Check your environment is ready
|
|
54
|
+
kj harden Add quality git hooks + CI to any repo
|
|
55
|
+
|
|
56
|
+
Everything else is advanced or internal — you rarely call it directly.`
|
|
57
|
+
);
|
|
58
|
+
|
|
44
59
|
registerPipeline(program, { pkgVersion: PKG_VERSION });
|
|
45
60
|
registerPlan(program, { pkgVersion: PKG_VERSION });
|
|
46
61
|
registerRolesSkills(program, { pkgVersion: PKG_VERSION });
|
|
@@ -116,7 +131,6 @@ program.action(async (_opts, command) => {
|
|
|
116
131
|
try {
|
|
117
132
|
await migrateKjToKarajan();
|
|
118
133
|
} catch (err) {
|
|
119
|
-
// eslint-disable-next-line no-console
|
|
120
134
|
console.warn(`\x1b[33m[warn]\x1b[0m home migration skipped: ${err.message}`);
|
|
121
135
|
}
|
|
122
136
|
|
package/src/commands/harden.js
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
|
|
12
|
+
import { compareHarden, formatAdvisoryReport } from "../harden/advisory.js";
|
|
13
|
+
import { interactiveHarden } from "../harden/interactive.js";
|
|
14
|
+
import { createWizard, isTTY } from "../utils/wizard.js";
|
|
12
15
|
import { installConfigsForRoots } from "../harden/config-engine.js";
|
|
13
16
|
import { installGuidelines } from "../harden/guidelines-engine.js";
|
|
14
17
|
import { commandsForLanguage } from "../harden/hook-commands.js";
|
|
@@ -23,6 +26,21 @@ const JS_TEST_CMD = {
|
|
|
23
26
|
mocha: "npx mocha",
|
|
24
27
|
};
|
|
25
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Per-repo harden scope, persisted in package.json `kj.harden` so a repo with
|
|
31
|
+
* sub-tools (e.g. a python wrapper) excludes them once instead of on every
|
|
32
|
+
* invocation (KJC-TSK-0564). CLI flags are merged on top.
|
|
33
|
+
*/
|
|
34
|
+
function readHardenConfig(projectDir) {
|
|
35
|
+
const pkgPath = join(projectDir, "package.json");
|
|
36
|
+
if (!existsSync(pkgPath)) return {};
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(pkgPath, "utf8")).kj?.harden ?? {};
|
|
39
|
+
} catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
/**
|
|
27
45
|
* Resolve the hook's lint/format/test commands for the primary language.
|
|
28
46
|
* Non-JS stacks use their native toolchain (go/ruff/…); JS/TS is built from
|
|
@@ -60,9 +78,46 @@ export async function hardenCommand({
|
|
|
60
78
|
guidelines = true,
|
|
61
79
|
dryRun = false,
|
|
62
80
|
json = false,
|
|
81
|
+
report = false,
|
|
82
|
+
interactive = false,
|
|
83
|
+
only = [],
|
|
84
|
+
exclude = [],
|
|
63
85
|
logger = console,
|
|
64
86
|
} = {}) {
|
|
65
|
-
const
|
|
87
|
+
const repoCfg = readHardenConfig(projectDir);
|
|
88
|
+
const onlyDirs = [...(repoCfg.only ?? []), ...only];
|
|
89
|
+
const excludeDirs = [...(repoCfg.exclude ?? []), ...exclude];
|
|
90
|
+
|
|
91
|
+
// --report is read-only: compare against the kj standard and print/emit the
|
|
92
|
+
// advisory (Advisory B, KJC-TSK-0566), honouring the same scope as an install
|
|
93
|
+
// so it never proposes a root that `kj harden` would skip. Writes nothing.
|
|
94
|
+
if (report) {
|
|
95
|
+
const result = compareHarden({ projectDir, profile, only: onlyDirs, exclude: excludeDirs });
|
|
96
|
+
if (json) logger.info?.(JSON.stringify(result));
|
|
97
|
+
else for (const line of formatAdvisoryReport(result)) logger.info?.(line);
|
|
98
|
+
return { ok: true, report: true, ...result };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --interactive: adopt the kj standard piece by piece, default-safe (keep the
|
|
102
|
+
// user's own). Only meaningful with a TTY — without one (CI, --json) we fall
|
|
103
|
+
// through to the normal seed-if-absent install so nothing breaks (KJC-TSK-0567).
|
|
104
|
+
if (interactive && isTTY() && !json) {
|
|
105
|
+
const wizard = createWizard();
|
|
106
|
+
try {
|
|
107
|
+
return await interactiveHarden({
|
|
108
|
+
projectDir,
|
|
109
|
+
profile,
|
|
110
|
+
only: onlyDirs,
|
|
111
|
+
exclude: excludeDirs,
|
|
112
|
+
ask: (prompt, def) => wizard.confirm(prompt, def),
|
|
113
|
+
logger,
|
|
114
|
+
});
|
|
115
|
+
} finally {
|
|
116
|
+
wizard.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const roots = detectStackRoots(projectDir, { only: onlyDirs, exclude: excludeDirs });
|
|
66
121
|
const cmds = await resolveCmds(projectDir, roots[0]?.language ?? null);
|
|
67
122
|
let result;
|
|
68
123
|
try {
|
|
@@ -102,6 +157,6 @@ export async function hardenCommand({
|
|
|
102
157
|
for (const c of out.configs) logger.info?.(` • ${c.file}: ${c.action}`);
|
|
103
158
|
for (const w of out.workflows) logger.info?.(` • ${w.file}: ${w.action}`);
|
|
104
159
|
for (const g of out.guidelines) logger.info?.(` • ${g.file}: ${g.action}`);
|
|
105
|
-
if (!dryRun) logger.info?.("core.hooksPath set. Verify later with `kj check
|
|
160
|
+
if (!dryRun) logger.info?.("core.hooksPath set. Verify later with `kj check`.");
|
|
106
161
|
return out;
|
|
107
162
|
}
|