Skip to content

Command Hooks ​

Gunshi provides powerful lifecycle hooks that allow you to intercept and control command execution at various stages.

These hooks enable advanced scenarios like logging, monitoring, validation, and error handling.

Understanding Command Lifecycle ​

The command execution lifecycle in Gunshi follows these stages:

Available Hooks ​

Gunshi provides three main lifecycle hooks:

  • onBeforeCommand: Executes before the command runs
  • onAfterCommand: Executes after successful command completion
  • onErrorCommand: Executes when a command throws an error

Basic Hook Usage ​

Setting Up Hooks ​

The following example demonstrates how to configure lifecycle hooks when initializing your CLI application.

In this setup, we define three hooks that will execute at different stages of the command lifecycle:

cli.ts
ts
import { cli } from 'gunshi'

await cli(
  process.argv.slice(2),
  {
    name: 'server',
    run: () => {
      console.log('Starting server...')
    }
  },
  {
    name: 'my-app',
    version: '1.0.0',

    // Define lifecycle hooks
    onBeforeCommand: ctx => {
      console.log(`About to run: ${ctx.name}`)
    },

    onAfterCommand: (ctx, result) => {
      console.log(`Command ${ctx.name} completed successfully`)
    },

    onErrorCommand: (ctx, error) => {
      console.error(`Command ${ctx.name} failed:`, error)
    }
  }
)

TIP

The example fully code is here.

Hook Parameters ​

Each lifecycle hook receives specific parameters that provide context about the command execution.

The CommandContext parameter is read-only and contains all command metadata, while onAfterCommand also receives the command result and onErrorCommand receives the thrown error:

ts
{
  // Before command execution
  onBeforeCommand?: (ctx: Readonly<CommandContext>) => Awaitable<void>

  // After successful execution
  onAfterCommand?: (ctx: Readonly<CommandContext>, result: string | undefined) => Awaitable<void>

  // On command error
  onErrorCommand?: (ctx: Readonly<CommandContext>, error: Error) => Awaitable<void>
}

NOTE

The CommandContext object provides comprehensive information about command execution. For the complete CommandContext API reference including all properties and types, see the CommandContext interface documentation.

With an understanding of how hooks work and their parameters, let's explore how they differ from and interact with plugin decorators.

Hooks vs Decorators ​

Gunshi provides two distinct mechanisms for controlling command execution:

  1. CLI-level Hooks: Lifecycle hooks that run before and after command execution

    • onBeforeCommand: Pre-execution processing (logging, validation, initialization)
    • onAfterCommand: Post-success processing (cleanup, metrics recording)
    • onErrorCommand: Error handling (error logging, rollback)
  2. Plugin Decorators: Wrap the command itself to modify its behavior

    • decorateCommand: Wraps command runner to add or modify functionality
    • Multiple plugins can chain decorators (decorator pattern)

TIP

The decorateCommand method is a powerful plugin API that allows wrapping command execution to add or modify functionality. It enables plugins to implement cross-cutting concerns like authentication, logging, and transaction management. For comprehensive information about how to use decorators in plugins, see the Plugin Decorators documentation.

Execution Flow ​

The following diagram illustrates how hooks and decorators interact during command execution:

Detailed Execution Sequence ​

  1. onBeforeCommand Hook - Pre-execution setup and validation
  2. Plugin Decorator Chain - Command wrapping by plugins
    • Applied in reverse order (LIFO - last registered, first executed)
    • Each decorator wraps the next runner in the chain
  3. Command Runner - Actual command execution
  4. onAfterCommand Hook - Post-success processing
  5. onErrorCommand Hook - Error handling when exceptions occur

Plugin Decorator Example ​

The following example demonstrates how to use the decorateCommand method in a plugin to measure command execution time:

ts
import { plugin } from 'gunshi/plugin'

// Using decorateCommand in a plugin
export default plugin({
  id: 'timing-plugin',
  setup: ctx => {
    // Wrap command execution to measure execution time
    ctx.decorateCommand(baseRunner => {
      return async commandCtx => {
        const start = Date.now()
        try {
          const result = await baseRunner(commandCtx)
          console.log(`Execution time: ${Date.now() - start}ms`)
          return result
        } catch (error) {
          console.log(`Failed after: ${Date.now() - start}ms`)
          throw error
        }
      }
    })
  }
})

NOTE

Plugins don't have CLI-level hooks (onBeforeCommand, etc.). Instead, they use the decorateCommand method to wrap command execution and add custom logic. This allows plugins to extend and modify command behavior through the decorator pattern.

Practical Use Cases ​

TIP

The following examples use plugin extensions through ctx.extensions. Extensions are how plugins add functionality to the command context, allowing you to access plugin-provided features like logging, metrics, authentication, and database connections. For comprehensive information about working with extensions, including type-safe patterns and best practices, see the Context Extensions documentation.

Logging and Monitoring ​

Implement comprehensive logging across all commands:

ts
import { cli } from 'gunshi'
import logger, { pluginId as loggerId } from '@my/plugin-logger'

