UNPKG

@kya-os/cli

Version:

CLI for MCP-I setup and management

159 lines (156 loc) 6.14 kB
import { execFile } from "child_process"; import { promisify } from "util"; import { existsSync, mkdirSync, readFileSync, realpathSync } from "fs"; import { isAbsolute, join, resolve, sep } from "path"; import { writeFileAtomicSafe } from "./fs-safe.js"; const execFileAsync = promisify(execFile); /** Resolve symlinks when the path exists; fall back to the input otherwise. */ function realpathIfExists(path) { try { return realpathSync(path); } catch { return resolve(path); } } export const HOOK_MARKER = "# mcpi-dco-hook"; async function git(args, cwd) { const { stdout } = await execFileAsync("git", args, { cwd, encoding: "utf8", }); return stdout.trim(); } export async function isGitRepo(cwd) { try { return (await git(["rev-parse", "--is-inside-work-tree"], cwd)) === "true"; } catch { return false; } } export async function getGitConfigValue(key, cwd, scope) { const args = ["config"]; if (scope) { args.push(`--${scope}`); } args.push("--get", key); try { return await git(args, cwd); } catch { return null; } } export async function setGitConfig(entries, options) { const scope = options.global ? "--global" : "--local"; for (const [key, value] of Object.entries(entries)) { await git(["config", scope, key, value], options.cwd); } } /** * Detect pre-existing signing configuration that our setup would silently * override, e.g. a user who already GPG-signs commits. Only differing values * count; matching values mean a previous run and are fine to reapply. * * `scope` selects which config layer to read: the default (undefined) reads * the effective value; "local" reads only repo-local entries, which is how a * `--global` write detects repo-local values that would still shadow it. */ export async function detectSigningConflicts(cwd, desired, scope) { const conflicts = []; for (const [key, desiredValue] of Object.entries(desired)) { const current = await getGitConfigValue(key, cwd, scope); if (current !== null && current !== desiredValue) { conflicts.push({ key, current, desired: desiredValue }); } } return conflicts; } export async function getHooksDir(cwd) { const hooksPath = await getGitConfigValue("core.hooksPath", cwd); if (hooksPath) { // core.hooksPath is relative to the working-tree root, not cwd. const topLevel = await git(["rev-parse", "--show-toplevel"], cwd); const dir = isAbsolute(hooksPath) ? hooksPath : resolve(topLevel, hooksPath); return { dir, source: "core.hooksPath" }; } // --git-path resolves "hooks" against the COMMON git dir (where git actually // runs hooks from, which matters in linked worktrees where the per-worktree // git dir differs), and --path-format=absolute removes the cwd-relative // ambiguity that made a bare --git-path point at the wrong directory from a // subdirectory. Requires git >= 2.31, well below the >= 2.34 that SSH commit // signing (the point of this feature) already needs. const hooksDir = await git(["rev-parse", "--path-format=absolute", "--git-path", "hooks"], cwd); return { dir: hooksDir, source: "default" }; } function shQuote(value) { return `'${value.replace(/'/g, "'\\''")}'`; } export function buildTrailerHook(human, agent) { const signedOffBy = shQuote(`Signed-off-by: ${human.name} <${human.email}>`); const coAuthoredBy = shQuote(`Co-authored-by: ${agent.name} <${agent.email}>`); return `#!/bin/sh ${HOOK_MARKER} v1 # Managed by \`mcpi dco setup\`. Appends the DCO sign-off and agent # attribution trailers to commit messages when they are missing. COMMIT_MSG_FILE="$1" COMMIT_SOURCE="$2" case "$COMMIT_SOURCE" in merge|squash) exit 0 ;; esac git interpret-trailers --in-place --if-exists addIfDifferent \\ --trailer ${signedOffBy} \\ --trailer ${coAuthoredBy} \\ "$COMMIT_MSG_FILE" `; } /** * Install (or refresh) the prepare-commit-msg trailer hook. A hook we did not * write is never modified, and a core.hooksPath pointing outside the repo is * never written to (a crafted repo config must not make us create files at * arbitrary locations); the caller falls back to instruction-based trailers * in both cases. */ export async function installTrailerHook(cwd, human, agent, options = {}) { const { dir, source } = await getHooksDir(cwd); const hookPath = join(dir, "prepare-commit-msg"); if (source === "core.hooksPath") { const topLevel = realpathIfExists(await git(["rev-parse", "--show-toplevel"], cwd)); const gitDir = realpathIfExists(await git(["rev-parse", "--absolute-git-dir"], cwd)); const resolvedDir = realpathIfExists(dir); const inside = (parent) => resolvedDir === parent || resolvedDir.startsWith(parent + sep); if (!inside(topLevel) && !inside(gitDir)) { return { result: "unsafe-hooks-path", path: hookPath }; } } const content = buildTrailerHook(human, agent); if (existsSync(hookPath)) { const existing = readFileSync(hookPath, "utf-8"); if (!existing.includes(HOOK_MARKER)) { return { result: "foreign-hook", path: hookPath }; } if (existing === content) { return { result: "unchanged", path: hookPath }; } if (options.dryRun) { return { result: "would-change", path: hookPath }; } // Symlink-safe: hookPath lives under a possibly-untrusted repo, and a // planted symlink there would otherwise be followed. 0755 so git can // execute the hook. writeFileAtomicSafe(hookPath, content, 0o755); return { result: "updated", path: hookPath }; } if (options.dryRun) { return { result: "would-change", path: hookPath }; } if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileAtomicSafe(hookPath, content, 0o755); return { result: "installed", path: hookPath }; } //# sourceMappingURL=git-config.js.map