mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-09 07:29:51 +00:00
234 lines
9.4 KiB
TypeScript
234 lines
9.4 KiB
TypeScript
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { isAbsolute, join, relative } from 'node:path';
|
|
import { logger } from '@/ui/logger';
|
|
import { configuration } from '@/configuration';
|
|
|
|
const PLUGIN_FILENAME = 'hapi-hook.ts';
|
|
const PACKAGE_JSON_FILENAME = 'package.json';
|
|
const PLUGIN_PACKAGE = '@opencode-ai/plugin';
|
|
// Pinned to the major version HAPI has been validated against (opencode
|
|
// 1.14.x). Using '*' would let an unrelated registry change pull a moving
|
|
// target into a code-execution path (the plugin runtime executes inside
|
|
// opencode with the session hook URL + token in scope).
|
|
const PLUGIN_PACKAGE_VERSION = '^1.14.0';
|
|
|
|
function buildPluginSource(hookUrl: string, token: string): string {
|
|
const escapedUrl = JSON.stringify(hookUrl);
|
|
const escapedToken = JSON.stringify(token);
|
|
|
|
return [
|
|
'// Generated by HAPI. Do not edit manually.',
|
|
'',
|
|
`const DEFAULT_HOOK_URL = ${escapedUrl};`,
|
|
`const DEFAULT_HOOK_TOKEN = ${escapedToken};`,
|
|
'const HOOK_URL = process.env.HAPI_OPENCODE_HOOK_URL || DEFAULT_HOOK_URL;',
|
|
'const HOOK_TOKEN = process.env.HAPI_OPENCODE_HOOK_TOKEN || DEFAULT_HOOK_TOKEN;',
|
|
'',
|
|
'const EVENT_NAMES = new Set([',
|
|
" 'message.updated',",
|
|
" 'message.part.updated',",
|
|
" 'permission.updated',",
|
|
" 'permission.asked',",
|
|
" 'permission.replied',",
|
|
" 'session.created',",
|
|
" 'session.updated',",
|
|
" 'tool.execute.before',",
|
|
" 'tool.execute.after',",
|
|
']);',
|
|
'',
|
|
'function pickString(value) {',
|
|
" return typeof value === 'string' && value.length > 0 ? value : null;",
|
|
'}',
|
|
'',
|
|
'function extractSessionId(value) {',
|
|
" if (!value || typeof value !== 'object') return null;",
|
|
' const record = value;',
|
|
' const direct = (',
|
|
' pickString(record.sessionId)',
|
|
' || pickString(record.sessionID)',
|
|
' || pickString(record.session_id)',
|
|
' || (record.session && pickString(record.session.id))',
|
|
' );',
|
|
' if (direct) return direct;',
|
|
' if (record.part && typeof record.part === \'object\') {',
|
|
' const nested = extractSessionId(record.part);',
|
|
' if (nested) return nested;',
|
|
' }',
|
|
' if (record.info && typeof record.info === \'object\') {',
|
|
' const nested = extractSessionId(record.info);',
|
|
' if (nested) return nested;',
|
|
' }',
|
|
' return null;',
|
|
'}',
|
|
'',
|
|
'function extractSessionIdFromEvent(eventName, payload) {',
|
|
' const direct = extractSessionId(payload);',
|
|
' if (direct) return direct;',
|
|
' if (!payload || typeof payload !== \'object\') return null;',
|
|
' const record = payload;',
|
|
' if (record.info && typeof record.info === \'object\') {',
|
|
' const fromInfo = extractSessionId(record.info);',
|
|
' if (fromInfo) return fromInfo;',
|
|
' if (eventName.startsWith(\'session.\')) {',
|
|
' return pickString(record.info.id);',
|
|
' }',
|
|
' }',
|
|
' return null;',
|
|
'}',
|
|
'',
|
|
'async function sendHook(eventName, payload, sessionId) {',
|
|
' if (!HOOK_URL || !HOOK_TOKEN) {',
|
|
' return;',
|
|
' }',
|
|
'',
|
|
' const body = JSON.stringify({',
|
|
' event: eventName,',
|
|
' payload,',
|
|
' sessionId',
|
|
' });',
|
|
'',
|
|
' try {',
|
|
' await fetch(HOOK_URL, {',
|
|
" method: 'POST',",
|
|
' headers: {',
|
|
" 'Content-Type': 'application/json',",
|
|
" 'x-hapi-hook-token': HOOK_TOKEN",
|
|
' },',
|
|
' body',
|
|
' });',
|
|
' } catch {',
|
|
' // Ignore hook errors to avoid disrupting OpenCode',
|
|
' }',
|
|
'}',
|
|
'',
|
|
'export const HapiHookPlugin = async () => {',
|
|
' return {',
|
|
' event: async ({ event }) => {',
|
|
' if (!event || typeof event.type !== \'string\') {',
|
|
' return;',
|
|
' }',
|
|
' if (EVENT_NAMES.size > 0 && !EVENT_NAMES.has(event.type)) {',
|
|
' return;',
|
|
' }',
|
|
' const sessionId = extractSessionIdFromEvent(event.type, event.properties);',
|
|
' await sendHook(event.type, event.properties, sessionId);',
|
|
' }',
|
|
' };',
|
|
'};',
|
|
''
|
|
].join('\n');
|
|
}
|
|
|
|
function resolvePluginDir(rootPath: string): string {
|
|
return join(rootPath, 'plugins');
|
|
}
|
|
|
|
/**
|
|
* `rootPath` is a HAPI-managed directory only when it lives under
|
|
* `configuration.happyHomeDir` (the default `OPENCODE_CONFIG_DIR` we
|
|
* synthesize per-session). If a user has exported `OPENCODE_CONFIG_DIR`
|
|
* pointing at e.g. their own `~/.config/opencode`, we must not pollute it
|
|
* with a placeholder `package.json`.
|
|
*/
|
|
function isHapiManagedDir(rootPath: string): boolean {
|
|
const home = configuration.happyHomeDir;
|
|
if (!home) {
|
|
return false;
|
|
}
|
|
const rel = relative(home, rootPath);
|
|
// `relative` returns a path that does NOT start with '..' and is not
|
|
// absolute when rootPath is inside home. On Windows a cross-volume
|
|
// input (e.g. home=`C:\\hapi`, rootPath=`D:\\…`) makes `relative`
|
|
// return the absolute `D:\\…` verbatim — `startsWith('/')` would miss
|
|
// that, so use `isAbsolute` which covers both POSIX `/` and win32
|
|
// `<letter>:\\`.
|
|
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
}
|
|
|
|
function hasDeclaredPluginPackage(packageJsonPath: string): boolean {
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as {
|
|
dependencies?: Record<string, string>;
|
|
devDependencies?: Record<string, string>;
|
|
};
|
|
return Boolean(parsed.dependencies?.[PLUGIN_PACKAGE] ?? parsed.devDependencies?.[PLUGIN_PACKAGE]);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function buildMinimalPackageJson(): string {
|
|
return `${JSON.stringify({
|
|
dependencies: { [PLUGIN_PACKAGE]: PLUGIN_PACKAGE_VERSION }
|
|
}, null, 2)}\n`;
|
|
}
|
|
|
|
/**
|
|
* Ensure the opencode runtime can resolve `@opencode-ai/plugin` from our
|
|
* isolated config dir. Since opencode 1.14.x the plugin loader is a separate
|
|
* npm package, and `<configDir>/plugins/*.ts` files are only evaluated when
|
|
* that package is resolvable from `<configDir>`. HAPI's per-session
|
|
* OPENCODE_CONFIG_DIR is otherwise an empty directory, so plugin discovery
|
|
* never even attempts to load hapi-hook.ts.
|
|
*
|
|
* Writing a minimal `package.json` declaring the dependency is enough:
|
|
* opencode itself materializes `node_modules` and `package-lock.json` on
|
|
* the next launch (the same install path `opencode plugin install`
|
|
* follows). No symlinks, no install spawn, no cross-platform branches —
|
|
* just a one-line dependency declaration.
|
|
*
|
|
* Guards against three failure modes:
|
|
* - **Non-managed dir**: when the caller pointed OPENCODE_CONFIG_DIR at a
|
|
* directory outside `happyHomeDir` (e.g. the user's global opencode
|
|
* config) we leave it alone — that filesystem is not ours to mutate.
|
|
* - **Existing package.json missing our dep**: parse it and only short-
|
|
* circuit when `@opencode-ai/plugin` is already declared. Otherwise
|
|
* overwrite the placeholder so plugin discovery actually works.
|
|
* - **Write failure**: log and continue; the scanner channel restored in
|
|
* upstream #589 still carries messages, so a non-writable cfg dir is
|
|
* degraded but not fatal.
|
|
*/
|
|
function ensurePluginRuntime(rootPath: string): void {
|
|
if (!isHapiManagedDir(rootPath)) {
|
|
logger.debug(`[opencode-hook] Skipping plugin runtime materialization for non-HAPI dir: ${rootPath}`);
|
|
return;
|
|
}
|
|
|
|
const packageJsonPath = join(rootPath, PACKAGE_JSON_FILENAME);
|
|
if (existsSync(packageJsonPath)) {
|
|
if (hasDeclaredPluginPackage(packageJsonPath)) {
|
|
// Existing file already declares the plugin (likely opencode-
|
|
// installed with a matching lock file). Leave it untouched.
|
|
return;
|
|
}
|
|
logger.debug(`[opencode-hook] package.json exists at ${packageJsonPath} but does not declare ${PLUGIN_PACKAGE}; overwriting placeholder.`);
|
|
}
|
|
|
|
try {
|
|
writeFileSync(packageJsonPath, buildMinimalPackageJson(), 'utf-8');
|
|
} catch (error) {
|
|
logger.warn(`[opencode-hook] Failed to materialize ${packageJsonPath}; the hook plugin channel may stay inert. Storage scanner remains as fallback. Error: ${(error as Error).message}`);
|
|
}
|
|
}
|
|
|
|
export function ensureOpencodeHookPlugin(rootPath: string, hookUrl: string, token: string): string {
|
|
const pluginDir = resolvePluginDir(rootPath);
|
|
mkdirSync(pluginDir, { recursive: true });
|
|
ensurePluginRuntime(rootPath);
|
|
|
|
const pluginPath = join(pluginDir, PLUGIN_FILENAME);
|
|
const nextSource = buildPluginSource(hookUrl, token);
|
|
|
|
try {
|
|
const current = readFileSync(pluginPath, 'utf-8');
|
|
if (current === nextSource) {
|
|
return pluginPath;
|
|
}
|
|
} catch {
|
|
// Ignore missing or unreadable file.
|
|
}
|
|
|
|
writeFileSync(pluginPath, nextSource, 'utf-8');
|
|
return pluginPath;
|
|
}
|