diff --git a/packages/snap/src/cli.ts b/packages/snap/src/cli.ts index 85ac40503..88fff679d 100755 --- a/packages/snap/src/cli.ts +++ b/packages/snap/src/cli.ts @@ -3,6 +3,7 @@ import { program } from 'commander' import './cloud' import { handler } from './cloud/config-utils' +import { wrapAction } from './utils/analytics' import { version } from './version' const defaultPort = 3000 @@ -58,20 +59,24 @@ program program .command('generate-types') .description('Generate types.d.ts file for your project') - .action(async () => { - const { generateTypes } = require('./generate-types') - await generateTypes(process.cwd()) - process.exit(0) - }) + .action( + wrapAction(async () => { + const { generateTypes } = require('./generate-types') + await generateTypes(process.cwd()) + process.exit(0) + }), + ) program .command('install') .description('Sets up Python virtual environment and install dependencies') .option('-v, --verbose', 'Enable verbose logging') - .action(async (options) => { - const { install } = require('./install') - await install({ isVerbose: options.verbose }) - }) + .action( + wrapAction(async (options) => { + const { install } = require('./install') + await install({ isVerbose: options.verbose }) + }), + ) program .command('dev') @@ -82,17 +87,19 @@ program .option('-d, --debug', 'Enable debug logging') .option('-m, --mermaid', 'Enable mermaid diagram generation') .option('--motia-dir ', 'Path where .motia folder will be created') - .action(async (arg) => { - if (arg.debug) { - console.log('🔍 Debug logging enabled') - process.env.LOG_LEVEL = 'debug' - } - - const port = arg.port ? parseInt(arg.port) : defaultPort - const host = arg.host ? arg.host : defaultHost - const { dev } = require('./dev') - await dev(port, host, arg.disableVerbose, arg.mermaid, arg.motiaDir) - }) + .action( + wrapAction(async (arg) => { + if (arg.debug) { + console.log('🔍 Debug logging enabled') + process.env.LOG_LEVEL = 'debug' + } + + const port = arg.port ? parseInt(arg.port) : defaultPort + const host = arg.host ? arg.host : defaultHost + const { dev } = require('./dev') + await dev(port, host, arg.disableVerbose, arg.mermaid, arg.motiaDir) + }), + ) program .command('start') @@ -102,17 +109,19 @@ program .option('-v, --disable-verbose', 'Disable verbose logging') .option('-d, --debug', 'Enable debug logging') .option('--motia-dir ', 'Path where .motia folder will be created') - .action(async (arg) => { - if (arg.debug) { - console.log('🔍 Debug logging enabled') - process.env.LOG_LEVEL = 'debug' - } - - const port = arg.port ? parseInt(arg.port) : defaultPort - const host = arg.host ? arg.host : defaultHost - const { start } = require('./start') - await start(port, host, arg.disableVerbose, arg.motiaDir) - }) + .action( + wrapAction(async (arg) => { + if (arg.debug) { + console.log('🔍 Debug logging enabled') + process.env.LOG_LEVEL = 'debug' + } + + const port = arg.port ? parseInt(arg.port) : defaultPort + const host = arg.host ? arg.host : defaultHost + const { start } = require('./start') + await start(port, host, arg.disableVerbose, arg.motiaDir) + }), + ) program .command('emit') @@ -120,11 +129,11 @@ program .requiredOption('--topic ', 'Event topic/type to emit') .requiredOption('--message ', 'Event payload as JSON string') .option('-p, --port ', 'Port number (default: 3000)') - .action(async (options) => { - const port = options.port || 3000 - const url = `http://localhost:${port}/emit` + .action( + wrapAction(async (options) => { + const port = options.port || 3000 + const url = `http://localhost:${port}/emit` - try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -140,11 +149,8 @@ program const result = await response.json() console.log('Event emitted successfully:', result) - } catch (error) { - console.error('Error:', error instanceof Error ? error.message : 'Unknown error') - process.exit(1) - } - }) + }), + ) const generate = program.command('generate').description('Generate motia resources') @@ -152,12 +158,14 @@ generate .command('step') .description('Create a new step with interactive prompts') .option('-d, --dir ', 'The path relative to the steps directory, used to create the step file') - .action(async (arg) => { - const { createStep } = require('./create-step') - await createStep({ - stepFilePath: arg.dir, - }) - }) + .action( + wrapAction(async (arg) => { + const { createStep } = require('./create-step') + await createStep({ + stepFilePath: arg.dir, + }) + }), + ) generate .command('openapi') @@ -165,27 +173,31 @@ generate .option('-t, --title ', 'Title for the OpenAPI document. Defaults to project name') .option('-v, --version <version>', 'Version for the OpenAPI document. Defaults to 1.0.0', '1.0.0') .option('-o, --output <output>', 'Output file for the OpenAPI document. Defaults to openapi.json', 'openapi.json') - .action(async (options) => { - const { generateLockedData } = require('./generate-locked-data') - const { generateOpenApi } = require('./openapi/generate') + .action( + wrapAction(async (options) => { + const { generateLockedData } = require('./generate-locked-data') + const { generateOpenApi } = require('./openapi/generate') - const lockedData = await generateLockedData({ projectDir: process.cwd() }) - const apiSteps = lockedData.apiSteps() + const lockedData = await generateLockedData({ projectDir: process.cwd() }) + const apiSteps = lockedData.apiSteps() - generateOpenApi(process.cwd(), apiSteps, options.title, options.version, options.output) - process.exit(0) - }) + generateOpenApi(process.cwd(), apiSteps, options.title, options.version, options.output) + process.exit(0) + }), + ) const docker = program.command('docker').description('Motia docker commands') docker .command('setup') .description('Setup a motia-docker for your project') - .action(async () => { - const { setup } = require('./docker/setup') - await setup() - process.exit(0) - }) + .action( + wrapAction(async () => { + const { setup } = require('./docker/setup') + await setup() + process.exit(0) + }), + ) docker .command('run') @@ -193,21 +205,27 @@ docker .option('-p, --port <port>', 'The port to run the server on', `${defaultPort}`) .option('-n, --project-name <project name>', 'The name for your project') .option('-s, --skip-build', 'Skip docker build') - .action(async (arg) => { - const { run } = require('./docker/run') - await run(arg.port, arg.projectName, arg.skipBuild) - process.exit(0) - }) + .action( + wrapAction(async (arg) => { + const { run } = require('./docker/run') + await run(arg.port, arg.projectName, arg.skipBuild) + process.exit(0) + }), + ) docker .command('build') .description('Build your project in a docker container') .option('-n, --project-name <project name>', 'The name for your project') - .action(async (arg) => { - const { build } = require('./docker/build') - await build(arg.projectName) - process.exit(0) - }) + .action( + wrapAction(async (arg) => { + const { build } = require('./docker/build') + await build(arg.projectName) + process.exit(0) + }), + ) program.version(version, '-V, --version', 'Output the current version') -program.parse(process.argv) +program.parseAsync(process.argv).catch(() => { + process.exit(1) +}) diff --git a/packages/snap/src/cloud/config-utils.ts b/packages/snap/src/cloud/config-utils.ts index 1ec139490..e9e2863fe 100644 --- a/packages/snap/src/cloud/config-utils.ts +++ b/packages/snap/src/cloud/config-utils.ts @@ -1,5 +1,23 @@ +import { flush } from '@amplitude/analytics-node' +import { logCliError } from '../utils/analytics' import { CLIOutputManager, type Message } from './cli-output-manager' +const getCommandName = (): string => { + const args = process.argv.slice(2) + const commandParts: string[] = [] + + for (let i = 0; i < args.length && i < 3; i++) { + const arg = args[i] + if (!arg.startsWith('-') && !arg.startsWith('--')) { + commandParts.push(arg) + } else { + break + } + } + + return commandParts.join(' ') || 'unknown' +} + export class CliContext { private readonly output = new CLIOutputManager() @@ -28,10 +46,15 @@ export type CliHandler = <TArgs extends Record<string, any>>(args: TArgs, contex export function handler(handler: CliHandler): (args: Record<string, any>) => Promise<void> { return async (args: Record<string, unknown>) => { const context = new CliContext() + const commandName = getCommandName() try { await handler(args, context) } catch (error: any) { + logCliError(commandName, error) + await flush().promise.catch(() => { + // Silently fail + }) if (error instanceof Error) { context.log('error', (message) => message.tag('failed').append(error.message)) context.exit(1) diff --git a/packages/snap/src/utils/analytics.ts b/packages/snap/src/utils/analytics.ts index 184def9b8..826a1d466 100644 --- a/packages/snap/src/utils/analytics.ts +++ b/packages/snap/src/utils/analytics.ts @@ -1,5 +1,5 @@ -import { add, Identify, identify, init, setOptOut, Types } from '@amplitude/analytics-node' -import { getUserIdentifier, isAnalyticsEnabled } from '@motiadev/core' +import { add, flush, Identify, identify, init, setOptOut, Types } from '@amplitude/analytics-node' +import { getUserIdentifier, isAnalyticsEnabled, trackEvent } from '@motiadev/core' import { getProjectName } from '@motiadev/core/dist/src/analytics/utils' import { version } from '../version' import { MotiaEnrichmentPlugin } from './amplitude/enrichment-plugin' @@ -38,3 +38,54 @@ export const identifyUser = () => { // Silently fail } } + +export const logCliError = (command: string, error: unknown) => { + try { + const errorMessage = error instanceof Error ? error.message : String(error) + const errorType = error instanceof Error ? error.constructor.name : 'Unknown' + const errorStack = + error instanceof Error && error.stack ? error.stack.split('\n').slice(0, 10).join('\n') : undefined + + const truncatedMessage = errorMessage.length > 500 ? `${errorMessage.substring(0, 500)}...` : errorMessage + + trackEvent('cli_command_error', { + command, + error_message: truncatedMessage, + error_type: errorType, + ...(errorStack && { error_stack: errorStack }), + }) + } catch (logError) { + // Silently fail to not disrupt CLI operations + } +} + +const getCommandNameFromArgs = (): string => { + const args = process.argv.slice(2) + const commandParts: string[] = [] + + for (let i = 0; i < args.length && i < 3; i++) { + const arg = args[i] + if (!arg.startsWith('-') && !arg.startsWith('--')) { + commandParts.push(arg) + } else { + break + } + } + + return commandParts.join(' ') || 'unknown' +} + +export const wrapAction = <T extends (...args: any[]) => Promise<any>>(action: T): T => { + return (async (...args: any[]) => { + try { + return await action(...args) + } catch (error) { + const commandName = getCommandNameFromArgs() + logCliError(commandName, error) + await flush().promise.catch(() => { + // Silently fail + }) + throw error + } + }) as T +}