await cli(process.argv.slice(2), commands, {
  name: 'my-app',

  // Install logger plugin
  plugins: [logger()],

  onBeforeCommand: ctx => {
    const logger = ctx.extensions[loggerId]
    // Log command start with arguments
    logger?.info('Command started', {
      command: ctx.name,
      args: ctx.values,
      timestamp: new Date().toISOString()
    })
  },

  onAfterCommand: (ctx, result) => {
    const logger = ctx.extensions[loggerId]
    // Log successful completion
    logger?.info('Command completed', {
      command: ctx.name,
      duration: Date.now() - logger?.startTime,
      result: typeof result
    })
  },

  onErrorCommand: (ctx, error) => {
    const logger = ctx.extensions[loggerId]
    // Log errors with full context
    logger?.error('Command failed', {
      command: ctx.name,
      error: error.message,
      stack: error.stack,
      args: ctx.values
    })
  }
})

Performance Monitoring ​

Track command execution times and performance metrics:

ts
import { cli } from 'gunshi'
import metrics, { pluginId as metricsId } from '@my/plugin-metrics'

await cli(process.argv.slice(2), commands, {
  name: 'my-app',

  // Install metrics plugin
  plugins: [metrics()],

  onBeforeCommand: ctx => {
    const metrics = ctx.extensions[metricsId]
    // Start tracking command execution
    metrics?.startTracking({
      command: ctx.name,
      args: ctx.values,
      environment: process.env.NODE_ENV
    })
  },

  onAfterCommand: async (ctx, result) => {
    const metrics = ctx.extensions[metricsId]
    // Record successful completion
    const duration = metrics?.endTracking()

    // Send metrics to monitoring service
    await metrics?.send({
      command: ctx.name,
      status: 'success',
      duration,
      memoryUsage: process.memoryUsage(),
      resultSize: result?.length || 0
    })
  },

  onErrorCommand: async (ctx, error) => {
    const metrics = ctx.extensions[metricsId]
    // Record failure metrics
    const duration = metrics?.endTracking()

    // Send error metrics with additional context
    await metrics?.send({
      command: ctx.name,
      status: 'failed',
      duration,
      error: error.message,
      errorType: error.constructor.name,
      stackTrace: error.stack
    })
  }
})

Validation and Guards ​

Use hooks to implement global validation or access control:

ts
import { cli } from 'gunshi'
import auth, { pluginId as authId } from '@my/plugin-auth'

await cli(process.argv.slice(2), commands, {
  name: 'my-app',

  // Install auth plugin
  plugins: [
    auth({
      publicCommands: ['help', 'version', 'login'],
      tokenSource: ['env:AUTH_TOKEN', 'args:token']
    })
  ],

  onBeforeCommand: async ctx => {
    const auth = ctx.extensions[authId]

    // Skip auth for public commands
    if (auth?.isPublicCommand(ctx.name)) {
      return
    }

    // Verify authentication
    const token = auth?.getToken()
    if (!token) {
      throw new Error('Authentication required. Please run "login" first.')
    }

    const user = await auth?.verifyToken(token)
    if (!user) {
      throw new Error('Invalid or expired token. Please login again.')
    }

    // Store user info for command use
    await auth?.setCurrentUser(user)
  },

  onAfterCommand: async ctx => {
    const auth = ctx.extensions[authId]
    // Clean up sensitive data after command execution
    await auth?.clearSession()
  },

  onErrorCommand: async (ctx, error) => {
    const auth = ctx.extensions[authId]
    // Log security-related errors
    if (error.message.includes('Authentication') || error.message.includes('token')) {
      await auth?.logSecurityEvent({
        type: 'auth_failure',
        command: ctx.name,
        timestamp: new Date().toISOString()
      })
    }
  }
})

Transaction Management ​

Implement database transactions or rollback mechanisms:

ts
import { cli } from 'gunshi'
import database, { pluginId as dbId } from '@my/plugin-database'

await cli(process.argv.slice(2), commands, {
  name: 'my-app',

  // Install database plugin with transaction support
  plugins: [
    database({
      connectionString: process.env.DATABASE_URL,
      transactionalCommands: ['create', 'update', 'delete', 'migrate']
    })
  ],

  onBeforeCommand: async ctx => {
    const db = ctx.extensions[dbId]

    // Start transaction for data-modifying commands
    if (db?.isTransactionalCommand(ctx.name)) {
      const transaction = await db.beginTransaction()

      // Store transaction ID for tracking
      await db?.setCurrentTransaction(transaction.id)

      console.log(`Transaction ${transaction.id} started for command: ${ctx.name}`)
    }
  },

  onAfterCommand: async (ctx, result) => {
    const db = ctx.extensions[dbId]
    const transaction = db?.getCurrentTransaction()

    if (transaction) {
      // Commit on success
      await db.commit(transaction.id)
      console.log(`Transaction ${transaction.id} committed successfully`)

      // Clean up transaction reference
      await db.clearCurrentTransaction()
    }
  },

  onErrorCommand: async (ctx, error) => {
    const db = ctx.extensions[dbId]
    const transaction = db?.getCurrentTransaction()

    if (transaction) {
      // Rollback on error
      await db?.rollback(transaction.id)
      console.error(`Transaction ${transaction.id} rolled back due to error:`, error.message)

      // Log the failed transaction for audit
      await db?.logTransactionFailure({
        id: transaction.id,
        command: ctx.name,
        error: error.message,
        timestamp: new Date().toISOString()
      })

      // Clean up transaction reference
      await db?.clearCurrentTransaction()
    }
  }
})

Hook Execution Order ​

When multiple hooks and decorators are present, they execute in a specific sequence as shown in the Hooks vs Decorators section above.

Understanding this order is crucial for implementing complex behaviors like transaction management or error recovery.

For detailed execution flow, refer to the execution diagram in the Hooks vs Decorators section.

Released under the MIT License.