feat: add internal hooks system
This commit is contained in:
225
src/hooks/hooks-status.ts
Normal file
225
src/hooks/hooks-status.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { CONFIG_DIR } from "../utils.js";
|
||||
import {
|
||||
hasBinary,
|
||||
isConfigPathTruthy,
|
||||
resolveConfigPath,
|
||||
resolveHookConfig,
|
||||
} from "./config.js";
|
||||
import type {
|
||||
HookEligibilityContext,
|
||||
HookEntry,
|
||||
HookInstallSpec,
|
||||
} from "./types.js";
|
||||
import { loadWorkspaceHookEntries } from "./workspace.js";
|
||||
|
||||
export type HookStatusConfigCheck = {
|
||||
path: string;
|
||||
value: unknown;
|
||||
satisfied: boolean;
|
||||
};
|
||||
|
||||
export type HookInstallOption = {
|
||||
id: string;
|
||||
kind: HookInstallSpec["kind"];
|
||||
label: string;
|
||||
bins: string[];
|
||||
};
|
||||
|
||||
export type HookStatusEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
source: string;
|
||||
filePath: string;
|
||||
baseDir: string;
|
||||
handlerPath: string;
|
||||
hookKey: string;
|
||||
emoji?: string;
|
||||
homepage?: string;
|
||||
events: string[];
|
||||
always: boolean;
|
||||
disabled: boolean;
|
||||
eligible: boolean;
|
||||
requirements: {
|
||||
bins: string[];
|
||||
anyBins: string[];
|
||||
env: string[];
|
||||
config: string[];
|
||||
os: string[];
|
||||
};
|
||||
missing: {
|
||||
bins: string[];
|
||||
anyBins: string[];
|
||||
env: string[];
|
||||
config: string[];
|
||||
os: string[];
|
||||
};
|
||||
configChecks: HookStatusConfigCheck[];
|
||||
install: HookInstallOption[];
|
||||
};
|
||||
|
||||
export type HookStatusReport = {
|
||||
workspaceDir: string;
|
||||
managedHooksDir: string;
|
||||
hooks: HookStatusEntry[];
|
||||
};
|
||||
|
||||
function resolveHookKey(entry: HookEntry): string {
|
||||
return entry.clawdbot?.hookKey ?? entry.hook.name;
|
||||
}
|
||||
|
||||
function normalizeInstallOptions(entry: HookEntry): HookInstallOption[] {
|
||||
const install = entry.clawdbot?.install ?? [];
|
||||
if (install.length === 0) return [];
|
||||
|
||||
// For hooks, we just list all install options
|
||||
return install.map((spec, index) => {
|
||||
const id = (spec.id ?? `${spec.kind}-${index}`).trim();
|
||||
const bins = spec.bins ?? [];
|
||||
let label = (spec.label ?? "").trim();
|
||||
|
||||
if (!label) {
|
||||
if (spec.kind === "bundled") {
|
||||
label = "Bundled with Clawdbot";
|
||||
} else if (spec.kind === "npm" && spec.package) {
|
||||
label = `Install ${spec.package} (npm)`;
|
||||
} else if (spec.kind === "git" && spec.repository) {
|
||||
label = `Install from ${spec.repository}`;
|
||||
} else {
|
||||
label = "Run installer";
|
||||
}
|
||||
}
|
||||
|
||||
return { id, kind: spec.kind, label, bins };
|
||||
});
|
||||
}
|
||||
|
||||
function buildHookStatus(
|
||||
entry: HookEntry,
|
||||
config?: ClawdbotConfig,
|
||||
eligibility?: HookEligibilityContext,
|
||||
): HookStatusEntry {
|
||||
const hookKey = resolveHookKey(entry);
|
||||
const hookConfig = resolveHookConfig(config, hookKey);
|
||||
const disabled = hookConfig?.enabled === false;
|
||||
const always = entry.clawdbot?.always === true;
|
||||
const emoji = entry.clawdbot?.emoji ?? entry.frontmatter.emoji;
|
||||
const homepageRaw =
|
||||
entry.clawdbot?.homepage ??
|
||||
entry.frontmatter.homepage ??
|
||||
entry.frontmatter.website ??
|
||||
entry.frontmatter.url;
|
||||
const homepage = homepageRaw?.trim() ? homepageRaw.trim() : undefined;
|
||||
const events = entry.clawdbot?.events ?? [];
|
||||
|
||||
const requiredBins = entry.clawdbot?.requires?.bins ?? [];
|
||||
const requiredAnyBins = entry.clawdbot?.requires?.anyBins ?? [];
|
||||
const requiredEnv = entry.clawdbot?.requires?.env ?? [];
|
||||
const requiredConfig = entry.clawdbot?.requires?.config ?? [];
|
||||
const requiredOs = entry.clawdbot?.os ?? [];
|
||||
|
||||
const missingBins = requiredBins.filter((bin) => {
|
||||
if (hasBinary(bin)) return false;
|
||||
if (eligibility?.remote?.hasBin?.(bin)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const missingAnyBins =
|
||||
requiredAnyBins.length > 0 &&
|
||||
!(
|
||||
requiredAnyBins.some((bin) => hasBinary(bin)) ||
|
||||
eligibility?.remote?.hasAnyBin?.(requiredAnyBins)
|
||||
)
|
||||
? requiredAnyBins
|
||||
: [];
|
||||
|
||||
const missingOs =
|
||||
requiredOs.length > 0 &&
|
||||
!requiredOs.includes(process.platform) &&
|
||||
!eligibility?.remote?.platforms?.some((platform) => requiredOs.includes(platform))
|
||||
? requiredOs
|
||||
: [];
|
||||
|
||||
const missingEnv: string[] = [];
|
||||
for (const envName of requiredEnv) {
|
||||
if (process.env[envName]) continue;
|
||||
if (hookConfig?.env?.[envName]) continue;
|
||||
missingEnv.push(envName);
|
||||
}
|
||||
|
||||
const configChecks: HookStatusConfigCheck[] = requiredConfig.map((pathStr) => {
|
||||
const value = resolveConfigPath(config, pathStr);
|
||||
const satisfied = isConfigPathTruthy(config, pathStr);
|
||||
return { path: pathStr, value, satisfied };
|
||||
});
|
||||
|
||||
const missingConfig = configChecks
|
||||
.filter((check) => !check.satisfied)
|
||||
.map((check) => check.path);
|
||||
|
||||
const missing = always
|
||||
? { bins: [], anyBins: [], env: [], config: [], os: [] }
|
||||
: {
|
||||
bins: missingBins,
|
||||
anyBins: missingAnyBins,
|
||||
env: missingEnv,
|
||||
config: missingConfig,
|
||||
os: missingOs,
|
||||
};
|
||||
|
||||
const eligible =
|
||||
!disabled &&
|
||||
(always ||
|
||||
(missing.bins.length === 0 &&
|
||||
missing.anyBins.length === 0 &&
|
||||
missing.env.length === 0 &&
|
||||
missing.config.length === 0 &&
|
||||
missing.os.length === 0));
|
||||
|
||||
return {
|
||||
name: entry.hook.name,
|
||||
description: entry.hook.description,
|
||||
source: entry.hook.source,
|
||||
filePath: entry.hook.filePath,
|
||||
baseDir: entry.hook.baseDir,
|
||||
handlerPath: entry.hook.handlerPath,
|
||||
hookKey,
|
||||
emoji,
|
||||
homepage,
|
||||
events,
|
||||
always,
|
||||
disabled,
|
||||
eligible,
|
||||
requirements: {
|
||||
bins: requiredBins,
|
||||
anyBins: requiredAnyBins,
|
||||
env: requiredEnv,
|
||||
config: requiredConfig,
|
||||
os: requiredOs,
|
||||
},
|
||||
missing,
|
||||
configChecks,
|
||||
install: normalizeInstallOptions(entry),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorkspaceHookStatus(
|
||||
workspaceDir: string,
|
||||
opts?: {
|
||||
config?: ClawdbotConfig;
|
||||
managedHooksDir?: string;
|
||||
entries?: HookEntry[];
|
||||
eligibility?: HookEligibilityContext;
|
||||
},
|
||||
): HookStatusReport {
|
||||
const managedHooksDir = opts?.managedHooksDir ?? path.join(CONFIG_DIR, "hooks");
|
||||
const hookEntries = opts?.entries ?? loadWorkspaceHookEntries(workspaceDir, opts);
|
||||
|
||||
return {
|
||||
workspaceDir,
|
||||
managedHooksDir,
|
||||
hooks: hookEntries.map((entry) => buildHookStatus(entry, opts?.config, opts?.eligibility)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user