chore: initialize recovered claude workspace
This commit is contained in:
184
src/services/plugins/PluginInstallationManager.ts
Normal file
184
src/services/plugins/PluginInstallationManager.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Background plugin and marketplace installation manager
|
||||
*
|
||||
* This module handles automatic installation of plugins and marketplaces
|
||||
* from trusted sources (repository and user settings) without blocking startup.
|
||||
*/
|
||||
|
||||
import type { AppState } from '../../state/AppState.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import {
|
||||
clearMarketplacesCache,
|
||||
getDeclaredMarketplaces,
|
||||
loadKnownMarketplacesConfig,
|
||||
} from '../../utils/plugins/marketplaceManager.js'
|
||||
import { clearPluginCache } from '../../utils/plugins/pluginLoader.js'
|
||||
import {
|
||||
diffMarketplaces,
|
||||
reconcileMarketplaces,
|
||||
} from '../../utils/plugins/reconciler.js'
|
||||
import { refreshActivePlugins } from '../../utils/plugins/refresh.js'
|
||||
import { logEvent } from '../analytics/index.js'
|
||||
|
||||
type SetAppState = (f: (prevState: AppState) => AppState) => void
|
||||
|
||||
/**
|
||||
* Update marketplace installation status in app state
|
||||
*/
|
||||
function updateMarketplaceStatus(
|
||||
setAppState: SetAppState,
|
||||
name: string,
|
||||
status: 'pending' | 'installing' | 'installed' | 'failed',
|
||||
error?: string,
|
||||
): void {
|
||||
setAppState(prevState => ({
|
||||
...prevState,
|
||||
plugins: {
|
||||
...prevState.plugins,
|
||||
installationStatus: {
|
||||
...prevState.plugins.installationStatus,
|
||||
marketplaces: prevState.plugins.installationStatus.marketplaces.map(
|
||||
m => (m.name === name ? { ...m, status, error } : m),
|
||||
),
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform background plugin startup checks and installations.
|
||||
*
|
||||
* This is a thin wrapper around reconcileMarketplaces() that maps onProgress
|
||||
* events to AppState updates for the REPL UI. After marketplaces are
|
||||
* reconciled:
|
||||
* - New installs → auto-refresh plugins (fixes "plugin-not-found" errors
|
||||
* from the initial cache-only load on fresh homespace/cleared cache)
|
||||
* - Updates only → set needsRefresh, show notification for /reload-plugins
|
||||
*/
|
||||
export async function performBackgroundPluginInstallations(
|
||||
setAppState: SetAppState,
|
||||
): Promise<void> {
|
||||
logForDebugging('performBackgroundPluginInstallations called')
|
||||
|
||||
try {
|
||||
// Compute diff upfront for initial UI status (pending spinners)
|
||||
const declared = getDeclaredMarketplaces()
|
||||
const materialized = await loadKnownMarketplacesConfig().catch(() => ({}))
|
||||
const diff = diffMarketplaces(declared, materialized)
|
||||
|
||||
const pendingNames = [
|
||||
...diff.missing,
|
||||
...diff.sourceChanged.map(c => c.name),
|
||||
]
|
||||
|
||||
// Initialize AppState with pending status. No per-plugin pending status —
|
||||
// plugin load is fast (cache hit or local copy); marketplace clone is the
|
||||
// slow part worth showing progress for.
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
plugins: {
|
||||
...prev.plugins,
|
||||
installationStatus: {
|
||||
marketplaces: pendingNames.map(name => ({
|
||||
name,
|
||||
status: 'pending' as const,
|
||||
})),
|
||||
plugins: [],
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
if (pendingNames.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`Installing ${pendingNames.length} marketplace(s) in background`,
|
||||
)
|
||||
|
||||
const result = await reconcileMarketplaces({
|
||||
onProgress: event => {
|
||||
switch (event.type) {
|
||||
case 'installing':
|
||||
updateMarketplaceStatus(setAppState, event.name, 'installing')
|
||||
break
|
||||
case 'installed':
|
||||
updateMarketplaceStatus(setAppState, event.name, 'installed')
|
||||
break
|
||||
case 'failed':
|
||||
updateMarketplaceStatus(
|
||||
setAppState,
|
||||
event.name,
|
||||
'failed',
|
||||
event.error,
|
||||
)
|
||||
break
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const metrics = {
|
||||
installed_count: result.installed.length,
|
||||
updated_count: result.updated.length,
|
||||
failed_count: result.failed.length,
|
||||
up_to_date_count: result.upToDate.length,
|
||||
}
|
||||
logEvent('tengu_marketplace_background_install', metrics)
|
||||
logForDiagnosticsNoPII(
|
||||
'info',
|
||||
'tengu_marketplace_background_install',
|
||||
metrics,
|
||||
)
|
||||
|
||||
if (result.installed.length > 0) {
|
||||
// New marketplaces were installed — auto-refresh plugins. This fixes
|
||||
// "Plugin not found in marketplace" errors from the initial cache-only
|
||||
// load (e.g., fresh homespace where marketplace cache was empty).
|
||||
// refreshActivePlugins clears all caches, reloads plugins, and bumps
|
||||
// pluginReconnectKey so MCP connections are re-established.
|
||||
clearMarketplacesCache()
|
||||
logForDebugging(
|
||||
`Auto-refreshing plugins after ${result.installed.length} new marketplace(s) installed`,
|
||||
)
|
||||
try {
|
||||
await refreshActivePlugins(setAppState)
|
||||
} catch (refreshError) {
|
||||
// If auto-refresh fails, fall back to needsRefresh notification so
|
||||
// the user can manually run /reload-plugins to recover.
|
||||
logError(refreshError)
|
||||
logForDebugging(
|
||||
`Auto-refresh failed, falling back to needsRefresh: ${refreshError}`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
clearPluginCache(
|
||||
'performBackgroundPluginInstallations: auto-refresh failed',
|
||||
)
|
||||
setAppState(prev => {
|
||||
if (prev.plugins.needsRefresh) return prev
|
||||
return {
|
||||
...prev,
|
||||
plugins: { ...prev.plugins, needsRefresh: true },
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (result.updated.length > 0) {
|
||||
// Existing marketplaces updated — notify user to run /reload-plugins.
|
||||
// Updates are less urgent and the user should choose when to apply them.
|
||||
clearMarketplacesCache()
|
||||
clearPluginCache(
|
||||
'performBackgroundPluginInstallations: marketplaces reconciled',
|
||||
)
|
||||
setAppState(prev => {
|
||||
if (prev.plugins.needsRefresh) return prev
|
||||
return {
|
||||
...prev,
|
||||
plugins: { ...prev.plugins, needsRefresh: true },
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
}
|
||||
}
|
||||
344
src/services/plugins/pluginCliCommands.ts
Normal file
344
src/services/plugins/pluginCliCommands.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* CLI command wrappers for plugin operations
|
||||
*
|
||||
* This module provides thin wrappers around the core plugin operations
|
||||
* that handle CLI-specific concerns like console output and process exit.
|
||||
*
|
||||
* For the core operations (without CLI side effects), see pluginOperations.ts
|
||||
*/
|
||||
import figures from 'figures'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { gracefulShutdown } from '../../utils/gracefulShutdown.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { getManagedPluginNames } from '../../utils/plugins/managedPlugins.js'
|
||||
import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js'
|
||||
import type { PluginScope } from '../../utils/plugins/schemas.js'
|
||||
import { writeToStdout } from '../../utils/process.js'
|
||||
import {
|
||||
buildPluginTelemetryFields,
|
||||
classifyPluginCommandError,
|
||||
} from '../../utils/telemetry/pluginTelemetry.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
logEvent,
|
||||
} from '../analytics/index.js'
|
||||
import {
|
||||
disableAllPluginsOp,
|
||||
disablePluginOp,
|
||||
enablePluginOp,
|
||||
type InstallableScope,
|
||||
installPluginOp,
|
||||
uninstallPluginOp,
|
||||
updatePluginOp,
|
||||
VALID_INSTALLABLE_SCOPES,
|
||||
VALID_UPDATE_SCOPES,
|
||||
} from './pluginOperations.js'
|
||||
|
||||
export { VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES }
|
||||
|
||||
type PluginCliCommand =
|
||||
| 'install'
|
||||
| 'uninstall'
|
||||
| 'enable'
|
||||
| 'disable'
|
||||
| 'disable-all'
|
||||
| 'update'
|
||||
|
||||
/**
|
||||
* Generic error handler for plugin CLI commands. Emits
|
||||
* tengu_plugin_command_failed before exit so dashboards can compute a
|
||||
* success rate against the corresponding success events.
|
||||
*/
|
||||
function handlePluginCommandError(
|
||||
error: unknown,
|
||||
command: PluginCliCommand,
|
||||
plugin?: string,
|
||||
): never {
|
||||
logError(error)
|
||||
const operation = plugin
|
||||
? `${command} plugin "${plugin}"`
|
||||
: command === 'disable-all'
|
||||
? 'disable all plugins'
|
||||
: `${command} plugins`
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.error(
|
||||
`${figures.cross} Failed to ${operation}: ${errorMessage(error)}`,
|
||||
)
|
||||
const telemetryFields = plugin
|
||||
? (() => {
|
||||
const { name, marketplace } = parsePluginIdentifier(plugin)
|
||||
return {
|
||||
_PROTO_plugin_name:
|
||||
name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
...(marketplace && {
|
||||
_PROTO_marketplace_name:
|
||||
marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
}),
|
||||
...buildPluginTelemetryFields(
|
||||
name,
|
||||
marketplace,
|
||||
getManagedPluginNames(),
|
||||
),
|
||||
}
|
||||
})()
|
||||
: {}
|
||||
logEvent('tengu_plugin_command_failed', {
|
||||
command:
|
||||
command as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
error_category: classifyPluginCommandError(
|
||||
error,
|
||||
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
...telemetryFields,
|
||||
})
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI command: Install a plugin non-interactively
|
||||
* @param plugin Plugin identifier (name or plugin@marketplace)
|
||||
* @param scope Installation scope: user, project, or local (defaults to 'user')
|
||||
*/
|
||||
export async function installPlugin(
|
||||
plugin: string,
|
||||
scope: InstallableScope = 'user',
|
||||
): Promise<void> {
|
||||
try {
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`Installing plugin "${plugin}"...`)
|
||||
|
||||
const result = await installPluginOp(plugin, scope)
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${figures.tick} ${result.message}`)
|
||||
|
||||
// _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns.
|
||||
// Unredacted plugin_id was previously logged to general-access
|
||||
// additional_metadata for all users — dropped in favor of the privileged
|
||||
// column route.
|
||||
const { name, marketplace } = parsePluginIdentifier(
|
||||
result.pluginId || plugin,
|
||||
)
|
||||
logEvent('tengu_plugin_installed_cli', {
|
||||
_PROTO_plugin_name:
|
||||
name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
...(marketplace && {
|
||||
_PROTO_marketplace_name:
|
||||
marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
}),
|
||||
scope: (result.scope ||
|
||||
scope) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
install_source:
|
||||
'cli-explicit' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
...buildPluginTelemetryFields(name, marketplace, getManagedPluginNames()),
|
||||
})
|
||||
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
handlePluginCommandError(error, 'install', plugin)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI command: Uninstall a plugin non-interactively
|
||||
* @param plugin Plugin name or plugin@marketplace identifier
|
||||
* @param scope Uninstall from scope: user, project, or local (defaults to 'user')
|
||||
*/
|
||||
export async function uninstallPlugin(
|
||||
plugin: string,
|
||||
scope: InstallableScope = 'user',
|
||||
keepData = false,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await uninstallPluginOp(plugin, scope, !keepData)
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${figures.tick} ${result.message}`)
|
||||
|
||||
const { name, marketplace } = parsePluginIdentifier(
|
||||
result.pluginId || plugin,
|
||||
)
|
||||
logEvent('tengu_plugin_uninstalled_cli', {
|
||||
_PROTO_plugin_name:
|
||||
name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
...(marketplace && {
|
||||
_PROTO_marketplace_name:
|
||||
marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
}),
|
||||
scope: (result.scope ||
|
||||
scope) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
...buildPluginTelemetryFields(name, marketplace, getManagedPluginNames()),
|
||||
})
|
||||
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
handlePluginCommandError(error, 'uninstall', plugin)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI command: Enable a plugin non-interactively
|
||||
* @param plugin Plugin name or plugin@marketplace identifier
|
||||
* @param scope Optional scope. If not provided, finds the most specific scope for the current project.
|
||||
*/
|
||||
export async function enablePlugin(
|
||||
plugin: string,
|
||||
scope?: InstallableScope,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await enablePluginOp(plugin, scope)
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${figures.tick} ${result.message}`)
|
||||
|
||||
const { name, marketplace } = parsePluginIdentifier(
|
||||
result.pluginId || plugin,
|
||||
)
|
||||
logEvent('tengu_plugin_enabled_cli', {
|
||||
_PROTO_plugin_name:
|
||||
name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
...(marketplace && {
|
||||
_PROTO_marketplace_name:
|
||||
marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
}),
|
||||
scope:
|
||||
result.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
...buildPluginTelemetryFields(name, marketplace, getManagedPluginNames()),
|
||||
})
|
||||
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
handlePluginCommandError(error, 'enable', plugin)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI command: Disable a plugin non-interactively
|
||||
* @param plugin Plugin name or plugin@marketplace identifier
|
||||
* @param scope Optional scope. If not provided, finds the most specific scope for the current project.
|
||||
*/
|
||||
export async function disablePlugin(
|
||||
plugin: string,
|
||||
scope?: InstallableScope,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await disablePluginOp(plugin, scope)
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${figures.tick} ${result.message}`)
|
||||
|
||||
const { name, marketplace } = parsePluginIdentifier(
|
||||
result.pluginId || plugin,
|
||||
)
|
||||
logEvent('tengu_plugin_disabled_cli', {
|
||||
_PROTO_plugin_name:
|
||||
name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
...(marketplace && {
|
||||
_PROTO_marketplace_name:
|
||||
marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
}),
|
||||
scope:
|
||||
result.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
...buildPluginTelemetryFields(name, marketplace, getManagedPluginNames()),
|
||||
})
|
||||
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
handlePluginCommandError(error, 'disable', plugin)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI command: Disable all enabled plugins non-interactively
|
||||
*/
|
||||
export async function disableAllPlugins(): Promise<void> {
|
||||
try {
|
||||
const result = await disableAllPluginsOp()
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${figures.tick} ${result.message}`)
|
||||
|
||||
logEvent('tengu_plugin_disabled_all_cli', {})
|
||||
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
handlePluginCommandError(error, 'disable-all')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI command: Update a plugin non-interactively
|
||||
* @param plugin Plugin name or plugin@marketplace identifier
|
||||
* @param scope Scope to update
|
||||
*/
|
||||
export async function updatePluginCli(
|
||||
plugin: string,
|
||||
scope: PluginScope,
|
||||
): Promise<void> {
|
||||
try {
|
||||
writeToStdout(
|
||||
`Checking for updates for plugin "${plugin}" at ${scope} scope…\n`,
|
||||
)
|
||||
|
||||
const result = await updatePluginOp(plugin, scope)
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
|
||||
writeToStdout(`${figures.tick} ${result.message}\n`)
|
||||
|
||||
if (!result.alreadyUpToDate) {
|
||||
const { name, marketplace } = parsePluginIdentifier(
|
||||
result.pluginId || plugin,
|
||||
)
|
||||
logEvent('tengu_plugin_updated_cli', {
|
||||
_PROTO_plugin_name:
|
||||
name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
...(marketplace && {
|
||||
_PROTO_marketplace_name:
|
||||
marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
|
||||
}),
|
||||
old_version: (result.oldVersion ||
|
||||
'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
new_version: (result.newVersion ||
|
||||
'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
...buildPluginTelemetryFields(
|
||||
name,
|
||||
marketplace,
|
||||
getManagedPluginNames(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
await gracefulShutdown(0)
|
||||
} catch (error) {
|
||||
handlePluginCommandError(error, 'update', plugin)
|
||||
}
|
||||
}
|
||||
1088
src/services/plugins/pluginOperations.ts
Normal file
1088
src/services/plugins/pluginOperations.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user