@kya-os/cli
Version:
CLI for MCP-I setup and management
159 lines (156 loc) • 6.14 kB
JavaScript
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