/ services / plugins / pluginCliCommands.ts
pluginCliCommands.ts
  1  /**
  2   * CLI command wrappers for plugin operations
  3   *
  4   * This module provides thin wrappers around the core plugin operations
  5   * that handle CLI-specific concerns like console output and process exit.
  6   *
  7   * For the core operations (without CLI side effects), see pluginOperations.ts
  8   */
  9  import figures from 'figures'
 10  import { errorMessage } from '../../utils/errors.js'
 11  import { gracefulShutdown } from '../../utils/gracefulShutdown.js'
 12  import { logError } from '../../utils/log.js'
 13  import { getManagedPluginNames } from '../../utils/plugins/managedPlugins.js'
 14  import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js'
 15  import type { PluginScope } from '../../utils/plugins/schemas.js'
 16  import { writeToStdout } from '../../utils/process.js'
 17  import {
 18    buildPluginTelemetryFields,
 19    classifyPluginCommandError,
 20  } from '../../utils/telemetry/pluginTelemetry.js'
 21  import {
 22    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 23    type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
 24    logEvent,
 25  } from '../analytics/index.js'
 26  import {
 27    disableAllPluginsOp,
 28    disablePluginOp,
 29    enablePluginOp,
 30    type InstallableScope,
 31    installPluginOp,
 32    uninstallPluginOp,
 33    updatePluginOp,
 34    VALID_INSTALLABLE_SCOPES,
 35    VALID_UPDATE_SCOPES,
 36  } from './pluginOperations.js'
 37  
 38  export { VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES }
 39  
 40  type PluginCliCommand =
 41    | 'install'
 42    | 'uninstall'
 43    | 'enable'
 44    | 'disable'
 45    | 'disable-all'
 46    | 'update'
 47  
 48  /**
 49   * Generic error handler for plugin CLI commands. Emits
 50   * tengu_plugin_command_failed before exit so dashboards can compute a
 51   * success rate against the corresponding success events.
 52   */
 53  function handlePluginCommandError(
 54    error: unknown,
 55    command: PluginCliCommand,
 56    plugin?: string,
 57  ): never {
 58    logError(error)
 59    const operation = plugin
 60      ? `${command} plugin "${plugin}"`
 61      : command === 'disable-all'
 62        ? 'disable all plugins'
 63        : `${command} plugins`
 64    // biome-ignore lint/suspicious/noConsole:: intentional console output
 65    console.error(
 66      `${figures.cross} Failed to ${operation}: ${errorMessage(error)}`,
 67    )
 68    const telemetryFields = plugin
 69      ? (() => {
 70          const { name, marketplace } = parsePluginIdentifier(plugin)
 71          return {
 72            _PROTO_plugin_name:
 73              name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
 74            ...(marketplace && {
 75              _PROTO_marketplace_name:
 76                marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
 77            }),
 78            ...buildPluginTelemetryFields(
 79              name,
 80              marketplace,
 81              getManagedPluginNames(),
 82            ),
 83          }
 84        })()
 85      : {}
 86    logEvent('tengu_plugin_command_failed', {
 87      command:
 88        command as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 89      error_category: classifyPluginCommandError(
 90        error,
 91      ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 92      ...telemetryFields,
 93    })
 94    // eslint-disable-next-line custom-rules/no-process-exit
 95    process.exit(1)
 96  }
 97  
 98  /**
 99   * CLI command: Install a plugin non-interactively
100   * @param plugin Plugin identifier (name or plugin@marketplace)
101   * @param scope Installation scope: user, project, or local (defaults to 'user')
102   */
103  export async function installPlugin(
104    plugin: string,
105    scope: InstallableScope = 'user',
106  ): Promise<void> {
107    try {
108      // biome-ignore lint/suspicious/noConsole:: intentional console output
109      console.log(`Installing plugin "${plugin}"...`)
110  
111      const result = await installPluginOp(plugin, scope)
112  
113      if (!result.success) {
114        throw new Error(result.message)
115      }
116  
117      // biome-ignore lint/suspicious/noConsole:: intentional console output
118      console.log(`${figures.tick} ${result.message}`)
119  
120      // _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns.
121      // Unredacted plugin_id was previously logged to general-access
122      // additional_metadata for all users — dropped in favor of the privileged
123      // column route.
124      const { name, marketplace } = parsePluginIdentifier(
125        result.pluginId || plugin,
126      )
127      logEvent('tengu_plugin_installed_cli', {
128        _PROTO_plugin_name:
129          name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
130        ...(marketplace && {
131          _PROTO_marketplace_name:
132            marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
133        }),
134        scope: (result.scope ||
135          scope) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
136        install_source:
137          'cli-explicit' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
138        ...buildPluginTelemetryFields(name, marketplace, getManagedPluginNames()),
139      })
140  
141      // eslint-disable-next-line custom-rules/no-process-exit
142      process.exit(0)
143    } catch (error) {
144      handlePluginCommandError(error, 'install', plugin)
145    }
146  }
147  
148  /**
149   * CLI command: Uninstall a plugin non-interactively
150   * @param plugin Plugin name or plugin@marketplace identifier
151   * @param scope Uninstall from scope: user, project, or local (defaults to 'user')
152   */
153  export async function uninstallPlugin(
154    plugin: string,
155    scope: InstallableScope = 'user',
156    keepData = false,
157  ): Promise<void> {
158    try {
159      const result = await uninstallPluginOp(plugin, scope, !keepData)
160  
161      if (!result.success) {
162        throw new Error(result.message)
163      }
164  
165      // biome-ignore lint/suspicious/noConsole:: intentional console output
166      console.log(`${figures.tick} ${result.message}`)
167  
168      const { name, marketplace } = parsePluginIdentifier(
169        result.pluginId || plugin,
170      )
171      logEvent('tengu_plugin_uninstalled_cli', {
172        _PROTO_plugin_name:
173          name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
174        ...(marketplace && {
175          _PROTO_marketplace_name:
176            marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
177        }),
178        scope: (result.scope ||
179          scope) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
180        ...buildPluginTelemetryFields(name, marketplace, getManagedPluginNames()),
181      })
182  
183      // eslint-disable-next-line custom-rules/no-process-exit
184      process.exit(0)
185    } catch (error) {
186      handlePluginCommandError(error, 'uninstall', plugin)
187    }
188  }
189  
190  /**
191   * CLI command: Enable a plugin non-interactively
192   * @param plugin Plugin name or plugin@marketplace identifier
193   * @param scope Optional scope. If not provided, finds the most specific scope for the current project.
194   */
195  export async function enablePlugin(
196    plugin: string,
197    scope?: InstallableScope,
198  ): Promise<void> {
199    try {
200      const result = await enablePluginOp(plugin, scope)
201  
202      if (!result.success) {
203        throw new Error(result.message)
204      }
205  
206      // biome-ignore lint/suspicious/noConsole:: intentional console output
207      console.log(`${figures.tick} ${result.message}`)
208  
209      const { name, marketplace } = parsePluginIdentifier(
210        result.pluginId || plugin,
211      )
212      logEvent('tengu_plugin_enabled_cli', {
213        _PROTO_plugin_name:
214          name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
215        ...(marketplace && {
216          _PROTO_marketplace_name:
217            marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
218        }),
219        scope:
220          result.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
221        ...buildPluginTelemetryFields(name, marketplace, getManagedPluginNames()),
222      })
223  
224      // eslint-disable-next-line custom-rules/no-process-exit
225      process.exit(0)
226    } catch (error) {
227      handlePluginCommandError(error, 'enable', plugin)
228    }
229  }
230  
231  /**
232   * CLI command: Disable a plugin non-interactively
233   * @param plugin Plugin name or plugin@marketplace identifier
234   * @param scope Optional scope. If not provided, finds the most specific scope for the current project.
235   */
236  export async function disablePlugin(
237    plugin: string,
238    scope?: InstallableScope,
239  ): Promise<void> {
240    try {
241      const result = await disablePluginOp(plugin, scope)
242  
243      if (!result.success) {
244        throw new Error(result.message)
245      }
246  
247      // biome-ignore lint/suspicious/noConsole:: intentional console output
248      console.log(`${figures.tick} ${result.message}`)
249  
250      const { name, marketplace } = parsePluginIdentifier(
251        result.pluginId || plugin,
252      )
253      logEvent('tengu_plugin_disabled_cli', {
254        _PROTO_plugin_name:
255          name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
256        ...(marketplace && {
257          _PROTO_marketplace_name:
258            marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
259        }),
260        scope:
261          result.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
262        ...buildPluginTelemetryFields(name, marketplace, getManagedPluginNames()),
263      })
264  
265      // eslint-disable-next-line custom-rules/no-process-exit
266      process.exit(0)
267    } catch (error) {
268      handlePluginCommandError(error, 'disable', plugin)
269    }
270  }
271  
272  /**
273   * CLI command: Disable all enabled plugins non-interactively
274   */
275  export async function disableAllPlugins(): Promise<void> {
276    try {
277      const result = await disableAllPluginsOp()
278  
279      if (!result.success) {
280        throw new Error(result.message)
281      }
282  
283      // biome-ignore lint/suspicious/noConsole:: intentional console output
284      console.log(`${figures.tick} ${result.message}`)
285  
286      logEvent('tengu_plugin_disabled_all_cli', {})
287  
288      // eslint-disable-next-line custom-rules/no-process-exit
289      process.exit(0)
290    } catch (error) {
291      handlePluginCommandError(error, 'disable-all')
292    }
293  }
294  
295  /**
296   * CLI command: Update a plugin non-interactively
297   * @param plugin Plugin name or plugin@marketplace identifier
298   * @param scope Scope to update
299   */
300  export async function updatePluginCli(
301    plugin: string,
302    scope: PluginScope,
303  ): Promise<void> {
304    try {
305      writeToStdout(
306        `Checking for updates for plugin "${plugin}" at ${scope} scope…\n`,
307      )
308  
309      const result = await updatePluginOp(plugin, scope)
310  
311      if (!result.success) {
312        throw new Error(result.message)
313      }
314  
315      writeToStdout(`${figures.tick} ${result.message}\n`)
316  
317      if (!result.alreadyUpToDate) {
318        const { name, marketplace } = parsePluginIdentifier(
319          result.pluginId || plugin,
320        )
321        logEvent('tengu_plugin_updated_cli', {
322          _PROTO_plugin_name:
323            name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
324          ...(marketplace && {
325            _PROTO_marketplace_name:
326              marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
327          }),
328          old_version: (result.oldVersion ||
329            'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
330          new_version: (result.newVersion ||
331            'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
332          ...buildPluginTelemetryFields(
333            name,
334            marketplace,
335            getManagedPluginNames(),
336          ),
337        })
338      }
339  
340      await gracefulShutdown(0)
341    } catch (error) {
342      handlePluginCommandError(error, 'update', plugin)
343    }
344  }