Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 88 additions & 70 deletions packages/snap/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand All @@ -82,17 +87,19 @@ program
.option('-d, --debug', 'Enable debug logging')
.option('-m, --mermaid', 'Enable mermaid diagram generation')
.option('--motia-dir <path>', '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')
Expand All @@ -102,29 +109,31 @@ program
.option('-v, --disable-verbose', 'Disable verbose logging')
.option('-d, --debug', 'Enable debug logging')
.option('--motia-dir <path>', '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')
.description('Emit an event to the Motia server')
.requiredOption('--topic <topic>', 'Event topic/type to emit')
.requiredOption('--message <message>', 'Event payload as JSON string')
.option('-p, --port <number>', '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' },
Expand All @@ -140,74 +149,83 @@ 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')

generate
.command('step')
.description('Create a new step with interactive prompts')
.option('-d, --dir <step file path>', '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')
.description('Generate OpenAPI spec for your project')
.option('-t, --title <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')
.description('Build and run your project in a docker container')
.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)
})
23 changes: 23 additions & 0 deletions packages/snap/src/cloud/config-utils.ts
Original file line number Diff line number Diff line change
@@ -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()

Expand Down Expand Up @@ -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)
Expand Down
55 changes: 53 additions & 2 deletions packages/snap/src/utils/analytics.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
}
Loading