diff --git a/src/hooks/install.ts b/src/hooks/install.ts index a351bd79e..13edb40c1 100644 --- a/src/hooks/install.ts +++ b/src/hooks/install.ts @@ -9,6 +9,7 @@ import { resolveArchiveKind, resolvePackedRootDir, } from "../infra/archive.js"; +import { installPackageDir } from "../infra/install-package-dir.js"; import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; @@ -214,48 +215,20 @@ async function installHookPackageFromDir(params: { }; } - logger.info?.(`Installing to ${targetDir}…`); - let backupDir: string | null = null; - if (mode === "update" && (await fileExists(targetDir))) { - backupDir = `${targetDir}.backup-${Date.now()}`; - await fs.rename(targetDir, backupDir); - } - - try { - await fs.cp(params.packageDir, targetDir, { recursive: true }); - } catch (err) { - if (backupDir) { - await fs.rm(targetDir, { recursive: true, force: true }).catch(() => undefined); - await fs.rename(backupDir, targetDir).catch(() => undefined); - } - return { ok: false, error: `failed to copy hook pack: ${String(err)}` }; - } - const deps = manifest.dependencies ?? {}; const hasDeps = Object.keys(deps).length > 0; - if (hasDeps) { - logger.info?.("Installing hook pack dependencies…"); - const npmRes = await runCommandWithTimeout( - ["npm", "install", "--omit=dev", "--silent", "--ignore-scripts"], - { - timeoutMs: Math.max(timeoutMs, 300_000), - cwd: targetDir, - }, - ); - if (npmRes.code !== 0) { - if (backupDir) { - await fs.rm(targetDir, { recursive: true, force: true }).catch(() => undefined); - await fs.rename(backupDir, targetDir).catch(() => undefined); - } - return { - ok: false, - error: `npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`, - }; - } - } - - if (backupDir) { - await fs.rm(backupDir, { recursive: true, force: true }).catch(() => undefined); + const installRes = await installPackageDir({ + sourceDir: params.packageDir, + targetDir, + mode, + timeoutMs, + logger, + copyErrorPrefix: "failed to copy hook pack", + hasDeps, + depsLogMessage: "Installing hook pack dependencies…", + }); + if (!installRes.ok) { + return installRes; } return { diff --git a/src/infra/install-package-dir.ts b/src/infra/install-package-dir.ts new file mode 100644 index 000000000..ac397f0fb --- /dev/null +++ b/src/infra/install-package-dir.ts @@ -0,0 +1,68 @@ +import fs from "node:fs/promises"; +import { runCommandWithTimeout } from "../process/exec.js"; +import { fileExists } from "./archive.js"; + +export async function installPackageDir(params: { + sourceDir: string; + targetDir: string; + mode: "install" | "update"; + timeoutMs: number; + logger?: { info?: (message: string) => void }; + copyErrorPrefix: string; + hasDeps: boolean; + depsLogMessage: string; + afterCopy?: () => void | Promise; +}): Promise<{ ok: true } | { ok: false; error: string }> { + params.logger?.info?.(`Installing to ${params.targetDir}…`); + let backupDir: string | null = null; + if (params.mode === "update" && (await fileExists(params.targetDir))) { + backupDir = `${params.targetDir}.backup-${Date.now()}`; + await fs.rename(params.targetDir, backupDir); + } + + const rollback = async () => { + if (!backupDir) { + return; + } + await fs.rm(params.targetDir, { recursive: true, force: true }).catch(() => undefined); + await fs.rename(backupDir, params.targetDir).catch(() => undefined); + }; + + try { + await fs.cp(params.sourceDir, params.targetDir, { recursive: true }); + } catch (err) { + await rollback(); + return { ok: false, error: `${params.copyErrorPrefix}: ${String(err)}` }; + } + + try { + await params.afterCopy?.(); + } catch (err) { + await rollback(); + return { ok: false, error: `post-copy validation failed: ${String(err)}` }; + } + + if (params.hasDeps) { + params.logger?.info?.(params.depsLogMessage); + const npmRes = await runCommandWithTimeout( + ["npm", "install", "--omit=dev", "--silent", "--ignore-scripts"], + { + timeoutMs: Math.max(params.timeoutMs, 300_000), + cwd: params.targetDir, + }, + ); + if (npmRes.code !== 0) { + await rollback(); + return { + ok: false, + error: `npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`, + }; + } + } + + if (backupDir) { + await fs.rm(backupDir, { recursive: true, force: true }).catch(() => undefined); + } + + return { ok: true }; +} diff --git a/src/plugins/install.ts b/src/plugins/install.ts index fcd5867bf..287450f59 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -9,6 +9,7 @@ import { resolveArchiveKind, resolvePackedRootDir, } from "../infra/archive.js"; +import { installPackageDir } from "../infra/install-package-dir.js"; import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { runCommandWithTimeout } from "../process/exec.js"; import * as skillScanner from "../security/skill-scanner.js"; @@ -248,58 +249,32 @@ async function installPluginFromPackageDir(params: { }; } - logger.info?.(`Installing to ${targetDir}…`); - let backupDir: string | null = null; - if (mode === "update" && (await fileExists(targetDir))) { - backupDir = `${targetDir}.backup-${Date.now()}`; - await fs.rename(targetDir, backupDir); - } - try { - await fs.cp(params.packageDir, targetDir, { recursive: true }); - } catch (err) { - if (backupDir) { - await fs.rm(targetDir, { recursive: true, force: true }).catch(() => undefined); - await fs.rename(backupDir, targetDir).catch(() => undefined); - } - return { ok: false, error: `failed to copy plugin: ${String(err)}` }; - } - - for (const entry of extensions) { - const resolvedEntry = path.resolve(targetDir, entry); - if (!isPathInside(targetDir, resolvedEntry)) { - logger.warn?.(`extension entry escapes plugin directory: ${entry}`); - continue; - } - if (!(await fileExists(resolvedEntry))) { - logger.warn?.(`extension entry not found: ${entry}`); - } - } - const deps = manifest.dependencies ?? {}; const hasDeps = Object.keys(deps).length > 0; - if (hasDeps) { - logger.info?.("Installing plugin dependencies…"); - const npmRes = await runCommandWithTimeout( - ["npm", "install", "--omit=dev", "--silent", "--ignore-scripts"], - { - timeoutMs: Math.max(timeoutMs, 300_000), - cwd: targetDir, - }, - ); - if (npmRes.code !== 0) { - if (backupDir) { - await fs.rm(targetDir, { recursive: true, force: true }).catch(() => undefined); - await fs.rename(backupDir, targetDir).catch(() => undefined); + const installRes = await installPackageDir({ + sourceDir: params.packageDir, + targetDir, + mode, + timeoutMs, + logger, + copyErrorPrefix: "failed to copy plugin", + hasDeps, + depsLogMessage: "Installing plugin dependencies…", + afterCopy: async () => { + for (const entry of extensions) { + const resolvedEntry = path.resolve(targetDir, entry); + if (!isPathInside(targetDir, resolvedEntry)) { + logger.warn?.(`extension entry escapes plugin directory: ${entry}`); + continue; + } + if (!(await fileExists(resolvedEntry))) { + logger.warn?.(`extension entry not found: ${entry}`); + } } - return { - ok: false, - error: `npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`, - }; - } - } - - if (backupDir) { - await fs.rm(backupDir, { recursive: true, force: true }).catch(() => undefined); + }, + }); + if (!installRes.ok) { + return installRes; } return {