From 6a00a49de0a0a589b597ff6875fda87ca13c00dd Mon Sep 17 00:00:00 2001 From: Travis Fischer Date: Tue, 24 Jun 2025 08:17:45 -0500 Subject: [PATCH] feat: fix cli and publishing flow --- apps/api/src/api-v1/deployments/schemas.ts | 5 +- apps/api/src/api-v1/index.ts | 22 +-- apps/api/src/lib/openapi-utils.ts | 16 ++ packages/cli/package.json | 2 + packages/cli/src/cli.ts | 14 +- packages/cli/src/commands/debug.ts | 50 +++--- packages/cli/src/commands/deploy.ts | 82 +++++----- packages/cli/src/commands/get.ts | 31 ++-- packages/cli/src/commands/list.ts | 120 +++++++------- packages/cli/src/commands/publish.ts | 173 +++++++++++---------- packages/cli/src/commands/signin.ts | 35 +++-- packages/cli/src/commands/signout.ts | 22 ++- packages/cli/src/commands/signup.ts | 37 +++-- packages/cli/src/commands/whoami.ts | 17 +- packages/cli/src/lib/exit-hooks.ts | 14 ++ packages/cli/src/lib/handle-error.ts | 44 ++++++ packages/cli/src/types.ts | 1 + pnpm-lock.yaml | 62 ++------ pnpm-workspace.yaml | 2 +- 19 files changed, 437 insertions(+), 312 deletions(-) create mode 100644 packages/cli/src/lib/exit-hooks.ts create mode 100644 packages/cli/src/lib/handle-error.ts diff --git a/apps/api/src/api-v1/deployments/schemas.ts b/apps/api/src/api-v1/deployments/schemas.ts index 5bdea9e9..6baefc37 100644 --- a/apps/api/src/api-v1/deployments/schemas.ts +++ b/apps/api/src/api-v1/deployments/schemas.ts @@ -19,7 +19,10 @@ export const deploymentIdParamsSchema = z.object({ }) export const createDeploymentQuerySchema = z.object({ - publish: z.boolean().default(false).optional() + publish: z + .union([z.literal('true'), z.literal('false')]) + .default('false') + .transform((p) => p === 'true') }) export const filterDeploymentSchema = z.object({ diff --git a/apps/api/src/api-v1/index.ts b/apps/api/src/api-v1/index.ts index 2993f9e4..a5061ce5 100644 --- a/apps/api/src/api-v1/index.ts +++ b/apps/api/src/api-v1/index.ts @@ -1,10 +1,9 @@ import type { DefaultHonoEnv } from '@agentic/platform-hono' import { OpenAPIHono } from '@hono/zod-openapi' -import { fromError } from 'zod-validation-error' import type { AuthenticatedHonoEnv } from '@/lib/types' import * as middleware from '@/lib/middleware' -import { registerOpenAPIErrorResponses } from '@/lib/openapi-utils' +import { defaultHook, registerOpenAPIErrorResponses } from '@/lib/openapi-utils' import { registerV1GitHubOAuthCallback } from './auth/github-callback' import { registerV1GitHubOAuthExchange } from './auth/github-exchange' @@ -55,20 +54,7 @@ import { registerV1StripeWebhook } from './webhooks/stripe-webhook' // Note that the order of some of these routes is important because of // wildcards, so be careful when updating them or adding new routes. -export const apiV1 = new OpenAPIHono({ - defaultHook: (result, ctx) => { - if (!result.success) { - const requestId = ctx.get('requestId') - return ctx.json( - { - error: fromError(result.error).toString(), - requestId - }, - 400 - ) - } - } -}) +export const apiV1 = new OpenAPIHono({ defaultHook }) apiV1.openAPIRegistry.registerComponent('securitySchemes', 'Bearer', { type: 'http', @@ -79,10 +65,10 @@ apiV1.openAPIRegistry.registerComponent('securitySchemes', 'Bearer', { registerOpenAPIErrorResponses(apiV1) // Public routes -const publicRouter = new OpenAPIHono() +const publicRouter = new OpenAPIHono({ defaultHook }) // Private, authenticated routes -const privateRouter = new OpenAPIHono() +const privateRouter = new OpenAPIHono({ defaultHook }) registerHealthCheck(publicRouter) diff --git a/apps/api/src/lib/openapi-utils.ts b/apps/api/src/lib/openapi-utils.ts index cd18edba..9b1d06ac 100644 --- a/apps/api/src/lib/openapi-utils.ts +++ b/apps/api/src/lib/openapi-utils.ts @@ -1,3 +1,5 @@ +import { fromError } from 'zod-validation-error' + import type { HonoApp } from './types' export const openapiErrorResponses = { @@ -85,3 +87,17 @@ export function registerOpenAPIErrorResponses(app: HonoApp) { content: openapiErrorContent }) } + +export function defaultHook(result: any, ctx: any) { + if (!result.success) { + const requestId = ctx.get('requestId') + + return ctx.json( + { + error: fromError(result.error).toString(), + requestId + }, + 400 + ) + } +} diff --git a/packages/cli/package.json b/packages/cli/package.json index b7630c20..0724ff51 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -31,8 +31,10 @@ "@hono/node-server": "catalog:", "commander": "catalog:", "conf": "catalog:", + "exit-hook": "catalog:", "get-port": "catalog:", "hono": "catalog:", + "ky": "catalog:", "open": "catalog:", "ora": "catalog:", "restore-cursor": "catalog:", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b777797b..23e07cef 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,7 +1,7 @@ import { AgenticApiClient } from '@agentic/platform-api-client' import { Command } from 'commander' -import restoreCursor from 'restore-cursor' +import type { Context } from './types' import { registerDebugCommand } from './commands/debug' import { registerDeployCommand } from './commands/deploy' import { registerGetDeploymentCommand } from './commands/get' @@ -12,9 +12,11 @@ import { registerSignoutCommand } from './commands/signout' import { registerSignupCommand } from './commands/signup' import { registerWhoAmICommand } from './commands/whoami' import { AuthStore } from './lib/auth-store' +import { initExitHooks } from './lib/exit-hooks' +import { createErrorHandler } from './lib/handle-error' async function main() { - restoreCursor() + initExitHooks() // Initialize the API client const client = new AgenticApiClient({ @@ -64,12 +66,18 @@ async function main() { } } - const ctx = { + const partialCtx = { client, program, logger } + const errorHandler = createErrorHandler(partialCtx) + const ctx: Context = { + ...partialCtx, + handleError: errorHandler + } + // Register all commands registerSigninCommand(ctx) registerSignupCommand(ctx) diff --git a/packages/cli/src/commands/debug.ts b/packages/cli/src/commands/debug.ts index a449da34..80bc58fd 100644 --- a/packages/cli/src/commands/debug.ts +++ b/packages/cli/src/commands/debug.ts @@ -1,10 +1,15 @@ import { loadAgenticConfig } from '@agentic/platform' import { Command } from 'commander' +import { gracefulExit } from 'exit-hook' import { oraPromise } from 'ora' import type { Context } from '../types' -export function registerDebugCommand({ program, logger }: Context) { +export function registerDebugCommand({ + program, + logger, + handleError +}: Context) { const command = new Command('debug') .description('Prints config for a local project.') .option( @@ -12,27 +17,32 @@ export function registerDebugCommand({ program, logger }: Context) { 'The directory to load the Agentic project config from (defaults to cwd). This directory must contain an "agentic.config.{ts,js,json}" project file.' ) .action(async (opts) => { - // Load the Agentic project config, parse, and validate it. This will also - // validate any origin adapter config such as OpenAPI or MCP specs and - // embed them if they point to local files or URLs. Note that the server - // also performs validation; this is just a client-side convenience for - // failing fast and sharing 99% of the validation code between server and - // client. - const config = await oraPromise( - loadAgenticConfig({ - cwd: opts.cwd - }), - { - text: `Loading Agentic config from ${opts.cwd}`, - successText: `Agentic config loaded successfully.`, - failText: 'Failed to load Agentic config.' - } - ) + try { + // Load the Agentic project config, parse, and validate it. This will also + // validate any origin adapter config such as OpenAPI or MCP specs and + // embed them if they point to local files or URLs. Note that the server + // also performs validation; this is just a client-side convenience for + // failing fast and sharing 99% of the validation code between server and + // client. + const config = await oraPromise( + loadAgenticConfig({ + cwd: opts.cwd + }), + { + text: `Loading Agentic config from ${opts.cwd}`, + successText: `Agentic config loaded successfully.`, + failText: 'Failed to load Agentic config.' + } + ) - // TODO: we may want to resolve the resulting agentic config so we see - // the inferred `tools` (and `toolToOperationMap` for mcp servers) + // TODO: we may want to resolve the resulting agentic config so we see + // the inferred `tools` (and `toolToOperationMap` for mcp servers) - logger.log(config) + logger.log(config) + gracefulExit(0) + } catch (err) { + handleError(err) + } }) program.addCommand(command) diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index a3ef435f..119c48cc 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -1,11 +1,17 @@ import { loadAgenticConfig } from '@agentic/platform' import { Command } from 'commander' +import { gracefulExit } from 'exit-hook' import { oraPromise } from 'ora' import type { Context } from '../types' import { AuthStore } from '../lib/auth-store' -export function registerDeployCommand({ client, program, logger }: Context) { +export function registerDeployCommand({ + client, + program, + logger, + handleError +}: Context) { const command = new Command('deploy') .description('Creates a new deployment.') .option( @@ -17,42 +23,48 @@ export function registerDeployCommand({ client, program, logger }: Context) { .action(async (opts) => { AuthStore.requireAuth() - // Load the Agentic project config, parse, and validate it. This will also - // validate any origin adapter config such as OpenAPI or MCP specs and - // embed them if they point to local files or URLs. Note that the server - // also performs validation; this is just a client-side convenience for - // failing fast and sharing 99% of the validation code between server and - // client. - const config = await oraPromise( - loadAgenticConfig({ - cwd: opts.cwd - }), - { - text: `Loading Agentic config from ${opts.cwd}`, - successText: `Agentic config loaded successfully.`, - failText: 'Failed to load Agentic config.' - } - ) + try { + // Load the Agentic project config, parse, and validate it. This will also + // validate any origin adapter config such as OpenAPI or MCP specs and + // embed them if they point to local files or URLs. Note that the server + // also performs validation; this is just a client-side convenience for + // failing fast and sharing 99% of the validation code between server and + // client. + const config = await oraPromise( + loadAgenticConfig({ + cwd: opts.cwd + }), + { + text: `Loading Agentic config from ${opts.cwd}`, + successText: `Agentic config loaded successfully.`, + failText: 'Failed to load Agentic config.' + } + ) - if (opts.debug) { - logger.log(config) - return + if (opts.debug) { + logger.log(config) + gracefulExit(0) + return + } + + // Create the deployment on the backend, validate it, and optionally + // publish it. + const deployment = await oraPromise( + client.createDeployment(config, { + publish: !!opts.publish + }), + { + text: `Creating deployment for project "${config.name}"`, + successText: `Deployment created successfully`, + failText: 'Failed to create deployment' + } + ) + + logger.log(deployment) + gracefulExit(0) + } catch (err) { + handleError(err) } - - // Create the deployment on the backend, validate it, and optionally - // publish it. - const deployment = await oraPromise( - client.createDeployment(config, { - publish: !!opts.publish - }), - { - text: `Creating deployment for project "${config.name}"`, - successText: `Deployment created successfully`, - failText: 'Failed to create deployment' - } - ) - - logger.log(deployment) }) program.addCommand(command) diff --git a/packages/cli/src/commands/get.ts b/packages/cli/src/commands/get.ts index b510ada6..592c220f 100644 --- a/packages/cli/src/commands/get.ts +++ b/packages/cli/src/commands/get.ts @@ -1,4 +1,5 @@ import { Command } from 'commander' +import { gracefulExit } from 'exit-hook' import { oraPromise } from 'ora' import type { Context } from '../types' @@ -7,7 +8,8 @@ import { AuthStore } from '../lib/auth-store' export function registerGetDeploymentCommand({ client, program, - logger + logger, + handleError }: Context) { const command = new Command('get') .description('Gets details for a specific deployment.') @@ -15,18 +17,23 @@ export function registerGetDeploymentCommand({ .action(async (deploymentIdentifier) => { AuthStore.requireAuth() - const deployment = await oraPromise( - client.getDeploymentByIdentifier({ - deploymentIdentifier - }), - { - text: 'Resolving deployment...', - successText: 'Resolved deployment', - failText: 'Failed to resolve deployment' - } - ) + try { + const deployment = await oraPromise( + client.getDeploymentByIdentifier({ + deploymentIdentifier + }), + { + text: 'Resolving deployment...', + successText: 'Resolved deployment', + failText: 'Failed to resolve deployment' + } + ) - logger.log(deployment) + logger.log(deployment) + gracefulExit(0) + } catch (err) { + handleError(err) + } }) program.addCommand(command) diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index be25476e..bbfec7ae 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -1,6 +1,7 @@ import type { Deployment } from '@agentic/platform-types' import { parseDeploymentIdentifier } from '@agentic/platform-validators' import { Command } from 'commander' +import { gracefulExit } from 'exit-hook' import { oraPromise } from 'ora' import type { Context } from '../types' @@ -10,7 +11,8 @@ import { pruneDeployment } from '../lib/utils' export function registerListDeploymentsCommand({ client, program, - logger + logger, + handleError }: Context) { const command = new Command('list') .alias('ls') @@ -23,74 +25,82 @@ export function registerListDeploymentsCommand({ .action(async (identifier, opts) => { AuthStore.requireAuth() - const query: Parameters[0] = {} - let label = 'Fetching all projects and deployments' + try { + const query: Parameters[0] = {} + let label = 'Fetching all projects and deployments' - if (identifier) { - const parsedDeploymentIdentifier = parseDeploymentIdentifier( - identifier, - { - strict: false + if (identifier) { + const parsedDeploymentIdentifier = parseDeploymentIdentifier( + identifier, + { + strict: false + } + ) + + query.projectIdentifier = parsedDeploymentIdentifier.projectIdentifier + label = `Fetching deployments for project "${query.projectIdentifier}"` + + // TODO: this logic needs tweaking. + if ( + parsedDeploymentIdentifier.deploymentVersion !== 'latest' || + identifier.includes('@latest') + ) { + query.deploymentIdentifier = + parsedDeploymentIdentifier.deploymentIdentifier + label = `Fetching deployment "${query.deploymentIdentifier}"` } + } + + const deployments = await oraPromise( + client.listDeployments(query), + label ) - query.projectIdentifier = parsedDeploymentIdentifier.projectIdentifier - label = `Fetching deployments for project "${query.projectIdentifier}"` + const projectIdToDeploymentsMap: Record = {} + const sortedProjects: { + projectId: string + deployments: Deployment[] + }[] = [] - // TODO: this logic needs tweaking. - if ( - parsedDeploymentIdentifier.deploymentVersion !== 'latest' || - identifier.includes('@latest') - ) { - query.deploymentIdentifier = - parsedDeploymentIdentifier.deploymentIdentifier - label = `Fetching deployment "${query.deploymentIdentifier}"` - } - } + // Aggregate deployments by project + for (const deployment of deployments) { + const prunedDeployment = pruneDeployment(deployment, opts) - const deployments = await oraPromise(client.listDeployments(query), label) + const { projectId } = deployment + if (!projectIdToDeploymentsMap[projectId]) { + projectIdToDeploymentsMap[projectId] = [] + } - const projectIdToDeploymentsMap: Record = {} - const sortedProjects: { - projectId: string - deployments: Deployment[] - }[] = [] - - // Aggregate deployments by project - for (const deployment of deployments) { - const prunedDeployment = pruneDeployment(deployment, opts) - - const { projectId } = deployment - if (!projectIdToDeploymentsMap[projectId]) { - projectIdToDeploymentsMap[projectId] = [] + projectIdToDeploymentsMap[projectId].push(prunedDeployment) } - projectIdToDeploymentsMap[projectId].push(prunedDeployment) - } + // Sort deployments within each project with recently created first + for (const projectId of Object.keys(projectIdToDeploymentsMap)) { + const deployments = projectIdToDeploymentsMap[projectId]! + deployments.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) - // Sort deployments within each project with recently created first - for (const projectId of Object.keys(projectIdToDeploymentsMap)) { - const deployments = projectIdToDeploymentsMap[projectId]! - deployments.sort( + sortedProjects.push({ + projectId, + deployments + }) + } + + // Sort projects with most recently created first + sortedProjects.sort( (a, b) => - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + new Date(b.deployments[0]!.createdAt).getTime() - + new Date(a.deployments[0]!.createdAt).getTime() ) - sortedProjects.push({ - projectId, - deployments - }) + // TODO: better output formatting + logger.log(sortedProjects) + gracefulExit(0) + } catch (err) { + handleError(err) } - - // Sort projects with most recently created first - sortedProjects.sort( - (a, b) => - new Date(b.deployments[0]!.createdAt).getTime() - - new Date(a.deployments[0]!.createdAt).getTime() - ) - - // TODO: better output formatting - logger.log(sortedProjects) }) program.addCommand(command) diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index a651ee64..15753b77 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -1,5 +1,6 @@ import { select } from '@clack/prompts' import { Command } from 'commander' +import { gracefulExit } from 'exit-hook' import { oraPromise } from 'ora' import semver from 'semver' @@ -7,7 +8,12 @@ import type { Context } from '../types' import { AuthStore } from '../lib/auth-store' import { resolveDeployment } from '../lib/resolve-deployment' -export function registerPublishCommand({ client, program, logger }: Context) { +export function registerPublishCommand({ + client, + program, + logger, + handleError +}: Context) { const command = new Command('publish') .description( 'Publishes a deployment. Defaults to the most recent deployment for the project in the target directory. If a deployment identifier is provided, it will be used instead.' @@ -24,93 +30,98 @@ export function registerPublishCommand({ client, program, logger }: Context) { // TODO: parseToolIdentifier } - const deployment = await oraPromise( - resolveDeployment({ - client, - deploymentIdentifier, - fuzzyDeploymentIdentifierVersion: 'dev', - cwd: opts.cwd, - populate: ['project'] - }), - { - text: 'Resolving deployment...', - successText: 'Resolved deployment', - failText: 'Failed to resolve deployment' - } - ) - const { project } = deployment - - if (deployment.published) { - logger.error( - deploymentIdentifier - ? `Deployment "${deploymentIdentifier}" is already published` - : `Latest deployment "${deployment.identifier}" is already published` + try { + const deployment = await oraPromise( + resolveDeployment({ + client, + deploymentIdentifier, + fuzzyDeploymentIdentifierVersion: 'dev', + cwd: opts.cwd, + populate: ['project'] + }), + { + text: 'Resolving deployment...', + successText: 'Resolved deployment', + failText: 'Failed to resolve deployment' + } ) - return - } + const { project } = deployment - if (!project) { - logger.error( - deploymentIdentifier - ? `Deployment "${deploymentIdentifier}" failed to fetch project "${deployment.projectId}"` - : `Latest deployment "${deployment.identifier}" failed to fetch project "${deployment.projectId}"` - ) - return - } - - const initialVersion = deployment.version - const baseVersion = - initialVersion || project.lastPublishedDeploymentVersion || '0.0.0' - - const options = [ - initialVersion - ? { value: initialVersion, label: initialVersion } - : null, - { - value: semver.inc(baseVersion, 'patch'), - label: `${semver.inc(baseVersion, 'patch')} (patch)` - }, - { - value: semver.inc(baseVersion, 'minor'), - label: `${semver.inc(baseVersion, 'minor')} (minor)` - }, - { - value: semver.inc(baseVersion, 'major'), - label: `${semver.inc(baseVersion, 'major')} (major)` + if (deployment.published) { + logger.error( + deploymentIdentifier + ? `Deployment "${deploymentIdentifier}" is already published` + : `Latest deployment "${deployment.identifier}" is already published` + ) + return gracefulExit(1) } - ].filter(Boolean) - if (project.lastPublishedDeploymentVersion) { + if (!project) { + logger.error( + deploymentIdentifier + ? `Deployment "${deploymentIdentifier}" failed to fetch project "${deployment.projectId}"` + : `Latest deployment "${deployment.identifier}" failed to fetch project "${deployment.projectId}"` + ) + return gracefulExit(1) + } + + const initialVersion = deployment.version + const baseVersion = + initialVersion || project.lastPublishedDeploymentVersion || '0.0.0' + + const options = [ + initialVersion + ? { value: initialVersion, label: initialVersion } + : null, + { + value: semver.inc(baseVersion, 'patch'), + label: `${semver.inc(baseVersion, 'patch')} (patch)` + }, + { + value: semver.inc(baseVersion, 'minor'), + label: `${semver.inc(baseVersion, 'minor')} (minor)` + }, + { + value: semver.inc(baseVersion, 'major'), + label: `${semver.inc(baseVersion, 'major')} (major)` + } + ].filter(Boolean) + + if (project.lastPublishedDeploymentVersion) { + logger.info( + `Project "${project.identifier}" latest published version is "${project.lastPublishedDeploymentVersion}".\n` + ) + } else { + logger.info(`Project "${project.identifier}" is not published yet.\n`) + } + + const version = await select({ + message: `Select version of deployment "${deployment.identifier}" to publish:`, + options + }) + + if (!version || typeof version !== 'string') { + logger.error('No version selected') + return gracefulExit(1) + } + + const publishedDeployment = await client.publishDeployment( + { + version + }, + { + deploymentId: deployment.id + } + ) + logger.info( - `Project "${project.identifier}" latest published version is "${project.lastPublishedDeploymentVersion}".\n` + `Deployment "${publishedDeployment.identifier}" published with version "${publishedDeployment.version}"` ) - } else { - logger.info(`Project "${project.identifier}" is not published yet.\n`) + logger.log(publishedDeployment) + gracefulExit(0) + } catch (err) { + handleError(err) } - - const version = await select({ - message: `Select version of deployment "${deployment.identifier}" to publish:`, - options - }) - - if (!version || typeof version !== 'string') { - logger.error('No version selected') - return - } - - const publishedDeployment = await client.publishDeployment( - { - version - }, - { - deploymentId: deployment.id - } - ) - - logger.info( - `Deployment "${publishedDeployment.identifier}" published with version "${publishedDeployment.version}"` - ) - logger.log(publishedDeployment) }) program.addCommand(command) diff --git a/packages/cli/src/commands/signin.ts b/packages/cli/src/commands/signin.ts index ff4a1dbb..f5262703 100644 --- a/packages/cli/src/commands/signin.ts +++ b/packages/cli/src/commands/signin.ts @@ -1,9 +1,15 @@ import { Command } from 'commander' +import { gracefulExit } from 'exit-hook' import type { Context } from '../types' import { auth } from '../lib/auth' -export function registerSigninCommand({ client, program, logger }: Context) { +export function registerSigninCommand({ + client, + program, + logger, + handleError +}: Context) { const command = new Command('login') .alias('signin') .description( @@ -17,20 +23,25 @@ export function registerSigninCommand({ client, program, logger }: Context) { 'either pass email and password or neither (which will use github auth)' ) program.outputHelp() - return + return gracefulExit(1) } - if (opts.email && opts.password) { - await client.signInWithPassword({ - email: opts.email, - password: opts.password - }) - } else { - await auth({ client, provider: 'github' }) - } + try { + if (opts.email && opts.password) { + await client.signInWithPassword({ + email: opts.email, + password: opts.password + }) + } else { + await auth({ client, provider: 'github' }) + } - const user = await client.getMe() - logger.log(user) + const user = await client.getMe() + logger.log(user) + gracefulExit(0) + } catch (err) { + handleError(err) + } }) program.addCommand(command) diff --git a/packages/cli/src/commands/signout.ts b/packages/cli/src/commands/signout.ts index 7c6ebf8a..32c297f2 100644 --- a/packages/cli/src/commands/signout.ts +++ b/packages/cli/src/commands/signout.ts @@ -1,21 +1,33 @@ import { Command } from 'commander' +import { gracefulExit } from 'exit-hook' import type { Context } from '../types' import { AuthStore } from '../lib/auth-store' -export function registerSignoutCommand({ client, program, logger }: Context) { +export function registerSignoutCommand({ + client, + program, + logger, + handleError +}: Context) { const command = new Command('logout') .alias('signout') .description('Signs the current user out.') .action(async () => { if (!client.isAuthenticated) { - return + logger.log('You are already signed out') + return gracefulExit(0) } - await client.logout() - AuthStore.clearAuth() + try { + await client.logout() + AuthStore.clearAuth() - logger.log('Signed out') + logger.log('Signed out') + gracefulExit(0) + } catch (err) { + handleError(err) + } }) program.addCommand(command) diff --git a/packages/cli/src/commands/signup.ts b/packages/cli/src/commands/signup.ts index 3d1fbdda..c539b4b3 100644 --- a/packages/cli/src/commands/signup.ts +++ b/packages/cli/src/commands/signup.ts @@ -1,9 +1,15 @@ import { Command } from 'commander' +import { gracefulExit } from 'exit-hook' import type { Context } from '../types' import { auth } from '../lib/auth' -export function registerSignupCommand({ client, program, logger }: Context) { +export function registerSignupCommand({ + client, + program, + logger, + handleError +}: Context) { const command = new Command('signup') .description( 'Creates a new account for Agentic. If no credentials are provided, uses GitHub auth.' @@ -20,21 +26,26 @@ export function registerSignupCommand({ client, program, logger }: Context) { 'either pass email, username, and password or none of them (which will use github auth)' ) program.outputHelp() - return + return gracefulExit(1) } - if (opts.email && opts.username && opts.password) { - await client.signUpWithPassword({ - email: opts.email, - username: opts.username, - password: opts.password - }) - } else { - await auth({ client, provider: 'github' }) - } + try { + if (opts.email && opts.username && opts.password) { + await client.signUpWithPassword({ + email: opts.email, + username: opts.username, + password: opts.password + }) + } else { + await auth({ client, provider: 'github' }) + } - const user = await client.getMe() - logger.log(user) + const user = await client.getMe() + logger.log(user) + gracefulExit(0) + } catch (err) { + handleError(err) + } }) program.addCommand(command) diff --git a/packages/cli/src/commands/whoami.ts b/packages/cli/src/commands/whoami.ts index 4b6c761d..50cef469 100644 --- a/packages/cli/src/commands/whoami.ts +++ b/packages/cli/src/commands/whoami.ts @@ -1,16 +1,27 @@ import { Command } from 'commander' +import { gracefulExit } from 'exit-hook' import type { Context } from '../types' import { AuthStore } from '../lib/auth-store' -export function registerWhoAmICommand({ client, program, logger }: Context) { +export function registerWhoAmICommand({ + client, + program, + logger, + handleError +}: Context) { const command = new Command('whoami') .description('Displays info about the current user.') .action(async () => { AuthStore.requireAuth() - const res = await client.getMe() - logger.log(res) + try { + const res = await client.getMe() + logger.log(res) + gracefulExit(0) + } catch (err) { + handleError(err) + } }) program.addCommand(command) diff --git a/packages/cli/src/lib/exit-hooks.ts b/packages/cli/src/lib/exit-hooks.ts new file mode 100644 index 00000000..99b804e8 --- /dev/null +++ b/packages/cli/src/lib/exit-hooks.ts @@ -0,0 +1,14 @@ +import restoreCursor from 'restore-cursor' + +export function initExitHooks() { + // Gracefully restore the cursor if run from a TTY + restoreCursor() + + process.on('SIGINT', () => { + process.exit(0) + }) + + process.on('SIGTERM', () => { + process.exit(0) + }) +} diff --git a/packages/cli/src/lib/handle-error.ts b/packages/cli/src/lib/handle-error.ts new file mode 100644 index 00000000..f6bfafd4 --- /dev/null +++ b/packages/cli/src/lib/handle-error.ts @@ -0,0 +1,44 @@ +import { gracefulExit } from 'exit-hook' +import { HTTPError } from 'ky' + +import type { Context } from '../types' + +export function createErrorHandler(ctx: Omit) { + return async function handleError(error: any) { + let message: string | undefined + let details: Error | undefined + + if (typeof error === 'string') { + message = error + } else if (error instanceof Error) { + details = error + message = error.message + + if (error instanceof HTTPError) { + if (error.response) { + try { + message = error.response.statusText + + const body = await error.response.json() + if (body.error && typeof body.error === 'string') { + message = JSON.stringify( + { + ...body, + details: error.toString() + }, + null, + 2 + ) + details = undefined + } + } catch { + // TODO + } + } + } + } + + ctx.logger.error([message, details].filter(Boolean).join('\n')) + gracefulExit(1) + } +} diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 639c86ae..2b884b36 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -9,4 +9,5 @@ export type Context = { info: (...args: any[]) => void error: (...args: any[]) => void } + handleError: (error: any) => void } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee39e83f..79673dff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,7 +124,7 @@ catalogs: specifier: ^2.0.1 version: 2.0.1 exit-hook: - specifier: ^4.0.0 + specifier: 4.0.0 version: 4.0.0 fast-content-type-parse: specifier: ^3.0.0 @@ -242,7 +242,7 @@ catalogs: version: 3.2.4 wrangler: specifier: ^4.21.0 - version: 4.20.3 + version: 4.21.0 zod: specifier: ^3.25.67 version: 3.25.67 @@ -508,7 +508,7 @@ importers: version: 2.46.0 wrangler: specifier: 'catalog:' - version: 4.20.3(@cloudflare/workers-types@4.20250620.0) + version: 4.21.0(@cloudflare/workers-types@4.20250620.0) apps/web: dependencies: @@ -751,12 +751,18 @@ importers: conf: specifier: 'catalog:' version: 14.0.0 + exit-hook: + specifier: 'catalog:' + version: 4.0.0 get-port: specifier: 'catalog:' version: 7.1.0 hono: specifier: 'catalog:' version: 4.8.1 + ky: + specifier: 'catalog:' + version: 1.8.1 open: specifier: 'catalog:' version: 10.1.2 @@ -5934,11 +5940,6 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - miniflare@4.20250617.1: - resolution: {integrity: sha512-NjwKVzPGCAUgkCOJxHBwgV2Obu3g4/wAJE0JaA9whcYip4VAJwQ1fU9TMtoKLY91jD9ANR221+CqLyqxennjqg==} - engines: {node: '>=18.0.0'} - hasBin: true - miniflare@4.20250617.3: resolution: {integrity: sha512-j+LZycT11UdlVeNdaqD0XdNnYnqAL+wXmboz+tNPFgTq6zhD489Ujj3BfSDyEHDCA9UFBLbkc5ByGWBh+pYZ5Q==} engines: {node: '>=18.0.0'} @@ -7631,16 +7632,6 @@ packages: engines: {node: '>=16'} hasBin: true - wrangler@4.20.3: - resolution: {integrity: sha512-ugvmi43CFPbjeQFfhU7EqE1V0ek6ZFv80jzwHcPk/7jPFmOA4ahT5uUU1ga5ZP6vz6lUuG2bLnyl1T5qJah0cg==} - engines: {node: '>=18.0.0'} - hasBin: true - peerDependencies: - '@cloudflare/workers-types': ^4.20250617.0 - peerDependenciesMeta: - '@cloudflare/workers-types': - optional: true - wrangler@4.21.0: resolution: {integrity: sha512-37xm0CG2qMvsJUNZYQKje6HbCsJFYuE8dQSnu7981iDRT4DLrEIL1DAUnZJG9HiXteKPvrSj96AkZyomi5sYHw==} engines: {node: '>=18.0.0'} @@ -12687,24 +12678,6 @@ snapshots: min-indent@1.0.1: {} - miniflare@4.20250617.1: - dependencies: - '@cspotcode/source-map-support': 0.8.1 - acorn: 8.14.0 - acorn-walk: 8.3.2 - exit-hook: 2.2.1 - glob-to-regexp: 0.4.1 - sharp: 0.33.5 - stoppable: 1.1.0 - undici: 5.29.0 - workerd: 1.20250617.0 - ws: 8.18.0 - youch: 3.3.4 - zod: 3.22.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - miniflare@4.20250617.3: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -14591,23 +14564,6 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20250617.0 '@cloudflare/workerd-windows-64': 1.20250617.0 - wrangler@4.20.3(@cloudflare/workers-types@4.20250620.0): - dependencies: - '@cloudflare/kv-asset-handler': 0.4.0 - '@cloudflare/unenv-preset': 2.3.3(unenv@2.0.0-rc.17)(workerd@1.20250617.0) - blake3-wasm: 2.1.5 - esbuild: 0.25.4 - miniflare: 4.20250617.1 - path-to-regexp: 6.3.0 - unenv: 2.0.0-rc.17 - workerd: 1.20250617.0 - optionalDependencies: - '@cloudflare/workers-types': 4.20250620.0 - fsevents: 2.3.3 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - wrangler@4.21.0(@cloudflare/workers-types@4.20250620.0): dependencies: '@cloudflare/kv-asset-handler': 0.4.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 17efb6a1..0b818acb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -47,7 +47,7 @@ catalog: eslint: ^9.29.0 eslint-plugin-drizzle: ^0.2.3 eventid: ^2.0.1 - exit-hook: ^4.0.0 + exit-hook: 4.0.0 fast-content-type-parse: ^3.0.0 fast-uri: ^3.0.6 fastmcp: ^3.4.0