Files
upstream-atomic-crm/.claude/hooks/lib/context.mjs
upstream-mirror 93ecc1a6f8
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
2026-09-18 01:48:50 +05:00

171 lines
5.8 KiB
JavaScript

// Build a hook context from a hook's stdin payload: identity, session-scoped
// paths, a logger, and terminal accept/block/fail helpers. The `name` is the
// hook's label; log() and error() prefix every line with `[name]`, and
// accept/block/fail add the `ACCEPT|BLOCK|FAIL` verb on top — so call sites pass
// only the detail and never repeat the name.
import { appendFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
import { basename, dirname, isAbsolute, join } from "node:path";
import { decisionBlock } from "./io.mjs";
import { REPO, TMP_ROOT, sanitizePath } from "./paths.mjs";
import { exec } from "./process.mjs";
import { loadConfig, worktreeProvision } from "./config.mjs";
/**
* @param {string | Record<string, unknown>} input
* @param {string} [name]
* @returns {object}
*/
export function createHookContext(input, name = "hook") {
const i = typeof input === "string" ? JSON.parse(input) : input || {};
const clean = (s) => String(s ?? "").replace(/[\t\n]/g, " ");
const agentId = clean(i.agent_id);
// agentName: env var carries the suffixed runtime name (e.g. developer-TASK-001)
// in PostToolUse/PreToolUse contexts; for SubagentStart the env is the parent's,
// so fall back to i.agent_name which Claude Code populates with the child's name.
const agentName = process.env.CLAUDE_AGENT_NAME || clean(i.agent_name) || "";
const chatSessionId = process.env.CHAT_SESSION_DIR ? basename(process.env.CHAT_SESSION_DIR) : "";
const sessionId =
clean(i.session_id) || process.env.CLAUDE_CODE_SESSION_ID || chatSessionId || "default";
const agentType = clean(i.agent_type) || agentName;
const sessionShort = sessionId.split("-")[0];
const sessionDir = join(TMP_ROOT, sanitizePath(REPO), sessionId);
const logFile = join(sessionDir, "hooks.log");
/**
* @param {...unknown} parts
* @returns {void}
*/
const log = (...parts) => {
try {
mkdirSync(dirname(logFile), { recursive: true });
appendFileSync(
logFile,
`[${new Date().toISOString()}] [${name}] ${parts.join(" ")}\n`,
);
} catch {
// logging must never break a hook
}
};
/**
* Write to stderr (the agent-visible channel), prefixed with `[${name}]` the
* same way log() prefixes each log line. The prefix is added once, so
* multi-line payloads (compiler/test output) stay readable; a trailing
* newline is ensured.
* @param {...unknown} parts
* @returns {void}
*/
const error = (...parts) => {
const text = parts.join(" ");
process.stderr.write(`[${name}] ${text}${text.endsWith("\n") ? "" : "\n"}`);
};
const verdict = (verb, detail) => log(detail ? `${verb} ${detail}` : verb);
// The `npm-link` provisioning builtin: mirror $REPO/node_modules into the
// worktree. See provisionWorktree below for the config-driven dispatcher.
const linkNodeModules = (wt) => {
const target = join(wt, "node_modules");
if (existsSync(target)) return;
const source = join(REPO, "node_modules");
if (!existsSync(source)) return;
// Fast path: hardlink tree (cp -al) when the worktree base shares a
// filesystem with the repo. In dev containers /tmp and /workspaces are
// different mounts (cross-device), so cp -al fails; fall back to a full
// real copy. A symlinked node_modules is NOT an option: vitest browser
// mode (Chromium) hangs on it. See memory worktree-node-modules-provisioning.
if (exec("cp", ["-al", source, target]).status === 0) return;
rmSync(target, { recursive: true, force: true });
if (exec("cp", ["-a", source, target]).status !== 0) {
rmSync(target, { recursive: true, force: true });
throw new Error(`node_modules provisioning failed for ${wt} - cp -al and cp -a both failed`);
}
};
// Config-driven worktree provisioning (config.worktree.provision): the
// `npm-link` builtin (default, mirrors node_modules), `none` (no provisioning,
// for a stack that needs none), or a script path (run with the worktree as its
// one argument). Removes the npm assumption from worktree setup. Fail-closed on
// a provision-script error, like the npm-link builtin.
const provisionWorktree = (wt) => {
let mode = "npm-link";
try {
mode = worktreeProvision(loadConfig());
} catch {
mode = "npm-link";
}
if (mode === "none") {
log(`provision skipped (none) wt=${wt}`);
return;
}
if (mode === "npm-link") {
linkNodeModules(wt);
return;
}
const script = isAbsolute(mode) ? mode : join(REPO, mode);
const r = exec("bash", [script, wt], { cwd: REPO });
if (r.status !== 0) {
throw new Error(
`worktree provision script failed (${script}): ${(r.stderr || r.stdout || "").slice(0, 300)}`,
);
}
log(`provisioned via script ${script} wt=${wt}`);
};
return {
name,
repo: REPO,
sessionId,
agentType,
agentName,
agentId,
sessionShort,
sessionDir,
worktreeBase: sessionDir,
logFile,
ticketsDir: process.env.TICKETS_DIR || join(sessionDir, "tickets"),
log,
error,
/**
* @param {string} [detail]
* @returns {never}
*/
accept(detail) {
verdict("ACCEPT", detail);
process.exit(0);
},
/**
* @param {{ reason: string, log?: string }} options
* @returns {never}
*/
block({ reason, log: detail }) {
verdict("BLOCK", detail);
decisionBlock(reason);
process.exit(0);
},
/**
* @param {string} message
* @param {{ log?: string }} [options]
* @returns {never}
*/
fail(message, { log: detail } = {}) {
verdict("FAIL", detail);
error(message);
process.exit(2);
},
// Worktree provisioning: the config-driven dispatcher plus the npm-link
// builtin it wraps (both defined as closures above).
provisionWorktree,
linkNodeModules,
};
}