Files
hapi/cli/src/opencode/utils/hookPlugin.ts
T

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;
}