init claude-code
This commit is contained in:
@@ -0,0 +1,595 @@
|
||||
/**
|
||||
* Shared helper functions for plugin installation
|
||||
*
|
||||
* This module contains common utilities used across the plugin installation
|
||||
* system to reduce code duplication and improve maintainability.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'crypto'
|
||||
import { rename, rm } from 'fs/promises'
|
||||
import { dirname, join, resolve, sep } from 'path'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js'
|
||||
import { getCwd } from '../cwd.js'
|
||||
import { toError } from '../errors.js'
|
||||
import { getFsImplementation } from '../fsOperations.js'
|
||||
import { logError } from '../log.js'
|
||||
import {
|
||||
getSettingsForSource,
|
||||
updateSettingsForSource,
|
||||
} from '../settings/settings.js'
|
||||
import { buildPluginTelemetryFields } from '../telemetry/pluginTelemetry.js'
|
||||
import { clearAllCaches } from './cacheUtils.js'
|
||||
import {
|
||||
formatDependencyCountSuffix,
|
||||
getEnabledPluginIdsForScope,
|
||||
type ResolutionResult,
|
||||
resolveDependencyClosure,
|
||||
} from './dependencyResolver.js'
|
||||
import {
|
||||
addInstalledPlugin,
|
||||
getGitCommitSha,
|
||||
} from './installedPluginsManager.js'
|
||||
import { getManagedPluginNames } from './managedPlugins.js'
|
||||
import { getMarketplaceCacheOnly, getPluginById } from './marketplaceManager.js'
|
||||
import {
|
||||
isOfficialMarketplaceName,
|
||||
parsePluginIdentifier,
|
||||
scopeToSettingSource,
|
||||
} from './pluginIdentifier.js'
|
||||
import {
|
||||
cachePlugin,
|
||||
getVersionedCachePath,
|
||||
getVersionedZipCachePath,
|
||||
} from './pluginLoader.js'
|
||||
import { isPluginBlockedByPolicy } from './pluginPolicy.js'
|
||||
import { calculatePluginVersion } from './pluginVersioning.js'
|
||||
import {
|
||||
isLocalPluginSource,
|
||||
type PluginMarketplaceEntry,
|
||||
type PluginScope,
|
||||
type PluginSource,
|
||||
} from './schemas.js'
|
||||
import {
|
||||
convertDirectoryToZipInPlace,
|
||||
isPluginZipCacheEnabled,
|
||||
} from './zipCache.js'
|
||||
|
||||
/**
|
||||
* Plugin installation metadata for installed_plugins.json
|
||||
*/
|
||||
export type PluginInstallationInfo = {
|
||||
pluginId: string
|
||||
installPath: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current ISO timestamp
|
||||
*/
|
||||
export function getCurrentTimestamp(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a resolved path stays within a base directory.
|
||||
* Prevents path traversal attacks where malicious paths like './../../../etc/passwd'
|
||||
* could escape the expected directory.
|
||||
*
|
||||
* @param basePath - The base directory that the resolved path must stay within
|
||||
* @param relativePath - The relative path to validate
|
||||
* @returns The validated absolute path
|
||||
* @throws Error if the path would escape the base directory
|
||||
*/
|
||||
export function validatePathWithinBase(
|
||||
basePath: string,
|
||||
relativePath: string,
|
||||
): string {
|
||||
const resolvedPath = resolve(basePath, relativePath)
|
||||
const normalizedBase = resolve(basePath) + sep
|
||||
|
||||
// Check if the resolved path starts with the base path
|
||||
// Adding sep ensures we don't match partial directory names
|
||||
// e.g., /foo/bar should not match /foo/barbaz
|
||||
if (
|
||||
!resolvedPath.startsWith(normalizedBase) &&
|
||||
resolvedPath !== resolve(basePath)
|
||||
) {
|
||||
throw new Error(
|
||||
`Path traversal detected: "${relativePath}" would escape the base directory`,
|
||||
)
|
||||
}
|
||||
|
||||
return resolvedPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache a plugin (local or external) and add it to installed_plugins.json
|
||||
*
|
||||
* This function combines the common pattern of:
|
||||
* 1. Caching a plugin to ~/.claude/plugins/cache/
|
||||
* 2. Adding it to the installed plugins registry
|
||||
*
|
||||
* Both local plugins (with string source like "./path") and external plugins
|
||||
* (with object source like {source: "github", ...}) are cached to the same
|
||||
* location to ensure consistent behavior.
|
||||
*
|
||||
* @param pluginId - Plugin ID in "plugin@marketplace" format
|
||||
* @param entry - Plugin marketplace entry
|
||||
* @param scope - Installation scope (user, project, local, or managed). Defaults to 'user'.
|
||||
* 'managed' scope is used for plugins installed automatically from managed settings.
|
||||
* @param projectPath - Project path (required for project/local scopes)
|
||||
* @param localSourcePath - For local plugins, the resolved absolute path to the source directory
|
||||
* @returns The installation path
|
||||
*/
|
||||
export async function cacheAndRegisterPlugin(
|
||||
pluginId: string,
|
||||
entry: PluginMarketplaceEntry,
|
||||
scope: PluginScope = 'user',
|
||||
projectPath?: string,
|
||||
localSourcePath?: string,
|
||||
): Promise<string> {
|
||||
// For local plugins, we need the resolved absolute path
|
||||
// Cast to PluginSource since cachePlugin handles any string path at runtime
|
||||
const source: PluginSource =
|
||||
typeof entry.source === 'string' && localSourcePath
|
||||
? (localSourcePath as PluginSource)
|
||||
: entry.source
|
||||
|
||||
const cacheResult = await cachePlugin(source, {
|
||||
manifest: entry as PluginMarketplaceEntry,
|
||||
})
|
||||
|
||||
// For local plugins, use the original source path for Git SHA calculation
|
||||
// because the cached temp directory doesn't have .git (it's copied from a
|
||||
// subdirectory of the marketplace git repo). For external plugins, use the
|
||||
// cached path. For git-subdir sources, cachePlugin already captured the SHA
|
||||
// before discarding the ephemeral clone (the extracted subdir has no .git).
|
||||
const pathForGitSha = localSourcePath || cacheResult.path
|
||||
const gitCommitSha =
|
||||
cacheResult.gitCommitSha ?? (await getGitCommitSha(pathForGitSha))
|
||||
|
||||
const now = getCurrentTimestamp()
|
||||
const version = await calculatePluginVersion(
|
||||
pluginId,
|
||||
entry.source,
|
||||
cacheResult.manifest,
|
||||
pathForGitSha,
|
||||
entry.version,
|
||||
cacheResult.gitCommitSha,
|
||||
)
|
||||
|
||||
// Move the cached plugin to the versioned path: cache/marketplace/plugin/version/
|
||||
const versionedPath = getVersionedCachePath(pluginId, version)
|
||||
let finalPath = cacheResult.path
|
||||
|
||||
// Only move if the paths are different and plugin was cached to a different location
|
||||
if (cacheResult.path !== versionedPath) {
|
||||
// Create the versioned directory structure
|
||||
await getFsImplementation().mkdir(dirname(versionedPath))
|
||||
|
||||
// Remove existing versioned path if present (force: no-op if missing)
|
||||
await rm(versionedPath, { recursive: true, force: true })
|
||||
|
||||
// Check if versionedPath is a subdirectory of cacheResult.path
|
||||
// This happens when marketplace name equals plugin name (e.g., "exa-mcp-server@exa-mcp-server")
|
||||
// In this case, we can't directly rename because we'd be moving a directory into itself
|
||||
const normalizedCachePath = cacheResult.path.endsWith(sep)
|
||||
? cacheResult.path
|
||||
: cacheResult.path + sep
|
||||
const isSubdirectory = versionedPath.startsWith(normalizedCachePath)
|
||||
|
||||
if (isSubdirectory) {
|
||||
// Move to a temp location first, then to final destination
|
||||
// We can't directly rename/copy a directory into its own subdirectory
|
||||
// Use the parent of cacheResult.path (same filesystem) to avoid EXDEV
|
||||
// errors when /tmp is on a different filesystem (e.g., tmpfs)
|
||||
const tempPath = join(
|
||||
dirname(cacheResult.path),
|
||||
`.claude-plugin-temp-${Date.now()}-${randomBytes(4).toString('hex')}`,
|
||||
)
|
||||
await rename(cacheResult.path, tempPath)
|
||||
await getFsImplementation().mkdir(dirname(versionedPath))
|
||||
await rename(tempPath, versionedPath)
|
||||
} else {
|
||||
// Move the cached plugin to the versioned location
|
||||
await rename(cacheResult.path, versionedPath)
|
||||
}
|
||||
finalPath = versionedPath
|
||||
}
|
||||
|
||||
// Zip cache mode: convert directory to ZIP and remove the directory
|
||||
if (isPluginZipCacheEnabled()) {
|
||||
const zipPath = getVersionedZipCachePath(pluginId, version)
|
||||
await convertDirectoryToZipInPlace(finalPath, zipPath)
|
||||
finalPath = zipPath
|
||||
}
|
||||
|
||||
// Add to both V1 and V2 installed_plugins files with correct scope
|
||||
addInstalledPlugin(
|
||||
pluginId,
|
||||
{
|
||||
version,
|
||||
installedAt: now,
|
||||
lastUpdated: now,
|
||||
installPath: finalPath,
|
||||
gitCommitSha,
|
||||
},
|
||||
scope,
|
||||
projectPath,
|
||||
)
|
||||
|
||||
return finalPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a plugin installation without caching
|
||||
*
|
||||
* Used for local plugins that are already on disk and don't need remote caching.
|
||||
* External plugins should use cacheAndRegisterPlugin() instead.
|
||||
*
|
||||
* @param info - Plugin installation information
|
||||
* @param scope - Installation scope (user, project, local, or managed). Defaults to 'user'.
|
||||
* 'managed' scope is used for plugins registered from managed settings.
|
||||
* @param projectPath - Project path (required for project/local scopes)
|
||||
*/
|
||||
export function registerPluginInstallation(
|
||||
info: PluginInstallationInfo,
|
||||
scope: PluginScope = 'user',
|
||||
projectPath?: string,
|
||||
): void {
|
||||
const now = getCurrentTimestamp()
|
||||
addInstalledPlugin(
|
||||
info.pluginId,
|
||||
{
|
||||
version: info.version || 'unknown',
|
||||
installedAt: now,
|
||||
lastUpdated: now,
|
||||
installPath: info.installPath,
|
||||
},
|
||||
scope,
|
||||
projectPath,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse plugin ID into components
|
||||
*
|
||||
* @param pluginId - Plugin ID in "plugin@marketplace" format
|
||||
* @returns Parsed components or null if invalid
|
||||
*/
|
||||
export function parsePluginId(
|
||||
pluginId: string,
|
||||
): { name: string; marketplace: string } | null {
|
||||
const parts = pluginId.split('@')
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
name: parts[0],
|
||||
marketplace: parts[1],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured result from the install core. Wrappers format messages and
|
||||
* handle analytics/error-catching around this.
|
||||
*/
|
||||
export type InstallCoreResult =
|
||||
| { ok: true; closure: string[]; depNote: string }
|
||||
| { ok: false; reason: 'local-source-no-location'; pluginName: string }
|
||||
| { ok: false; reason: 'settings-write-failed'; message: string }
|
||||
| {
|
||||
ok: false
|
||||
reason: 'resolution-failed'
|
||||
resolution: ResolutionResult & { ok: false }
|
||||
}
|
||||
| { ok: false; reason: 'blocked-by-policy'; pluginName: string }
|
||||
| {
|
||||
ok: false
|
||||
reason: 'dependency-blocked-by-policy'
|
||||
pluginName: string
|
||||
blockedDependency: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a failed ResolutionResult into a user-facing message. Unified on
|
||||
* the richer CLI messages (the "Is the X marketplace added?" hint is useful
|
||||
* for UI users too).
|
||||
*/
|
||||
export function formatResolutionError(
|
||||
r: ResolutionResult & { ok: false },
|
||||
): string {
|
||||
switch (r.reason) {
|
||||
case 'cycle':
|
||||
return `Dependency cycle: ${r.chain.join(' → ')}`
|
||||
case 'cross-marketplace': {
|
||||
const depMkt = parsePluginIdentifier(r.dependency).marketplace
|
||||
const where = depMkt
|
||||
? `marketplace "${depMkt}"`
|
||||
: 'a different marketplace'
|
||||
const hint = depMkt
|
||||
? ` Add "${depMkt}" to allowCrossMarketplaceDependenciesOn in the ROOT marketplace's marketplace.json (the marketplace of the plugin you're installing — only its allowlist applies; no transitive trust).`
|
||||
: ''
|
||||
return `Dependency "${r.dependency}" (required by ${r.requiredBy}) is in ${where}, which is not in the allowlist — cross-marketplace dependencies are blocked by default. Install it manually first.${hint}`
|
||||
}
|
||||
case 'not-found': {
|
||||
const { marketplace: depMkt } = parsePluginIdentifier(r.missing)
|
||||
return depMkt
|
||||
? `Dependency "${r.missing}" (required by ${r.requiredBy}) not found. Is the "${depMkt}" marketplace added?`
|
||||
: `Dependency "${r.missing}" (required by ${r.requiredBy}) not found in any configured marketplace`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core plugin install logic, shared by the CLI path (`installPluginOp`) and
|
||||
* the interactive UI path (`installPluginFromMarketplace`). Given a
|
||||
* pre-resolved marketplace entry, this:
|
||||
*
|
||||
* 1. Guards against local-source plugins without a marketplace install
|
||||
* location (would silently no-op otherwise).
|
||||
* 2. Resolves the transitive dependency closure (when PLUGIN_DEPENDENCIES
|
||||
* is on; trivial single-plugin closure otherwise).
|
||||
* 3. Writes the entire closure to enabledPlugins in one settings update.
|
||||
* 4. Caches each closure member (downloads/copies sources as needed).
|
||||
* 5. Clears memoization caches.
|
||||
*
|
||||
* Returns a structured result. Message formatting, analytics, and top-level
|
||||
* error wrapping stay in the caller-specific wrappers.
|
||||
*
|
||||
* @param marketplaceInstallLocation Pass this if the caller already has it
|
||||
* (from a prior marketplace search) to avoid a redundant lookup.
|
||||
*/
|
||||
export async function installResolvedPlugin({
|
||||
pluginId,
|
||||
entry,
|
||||
scope,
|
||||
marketplaceInstallLocation,
|
||||
}: {
|
||||
pluginId: string
|
||||
entry: PluginMarketplaceEntry
|
||||
scope: 'user' | 'project' | 'local'
|
||||
marketplaceInstallLocation?: string
|
||||
}): Promise<InstallCoreResult> {
|
||||
const settingSource = scopeToSettingSource(scope)
|
||||
|
||||
// ── Policy guard ──
|
||||
// Org-blocked plugins (managed-settings.json enabledPlugins: false) cannot
|
||||
// be installed. Checked here so all install paths (CLI, UI, hint-triggered)
|
||||
// are covered in one place.
|
||||
if (isPluginBlockedByPolicy(pluginId)) {
|
||||
return { ok: false, reason: 'blocked-by-policy', pluginName: entry.name }
|
||||
}
|
||||
|
||||
// ── Resolve dependency closure ──
|
||||
// depInfo caches marketplace lookups so the materialize loop doesn't
|
||||
// re-fetch. Seed the root if the caller gave us its install location.
|
||||
const depInfo = new Map<
|
||||
string,
|
||||
{ entry: PluginMarketplaceEntry; marketplaceInstallLocation: string }
|
||||
>()
|
||||
// Without this guard, a local-source root with undefined
|
||||
// marketplaceInstallLocation falls through: depInfo isn't seeded, the
|
||||
// materialize loop's `if (!info) continue` skips the root, and the user
|
||||
// sees "Successfully installed" while nothing is cached.
|
||||
if (isLocalPluginSource(entry.source) && !marketplaceInstallLocation) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'local-source-no-location',
|
||||
pluginName: entry.name,
|
||||
}
|
||||
}
|
||||
if (marketplaceInstallLocation) {
|
||||
depInfo.set(pluginId, { entry, marketplaceInstallLocation })
|
||||
}
|
||||
|
||||
const rootMarketplace = parsePluginIdentifier(pluginId).marketplace
|
||||
const allowedCrossMarketplaces = new Set(
|
||||
(rootMarketplace
|
||||
? (await getMarketplaceCacheOnly(rootMarketplace))
|
||||
?.allowCrossMarketplaceDependenciesOn
|
||||
: undefined) ?? [],
|
||||
)
|
||||
const resolution = await resolveDependencyClosure(
|
||||
pluginId,
|
||||
async id => {
|
||||
if (depInfo.has(id)) return depInfo.get(id)!.entry
|
||||
if (id === pluginId) return entry
|
||||
const info = await getPluginById(id)
|
||||
if (info) depInfo.set(id, info)
|
||||
return info?.entry ?? null
|
||||
},
|
||||
getEnabledPluginIdsForScope(settingSource),
|
||||
allowedCrossMarketplaces,
|
||||
)
|
||||
if (!resolution.ok) {
|
||||
return { ok: false, reason: 'resolution-failed', resolution }
|
||||
}
|
||||
|
||||
// ── Policy guard for transitive dependencies ──
|
||||
// The root plugin was already checked above, but any dependency in the
|
||||
// closure could also be policy-blocked. Check before writing to settings
|
||||
// so a non-blocked plugin can't pull in a blocked dependency.
|
||||
for (const id of resolution.closure) {
|
||||
if (id !== pluginId && isPluginBlockedByPolicy(id)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'dependency-blocked-by-policy',
|
||||
pluginName: entry.name,
|
||||
blockedDependency: id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ACTION: write entire closure to settings in one call ──
|
||||
const closureEnabled: Record<string, true> = {}
|
||||
for (const id of resolution.closure) closureEnabled[id] = true
|
||||
const { error } = updateSettingsForSource(settingSource, {
|
||||
enabledPlugins: {
|
||||
...getSettingsForSource(settingSource)?.enabledPlugins,
|
||||
...closureEnabled,
|
||||
},
|
||||
})
|
||||
if (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'settings-write-failed',
|
||||
message: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Materialize: cache each closure member ──
|
||||
const projectPath = scope !== 'user' ? getCwd() : undefined
|
||||
for (const id of resolution.closure) {
|
||||
let info = depInfo.get(id)
|
||||
// Root wasn't pre-seeded (caller didn't pass marketplaceInstallLocation
|
||||
// for a non-local source). Fetch now; it's needed for the cache write.
|
||||
if (!info && id === pluginId) {
|
||||
const mktLocation = (await getPluginById(id))?.marketplaceInstallLocation
|
||||
if (mktLocation) info = { entry, marketplaceInstallLocation: mktLocation }
|
||||
}
|
||||
if (!info) continue
|
||||
|
||||
let localSourcePath: string | undefined
|
||||
const { source } = info.entry
|
||||
if (isLocalPluginSource(source)) {
|
||||
localSourcePath = validatePathWithinBase(
|
||||
info.marketplaceInstallLocation,
|
||||
source,
|
||||
)
|
||||
}
|
||||
await cacheAndRegisterPlugin(
|
||||
id,
|
||||
info.entry,
|
||||
scope,
|
||||
projectPath,
|
||||
localSourcePath,
|
||||
)
|
||||
}
|
||||
|
||||
clearAllCaches()
|
||||
|
||||
const depNote = formatDependencyCountSuffix(
|
||||
resolution.closure.filter(id => id !== pluginId),
|
||||
)
|
||||
return { ok: true, closure: resolution.closure, depNote }
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a plugin installation operation
|
||||
*/
|
||||
export type InstallPluginResult =
|
||||
| { success: true; message: string }
|
||||
| { success: false; error: string }
|
||||
|
||||
/**
|
||||
* Parameters for installing a plugin from marketplace
|
||||
*/
|
||||
export type InstallPluginParams = {
|
||||
pluginId: string
|
||||
entry: PluginMarketplaceEntry
|
||||
marketplaceName: string
|
||||
scope?: 'user' | 'project' | 'local'
|
||||
trigger?: 'hint' | 'user'
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a single plugin from a marketplace with the specified scope.
|
||||
* Interactive-UI wrapper around `installResolvedPlugin` — adds try/catch,
|
||||
* analytics, and UI-style message formatting.
|
||||
*/
|
||||
export async function installPluginFromMarketplace({
|
||||
pluginId,
|
||||
entry,
|
||||
marketplaceName,
|
||||
scope = 'user',
|
||||
trigger = 'user',
|
||||
}: InstallPluginParams): Promise<InstallPluginResult> {
|
||||
try {
|
||||
// Look up the marketplace install location for local-source plugins.
|
||||
// Without this, plugins with relative-path sources fail from the
|
||||
// interactive UI path (/plugin install) even though the CLI path works.
|
||||
const pluginInfo = await getPluginById(pluginId)
|
||||
const marketplaceInstallLocation = pluginInfo?.marketplaceInstallLocation
|
||||
|
||||
const result = await installResolvedPlugin({
|
||||
pluginId,
|
||||
entry,
|
||||
scope,
|
||||
marketplaceInstallLocation,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
switch (result.reason) {
|
||||
case 'local-source-no-location':
|
||||
return {
|
||||
success: false,
|
||||
error: `Cannot install local plugin "${result.pluginName}" without marketplace install location`,
|
||||
}
|
||||
case 'settings-write-failed':
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to update settings: ${result.message}`,
|
||||
}
|
||||
case 'resolution-failed':
|
||||
return {
|
||||
success: false,
|
||||
error: formatResolutionError(result.resolution),
|
||||
}
|
||||
case 'blocked-by-policy':
|
||||
return {
|
||||
success: false,
|
||||
error: `Plugin "${result.pluginName}" is blocked by your organization's policy and cannot be installed`,
|
||||
}
|
||||
case 'dependency-blocked-by-policy':
|
||||
return {
|
||||
success: false,
|
||||
error: `Cannot install "${result.pluginName}": dependency "${result.blockedDependency}" is blocked by your organization's policy`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns.
|
||||
// plugin_id kept in additional_metadata (redacted to 'third-party' for
|
||||
// non-official) because dbt external_claude_code_plugin_installs.sql
|
||||
// extracts $.plugin_id for official-marketplace install tracking. Other
|
||||
// plugin lifecycle events drop the blob key — no downstream consumers.
|
||||
logEvent('tengu_plugin_installed', {
|
||||
_PROTO_plugin_name:
|
||||
entry.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
_PROTO_marketplace_name:
|
||||
marketplaceName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
plugin_id: (isOfficialMarketplaceName(marketplaceName)
|
||||
? pluginId
|
||||
: 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
trigger:
|
||||
trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
install_source: (trigger === 'hint'
|
||||
? 'ui-suggestion'
|
||||
: 'ui-discover') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
...buildPluginTelemetryFields(
|
||||
entry.name,
|
||||
marketplaceName,
|
||||
getManagedPluginNames(),
|
||||
),
|
||||
...(entry.version && {
|
||||
version:
|
||||
entry.version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
}),
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `✓ Installed ${entry.name}${result.depNote}. Run /reload-plugins to activate.`,
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||
logError(toError(err))
|
||||
return { success: false, error: `Failed to install: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user