163 lines
5.9 KiB
TypeScript
163 lines
5.9 KiB
TypeScript
/**
|
|
* Smoke-check the Cursor install: hooks.json exists + parses + references
|
|
* live hook scripts, and hive-mind-cli answers a `--help` probe. Plus the
|
|
* cursor-specific advisory (spec §5.3 / §6.2): editing `hooks.json` likely
|
|
* needs a Cursor restart for the hooks to take effect.
|
|
*
|
|
* Probe priority for `cli_path`:
|
|
* 1. Explicit `opts.cliPath` (caller override)
|
|
* 2. `cli_path` recorded in `~/.cursor/hive-mind-install.json`
|
|
* 3. Bare `'hive-mind-cli'` on PATH
|
|
*/
|
|
|
|
import { readFile, access } from 'node:fs/promises';
|
|
import { constants, existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { spawn } from 'node:child_process';
|
|
import { createLogger, type Logger } from '@waggle/hive-mind-shim-core';
|
|
import { hasHiveEntries } from '@waggle/hive-mind-hooks-core';
|
|
import { resolvePaths, allHookBasenames, type ResolvePathsOptions } from './paths.js';
|
|
import { cursorRegisterSpec } from './adapter.js';
|
|
|
|
export interface VerifyCheck {
|
|
name: string;
|
|
ok: boolean;
|
|
detail?: string;
|
|
}
|
|
|
|
export interface VerifyResult {
|
|
ok: boolean;
|
|
checks: VerifyCheck[];
|
|
}
|
|
|
|
export interface VerifyOptions extends ResolvePathsOptions {
|
|
logger?: Logger;
|
|
/** Override hive-mind-cli executable name. Default 'hive-mind-cli'. */
|
|
cliPath?: string;
|
|
/** Test hook for spawn. */
|
|
spawnImpl?: typeof spawn;
|
|
}
|
|
|
|
async function fileReadable(p: string): Promise<boolean> {
|
|
try { await access(p, constants.R_OK); return true; } catch { return false; }
|
|
}
|
|
|
|
function isJsPath(p: string): boolean {
|
|
return p.endsWith('.js') || p.endsWith('.mjs') || p.endsWith('.cjs');
|
|
}
|
|
|
|
function probeCliVersion(
|
|
cliPath: string,
|
|
spawnImpl: typeof spawn,
|
|
timeoutMs: number,
|
|
): Promise<{ ok: boolean; output: string }> {
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const command = isJsPath(cliPath) ? process.execPath : cliPath;
|
|
const args = isJsPath(cliPath) ? [cliPath, '--help'] : ['--help'];
|
|
const child = spawnImpl(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
const stdout: Buffer[] = [];
|
|
const stderr: Buffer[] = [];
|
|
const timer = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
try { child.kill('SIGTERM'); } catch { /* gone */ }
|
|
resolve({ ok: false, output: 'timed out probing hive-mind-cli' });
|
|
}, timeoutMs);
|
|
child.stdout?.on('data', (c: Buffer) => stdout.push(c));
|
|
child.stderr?.on('data', (c: Buffer) => stderr.push(c));
|
|
child.on('error', (err) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
resolve({ ok: false, output: err instanceof Error ? err.message : String(err) });
|
|
});
|
|
child.on('exit', (code) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
const out = Buffer.concat(stdout).toString('utf-8').slice(0, 200);
|
|
const err = Buffer.concat(stderr).toString('utf-8').slice(0, 200);
|
|
resolve({ ok: code === 0, output: code === 0 ? out : err });
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function verify(opts: VerifyOptions = {}): Promise<VerifyResult> {
|
|
const log = opts.logger ?? createLogger({ name: 'cursor-hooks/verify' });
|
|
const paths = resolvePaths({
|
|
...(opts.home !== undefined ? { home: opts.home } : {}),
|
|
...(opts.hooksDir !== undefined ? { hooksDir: opts.hooksDir } : { moduleUrl: import.meta.url }),
|
|
});
|
|
const checks: VerifyCheck[] = [];
|
|
|
|
// 1. hooks.json exists and parses.
|
|
if (!existsSync(paths.configPath)) {
|
|
checks.push({ name: 'hooks.json exists', ok: false, detail: paths.configPath });
|
|
return { ok: false, checks };
|
|
}
|
|
checks.push({ name: 'hooks.json exists', ok: true, detail: paths.configPath });
|
|
|
|
let parsed: Record<string, unknown>;
|
|
try {
|
|
const raw: unknown = JSON.parse(await readFile(paths.configPath, 'utf-8'));
|
|
parsed = raw && typeof raw === 'object' && !Array.isArray(raw)
|
|
? (raw as Record<string, unknown>)
|
|
: {};
|
|
checks.push({ name: 'hooks.json parses as JSON', ok: true });
|
|
} catch (err) {
|
|
checks.push({
|
|
name: 'hooks.json parses as JSON',
|
|
ok: false,
|
|
detail: err instanceof Error ? err.message : String(err),
|
|
});
|
|
return { ok: false, checks };
|
|
}
|
|
|
|
// 2. hive-mind entries present.
|
|
checks.push({
|
|
name: 'hooks.json contains hive-mind entries',
|
|
ok: hasHiveEntries(parsed, cursorRegisterSpec),
|
|
});
|
|
|
|
// 3. each hive hook entry points at an existing dist file.
|
|
for (const basename of allHookBasenames()) {
|
|
const scriptPath = join(paths.hooksDir, `${basename}.js`);
|
|
const ok = await fileReadable(scriptPath);
|
|
checks.push({ name: `${basename}.js readable on disk`, ok, detail: scriptPath });
|
|
}
|
|
|
|
// 4. Cursor restart advisory (informational — editing hooks.json needs a
|
|
// Cursor restart for the hooks to take effect; reload semantics are
|
|
// unverified across 1.7.x).
|
|
checks.push({
|
|
name: 'Cursor restart (after install/uninstall)',
|
|
ok: true,
|
|
detail: 'Restart Cursor for hive-mind hooks to take effect.',
|
|
});
|
|
|
|
// 5. hive-mind-cli responds to --help (prefer install-pinned --cli-path).
|
|
let cliPathFromPointer: string | undefined;
|
|
if (existsSync(paths.pointerPath)) {
|
|
try {
|
|
const pointerObj = JSON.parse(await readFile(paths.pointerPath, 'utf-8')) as Record<string, unknown>;
|
|
const pointerCliPath = pointerObj['cli_path'];
|
|
if (typeof pointerCliPath === 'string' && pointerCliPath.length > 0) {
|
|
cliPathFromPointer = pointerCliPath;
|
|
}
|
|
} catch { /* pointer unreadable — fall through */ }
|
|
}
|
|
const cliPath = opts.cliPath ?? cliPathFromPointer ?? 'hive-mind-cli';
|
|
const spawnImpl = opts.spawnImpl ?? spawn;
|
|
const probe = await probeCliVersion(cliPath, spawnImpl, 4000);
|
|
checks.push({
|
|
name: 'hive-mind-cli reachable',
|
|
ok: probe.ok,
|
|
detail: cliPathFromPointer ? `${probe.output} (pinned: ${cliPath})` : probe.output,
|
|
});
|
|
|
|
const ok = checks.every((c) => c.ok);
|
|
log.info('verify complete', { ok, total: checks.length, failed: checks.filter((c) => !c.ok).length });
|
|
return { ok, checks };
|
|
}
|