From 07888bee3484faaa8f184bb30e549586796f9be2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 18:36:28 +0000 Subject: [PATCH] refactor: share install flows across hooks and plugins --- src/hooks/install.ts | 141 ++++++++++-------------------- src/infra/install-flow.ts | 61 +++++++++++++ src/infra/install-mode-options.ts | 42 +++++++++ src/infra/npm-pack-install.ts | 52 +++++++++++ src/plugins/install.ts | 136 +++++++++------------------- 5 files changed, 243 insertions(+), 189 deletions(-) create mode 100644 src/infra/install-flow.ts create mode 100644 src/infra/install-mode-options.ts diff --git a/src/hooks/install.ts b/src/hooks/install.ts index a394d92d570..c6032b8247e 100644 --- a/src/hooks/install.ts +++ b/src/hooks/install.ts @@ -1,22 +1,23 @@ import fs from "node:fs/promises"; import path from "node:path"; import { MANIFEST_KEY } from "../compat/legacy-names.js"; +import { fileExists, readJsonFile, resolveArchiveKind } from "../infra/archive.js"; +import { resolveExistingInstallPath, withExtractedArchiveRoot } from "../infra/install-flow.js"; import { - extractArchive, - fileExists, - readJsonFile, - resolveArchiveKind, - resolvePackedRootDir, -} from "../infra/archive.js"; + resolveInstallModeOptions, + resolveTimedInstallModeOptions, +} from "../infra/install-mode-options.js"; import { installPackageDir } from "../infra/install-package-dir.js"; import { resolveSafeInstallDir, unscopedPackageName } from "../infra/install-safe-path.js"; import { type NpmIntegrityDrift, type NpmSpecResolution, resolveArchiveSourcePath, - withTempDir, } from "../infra/install-source-utils.js"; -import { installFromNpmSpecArchive } from "../infra/npm-pack-install.js"; +import { + finalizeNpmSpecArchiveInstall, + installFromNpmSpecArchiveWithInstaller, +} from "../infra/npm-pack-install.js"; import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { isPathInside, isPathInsideWithRealpath } from "../security/scan-paths.js"; import { CONFIG_DIR, resolveUserPath } from "../utils.js"; @@ -96,30 +97,6 @@ async function ensureOpenClawHooks(manifest: HookPackageManifest) { return list; } -function resolveHookInstallModeOptions(params: { - logger?: HookInstallLogger; - mode?: "install" | "update"; - dryRun?: boolean; -}): { logger: HookInstallLogger; mode: "install" | "update"; dryRun: boolean } { - return { - logger: params.logger ?? defaultLogger, - mode: params.mode ?? "install", - dryRun: params.dryRun ?? false, - }; -} - -function resolveTimedHookInstallModeOptions(params: { - logger?: HookInstallLogger; - timeoutMs?: number; - mode?: "install" | "update"; - dryRun?: boolean; -}): { logger: HookInstallLogger; timeoutMs: number; mode: "install" | "update"; dryRun: boolean } { - return { - ...resolveHookInstallModeOptions(params), - timeoutMs: params.timeoutMs ?? 120_000, - }; -} - async function resolveInstallTargetDir( id: string, hooksDir?: string, @@ -173,7 +150,7 @@ async function installHookPackageFromDir(params: { dryRun?: boolean; expectedHookPackId?: string; }): Promise { - const { logger, timeoutMs, mode, dryRun } = resolveTimedHookInstallModeOptions(params); + const { logger, timeoutMs, mode, dryRun } = resolveTimedInstallModeOptions(params, defaultLogger); const manifestPath = path.join(params.packageDir, "package.json"); if (!(await fileExists(manifestPath))) { @@ -283,7 +260,7 @@ async function installHookFromDir(params: { dryRun?: boolean; expectedHookPackId?: string; }): Promise { - const { logger, mode, dryRun } = resolveHookInstallModeOptions(params); + const { logger, mode, dryRun } = resolveInstallModeOptions(params, defaultLogger); await validateHookDir(params.hookDir); const hookName = await resolveHookNameFromDir(params.hookDir); @@ -353,45 +330,34 @@ export async function installHooksFromArchive(params: { } const archivePath = archivePathResult.path; - return await withTempDir("openclaw-hook-", async (tmpDir) => { - const extractDir = path.join(tmpDir, "extract"); - await fs.mkdir(extractDir, { recursive: true }); + return await withExtractedArchiveRoot({ + archivePath, + tempDirPrefix: "openclaw-hook-", + timeoutMs, + logger, + onExtracted: async (rootDir) => { + const manifestPath = path.join(rootDir, "package.json"); + if (await fileExists(manifestPath)) { + return await installHookPackageFromDir({ + packageDir: rootDir, + hooksDir: params.hooksDir, + timeoutMs, + logger, + mode: params.mode, + dryRun: params.dryRun, + expectedHookPackId: params.expectedHookPackId, + }); + } - logger.info?.(`Extracting ${archivePath}…`); - try { - await extractArchive({ archivePath, destDir: extractDir, timeoutMs, logger }); - } catch (err) { - return { ok: false, error: `failed to extract archive: ${String(err)}` }; - } - - let rootDir = ""; - try { - rootDir = await resolvePackedRootDir(extractDir); - } catch (err) { - return { ok: false, error: String(err) }; - } - - const manifestPath = path.join(rootDir, "package.json"); - if (await fileExists(manifestPath)) { - return await installHookPackageFromDir({ - packageDir: rootDir, + return await installHookFromDir({ + hookDir: rootDir, hooksDir: params.hooksDir, - timeoutMs, logger, mode: params.mode, dryRun: params.dryRun, expectedHookPackId: params.expectedHookPackId, }); - } - - return await installHookFromDir({ - hookDir: rootDir, - hooksDir: params.hooksDir, - logger, - mode: params.mode, - dryRun: params.dryRun, - expectedHookPackId: params.expectedHookPackId, - }); + }, }); } @@ -406,7 +372,7 @@ export async function installHooksFromNpmSpec(params: { expectedIntegrity?: string; onIntegrityDrift?: (params: HookNpmIntegrityDriftParams) => boolean | Promise; }): Promise { - const { logger, timeoutMs, mode, dryRun } = resolveTimedHookInstallModeOptions(params); + const { logger, timeoutMs, mode, dryRun } = resolveTimedInstallModeOptions(params, defaultLogger); const expectedHookPackId = params.expectedHookPackId; const spec = params.spec.trim(); const specError = validateRegistryNpmSpec(spec); @@ -415,7 +381,7 @@ export async function installHooksFromNpmSpec(params: { } logger.info?.(`Downloading ${spec}…`); - const flowResult = await installFromNpmSpecArchive({ + const flowResult = await installFromNpmSpecArchiveWithInstaller({ tempDirPrefix: "openclaw-hook-pack-", spec, timeoutMs, @@ -424,28 +390,17 @@ export async function installHooksFromNpmSpec(params: { warn: (message) => { logger.warn?.(message); }, - installFromArchive: async ({ archivePath }) => - await installHooksFromArchive({ - archivePath, - hooksDir: params.hooksDir, - timeoutMs, - logger, - mode, - dryRun, - expectedHookPackId, - }), + installFromArchive: installHooksFromArchive, + archiveInstallParams: { + hooksDir: params.hooksDir, + timeoutMs, + logger, + mode, + dryRun, + expectedHookPackId, + }, }); - if (!flowResult.ok) { - return flowResult; - } - if (!flowResult.installResult.ok) { - return flowResult.installResult; - } - return { - ...flowResult.installResult, - npmResolution: flowResult.npmResolution, - integrityDrift: flowResult.integrityDrift, - }; + return finalizeNpmSpecArchiveInstall(flowResult); } export async function installHooksFromPath(params: { @@ -457,12 +412,12 @@ export async function installHooksFromPath(params: { dryRun?: boolean; expectedHookPackId?: string; }): Promise { - const resolved = resolveUserPath(params.path); - if (!(await fileExists(resolved))) { - return { ok: false, error: `path not found: ${resolved}` }; + const pathResult = await resolveExistingInstallPath(params.path); + if (!pathResult.ok) { + return pathResult; } + const { resolvedPath: resolved, stat } = pathResult; - const stat = await fs.stat(resolved); if (stat.isDirectory()) { const manifestPath = path.join(resolved, "package.json"); if (await fileExists(manifestPath)) { diff --git a/src/infra/install-flow.ts b/src/infra/install-flow.ts new file mode 100644 index 00000000000..17d4cad2c7f --- /dev/null +++ b/src/infra/install-flow.ts @@ -0,0 +1,61 @@ +import type { Stats } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveUserPath } from "../utils.js"; +import { type ArchiveLogger, extractArchive, fileExists, resolvePackedRootDir } from "./archive.js"; +import { withTempDir } from "./install-source-utils.js"; + +export type ExistingInstallPathResult = + | { + ok: true; + resolvedPath: string; + stat: Stats; + } + | { + ok: false; + error: string; + }; + +export async function resolveExistingInstallPath( + inputPath: string, +): Promise { + const resolvedPath = resolveUserPath(inputPath); + if (!(await fileExists(resolvedPath))) { + return { ok: false, error: `path not found: ${resolvedPath}` }; + } + const stat = await fs.stat(resolvedPath); + return { ok: true, resolvedPath, stat }; +} + +export async function withExtractedArchiveRoot(params: { + archivePath: string; + tempDirPrefix: string; + timeoutMs: number; + logger?: ArchiveLogger; + onExtracted: (rootDir: string) => Promise; +}): Promise { + return await withTempDir(params.tempDirPrefix, async (tmpDir) => { + const extractDir = path.join(tmpDir, "extract"); + await fs.mkdir(extractDir, { recursive: true }); + + params.logger?.info?.(`Extracting ${params.archivePath}…`); + try { + await extractArchive({ + archivePath: params.archivePath, + destDir: extractDir, + timeoutMs: params.timeoutMs, + logger: params.logger, + }); + } catch (err) { + return { ok: false, error: `failed to extract archive: ${String(err)}` }; + } + + let rootDir = ""; + try { + rootDir = await resolvePackedRootDir(extractDir); + } catch (err) { + return { ok: false, error: String(err) }; + } + return await params.onExtracted(rootDir); + }); +} diff --git a/src/infra/install-mode-options.ts b/src/infra/install-mode-options.ts new file mode 100644 index 00000000000..dabb8f5380e --- /dev/null +++ b/src/infra/install-mode-options.ts @@ -0,0 +1,42 @@ +export type InstallMode = "install" | "update"; + +export type InstallModeOptions = { + logger?: TLogger; + mode?: InstallMode; + dryRun?: boolean; +}; + +export type TimedInstallModeOptions = InstallModeOptions & { + timeoutMs?: number; +}; + +export function resolveInstallModeOptions( + params: InstallModeOptions, + defaultLogger: TLogger, +): { + logger: TLogger; + mode: InstallMode; + dryRun: boolean; +} { + return { + logger: params.logger ?? defaultLogger, + mode: params.mode ?? "install", + dryRun: params.dryRun ?? false, + }; +} + +export function resolveTimedInstallModeOptions( + params: TimedInstallModeOptions, + defaultLogger: TLogger, + defaultTimeoutMs = 120_000, +): { + logger: TLogger; + timeoutMs: number; + mode: InstallMode; + dryRun: boolean; +} { + return { + ...resolveInstallModeOptions(params, defaultLogger), + timeoutMs: params.timeoutMs ?? defaultTimeoutMs, + }; +} diff --git a/src/infra/npm-pack-install.ts b/src/infra/npm-pack-install.ts index 6663c416993..447563c3015 100644 --- a/src/infra/npm-pack-install.ts +++ b/src/infra/npm-pack-install.ts @@ -21,6 +21,58 @@ export type NpmSpecArchiveInstallFlowResult = integrityDrift?: NpmIntegrityDrift; }; +export async function installFromNpmSpecArchiveWithInstaller< + TResult extends { ok: boolean }, + TArchiveInstallParams extends { archivePath: string }, +>(params: { + tempDirPrefix: string; + spec: string; + timeoutMs: number; + expectedIntegrity?: string; + onIntegrityDrift?: (payload: NpmIntegrityDriftPayload) => boolean | Promise; + warn?: (message: string) => void; + installFromArchive: (params: TArchiveInstallParams) => Promise; + archiveInstallParams: Omit; +}): Promise> { + return await installFromNpmSpecArchive({ + tempDirPrefix: params.tempDirPrefix, + spec: params.spec, + timeoutMs: params.timeoutMs, + expectedIntegrity: params.expectedIntegrity, + onIntegrityDrift: params.onIntegrityDrift, + warn: params.warn, + installFromArchive: async ({ archivePath }) => + await params.installFromArchive({ + archivePath, + ...params.archiveInstallParams, + } as TArchiveInstallParams), + }); +} + +export type NpmSpecArchiveFinalInstallResult = + | { ok: false; error: string } + | Exclude + | (Extract & { + npmResolution: NpmSpecResolution; + integrityDrift?: NpmIntegrityDrift; + }); + +export function finalizeNpmSpecArchiveInstall( + flowResult: NpmSpecArchiveInstallFlowResult, +): NpmSpecArchiveFinalInstallResult { + if (!flowResult.ok) { + return flowResult; + } + if (!flowResult.installResult.ok) { + return flowResult.installResult; + } + return { + ...flowResult.installResult, + npmResolution: flowResult.npmResolution, + integrityDrift: flowResult.integrityDrift, + }; +} + export async function installFromNpmSpecArchive(params: { tempDirPrefix: string; spec: string; diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 500162af703..40aeb3c5a63 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -1,13 +1,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import { MANIFEST_KEY } from "../compat/legacy-names.js"; +import { fileExists, readJsonFile, resolveArchiveKind } from "../infra/archive.js"; +import { resolveExistingInstallPath, withExtractedArchiveRoot } from "../infra/install-flow.js"; import { - extractArchive, - fileExists, - readJsonFile, - resolveArchiveKind, - resolvePackedRootDir, -} from "../infra/archive.js"; + resolveInstallModeOptions, + resolveTimedInstallModeOptions, +} from "../infra/install-mode-options.js"; import { installPackageDir } from "../infra/install-package-dir.js"; import { resolveSafeInstallDir, @@ -18,9 +17,11 @@ import { type NpmIntegrityDrift, type NpmSpecResolution, resolveArchiveSourcePath, - withTempDir, } from "../infra/install-source-utils.js"; -import { installFromNpmSpecArchive } from "../infra/npm-pack-install.js"; +import { + finalizeNpmSpecArchiveInstall, + installFromNpmSpecArchiveWithInstaller, +} from "../infra/npm-pack-install.js"; import { validateRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { extensionUsesSkippedScannerPath, isPathInside } from "../security/scan-paths.js"; import * as skillScanner from "../security/skill-scanner.js"; @@ -87,35 +88,6 @@ async function ensureOpenClawExtensions(manifest: PackageManifest) { return list; } -function resolvePluginInstallModeOptions(params: { - logger?: PluginInstallLogger; - mode?: "install" | "update"; - dryRun?: boolean; -}): { logger: PluginInstallLogger; mode: "install" | "update"; dryRun: boolean } { - return { - logger: params.logger ?? defaultLogger, - mode: params.mode ?? "install", - dryRun: params.dryRun ?? false, - }; -} - -function resolveTimedPluginInstallModeOptions(params: { - logger?: PluginInstallLogger; - timeoutMs?: number; - mode?: "install" | "update"; - dryRun?: boolean; -}): { - logger: PluginInstallLogger; - timeoutMs: number; - mode: "install" | "update"; - dryRun: boolean; -} { - return { - ...resolvePluginInstallModeOptions(params), - timeoutMs: params.timeoutMs ?? 120_000, - }; -} - function buildFileInstallResult(pluginId: string, targetFile: string): InstallPluginResult { return { ok: true, @@ -155,7 +127,7 @@ async function installPluginFromPackageDir(params: { dryRun?: boolean; expectedPluginId?: string; }): Promise { - const { logger, timeoutMs, mode, dryRun } = resolveTimedPluginInstallModeOptions(params); + const { logger, timeoutMs, mode, dryRun } = resolveTimedInstallModeOptions(params, defaultLogger); const manifestPath = path.join(params.packageDir, "package.json"); if (!(await fileExists(manifestPath))) { @@ -318,38 +290,21 @@ export async function installPluginFromArchive(params: { } const archivePath = archivePathResult.path; - return await withTempDir("openclaw-plugin-", async (tmpDir) => { - const extractDir = path.join(tmpDir, "extract"); - await fs.mkdir(extractDir, { recursive: true }); - - logger.info?.(`Extracting ${archivePath}…`); - try { - await extractArchive({ - archivePath, - destDir: extractDir, + return await withExtractedArchiveRoot({ + archivePath, + tempDirPrefix: "openclaw-plugin-", + timeoutMs, + logger, + onExtracted: async (packageDir) => + await installPluginFromPackageDir({ + packageDir, + extensionsDir: params.extensionsDir, timeoutMs, logger, - }); - } catch (err) { - return { ok: false, error: `failed to extract archive: ${String(err)}` }; - } - - let packageDir = ""; - try { - packageDir = await resolvePackedRootDir(extractDir); - } catch (err) { - return { ok: false, error: String(err) }; - } - - return await installPluginFromPackageDir({ - packageDir, - extensionsDir: params.extensionsDir, - timeoutMs, - logger, - mode, - dryRun: params.dryRun, - expectedPluginId: params.expectedPluginId, - }); + mode, + dryRun: params.dryRun, + expectedPluginId: params.expectedPluginId, + }), }); } @@ -389,7 +344,7 @@ export async function installPluginFromFile(params: { mode?: "install" | "update"; dryRun?: boolean; }): Promise { - const { logger, mode, dryRun } = resolvePluginInstallModeOptions(params); + const { logger, mode, dryRun } = resolveInstallModeOptions(params, defaultLogger); const filePath = resolveUserPath(params.filePath); if (!(await fileExists(filePath))) { @@ -434,7 +389,7 @@ export async function installPluginFromNpmSpec(params: { expectedIntegrity?: string; onIntegrityDrift?: (params: PluginNpmIntegrityDriftParams) => boolean | Promise; }): Promise { - const { logger, timeoutMs, mode, dryRun } = resolveTimedPluginInstallModeOptions(params); + const { logger, timeoutMs, mode, dryRun } = resolveTimedInstallModeOptions(params, defaultLogger); const expectedPluginId = params.expectedPluginId; const spec = params.spec.trim(); const specError = validateRegistryNpmSpec(spec); @@ -443,7 +398,7 @@ export async function installPluginFromNpmSpec(params: { } logger.info?.(`Downloading ${spec}…`); - const flowResult = await installFromNpmSpecArchive({ + const flowResult = await installFromNpmSpecArchiveWithInstaller({ tempDirPrefix: "openclaw-npm-pack-", spec, timeoutMs, @@ -452,28 +407,17 @@ export async function installPluginFromNpmSpec(params: { warn: (message) => { logger.warn?.(message); }, - installFromArchive: async ({ archivePath }) => - await installPluginFromArchive({ - archivePath, - extensionsDir: params.extensionsDir, - timeoutMs, - logger, - mode, - dryRun, - expectedPluginId, - }), + installFromArchive: installPluginFromArchive, + archiveInstallParams: { + extensionsDir: params.extensionsDir, + timeoutMs, + logger, + mode, + dryRun, + expectedPluginId, + }, }); - if (!flowResult.ok) { - return flowResult; - } - if (!flowResult.installResult.ok) { - return flowResult.installResult; - } - return { - ...flowResult.installResult, - npmResolution: flowResult.npmResolution, - integrityDrift: flowResult.integrityDrift, - }; + return finalizeNpmSpecArchiveInstall(flowResult); } export async function installPluginFromPath(params: { @@ -485,12 +429,12 @@ export async function installPluginFromPath(params: { dryRun?: boolean; expectedPluginId?: string; }): Promise { - const resolved = resolveUserPath(params.path); - if (!(await fileExists(resolved))) { - return { ok: false, error: `path not found: ${resolved}` }; + const pathResult = await resolveExistingInstallPath(params.path); + if (!pathResult.ok) { + return pathResult; } + const { resolvedPath: resolved, stat } = pathResult; - const stat = await fs.stat(resolved); if (stat.isDirectory()) { return await installPluginFromDir({ dirPath: resolved,