From 980ab01fffec890f4455a357c640d11b0db45bfd Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Tue, 17 Mar 2026 11:11:31 +0000 Subject: [PATCH 1/8] feat: suggest next actions --- src/cli-core.ts | 10 +- src/global.d.ts | 4 + src/lib/access-keys/assign.ts | 59 +++++-- src/lib/access-keys/create.ts | 40 +++-- src/lib/access-keys/delete.ts | 15 +- src/lib/access-keys/get.ts | 15 +- src/lib/access-keys/list.ts | 13 +- src/lib/buckets/create.ts | 25 ++- src/lib/buckets/delete.ts | 20 ++- src/lib/buckets/get.ts | 5 +- src/lib/buckets/list.ts | 7 +- src/lib/buckets/set-cors.ts | 11 +- src/lib/buckets/set-locations.ts | 7 +- src/lib/buckets/set-migration.ts | 14 +- src/lib/buckets/set-notifications.ts | 21 ++- src/lib/buckets/set-transition.ts | 31 +++- src/lib/buckets/set-ttl.ts | 18 +- src/lib/buckets/set.ts | 7 +- src/lib/configure/index.ts | 8 +- src/lib/cp.ts | 47 +++--- src/lib/credentials/test.ts | 10 +- src/lib/forks/create.ts | 7 +- src/lib/forks/list.ts | 7 +- src/lib/iam/policies/create.ts | 27 ++- src/lib/iam/policies/delete.ts | 15 +- src/lib/iam/policies/edit.ts | 28 +++- src/lib/iam/policies/get.ts | 15 +- src/lib/iam/policies/list.ts | 13 +- src/lib/iam/users/invite.ts | 20 ++- src/lib/iam/users/list.ts | 13 +- src/lib/iam/users/remove.ts | 15 +- src/lib/iam/users/revoke-invitation.ts | 15 +- src/lib/iam/users/update-role.ts | 30 +++- src/lib/login/credentials.ts | 4 +- src/lib/login/oauth.ts | 7 +- src/lib/logout.ts | 3 +- src/lib/ls.ts | 7 +- src/lib/mk.ts | 11 +- src/lib/mv.ts | 25 +-- src/lib/objects/delete.ts | 25 ++- src/lib/objects/get.ts | 9 +- src/lib/objects/list.ts | 5 +- src/lib/objects/put.ts | 12 +- src/lib/objects/set.ts | 12 +- src/lib/organizations/create.ts | 6 +- src/lib/organizations/list.ts | 3 +- src/lib/organizations/select.ts | 8 +- src/lib/presign.ts | 31 ++-- src/lib/rm.ts | 17 +- src/lib/snapshots/list.ts | 5 +- src/lib/snapshots/take.ts | 5 +- src/lib/stat.ts | 11 +- src/lib/touch.ts | 9 +- src/lib/whoami.ts | 5 +- src/specs.yaml | 41 +++++ src/types.ts | 6 + src/utils/errors.ts | 170 +++++++++++++++++++ src/utils/exit.ts | 92 ++++++++++ test/specs-completeness.test.ts | 32 ++++ test/utils/errors.test.ts | 205 ++++++++++++++++++++++ test/utils/exit.test.ts | 224 +++++++++++++++++++++++++ 61 files changed, 1329 insertions(+), 253 deletions(-) create mode 100644 src/utils/errors.ts create mode 100644 src/utils/exit.ts create mode 100644 test/utils/errors.test.ts create mode 100644 test/utils/exit.test.ts diff --git a/src/cli-core.ts b/src/cli-core.ts index a19d932..4dabda7 100644 --- a/src/cli-core.ts +++ b/src/cli-core.ts @@ -5,6 +5,7 @@ import { Command as CommanderCommand } from 'commander'; import type { Argument, CommandSpec, Specs } from './types.js'; import { printDeprecated } from './utils/messages.js'; +import { exitWithError } from './utils/exit.js'; export interface ModuleLoader { (commandPath: string[]): Promise<{ @@ -37,12 +38,12 @@ export function setupErrorHandlers() { '\nError:', reason instanceof Error ? reason.message : reason ); - process.exit(1); + exitWithError(reason); }); process.on('uncaughtException', (error) => { console.error('\nError:', error.message); - process.exit(1); + exitWithError(error); }); } @@ -369,6 +370,11 @@ async function loadAndExecuteCommand( positionalArgs: string[] = [], options: Record = {} ) { + // Set JSON mode globally for error handlers + if (options.json || options.format === 'json') { + globalThis.__TIGRIS_JSON_MODE = true; + } + const { module, error: loadError } = await loadModule(pathParts); if (loadError || !module) { diff --git a/src/global.d.ts b/src/global.d.ts index b680f2c..727ff22 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -2,3 +2,7 @@ declare module '*.yaml' { const content: string; export default content; } + +// Global JSON mode flag set by CLI core when --json or --format=json is used +// eslint-disable-next-line no-var +declare var __TIGRIS_JSON_MODE: boolean | undefined; diff --git a/src/lib/access-keys/assign.ts b/src/lib/access-keys/assign.ts index 5b388e3..dcf55cc 100644 --- a/src/lib/access-keys/assign.ts +++ b/src/lib/access-keys/assign.ts @@ -10,6 +10,11 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { + exitWithError, + getSuccessNextActions, + printNextActions, +} from '../../utils/exit.js'; const context = msg('access-keys', 'assign'); @@ -44,12 +49,12 @@ export default async function assign(options: Record) { if (!id) { printFailure(context, 'Access key ID is required'); - process.exit(1); + exitWithError('Access key ID is required', context); } if (admin && revokeRoles) { printFailure(context, 'Cannot use --admin and --revoke-roles together'); - process.exit(1); + exitWithError('Cannot use --admin and --revoke-roles together', context); } const loginMethod = await getLoginMethod(); @@ -59,7 +64,10 @@ export default async function assign(options: Record) { context, 'Bucket roles can only be managed when logged in via OAuth.\nRun "tigris login oauth" first.' ); - process.exit(1); + exitWithError( + 'Bucket roles can only be managed when logged in via OAuth.\nRun "tigris login oauth" first.', + context + ); } const authClient = getAuthClient(); @@ -67,7 +75,10 @@ export default async function assign(options: Record) { if (!isAuthenticated) { printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); - process.exit(1); + exitWithError( + 'Not authenticated. Run "tigris login oauth" first.', + context + ); } const accessToken = await authClient.getAccessToken(); @@ -85,14 +96,18 @@ export default async function assign(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } if (format === 'json') { - console.log(JSON.stringify({ action: 'revoked', id })); + const nextActions = getSuccessNextActions(context); + const output: Record = { action: 'revoked', id }; + if (nextActions.length > 0) output.nextActions = nextActions; + console.log(JSON.stringify(output)); } printSuccess(context); + printNextActions(context); return; } @@ -107,7 +122,10 @@ export default async function assign(options: Record) { context, 'At least one bucket name is required (or use --admin or --revoke-roles)' ); - process.exit(1); + exitWithError( + 'At least one bucket name is required (or use --admin or --revoke-roles)', + context + ); } if (roles.length === 0) { @@ -115,7 +133,10 @@ export default async function assign(options: Record) { context, 'At least one role is required (or use --admin or --revoke-roles)' ); - process.exit(1); + exitWithError( + 'At least one role is required (or use --admin or --revoke-roles)', + context + ); } // Validate all roles @@ -125,7 +146,10 @@ export default async function assign(options: Record) { context, `Invalid role "${role}". Valid roles are: ${validRoles.join(', ')}` ); - process.exit(1); + exitWithError( + `Invalid role "${role}". Valid roles are: ${validRoles.join(', ')}`, + context + ); } } @@ -147,7 +171,10 @@ export default async function assign(options: Record) { context, `Number of roles (${roles.length}) must be 1 or match number of buckets (${buckets.length})` ); - process.exit(1); + exitWithError( + `Number of roles (${roles.length}) must be 1 or match number of buckets (${buckets.length})`, + context + ); } } @@ -155,12 +182,20 @@ export default async function assign(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } if (format === 'json') { - console.log(JSON.stringify({ action: 'assigned', id, assignments })); + const nextActions = getSuccessNextActions(context); + const output: Record = { + action: 'assigned', + id, + assignments, + }; + if (nextActions.length > 0) output.nextActions = nextActions; + console.log(JSON.stringify(output)); } printSuccess(context); + printNextActions(context); } diff --git a/src/lib/access-keys/create.ts b/src/lib/access-keys/create.ts index 2dbe5fe..3d06c85 100644 --- a/src/lib/access-keys/create.ts +++ b/src/lib/access-keys/create.ts @@ -10,6 +10,11 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { + exitWithError, + getSuccessNextActions, + printNextActions, +} from '../../utils/exit.js'; const context = msg('access-keys', 'create'); @@ -25,7 +30,7 @@ export default async function create(options: Record) { if (!name) { printFailure(context, 'Access key name is required'); - process.exit(1); + exitWithError('Access key name is required', context); } const loginMethod = await getLoginMethod(); @@ -35,7 +40,10 @@ export default async function create(options: Record) { context, 'Access keys can only be created when logged in via OAuth.\nRun "tigris login oauth" first.' ); - process.exit(1); + exitWithError( + 'Access keys can only be created when logged in via OAuth.\nRun "tigris login oauth" first.', + context + ); } const authClient = getAuthClient(); @@ -43,7 +51,10 @@ export default async function create(options: Record) { if (!isAuthenticated) { printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); - process.exit(1); + exitWithError( + 'Not authenticated. Run "tigris login oauth" first.', + context + ); } const accessToken = await authClient.getAccessToken(); @@ -60,18 +71,22 @@ export default async function create(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } if (format === 'json') { - console.log( - JSON.stringify({ - action: 'created', - name: data.name, - id: data.id, - secret: data.secret, - }) - ); + const nextActions = getSuccessNextActions(context, { + name: data.name, + id: data.id, + }); + const output: Record = { + action: 'created', + name: data.name, + id: data.id, + secret: data.secret, + }; + if (nextActions.length > 0) output.nextActions = nextActions; + console.log(JSON.stringify(output)); } else { console.log(` Name: ${data.name}`); console.log(` Access Key ID: ${data.id}`); @@ -83,4 +98,5 @@ export default async function create(options: Record) { } printSuccess(context); + printNextActions(context, { name: data.name, id: data.id }); } diff --git a/src/lib/access-keys/delete.ts b/src/lib/access-keys/delete.ts index 8143ba9..f5305fb 100644 --- a/src/lib/access-keys/delete.ts +++ b/src/lib/access-keys/delete.ts @@ -10,6 +10,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; import { requireInteractive, confirm } from '../../utils/interactive.js'; const context = msg('access-keys', 'delete'); @@ -27,7 +28,7 @@ export default async function remove(options: Record) { if (!id) { printFailure(context, 'Access key ID is required'); - process.exit(1); + exitWithError('Access key ID is required', context); } const loginMethod = await getLoginMethod(); @@ -37,7 +38,10 @@ export default async function remove(options: Record) { context, 'Access keys can only be deleted when logged in via OAuth.\nRun "tigris login oauth" first.' ); - process.exit(1); + exitWithError( + 'Access keys can only be deleted when logged in via OAuth.\nRun "tigris login oauth" first.', + context + ); } const authClient = getAuthClient(); @@ -45,7 +49,10 @@ export default async function remove(options: Record) { if (!isAuthenticated) { printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); - process.exit(1); + exitWithError( + 'Not authenticated. Run "tigris login oauth" first.', + context + ); } if (!force) { @@ -71,7 +78,7 @@ export default async function remove(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } if (format === 'json') { diff --git a/src/lib/access-keys/get.ts b/src/lib/access-keys/get.ts index 23c9129..61255f1 100644 --- a/src/lib/access-keys/get.ts +++ b/src/lib/access-keys/get.ts @@ -10,6 +10,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('access-keys', 'get'); @@ -25,7 +26,7 @@ export default async function get(options: Record) { if (!id) { printFailure(context, 'Access key ID is required'); - process.exit(1); + exitWithError('Access key ID is required', context); } const loginMethod = await getLoginMethod(); @@ -35,7 +36,10 @@ export default async function get(options: Record) { context, 'Access keys can only be retrieved when logged in via OAuth.\nRun "tigris login oauth" first.' ); - process.exit(1); + exitWithError( + 'Access keys can only be retrieved when logged in via OAuth.\nRun "tigris login oauth" first.', + context + ); } const authClient = getAuthClient(); @@ -43,7 +47,10 @@ export default async function get(options: Record) { if (!isAuthenticated) { printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); - process.exit(1); + exitWithError( + 'Not authenticated. Run "tigris login oauth" first.', + context + ); } const accessToken = await authClient.getAccessToken(); @@ -60,7 +67,7 @@ export default async function get(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } if (format === 'json') { diff --git a/src/lib/access-keys/list.ts b/src/lib/access-keys/list.ts index ceddbb5..cf5be84 100644 --- a/src/lib/access-keys/list.ts +++ b/src/lib/access-keys/list.ts @@ -12,6 +12,7 @@ import { printEmpty, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('access-keys', 'list'); @@ -30,7 +31,10 @@ export default async function list(options: Record) { context, 'Access keys can only be listed when logged in via OAuth.\nRun "tigris login oauth" first.' ); - process.exit(1); + exitWithError( + 'Access keys can only be listed when logged in via OAuth.\nRun "tigris login oauth" first.', + context + ); } const authClient = getAuthClient(); @@ -38,7 +42,10 @@ export default async function list(options: Record) { if (!isAuthenticated) { printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); - process.exit(1); + exitWithError( + 'Not authenticated. Run "tigris login oauth" first.', + context + ); } const accessToken = await authClient.getAccessToken(); @@ -55,7 +62,7 @@ export default async function list(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } if (!data.accessKeys || data.accessKeys.length === 0) { diff --git a/src/lib/buckets/create.ts b/src/lib/buckets/create.ts index f625e6f..22e6ff7 100644 --- a/src/lib/buckets/create.ts +++ b/src/lib/buckets/create.ts @@ -12,6 +12,11 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { + exitWithError, + getSuccessNextActions, + printNextActions, +} from '../../utils/exit.js'; const { prompt } = enquirer; @@ -130,18 +135,18 @@ export default async function create(options: Record) { parsedLocations = await promptLocations(); } catch (err) { printFailure(context, (err as Error).message); - process.exit(1); + exitWithError(err, context); } } if (!name) { printFailure(context, 'Bucket name is required'); - process.exit(1); + exitWithError('Bucket name is required', context); } if (sourceSnapshot && !forkOf) { printFailure(context, '--source-snapshot requires --fork-of'); - process.exit(1); + exitWithError('--source-snapshot requires --fork-of', context); } const { error } = await createBucket(name, { @@ -156,14 +161,20 @@ export default async function create(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } if (format === 'json') { - console.log( - JSON.stringify({ action: 'created', name, ...(forkOf ? { forkOf } : {}) }) - ); + const nextActions = getSuccessNextActions(context, { name }); + const output: Record = { + action: 'created', + name, + ...(forkOf ? { forkOf } : {}), + }; + if (nextActions.length > 0) output.nextActions = nextActions; + console.log(JSON.stringify(output)); } printSuccess(context, { name }); + printNextActions(context, { name }); } diff --git a/src/lib/buckets/delete.ts b/src/lib/buckets/delete.ts index 7cc6520..86825bb 100644 --- a/src/lib/buckets/delete.ts +++ b/src/lib/buckets/delete.ts @@ -8,6 +8,11 @@ import { msg, } from '../../utils/messages.js'; import { requireInteractive, confirm } from '../../utils/interactive.js'; +import { + exitWithError, + getSuccessNextActions, + printNextActions, +} from '../../utils/exit.js'; const context = msg('buckets', 'delete'); @@ -24,7 +29,7 @@ export default async function deleteBucket(options: Record) { if (!names) { printFailure(context, 'Bucket name is required'); - process.exit(1); + exitWithError('Bucket name is required', context); } const bucketNames = Array.isArray(names) ? names : [names]; @@ -54,10 +59,19 @@ export default async function deleteBucket(options: Record) { } if (format === 'json') { - console.log(JSON.stringify({ action: 'deleted', names: deleted, errors })); + const nextActions = getSuccessNextActions(context); + const output: Record = { + action: 'deleted', + names: deleted, + errors, + }; + if (nextActions.length > 0) output.nextActions = nextActions; + console.log(JSON.stringify(output)); } + printNextActions(context); + if (errors.length > 0) { - process.exit(1); + exitWithError(errors[0].error, context); } } diff --git a/src/lib/buckets/get.ts b/src/lib/buckets/get.ts index 48844e3..9186758 100644 --- a/src/lib/buckets/get.ts +++ b/src/lib/buckets/get.ts @@ -9,6 +9,7 @@ import { msg, } from '../../utils/messages.js'; import { buildBucketInfo } from '../../utils/bucket-info.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('buckets', 'get'); @@ -23,7 +24,7 @@ export default async function get(options: Record) { if (!name) { printFailure(context, 'Bucket name is required'); - process.exit(1); + exitWithError('Bucket name is required', context); } const { data, error } = await getBucketInfo(name, { @@ -32,7 +33,7 @@ export default async function get(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } const info = [ diff --git a/src/lib/buckets/list.ts b/src/lib/buckets/list.ts index 8391a6b..5fd362a 100644 --- a/src/lib/buckets/list.ts +++ b/src/lib/buckets/list.ts @@ -9,6 +9,7 @@ import { printEmpty, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('buckets', 'list'); @@ -27,7 +28,7 @@ export default async function list(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } if (!data.buckets || data.buckets.length === 0) { @@ -44,7 +45,7 @@ export default async function list(options: Record) { if (infoError) { printFailure(context, infoError.message); - process.exit(1); + exitWithError(infoError, context); } if (!bucketInfo.hasForks) { @@ -95,6 +96,6 @@ export default async function list(options: Record) { } else { printFailure(context, 'An unknown error occurred'); } - process.exit(1); + exitWithError(error, context); } } diff --git a/src/lib/buckets/set-cors.ts b/src/lib/buckets/set-cors.ts index a3067b4..4fe3f35 100644 --- a/src/lib/buckets/set-cors.ts +++ b/src/lib/buckets/set-cors.ts @@ -8,6 +8,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('buckets', 'set-cors'); @@ -28,7 +29,7 @@ export default async function setCors(options: Record) { if (!name) { printFailure(context, 'Bucket name is required'); - process.exit(1); + exitWithError('Bucket name is required', context); } if ( @@ -41,17 +42,17 @@ export default async function setCors(options: Record) { override) ) { printFailure(context, 'Cannot use --reset with other options'); - process.exit(1); + exitWithError('Cannot use --reset with other options', context); } if (!reset && !origins) { printFailure(context, 'Provide --origins or --reset'); - process.exit(1); + exitWithError('Provide --origins or --reset', context); } if (maxAge !== undefined && (isNaN(Number(maxAge)) || Number(maxAge) <= 0)) { printFailure(context, '--max-age must be a positive number'); - process.exit(1); + exitWithError('--max-age must be a positive number', context); } const config = await getStorageConfig(); @@ -81,7 +82,7 @@ export default async function setCors(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } printSuccess(context, { name }); diff --git a/src/lib/buckets/set-locations.ts b/src/lib/buckets/set-locations.ts index 6720032..4cf0d1b 100644 --- a/src/lib/buckets/set-locations.ts +++ b/src/lib/buckets/set-locations.ts @@ -11,6 +11,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('buckets', 'set-locations'); @@ -22,7 +23,7 @@ export default async function setLocations(options: Record) { if (!name) { printFailure(context, 'Bucket name is required'); - process.exit(1); + exitWithError('Bucket name is required', context); } let parsedLocations: BucketLocations; @@ -34,7 +35,7 @@ export default async function setLocations(options: Record) { parsedLocations = await promptLocations(); } catch (err) { printFailure(context, (err as Error).message); - process.exit(1); + exitWithError(err, context); } } @@ -54,7 +55,7 @@ export default async function setLocations(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } printSuccess(context, { name }); diff --git a/src/lib/buckets/set-migration.ts b/src/lib/buckets/set-migration.ts index 7fad72e..6e79bde 100644 --- a/src/lib/buckets/set-migration.ts +++ b/src/lib/buckets/set-migration.ts @@ -8,6 +8,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('buckets', 'set-migration'); @@ -28,7 +29,7 @@ export default async function setMigration(options: Record) { if (!name) { printFailure(context, 'Bucket name is required'); - process.exit(1); + exitWithError('Bucket name is required', context); } if ( @@ -41,7 +42,7 @@ export default async function setMigration(options: Record) { writeThrough !== undefined) ) { printFailure(context, 'Cannot use --disable with other migration options'); - process.exit(1); + exitWithError('Cannot use --disable with other migration options', context); } const config = await getStorageConfig(); @@ -61,7 +62,7 @@ export default async function setMigration(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } printSuccess(context, { name }); @@ -73,7 +74,10 @@ export default async function setMigration(options: Record) { context, 'Required: --bucket, --endpoint, --region, --access-key, --secret-key' ); - process.exit(1); + exitWithError( + 'Required: --bucket, --endpoint, --region, --access-key, --secret-key', + context + ); } const { error } = await setBucketMigration(name, { @@ -91,7 +95,7 @@ export default async function setMigration(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } printSuccess(context, { name }); diff --git a/src/lib/buckets/set-notifications.ts b/src/lib/buckets/set-notifications.ts index 69dece0..dc94926 100644 --- a/src/lib/buckets/set-notifications.ts +++ b/src/lib/buckets/set-notifications.ts @@ -11,6 +11,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('buckets', 'set-notifications'); @@ -31,7 +32,7 @@ export default async function setNotifications( if (!name) { printFailure(context, 'Bucket name is required'); - process.exit(1); + exitWithError('Bucket name is required', context); } const flagCount = [enable, disable, reset].filter(Boolean).length; @@ -40,7 +41,10 @@ export default async function setNotifications( context, 'Only one of --enable, --disable, or --reset can be used' ); - process.exit(1); + exitWithError( + 'Only one of --enable, --disable, or --reset can be used', + context + ); } if ( @@ -52,7 +56,7 @@ export default async function setNotifications( password !== undefined) ) { printFailure(context, 'Cannot use --reset with other options'); - process.exit(1); + exitWithError('Cannot use --reset with other options', context); } if ( @@ -66,7 +70,7 @@ export default async function setNotifications( password === undefined ) { printFailure(context, 'Provide at least one option'); - process.exit(1); + exitWithError('Provide at least one option', context); } if (token && (username !== undefined || password !== undefined)) { @@ -74,7 +78,10 @@ export default async function setNotifications( context, 'Cannot use --token with --username/--password. Choose one auth method' ); - process.exit(1); + exitWithError( + 'Cannot use --token with --username/--password. Choose one auth method', + context + ); } if ( @@ -82,7 +89,7 @@ export default async function setNotifications( (username === undefined && password !== undefined) ) { printFailure(context, 'Both --username and --password are required'); - process.exit(1); + exitWithError('Both --username and --password are required', context); } const config = await getStorageConfig(); @@ -123,7 +130,7 @@ export default async function setNotifications( if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } printSuccess(context, { name }); diff --git a/src/lib/buckets/set-transition.ts b/src/lib/buckets/set-transition.ts index cb13355..ad92439 100644 --- a/src/lib/buckets/set-transition.ts +++ b/src/lib/buckets/set-transition.ts @@ -11,6 +11,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('buckets', 'set-transition'); @@ -31,12 +32,12 @@ export default async function setTransitions(options: Record) { if (!name) { printFailure(context, 'Bucket name is required'); - process.exit(1); + exitWithError('Bucket name is required', context); } if (enable && disable) { printFailure(context, 'Cannot use both --enable and --disable'); - process.exit(1); + exitWithError('Cannot use both --enable and --disable', context); } if ( @@ -47,12 +48,15 @@ export default async function setTransitions(options: Record) { context, 'Cannot use --disable with --days, --date, or --storage-class' ); - process.exit(1); + exitWithError( + 'Cannot use --disable with --days, --date, or --storage-class', + context + ); } if (!enable && !disable && days === undefined && date === undefined) { printFailure(context, 'Provide --days, --date, --enable, or --disable'); - process.exit(1); + exitWithError('Provide --days, --date, --enable, or --disable', context); } if ((days !== undefined || date !== undefined) && !storageClass) { @@ -60,7 +64,10 @@ export default async function setTransitions(options: Record) { context, '--storage-class is required when setting --days or --date' ); - process.exit(1); + exitWithError( + '--storage-class is required when setting --days or --date', + context + ); } if (storageClass && !VALID_TRANSITION_CLASSES.includes(storageClass)) { @@ -68,12 +75,15 @@ export default async function setTransitions(options: Record) { context, `--storage-class must be one of: ${VALID_TRANSITION_CLASSES.join(', ')} (STANDARD is not a valid transition target)` ); - process.exit(1); + exitWithError( + `--storage-class must be one of: ${VALID_TRANSITION_CLASSES.join(', ')} (STANDARD is not a valid transition target)`, + context + ); } if (days !== undefined && (isNaN(Number(days)) || Number(days) <= 0)) { printFailure(context, '--days must be a positive number'); - process.exit(1); + exitWithError('--days must be a positive number', context); } if (date !== undefined) { @@ -86,7 +96,10 @@ export default async function setTransitions(options: Record) { context, '--date must be a valid ISO-8601 date (e.g. 2026-06-01)' ); - process.exit(1); + exitWithError( + '--date must be a valid ISO-8601 date (e.g. 2026-06-01)', + context + ); } } @@ -116,7 +129,7 @@ export default async function setTransitions(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } printSuccess(context, { name }); diff --git a/src/lib/buckets/set-ttl.ts b/src/lib/buckets/set-ttl.ts index 89a3ea7..a186864 100644 --- a/src/lib/buckets/set-ttl.ts +++ b/src/lib/buckets/set-ttl.ts @@ -8,6 +8,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('buckets', 'set-ttl'); @@ -22,27 +23,27 @@ export default async function setTtl(options: Record) { if (!name) { printFailure(context, 'Bucket name is required'); - process.exit(1); + exitWithError('Bucket name is required', context); } if (enable && disable) { printFailure(context, 'Cannot use both --enable and --disable'); - process.exit(1); + exitWithError('Cannot use both --enable and --disable', context); } if (disable && (days !== undefined || date !== undefined)) { printFailure(context, 'Cannot use --disable with --days or --date'); - process.exit(1); + exitWithError('Cannot use --disable with --days or --date', context); } if (!enable && !disable && days === undefined && date === undefined) { printFailure(context, 'Provide --days, --date, --enable, or --disable'); - process.exit(1); + exitWithError('Provide --days, --date, --enable, or --disable', context); } if (days !== undefined && (isNaN(Number(days)) || Number(days) <= 0)) { printFailure(context, '--days must be a positive number'); - process.exit(1); + exitWithError('--days must be a positive number', context); } if (date !== undefined) { @@ -55,7 +56,10 @@ export default async function setTtl(options: Record) { context, '--date must be a valid ISO-8601 date (e.g. 2026-06-01)' ); - process.exit(1); + exitWithError( + '--date must be a valid ISO-8601 date (e.g. 2026-06-01)', + context + ); } } @@ -82,7 +86,7 @@ export default async function setTtl(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } printSuccess(context, { name }); diff --git a/src/lib/buckets/set.ts b/src/lib/buckets/set.ts index 2d70f3a..720fb9b 100644 --- a/src/lib/buckets/set.ts +++ b/src/lib/buckets/set.ts @@ -9,6 +9,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('buckets', 'set'); @@ -61,7 +62,7 @@ export default async function set(options: Record) { if (!name) { printFailure(context, 'Bucket name is required'); - process.exit(1); + exitWithError('Bucket name is required', context); } // Check if at least one setting is provided @@ -76,7 +77,7 @@ export default async function set(options: Record) { enableAdditionalHeaders === undefined ) { printFailure(context, 'At least one setting is required'); - process.exit(1); + exitWithError('At least one setting is required', context); } const config = await getStorageConfig(); @@ -136,7 +137,7 @@ export default async function set(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } if (format === 'json') { diff --git a/src/lib/configure/index.ts b/src/lib/configure/index.ts index 0795a6d..45ffba4 100644 --- a/src/lib/configure/index.ts +++ b/src/lib/configure/index.ts @@ -9,6 +9,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError, printNextActions } from '../../utils/exit.js'; const context = msg('configure'); @@ -78,7 +79,7 @@ export default async function configure(options: Record) { // Validate that all required fields are present if (!accessKey || !accessSecret || !endpoint) { printFailure(context, 'All credentials are required'); - process.exit(1); + exitWithError('All credentials are required', context); } // Store credentials @@ -93,8 +94,9 @@ export default async function configure(options: Record) { await storeLoginMethod('credentials'); printSuccess(context); - } catch { + printNextActions(context); + } catch (error) { printFailure(context, 'Failed to save credentials'); - process.exit(1); + exitWithError(error, context); } } diff --git a/src/lib/cp.ts b/src/lib/cp.ts index 7937657..e65f630 100644 --- a/src/lib/cp.ts +++ b/src/lib/cp.ts @@ -25,6 +25,7 @@ import { get, put, list, head } from '@tigrisdata/storage'; import { executeWithConcurrency } from '../utils/concurrency.js'; import { calculateUploadParams } from '../utils/upload.js'; import type { ParsedPath } from '../types.js'; +import { exitWithError } from '../utils/exit.js'; let _jsonMode = false; @@ -38,7 +39,9 @@ function detectDirection(src: string, dest: string): CopyDirection { console.error( 'At least one path must be a remote Tigris path (t3:// or tigris://)' ); - process.exit(1); + exitWithError( + 'At least one path must be a remote Tigris path (t3:// or tigris://)' + ); } if (srcRemote && destRemote) return 'remote-to-remote'; @@ -343,7 +346,7 @@ async function copyLocalToRemote( stats = statSync(localPath); } catch { console.error(`Source not found: ${src}`); - process.exit(1); + exitWithError(`Source not found: ${src}`); } if (stats.isDirectory()) { @@ -351,7 +354,9 @@ async function copyLocalToRemote( console.error( `${src} is a directory (not copied). Use -r to copy recursively.` ); - process.exit(1); + exitWithError( + `${src} is a directory (not copied). Use -r to copy recursively.` + ); } const files = listLocalFiles(localPath); @@ -439,7 +444,7 @@ async function copyLocalToRemote( ); if (result.error) { console.error(result.error); - process.exit(1); + exitWithError(result.error); } if (_jsonMode) { console.log( @@ -470,7 +475,7 @@ async function copyRemoteToLocal( const rawEndsWithSlash = src.endsWith('/'); if (!srcParsed.path && !rawEndsWithSlash) { console.error('Cannot copy a bucket. Provide a path within the bucket.'); - process.exit(1); + exitWithError('Cannot copy a bucket. Provide a path within the bucket.'); } const localDest = resolveLocalPath(dest); @@ -486,7 +491,9 @@ async function copyRemoteToLocal( console.error( `Source is a remote folder (not copied). Use -r to copy recursively.` ); - process.exit(1); + exitWithError( + 'Source is a remote folder (not copied). Use -r to copy recursively.' + ); } if (isWildcard || isFolder) { @@ -512,7 +519,7 @@ async function copyRemoteToLocal( if (error) { console.error(error.message); - process.exit(1); + exitWithError(error); } let filesToDownload = items.filter((item) => !item.name.endsWith('/')); @@ -606,7 +613,7 @@ async function copyRemoteToLocal( ); if (result.error) { console.error(result.error); - process.exit(1); + exitWithError(result.error); } if (_jsonMode) { console.log( @@ -639,7 +646,7 @@ async function copyRemoteToRemote( const rawEndsWithSlash = src.endsWith('/'); if (!srcParsed.path && !rawEndsWithSlash) { console.error('Cannot copy a bucket. Provide a path within the bucket.'); - process.exit(1); + exitWithError('Cannot copy a bucket. Provide a path within the bucket.'); } const isWildcard = src.includes('*'); @@ -654,7 +661,9 @@ async function copyRemoteToRemote( console.error( `Source is a remote folder (not copied). Use -r to copy recursively.` ); - process.exit(1); + exitWithError( + 'Source is a remote folder (not copied). Use -r to copy recursively.' + ); } if (isWildcard || isFolder) { @@ -685,7 +694,7 @@ async function copyRemoteToRemote( prefix === effectiveDestPrefixWithSlash ) { console.error('Source and destination are the same'); - process.exit(1); + exitWithError('Source and destination are the same'); } const { items, error } = await listAllItems( @@ -696,7 +705,7 @@ async function copyRemoteToRemote( if (error) { console.error(error.message); - process.exit(1); + exitWithError(error); } let itemsToCopy = items.filter((item) => item.name !== prefix); @@ -822,7 +831,7 @@ async function copyRemoteToRemote( if (srcParsed.bucket === destParsed.bucket && srcParsed.path === destKey) { console.error('Source and destination are the same'); - process.exit(1); + exitWithError('Source and destination are the same'); } const result = await copyObject( @@ -836,7 +845,7 @@ async function copyRemoteToRemote( if (result.error) { console.error(result.error); - process.exit(1); + exitWithError(result.error); } if (_jsonMode) { @@ -863,7 +872,7 @@ export default async function cp(options: Record) { if (!src || !dest) { console.error('Both src and dest arguments are required'); - process.exit(1); + exitWithError('Both src and dest arguments are required'); } const recursive = !!getOption(options, ['recursive', 'r']); @@ -881,7 +890,7 @@ export default async function cp(options: Record) { const destParsed = parseRemotePath(dest); if (!destParsed.bucket) { console.error('Invalid destination path'); - process.exit(1); + exitWithError('Invalid destination path'); } await copyLocalToRemote(src, destParsed, config, recursive); break; @@ -890,7 +899,7 @@ export default async function cp(options: Record) { const srcParsed = parseRemotePath(src); if (!srcParsed.bucket) { console.error('Invalid source path'); - process.exit(1); + exitWithError('Invalid source path'); } await copyRemoteToLocal(src, srcParsed, dest, config, recursive); break; @@ -900,11 +909,11 @@ export default async function cp(options: Record) { const destParsed = parseRemotePath(dest); if (!srcParsed.bucket) { console.error('Invalid source path'); - process.exit(1); + exitWithError('Invalid source path'); } if (!destParsed.bucket) { console.error('Invalid destination path'); - process.exit(1); + exitWithError('Invalid destination path'); } await copyRemoteToRemote(src, srcParsed, destParsed, config, recursive); break; diff --git a/src/lib/credentials/test.ts b/src/lib/credentials/test.ts index 99443d2..2db94ef 100644 --- a/src/lib/credentials/test.ts +++ b/src/lib/credentials/test.ts @@ -8,6 +8,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('credentials', 'test'); @@ -28,7 +29,10 @@ export default async function test(options: Record) { context, 'No credentials found. Run "tigris configure" or "tigris login" first.' ); - process.exit(1); + exitWithError( + 'No credentials found. Run "tigris configure" or "tigris login" first.', + context + ); } // Include organization ID if available @@ -51,7 +55,7 @@ export default async function test(options: Record) { context, `Current credentials don't have access to bucket "${bucket}"` ); - process.exit(1); + exitWithError(error, context); } if (format === 'json') { @@ -75,7 +79,7 @@ export default async function test(options: Record) { if (error) { printFailure(context, "Current credentials don't have sufficient access"); - process.exit(1); + exitWithError(error, context); } if (format === 'json') { diff --git a/src/lib/forks/create.ts b/src/lib/forks/create.ts index d15528c..b8b5d76 100644 --- a/src/lib/forks/create.ts +++ b/src/lib/forks/create.ts @@ -7,6 +7,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('forks', 'create'); @@ -19,12 +20,12 @@ export default async function create(options: Record) { if (!name) { printFailure(context, 'Source bucket name is required'); - process.exit(1); + exitWithError('Source bucket name is required', context); } if (!forkName) { printFailure(context, 'Fork name is required'); - process.exit(1); + exitWithError('Fork name is required', context); } const { error } = await createBucket(forkName, { @@ -35,7 +36,7 @@ export default async function create(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } printSuccess(context, { name, forkName }); diff --git a/src/lib/forks/list.ts b/src/lib/forks/list.ts index 9d13c4d..05d9e1d 100644 --- a/src/lib/forks/list.ts +++ b/src/lib/forks/list.ts @@ -9,6 +9,7 @@ import { printEmpty, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('forks', 'list'); @@ -23,7 +24,7 @@ export default async function list(options: Record) { if (!name) { printFailure(context, 'Source bucket name is required'); - process.exit(1); + exitWithError('Source bucket name is required', context); } const config = await getStorageConfig(); @@ -35,7 +36,7 @@ export default async function list(options: Record) { if (infoError) { printFailure(context, infoError.message); - process.exit(1); + exitWithError(infoError, context); } if (!bucketInfo.hasForks) { @@ -48,7 +49,7 @@ export default async function list(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } // Get info for each bucket to find forks diff --git a/src/lib/iam/policies/create.ts b/src/lib/iam/policies/create.ts index f6df64a..b19641a 100644 --- a/src/lib/iam/policies/create.ts +++ b/src/lib/iam/policies/create.ts @@ -11,6 +11,7 @@ import { printFailure, msg, } from '../../../utils/messages.js'; +import { exitWithError } from '../../../utils/exit.js'; import { readStdin, parseDocument } from './utils.js'; const context = msg('iam policies', 'create'); @@ -24,7 +25,7 @@ export default async function create(options: Record) { if (!name) { printFailure(context, 'Policy name is required'); - process.exit(1); + exitWithError('Policy name is required', context); } // Validate policy name: only alphanumeric and =,.@_- allowed @@ -34,7 +35,10 @@ export default async function create(options: Record) { context, 'Invalid policy name. Only alphanumeric characters and =,.@_- are allowed.' ); - process.exit(1); + exitWithError( + 'Invalid policy name. Only alphanumeric characters and =,.@_- are allowed.', + context + ); } const loginMethod = await getLoginMethod(); @@ -44,7 +48,10 @@ export default async function create(options: Record) { context, 'Policies can only be created when logged in via OAuth.\nRun "tigris login oauth" first.' ); - process.exit(1); + exitWithError( + 'Policies can only be created when logged in via OAuth.', + context + ); } const authClient = getAuthClient(); @@ -52,7 +59,10 @@ export default async function create(options: Record) { if (!isAuthenticated) { printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); - process.exit(1); + exitWithError( + 'Not authenticated. Run "tigris login oauth" first.', + context + ); } const accessToken = await authClient.getAccessToken(); @@ -84,7 +94,10 @@ export default async function create(options: Record) { context, 'Policy document is required. Provide via --document or pipe to stdin.' ); - process.exit(1); + exitWithError( + 'Policy document is required. Provide via --document or pipe to stdin.', + context + ); } // Parse and convert document @@ -93,7 +106,7 @@ export default async function create(options: Record) { document = parseDocument(documentJson); } catch { printFailure(context, 'Invalid JSON in policy document'); - process.exit(1); + exitWithError('Invalid JSON in policy document', context); } const { data, error } = await addPolicy(name, { @@ -104,7 +117,7 @@ export default async function create(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } printSuccess(context, { name: data.name }); diff --git a/src/lib/iam/policies/delete.ts b/src/lib/iam/policies/delete.ts index acfa464..850c3b2 100644 --- a/src/lib/iam/policies/delete.ts +++ b/src/lib/iam/policies/delete.ts @@ -14,6 +14,7 @@ import { printEmpty, msg, } from '../../../utils/messages.js'; +import { exitWithError } from '../../../utils/exit.js'; const context = msg('iam policies', 'delete'); @@ -30,7 +31,10 @@ export default async function del(options: Record) { context, 'Policies can only be deleted when logged in via OAuth.\nRun "tigris login oauth" first.' ); - process.exit(1); + exitWithError( + 'Policies can only be deleted when logged in via OAuth.', + context + ); } const authClient = getAuthClient(); @@ -38,7 +42,10 @@ export default async function del(options: Record) { if (!isAuthenticated) { printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); - process.exit(1); + exitWithError( + 'Not authenticated. Run "tigris login oauth" first.', + context + ); } const accessToken = await authClient.getAccessToken(); @@ -59,7 +66,7 @@ export default async function del(options: Record) { if (listError) { printFailure(context, listError.message); - process.exit(1); + exitWithError(listError, context); } if (!listData.policies || listData.policies.length === 0) { @@ -97,7 +104,7 @@ export default async function del(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } printSuccess(context, { resource }); diff --git a/src/lib/iam/policies/edit.ts b/src/lib/iam/policies/edit.ts index 3cb7cd4..062c2e4 100644 --- a/src/lib/iam/policies/edit.ts +++ b/src/lib/iam/policies/edit.ts @@ -20,6 +20,7 @@ import { printEmpty, msg, } from '../../../utils/messages.js'; +import { exitWithError } from '../../../utils/exit.js'; import { readStdin, parseDocument } from './utils.js'; const context = msg('iam policies', 'edit'); @@ -38,7 +39,10 @@ export default async function edit(options: Record) { context, 'Policies can only be edited when logged in via OAuth.\nRun "tigris login oauth" first.' ); - process.exit(1); + exitWithError( + 'Policies can only be edited when logged in via OAuth.', + context + ); } const authClient = getAuthClient(); @@ -46,7 +50,10 @@ export default async function edit(options: Record) { if (!isAuthenticated) { printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); - process.exit(1); + exitWithError( + 'Not authenticated. Run "tigris login oauth" first.', + context + ); } const accessToken = await authClient.getAccessToken(); @@ -67,7 +74,10 @@ export default async function edit(options: Record) { context, 'Policy ARN is required when piping document via stdin.' ); - process.exit(1); + exitWithError( + 'Policy ARN is required when piping document via stdin.', + context + ); } const { data: listData, error: listError } = await listPolicies({ @@ -76,7 +86,7 @@ export default async function edit(options: Record) { if (listError) { printFailure(context, listError.message); - process.exit(1); + exitWithError(listError, context); } if (!listData.policies || listData.policies.length === 0) { @@ -115,7 +125,7 @@ export default async function edit(options: Record) { newDocument = parseDocument(documentJson); } catch { printFailure(context, 'Invalid JSON in policy document'); - process.exit(1); + exitWithError('Invalid JSON in policy document', context); } } else if (!process.stdin.isTTY && !description) { // Read from stdin only if no description provided (description-only update doesn't need stdin) @@ -124,13 +134,13 @@ export default async function edit(options: Record) { newDocument = parseDocument(documentJson); } catch { printFailure(context, 'Invalid JSON in policy document'); - process.exit(1); + exitWithError('Invalid JSON in policy document', context); } } if (!newDocument && !description) { printFailure(context, 'Either --document or --description is required.'); - process.exit(1); + exitWithError('Either --document or --description is required.', context); } // Fetch existing policy to fill in missing values @@ -140,7 +150,7 @@ export default async function edit(options: Record) { if (getError) { printFailure(context, getError.message); - process.exit(1); + exitWithError(getError, context); } const { data, error } = await editPolicy(resource, { @@ -151,7 +161,7 @@ export default async function edit(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } printSuccess(context, { resource: data.resource }); diff --git a/src/lib/iam/policies/get.ts b/src/lib/iam/policies/get.ts index c731ff0..56debaf 100644 --- a/src/lib/iam/policies/get.ts +++ b/src/lib/iam/policies/get.ts @@ -14,6 +14,7 @@ import { printEmpty, msg, } from '../../../utils/messages.js'; +import { exitWithError } from '../../../utils/exit.js'; const context = msg('iam policies', 'get'); @@ -33,7 +34,10 @@ export default async function get(options: Record) { context, 'Policies can only be retrieved when logged in via OAuth.\nRun "tigris login oauth" first.' ); - process.exit(1); + exitWithError( + 'Policies can only be retrieved when logged in via OAuth.', + context + ); } const authClient = getAuthClient(); @@ -41,7 +45,10 @@ export default async function get(options: Record) { if (!isAuthenticated) { printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); - process.exit(1); + exitWithError( + 'Not authenticated. Run "tigris login oauth" first.', + context + ); } const accessToken = await authClient.getAccessToken(); @@ -62,7 +69,7 @@ export default async function get(options: Record) { if (listError) { printFailure(context, listError.message); - process.exit(1); + exitWithError(listError, context); } if (!listData.policies || listData.policies.length === 0) { @@ -89,7 +96,7 @@ export default async function get(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } if (format === 'json') { diff --git a/src/lib/iam/policies/list.ts b/src/lib/iam/policies/list.ts index aa1cf6a..a864875 100644 --- a/src/lib/iam/policies/list.ts +++ b/src/lib/iam/policies/list.ts @@ -12,6 +12,7 @@ import { printEmpty, msg, } from '../../../utils/messages.js'; +import { exitWithError } from '../../../utils/exit.js'; const context = msg('iam policies', 'list'); @@ -30,7 +31,10 @@ export default async function list(options: Record) { context, 'Policies can only be listed when logged in via OAuth.\nRun "tigris login oauth" first.' ); - process.exit(1); + exitWithError( + 'Policies can only be listed when logged in via OAuth.', + context + ); } const authClient = getAuthClient(); @@ -38,7 +42,10 @@ export default async function list(options: Record) { if (!isAuthenticated) { printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); - process.exit(1); + exitWithError( + 'Not authenticated. Run "tigris login oauth" first.', + context + ); } const accessToken = await authClient.getAccessToken(); @@ -55,7 +62,7 @@ export default async function list(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } if (!data.policies || data.policies.length === 0) { diff --git a/src/lib/iam/users/invite.ts b/src/lib/iam/users/invite.ts index 94c54ef..599a65e 100644 --- a/src/lib/iam/users/invite.ts +++ b/src/lib/iam/users/invite.ts @@ -11,6 +11,7 @@ import { printFailure, msg, } from '../../../utils/messages.js'; +import { exitWithError } from '../../../utils/exit.js'; const context = msg('iam users', 'invite'); @@ -24,7 +25,10 @@ export default async function invite(options: Record) { context, 'Users can only be invited when logged in via OAuth.\nRun "tigris login oauth" first.' ); - process.exit(1); + exitWithError( + 'Users can only be invited when logged in via OAuth.', + context + ); } const selectedOrg = getSelectedOrganization(); @@ -49,7 +53,7 @@ export default async function invite(options: Record) { if (emails.length === 0) { printFailure(context, 'Email address is required'); - process.exit(1); + exitWithError('Email address is required', context); } const validRoles = ['admin', 'member'] as const; @@ -60,7 +64,10 @@ export default async function invite(options: Record) { context, `Invalid role "${roleInput}". Must be one of: ${validRoles.join(', ')}` ); - process.exit(1); + exitWithError( + `Invalid role "${roleInput}". Must be one of: ${validRoles.join(', ')}`, + context + ); } const role: Role = roleInput as Role; @@ -70,7 +77,10 @@ export default async function invite(options: Record) { if (!isAuthenticated) { printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); - process.exit(1); + exitWithError( + 'Not authenticated. Run "tigris login oauth" first.', + context + ); } const accessToken = await authClient.getAccessToken(); @@ -89,7 +99,7 @@ export default async function invite(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } printSuccess(context, { email: emails.join(', ') }); diff --git a/src/lib/iam/users/list.ts b/src/lib/iam/users/list.ts index 81a35cc..ddb9a50 100644 --- a/src/lib/iam/users/list.ts +++ b/src/lib/iam/users/list.ts @@ -18,6 +18,7 @@ import { printEmpty, msg, } from '../../../utils/messages.js'; +import { exitWithError } from '../../../utils/exit.js'; const context = msg('iam users', 'list'); @@ -36,7 +37,10 @@ export default async function list(options: Record) { context, 'Users can only be listed when logged in via OAuth.\nRun "tigris login oauth" first.' ); - process.exit(1); + exitWithError( + 'Users can only be listed when logged in via OAuth.', + context + ); } const selectedOrg = getSelectedOrganization(); @@ -55,7 +59,10 @@ export default async function list(options: Record) { if (!isAuthenticated) { printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); - process.exit(1); + exitWithError( + 'Not authenticated. Run "tigris login oauth" first.', + context + ); } const accessToken = await authClient.getAccessToken(); @@ -72,7 +79,7 @@ export default async function list(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } const users = data.users.map((user) => ({ diff --git a/src/lib/iam/users/remove.ts b/src/lib/iam/users/remove.ts index 6e1cac3..0e6a73b 100644 --- a/src/lib/iam/users/remove.ts +++ b/src/lib/iam/users/remove.ts @@ -15,6 +15,7 @@ import { printEmpty, msg, } from '../../../utils/messages.js'; +import { exitWithError } from '../../../utils/exit.js'; const context = msg('iam users', 'remove'); @@ -31,7 +32,10 @@ export default async function removeUser(options: Record) { context, 'Users can only be removed when logged in via OAuth.\nRun "tigris login oauth" first.' ); - process.exit(1); + exitWithError( + 'Users can only be removed when logged in via OAuth.', + context + ); } const selectedOrg = getSelectedOrganization(); @@ -50,7 +54,10 @@ export default async function removeUser(options: Record) { if (!isAuthenticated) { printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); - process.exit(1); + exitWithError( + 'Not authenticated. Run "tigris login oauth" first.', + context + ); } const accessToken = await authClient.getAccessToken(); @@ -77,7 +84,7 @@ export default async function removeUser(options: Record) { if (listError) { printFailure(context, listError.message); - process.exit(1); + exitWithError(listError, context); } if (listData.users.length === 0) { @@ -115,7 +122,7 @@ export default async function removeUser(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } printSuccess(context); diff --git a/src/lib/iam/users/revoke-invitation.ts b/src/lib/iam/users/revoke-invitation.ts index cabd1ea..74b4ea8 100644 --- a/src/lib/iam/users/revoke-invitation.ts +++ b/src/lib/iam/users/revoke-invitation.ts @@ -15,6 +15,7 @@ import { printEmpty, msg, } from '../../../utils/messages.js'; +import { exitWithError } from '../../../utils/exit.js'; const context = msg('iam users', 'revoke-invitation'); @@ -33,7 +34,10 @@ export default async function revokeInvitation( context, 'Invitations can only be revoked when logged in via OAuth.\nRun "tigris login oauth" first.' ); - process.exit(1); + exitWithError( + 'Invitations can only be revoked when logged in via OAuth.', + context + ); } const selectedOrg = getSelectedOrganization(); @@ -52,7 +56,10 @@ export default async function revokeInvitation( if (!isAuthenticated) { printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); - process.exit(1); + exitWithError( + 'Not authenticated. Run "tigris login oauth" first.', + context + ); } const accessToken = await authClient.getAccessToken(); @@ -79,7 +86,7 @@ export default async function revokeInvitation( if (listError) { printFailure(context, listError.message); - process.exit(1); + exitWithError(listError, context); } if (listData.invitations.length === 0) { @@ -120,7 +127,7 @@ export default async function revokeInvitation( if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } printSuccess(context); diff --git a/src/lib/iam/users/update-role.ts b/src/lib/iam/users/update-role.ts index 52e6ef6..3627dd6 100644 --- a/src/lib/iam/users/update-role.ts +++ b/src/lib/iam/users/update-role.ts @@ -15,6 +15,7 @@ import { printEmpty, msg, } from '../../../utils/messages.js'; +import { exitWithError } from '../../../utils/exit.js'; const context = msg('iam users', 'update-role'); @@ -28,7 +29,10 @@ export default async function updateRole(options: Record) { context, 'User roles can only be updated when logged in via OAuth.\nRun "tigris login oauth" first.' ); - process.exit(1); + exitWithError( + 'User roles can only be updated when logged in via OAuth.', + context + ); } const selectedOrg = getSelectedOrganization(); @@ -52,7 +56,10 @@ export default async function updateRole(options: Record) { context, 'Role is required. Use --role admin or --role member' ); - process.exit(1); + exitWithError( + 'Role is required. Use --role admin or --role member', + context + ); } const roles = Array.isArray(roleOption) ? roleOption : [roleOption]; @@ -63,7 +70,10 @@ export default async function updateRole(options: Record) { context, `Invalid role "${r}". Must be one of: ${validRoles.join(', ')}` ); - process.exit(1); + exitWithError( + `Invalid role "${r}". Must be one of: ${validRoles.join(', ')}`, + context + ); } } @@ -80,7 +90,10 @@ export default async function updateRole(options: Record) { if (!isAuthenticated) { printFailure(context, 'Not authenticated. Run "tigris login oauth" first.'); - process.exit(1); + exitWithError( + 'Not authenticated. Run "tigris login oauth" first.', + context + ); } const accessToken = await authClient.getAccessToken(); @@ -101,7 +114,7 @@ export default async function updateRole(options: Record) { if (listError) { printFailure(context, listError.message); - process.exit(1); + exitWithError(listError, context); } if (listData.users.length === 0) { @@ -130,7 +143,10 @@ export default async function updateRole(options: Record) { context, `Number of roles (${roles.length}) must match number of users (${resources.length}), or provide a single role for all users` ); - process.exit(1); + exitWithError( + `Number of roles (${roles.length}) must match number of users (${resources.length}), or provide a single role for all users`, + context + ); } const roleUpdates = resources.map((userId, i) => ({ @@ -144,7 +160,7 @@ export default async function updateRole(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } printSuccess(context); diff --git a/src/lib/login/credentials.ts b/src/lib/login/credentials.ts index 35b5800..8472d1d 100644 --- a/src/lib/login/credentials.ts +++ b/src/lib/login/credentials.ts @@ -13,6 +13,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError, printNextActions } from '../../utils/exit.js'; const context = msg('login', 'credentials'); @@ -73,7 +74,7 @@ export default async function credentials(options: Record) { // Validate if (!accessKey || !accessSecret) { printFailure(context, 'Access key and secret are required'); - process.exit(1); + exitWithError('Access key and secret are required', context); } // Get endpoint: configured → default @@ -89,4 +90,5 @@ export default async function credentials(options: Record) { await storeLoginMethod('credentials'); printSuccess(context); + printNextActions(context); } diff --git a/src/lib/login/oauth.ts b/src/lib/login/oauth.ts index 9f90a02..edb1f96 100644 --- a/src/lib/login/oauth.ts +++ b/src/lib/login/oauth.ts @@ -8,6 +8,7 @@ import { printHint, msg, } from '../../utils/messages.js'; +import { exitWithError, printNextActions } from '../../utils/exit.js'; const context = msg('login', 'oauth'); @@ -38,16 +39,18 @@ export async function oauth(): Promise { const firstOrg = orgs[0]; await storeSelectedOrganization(firstOrg.id); printSuccess(context, { org: firstOrg.displayName || firstOrg.name }); + printNextActions(context); if (orgs.length > 1) { printHint(context, { count: orgs.length }); } } else { printSuccess(context, { org: 'none' }); + printNextActions(context); } - } catch { + } catch (error) { printFailure(context); - process.exit(1); + exitWithError(error, context); } } diff --git a/src/lib/logout.ts b/src/lib/logout.ts index 6e83527..b26e4d7 100644 --- a/src/lib/logout.ts +++ b/src/lib/logout.ts @@ -5,6 +5,7 @@ import { printFailure, msg, } from '../utils/messages.js'; +import { exitWithError } from '../utils/exit.js'; const context = msg('logout'); @@ -21,6 +22,6 @@ export default async function logout(): Promise { } else { printFailure(context); } - process.exit(1); + exitWithError(error, context); } } diff --git a/src/lib/ls.ts b/src/lib/ls.ts index eed18d7..19f456d 100644 --- a/src/lib/ls.ts +++ b/src/lib/ls.ts @@ -3,6 +3,7 @@ import { getOption } from '../utils/options.js'; import { formatOutput, formatSize } from '../utils/format.js'; import { getStorageConfig } from '../auth/s3-client.js'; import { list, listBuckets } from '@tigrisdata/storage'; +import { exitWithError } from '../utils/exit.js'; export default async function ls(options: Record) { const pathString = getOption(options, ['path']); @@ -23,7 +24,7 @@ export default async function ls(options: Record) { if (error) { console.error(error.message); - process.exit(1); + exitWithError(error); } const buckets = (data.buckets || []).map((bucket) => ({ @@ -44,7 +45,7 @@ export default async function ls(options: Record) { if (!bucket) { console.error('Invalid path'); - process.exit(1); + exitWithError('Invalid path'); } const config = await getStorageConfig(); @@ -63,7 +64,7 @@ export default async function ls(options: Record) { if (error) { console.error(error.message); - process.exit(1); + exitWithError(error); } const objects = (data.items || []) diff --git a/src/lib/mk.ts b/src/lib/mk.ts index cf6024e..94c53b5 100644 --- a/src/lib/mk.ts +++ b/src/lib/mk.ts @@ -3,20 +3,21 @@ import { getOption } from '../utils/options.js'; import { getStorageConfig } from '../auth/s3-client.js'; import { createBucket, put, type StorageClass } from '@tigrisdata/storage'; import { parseLocations } from '../utils/locations.js'; +import { exitWithError } from '../utils/exit.js'; export default async function mk(options: Record) { const pathString = getOption(options, ['path']); if (!pathString) { console.error('path argument is required'); - process.exit(1); + exitWithError('path argument is required'); } const { bucket, path } = parseAnyPath(pathString); if (!bucket) { console.error('Invalid path'); - process.exit(1); + exitWithError('Invalid path'); } const config = await getStorageConfig(); @@ -74,7 +75,7 @@ export default async function mk(options: Record) { if (sourceSnapshot && !forkOf) { console.error('--source-snapshot requires --fork-of'); - process.exit(1); + exitWithError('--source-snapshot requires --fork-of'); } const { error } = await createBucket(bucket, { @@ -89,7 +90,7 @@ export default async function mk(options: Record) { if (error) { console.error(error.message); - process.exit(1); + exitWithError(error); } if (format === 'json') { @@ -113,7 +114,7 @@ export default async function mk(options: Record) { if (error) { console.error(error.message); - process.exit(1); + exitWithError(error); } if (format === 'json') { diff --git a/src/lib/mv.ts b/src/lib/mv.ts index c2b68c4..c09b08c 100644 --- a/src/lib/mv.ts +++ b/src/lib/mv.ts @@ -12,6 +12,7 @@ import { formatSize } from '../utils/format.js'; import { requireInteractive, confirm } from '../utils/interactive.js'; import { get, put, remove, list, head } from '@tigrisdata/storage'; import { calculateUploadParams } from '../utils/upload.js'; +import { exitWithError } from '../utils/exit.js'; let _jsonMode = false; @@ -28,14 +29,16 @@ export default async function mv(options: Record) { if (!src || !dest) { console.error('both src and dest arguments are required'); - process.exit(1); + exitWithError('both src and dest arguments are required'); } if (!isRemotePath(src) || !isRemotePath(dest)) { console.error( 'Both src and dest must be remote Tigris paths (t3:// or tigris://)' ); - process.exit(1); + exitWithError( + 'Both src and dest must be remote Tigris paths (t3:// or tigris://)' + ); } const srcPath = parseRemotePath(src); @@ -43,12 +46,12 @@ export default async function mv(options: Record) { if (!srcPath.bucket) { console.error('Invalid source path'); - process.exit(1); + exitWithError('Invalid source path'); } if (!destPath.bucket) { console.error('Invalid destination path'); - process.exit(1); + exitWithError('Invalid destination path'); } // Cannot move a bucket itself @@ -57,7 +60,7 @@ export default async function mv(options: Record) { const rawEndsWithSlash = src.endsWith('/'); if (!srcPath.path && !rawEndsWithSlash) { console.error('Cannot move a bucket. Provide a path within the bucket.'); - process.exit(1); + exitWithError('Cannot move a bucket. Provide a path within the bucket.'); } const config = await getStorageConfig({ withCredentialProvider: true }); @@ -76,7 +79,9 @@ export default async function mv(options: Record) { console.error( `Source is a remote folder (not moved). Use -r to move recursively.` ); - process.exit(1); + exitWithError( + 'Source is a remote folder (not moved). Use -r to move recursively.' + ); } if (isWildcard || isFolder) { @@ -108,7 +113,7 @@ export default async function mv(options: Record) { prefix === effectiveDestPrefixWithSlash ) { console.error('Source and destination are the same'); - process.exit(1); + exitWithError('Source and destination are the same'); } const { items, error } = await listAllItems( @@ -119,7 +124,7 @@ export default async function mv(options: Record) { if (error) { console.error(error.message); - process.exit(1); + exitWithError(error); } // Filter out folder markers - they're handled separately below @@ -269,7 +274,7 @@ export default async function mv(options: Record) { // Check for same location if (srcPath.bucket === destPath.bucket && srcPath.path === destKey) { console.error('Source and destination are the same'); - process.exit(1); + exitWithError('Source and destination are the same'); } if (!force) { @@ -294,7 +299,7 @@ export default async function mv(options: Record) { if (result.error) { console.error(result.error); - process.exit(1); + exitWithError(result.error); } if (_jsonMode) { diff --git a/src/lib/objects/delete.ts b/src/lib/objects/delete.ts index 4cec92f..c869c2a 100644 --- a/src/lib/objects/delete.ts +++ b/src/lib/objects/delete.ts @@ -7,6 +7,11 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { + exitWithError, + getSuccessNextActions, + printNextActions, +} from '../../utils/exit.js'; import { requireInteractive, confirm } from '../../utils/interactive.js'; const context = msg('objects', 'delete'); @@ -25,12 +30,12 @@ export default async function deleteObject(options: Record) { if (!bucket) { printFailure(context, 'Bucket name is required'); - process.exit(1); + exitWithError('Bucket name is required', context); } if (!keys) { printFailure(context, 'Object key is required'); - process.exit(1); + exitWithError('Object key is required', context); } const config = await getStorageConfig(); @@ -67,12 +72,20 @@ export default async function deleteObject(options: Record) { } if (format === 'json') { - console.log( - JSON.stringify({ action: 'deleted', bucket, keys: deleted, errors }) - ); + const nextActions = getSuccessNextActions(context, { bucket }); + const jsonOutput: Record = { + action: 'deleted', + bucket, + keys: deleted, + errors, + }; + if (nextActions.length > 0) jsonOutput.nextActions = nextActions; + console.log(JSON.stringify(jsonOutput)); } + printNextActions(context, { bucket }); + if (errors.length > 0) { - process.exit(1); + exitWithError(errors[0].error, context); } } diff --git a/src/lib/objects/get.ts b/src/lib/objects/get.ts index 16560e0..1f06fab 100644 --- a/src/lib/objects/get.ts +++ b/src/lib/objects/get.ts @@ -11,6 +11,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('objects', 'get'); @@ -125,12 +126,12 @@ export default async function getObject(options: Record) { if (!bucket) { printFailure(context, 'Bucket name is required'); - process.exit(1); + exitWithError('Bucket name is required', context); } if (!key) { printFailure(context, 'Object key is required'); - process.exit(1); + exitWithError('Object key is required', context); } const config = await getStorageConfig(); @@ -149,7 +150,7 @@ export default async function getObject(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } if (output) { @@ -177,7 +178,7 @@ export default async function getObject(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } if (output) { diff --git a/src/lib/objects/list.ts b/src/lib/objects/list.ts index 16dfdf6..703d574 100644 --- a/src/lib/objects/list.ts +++ b/src/lib/objects/list.ts @@ -9,6 +9,7 @@ import { printEmpty, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('objects', 'list'); @@ -29,7 +30,7 @@ export default async function listObjects(options: Record) { if (!bucket) { printFailure(context, 'Bucket name is required'); - process.exit(1); + exitWithError('Bucket name is required', context); } const config = await getStorageConfig(); @@ -45,7 +46,7 @@ export default async function listObjects(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } if (!data.items || data.items.length === 0) { diff --git a/src/lib/objects/put.ts b/src/lib/objects/put.ts index d4c7df1..8c6d2e2 100644 --- a/src/lib/objects/put.ts +++ b/src/lib/objects/put.ts @@ -10,6 +10,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError, printNextActions } from '../../utils/exit.js'; import { calculateUploadParams } from '../../utils/upload.js'; const context = msg('objects', 'put'); @@ -34,12 +35,12 @@ export default async function putObject(options: Record) { if (!bucket) { printFailure(context, 'Bucket name is required'); - process.exit(1); + exitWithError('Bucket name is required', context); } if (!key) { printFailure(context, 'Object key is required'); - process.exit(1); + exitWithError('Object key is required', context); } // Check for stdin or file input @@ -47,7 +48,7 @@ export default async function putObject(options: Record) { if (!file && !hasStdin) { printFailure(context, 'File path is required (or pipe data via stdin)'); - process.exit(1); + exitWithError('File path is required (or pipe data via stdin)', context); } let body: ReadableStream; @@ -60,7 +61,7 @@ export default async function putObject(options: Record) { fileSize = stats.size; } catch { printFailure(context, `File not found: ${file}`); - process.exit(1); + exitWithError(`File not found: ${file}`, context); } const fileStream = createReadStream(file); body = Readable.toWeb(fileStream) as ReadableStream; @@ -100,7 +101,7 @@ export default async function putObject(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } const result = [ @@ -121,4 +122,5 @@ export default async function putObject(options: Record) { console.log(output); printSuccess(context, { key, bucket }); + printNextActions(context, { key, bucket }); } diff --git a/src/lib/objects/set.ts b/src/lib/objects/set.ts index d89926d..6eb096c 100644 --- a/src/lib/objects/set.ts +++ b/src/lib/objects/set.ts @@ -6,6 +6,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; import { updateObject } from '@tigrisdata/storage'; const context = msg('objects', 'set'); @@ -25,17 +26,20 @@ export default async function setObject(options: Record) { if (!bucket) { printFailure(context, 'Bucket name is required'); - process.exit(1); + exitWithError('Bucket name is required', context); } if (!key) { printFailure(context, 'Object key is required'); - process.exit(1); + exitWithError('Object key is required', context); } if (!access) { printFailure(context, 'Access level is required (--access public|private)'); - process.exit(1); + exitWithError( + 'Access level is required (--access public|private)', + context + ); } const config = await getStorageConfig(); @@ -51,7 +55,7 @@ export default async function setObject(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } if (format === 'json') { diff --git a/src/lib/organizations/create.ts b/src/lib/organizations/create.ts index a2777a4..2b4f4a4 100644 --- a/src/lib/organizations/create.ts +++ b/src/lib/organizations/create.ts @@ -14,6 +14,7 @@ import { printHint, msg, } from '../../utils/messages.js'; +import { exitWithError, printNextActions } from '../../utils/exit.js'; const context = msg('organizations', 'create'); @@ -53,7 +54,7 @@ export default async function create(options: Record) { if (!name) { printFailure(context, 'Organization name is required'); - process.exit(1); + exitWithError('Organization name is required', context); } const config = await getStorageConfig(); @@ -62,11 +63,12 @@ export default async function create(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } const id = data.id; printSuccess(context, { name, id }); printHint(context, { name }); + printNextActions(context, { name }); } diff --git a/src/lib/organizations/list.ts b/src/lib/organizations/list.ts index 3c9a155..0a7b912 100644 --- a/src/lib/organizations/list.ts +++ b/src/lib/organizations/list.ts @@ -19,6 +19,7 @@ import { printEmpty, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('organizations', 'list'); @@ -68,7 +69,7 @@ export default async function list(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } orgs = data?.organizations ?? []; diff --git a/src/lib/organizations/select.ts b/src/lib/organizations/select.ts index cc7180e..8696bcf 100644 --- a/src/lib/organizations/select.ts +++ b/src/lib/organizations/select.ts @@ -12,6 +12,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError, printNextActions } from '../../utils/exit.js'; const context = msg('organizations', 'select'); @@ -40,7 +41,7 @@ export default async function select(options: Record) { if (!name) { printFailure(context, 'Organization name or ID is required'); - process.exit(1); + exitWithError('Organization name or ID is required', context); } const config = await getStorageConfig(); @@ -49,7 +50,7 @@ export default async function select(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } const orgs = data?.organizations ?? []; @@ -65,11 +66,12 @@ export default async function select(options: Record) { context, `Organization "${name}" not found\n\nAvailable organizations:\n${availableOrgs}` ); - process.exit(1); + exitWithError(`Organization "${name}" not found`, context); } // Store selected organization await storeSelectedOrganization(org.id); printSuccess(context, { name: org.name }); + printNextActions(context, { name: org.name }); } diff --git a/src/lib/presign.ts b/src/lib/presign.ts index 4af628b..b49278c 100644 --- a/src/lib/presign.ts +++ b/src/lib/presign.ts @@ -8,6 +8,7 @@ import { getAuthClient } from '../auth/client.js'; import { getSelectedOrganization } from '../auth/storage.js'; import { getTigrisConfig } from '../auth/config.js'; import { formatJson } from '../utils/format.js'; +import { exitWithError } from '../utils/exit.js'; import enquirer from 'enquirer'; const { prompt } = enquirer; @@ -16,19 +17,19 @@ export default async function presign(options: Record) { if (!pathString) { console.error('path argument is required'); - process.exit(1); + exitWithError('path argument is required'); } const { bucket, path } = parseAnyPath(pathString); if (!bucket) { console.error('Invalid path'); - process.exit(1); + exitWithError('Invalid path'); } if (!path) { console.error('Object key is required'); - process.exit(1); + exitWithError('Object key is required'); } const method = getOption(options, ['method', 'm']) ?? 'get'; @@ -61,7 +62,9 @@ export default async function presign(options: Record) { console.error( 'Presigning requires an access key. Pass --access-key or configure credentials.' ); - process.exit(1); + exitWithError( + 'Presigning requires an access key. Pass --access-key or configure credentials.' + ); } accessKeyId = await resolveAccessKeyInteractively(bucket); @@ -71,7 +74,9 @@ export default async function presign(options: Record) { console.error( 'Presigning requires an access key. Pass --access-key or configure credentials.' ); - process.exit(1); + exitWithError( + 'Presigning requires an access key. Pass --access-key or configure credentials.' + ); } const { data, error } = await getPresignedUrl(path, { @@ -86,7 +91,7 @@ export default async function presign(options: Record) { if (error) { console.error(error.message); - process.exit(1); + exitWithError(error); } if (format === 'json') { @@ -113,7 +118,9 @@ async function resolveAccessKeyInteractively( console.error( 'Presigning requires an access key. Pass --access-key tid_...' ); - process.exit(1); + exitWithError( + 'Presigning requires an access key. Pass --access-key tid_...' + ); } const authClient = getAuthClient(); @@ -131,14 +138,16 @@ async function resolveAccessKeyInteractively( if (error) { console.error(`Failed to list access keys: ${error.message}`); - process.exit(1); + exitWithError(error); } if (!data.accessKeys || data.accessKeys.length === 0) { console.error( 'No access keys found. Create one with "tigris access-keys create "' ); - process.exit(1); + exitWithError( + 'No access keys found. Create one with "tigris access-keys create "' + ); } // Filter to active keys that have access to the target bucket @@ -162,7 +171,9 @@ async function resolveAccessKeyInteractively( console.error( 'No active access keys found. Create one with "tigris access-keys create "' ); - process.exit(1); + exitWithError( + 'No active access keys found. Create one with "tigris access-keys create "' + ); } console.error( diff --git a/src/lib/rm.ts b/src/lib/rm.ts index 937af3c..9ec2186 100644 --- a/src/lib/rm.ts +++ b/src/lib/rm.ts @@ -10,6 +10,7 @@ import { getOption } from '../utils/options.js'; import { getStorageConfig } from '../auth/s3-client.js'; import { remove, removeBucket, list } from '@tigrisdata/storage'; import { requireInteractive, confirm } from '../utils/interactive.js'; +import { exitWithError } from '../utils/exit.js'; let _jsonMode = false; @@ -25,19 +26,19 @@ export default async function rm(options: Record) { if (!pathString) { console.error('path argument is required'); - process.exit(1); + exitWithError('path argument is required'); } if (!isRemotePath(pathString)) { console.error('Path must be a remote Tigris path (t3:// or tigris://)'); - process.exit(1); + exitWithError('Path must be a remote Tigris path (t3:// or tigris://)'); } const { bucket, path } = parseRemotePath(pathString); if (!bucket) { console.error('Invalid path'); - process.exit(1); + exitWithError('Invalid path'); } const config = await getStorageConfig(); @@ -60,7 +61,7 @@ export default async function rm(options: Record) { if (error) { console.error(error.message); - process.exit(1); + exitWithError(error); } if (_jsonMode) { @@ -84,7 +85,9 @@ export default async function rm(options: Record) { console.error( `Source is a remote folder (not removed). Use -r to remove recursively.` ); - process.exit(1); + exitWithError( + 'Source is a remote folder (not removed). Use -r to remove recursively.' + ); } if (isWildcard || isFolder) { @@ -105,7 +108,7 @@ export default async function rm(options: Record) { if (error) { console.error(error.message); - process.exit(1); + exitWithError(error); } let itemsToRemove = items; @@ -228,7 +231,7 @@ export default async function rm(options: Record) { if (error) { console.error(error.message); - process.exit(1); + exitWithError(error); } if (_jsonMode) { diff --git a/src/lib/snapshots/list.ts b/src/lib/snapshots/list.ts index 0da6c44..8920e4b 100644 --- a/src/lib/snapshots/list.ts +++ b/src/lib/snapshots/list.ts @@ -9,6 +9,7 @@ import { printEmpty, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('snapshots', 'list'); @@ -23,7 +24,7 @@ export default async function list(options: Record) { if (!name) { printFailure(context, 'Bucket name is required'); - process.exit(1); + exitWithError('Bucket name is required', context); } const config = await getStorageConfig(); @@ -32,7 +33,7 @@ export default async function list(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } if (!data || data.length === 0) { diff --git a/src/lib/snapshots/take.ts b/src/lib/snapshots/take.ts index 21fa60b..fd9638f 100644 --- a/src/lib/snapshots/take.ts +++ b/src/lib/snapshots/take.ts @@ -7,6 +7,7 @@ import { printFailure, msg, } from '../../utils/messages.js'; +import { exitWithError } from '../../utils/exit.js'; const context = msg('snapshots', 'take'); @@ -21,7 +22,7 @@ export default async function take(options: Record) { if (!name) { printFailure(context, 'Bucket name is required'); - process.exit(1); + exitWithError('Bucket name is required', context); } const config = await getStorageConfig(); @@ -33,7 +34,7 @@ export default async function take(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } printSuccess(context, { diff --git a/src/lib/stat.ts b/src/lib/stat.ts index b201e7e..a112044 100644 --- a/src/lib/stat.ts +++ b/src/lib/stat.ts @@ -10,6 +10,7 @@ import { msg, } from '../utils/messages.js'; import { buildBucketInfo } from '../utils/bucket-info.js'; +import { exitWithError } from '../utils/exit.js'; const context = msg('stat'); @@ -34,7 +35,7 @@ export default async function stat(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } const stats = [ @@ -64,7 +65,7 @@ export default async function stat(options: Record) { if (!bucket) { printFailure(context, 'Invalid path'); - process.exit(1); + exitWithError('Invalid path', context); } // Bucket only (no path or just trailing slash): show bucket info @@ -73,7 +74,7 @@ export default async function stat(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } const info = buildBucketInfo(data).map(({ label, value }) => ({ @@ -102,12 +103,12 @@ export default async function stat(options: Record) { if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } if (!data) { printFailure(context, 'Object not found'); - process.exit(1); + exitWithError('Object not found', context); } const info = [ diff --git a/src/lib/touch.ts b/src/lib/touch.ts index 0c98914..45702ba 100644 --- a/src/lib/touch.ts +++ b/src/lib/touch.ts @@ -2,25 +2,26 @@ import { parseAnyPath } from '../utils/path.js'; import { getOption } from '../utils/options.js'; import { getStorageConfig } from '../auth/s3-client.js'; import { put } from '@tigrisdata/storage'; +import { exitWithError } from '../utils/exit.js'; export default async function touch(options: Record) { const pathString = getOption(options, ['path']); if (!pathString) { console.error('path argument is required'); - process.exit(1); + exitWithError('path argument is required'); } const { bucket, path } = parseAnyPath(pathString); if (!bucket) { console.error('Invalid path'); - process.exit(1); + exitWithError('Invalid path'); } if (!path) { console.error('Object key is required (use mk to create buckets)'); - process.exit(1); + exitWithError('Object key is required (use mk to create buckets)'); } const json = getOption(options, ['json']); @@ -39,7 +40,7 @@ export default async function touch(options: Record) { if (error) { console.error(error.message); - process.exit(1); + exitWithError(error); } if (format === 'json') { diff --git a/src/lib/whoami.ts b/src/lib/whoami.ts index 8929889..d368f93 100644 --- a/src/lib/whoami.ts +++ b/src/lib/whoami.ts @@ -7,6 +7,7 @@ import { } from '../auth/storage.js'; import { getStorageConfig } from '../auth/s3-client.js'; import { printFailure, printAlreadyDone, msg } from '../utils/messages.js'; +import { exitWithError } from '../utils/exit.js'; import { getOption } from '../utils/options.js'; const context = msg('whoami'); @@ -64,7 +65,7 @@ export default async function whoami( if (error) { printFailure(context, error.message); - process.exit(1); + exitWithError(error, context); } organizations = data?.organizations ?? []; @@ -121,6 +122,6 @@ export default async function whoami( } else { printFailure(context); } - process.exit(1); + exitWithError(error, context); } } diff --git a/src/specs.yaml b/src/specs.yaml index 5de5628..8925354 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -101,6 +101,11 @@ commands: onStart: 'Saving credentials...' onSuccess: 'Credentials saved to ~/.tigris/config.json. You can now use all tigris commands.' onFailure: 'Failed to save credentials' + nextActions: + - command: 'tigris credentials test' + description: 'Verify your credentials are working' + - command: 'tigris ls' + description: 'List your buckets' arguments: - name: access-key description: Your Tigris access key ID @@ -156,6 +161,11 @@ commands: onFailure: 'Authentication failed' onAlreadyDone: "Already logged in.\nRun \"tigris logout\" first to switch accounts." hint: "You have {{count}} organizations.\nRun \"tigris orgs list\" to switch." + nextActions: + - command: 'tigris orgs list' + description: 'List and switch organizations' + - command: 'tigris ls' + description: 'List your buckets' # credentials - name: credentials description: Login with an access key and secret. Creates a temporary session that is cleared on logout @@ -167,6 +177,9 @@ commands: onStart: 'Authenticating...' onSuccess: 'Logged in with credentials' onFailure: 'Authentication failed' + nextActions: + - command: 'tigris ls' + description: 'List your buckets' arguments: - name: access-key description: Your access key ID (will prompt if not provided) @@ -615,6 +628,9 @@ commands: onSuccess: "Organization '{{name}}' created successfully\nOrganization ID: {{id}}" onFailure: 'Failed to create organization' hint: "Next steps:\n - Select this organization: tigris orgs select {{name}}" + nextActions: + - command: 'tigris orgs select {{name}}' + description: 'Select this organization as active' arguments: - name: name type: positional @@ -631,6 +647,11 @@ commands: onStart: '' onSuccess: "Organization '{{name}}' selected" onFailure: 'Failed to select organization' + nextActions: + - command: 'tigris ls' + description: 'List your buckets' + - command: 'tigris buckets create ' + description: 'Create a new bucket' arguments: - name: name type: positional @@ -689,6 +710,11 @@ commands: onStart: 'Creating bucket...' onSuccess: "Bucket '{{name}}' created successfully" onFailure: 'Failed to create bucket' + nextActions: + - command: 'tigris access-keys create ' + description: 'Create an access key for programmatic access' + - command: 'tigris cp ./file t3://{{name}}/' + description: 'Upload files to the new bucket' arguments: - name: name description: Name of the bucket @@ -779,6 +805,9 @@ commands: onStart: 'Deleting bucket...' onSuccess: "Bucket '{{name}}' deleted successfully" onFailure: "Failed to delete bucket '{{name}}'" + nextActions: + - command: 'tigris ls' + description: 'List remaining buckets' arguments: - name: name description: Name of the bucket or comma separated list of buckets @@ -1289,6 +1318,9 @@ commands: onStart: 'Uploading object...' onSuccess: "Object '{{key}}' uploaded successfully" onFailure: 'Failed to upload object' + nextActions: + - command: 'tigris presign {{bucket}}/{{key}}' + description: 'Generate a presigned URL for the uploaded object' arguments: - name: bucket description: Name of the bucket @@ -1334,6 +1366,9 @@ commands: onStart: 'Deleting object...' onSuccess: "Object '{{key}}' deleted successfully" onFailure: 'Failed to delete object' + nextActions: + - command: 'tigris ls {{bucket}}' + description: 'List remaining objects in the bucket' arguments: - name: bucket description: Name of the bucket @@ -1435,6 +1470,9 @@ commands: onStart: 'Creating access key...' onSuccess: 'Access key created' onFailure: 'Failed to create access key' + nextActions: + - command: 'tigris access-keys assign --bucket --role Editor' + description: 'Assign bucket roles to the new access key' arguments: - name: name description: Name for the access key @@ -1513,6 +1551,9 @@ commands: onStart: 'Assigning bucket roles...' onSuccess: 'Bucket roles assigned' onFailure: 'Failed to assign bucket roles' + nextActions: + - command: 'tigris cp ./file t3:///' + description: 'Upload files using the assigned access key' arguments: - name: id description: Access key ID diff --git a/src/types.ts b/src/types.ts index adb0efa..f3ef84d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,11 @@ export interface Argument { examples?: string[]; } +export interface NextAction { + command: string; + description: string; +} + export interface Messages { onStart?: string; onSuccess?: string; @@ -21,6 +26,7 @@ export interface Messages { onAlreadyDone?: string; onDeprecated?: string; hint?: string; + nextActions?: NextAction[]; } // Recursive command structure - supports nth level nesting diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..81467fb --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,170 @@ +export enum ExitCode { + Success = 0, + GeneralError = 1, + AuthFailure = 2, + NotFound = 3, + RateLimit = 4, + NetworkError = 5, +} + +export type ErrorCategory = + | 'auth' + | 'permission' + | 'not_found' + | 'rate_limit' + | 'network' + | 'general'; + +export interface NextAction { + command: string; + description: string; +} + +export interface ClassifiedError { + exitCode: ExitCode; + category: ErrorCategory; + message: string; + nextActions: NextAction[]; +} + +// Pattern groups ordered by priority (auth > permission > not_found > rate_limit > network > general) +// AUTH = not logged in at all; PERMISSION = logged in but lacks access to resource +const AUTH_PATTERNS: RegExp[] = [ + /not authenticated/i, + /no organization selected/i, + /token refresh failed/i, + /please run "tigris login/i, +]; + +const PERMISSION_PATTERNS: RegExp[] = [/access denied/i, /forbidden/i]; + +const NOT_FOUND_PATTERNS: RegExp[] = [ + /not found/i, + /NoSuchBucket/, + /NoSuchKey/, + /does not exist/i, +]; + +const RATE_LIMIT_PATTERNS: RegExp[] = [ + /rate limit/i, + /too many requests/i, + /throttl/i, + /SlowDown/, +]; + +const NETWORK_PATTERNS: RegExp[] = [ + /ECONNREFUSED/, + /ENOTFOUND/, + /ETIMEDOUT/, + /ECONNRESET/, + /socket hang up/i, + /fetch failed/i, +]; + +function matchesAny(message: string, patterns: RegExp[]): boolean { + return patterns.some((p) => p.test(message)); +} + +function getAuthNextActions(): NextAction[] { + return [ + { command: 'tigris login', description: 'Authenticate via OAuth' }, + { + command: 'tigris configure', + description: 'Set up access key credentials', + }, + ]; +} + +function getPermissionNextActions(): NextAction[] { + return [ + { + command: 'tigris access-keys list', + description: 'Check your access key permissions', + }, + { command: 'tigris login', description: 'Re-authenticate if needed' }, + ]; +} + +function getNotFoundNextActions(): NextAction[] { + return [{ command: 'tigris ls', description: 'List available buckets' }]; +} + +function getRateLimitNextActions(): NextAction[] { + return [ + { + command: 'Retry after a short delay', + description: 'Wait a few seconds and retry the command', + }, + ]; +} + +function getNetworkNextActions(): NextAction[] { + return [ + { + command: 'tigris credentials test', + description: 'Test connectivity and credentials', + }, + ]; +} + +/** + * Classify an error by pattern-matching its message. + * Returns a ClassifiedError with the appropriate exit code, category, + * and suggested next actions for agents. + */ +export function classifyError(error: unknown): ClassifiedError { + const message = + error instanceof Error ? error.message : String(error || 'Unknown error'); + + if (matchesAny(message, AUTH_PATTERNS)) { + return { + exitCode: ExitCode.AuthFailure, + category: 'auth', + message, + nextActions: getAuthNextActions(), + }; + } + + if (matchesAny(message, PERMISSION_PATTERNS)) { + return { + exitCode: ExitCode.AuthFailure, + category: 'permission', + message, + nextActions: getPermissionNextActions(), + }; + } + + if (matchesAny(message, NOT_FOUND_PATTERNS)) { + return { + exitCode: ExitCode.NotFound, + category: 'not_found', + message, + nextActions: getNotFoundNextActions(), + }; + } + + if (matchesAny(message, RATE_LIMIT_PATTERNS)) { + return { + exitCode: ExitCode.RateLimit, + category: 'rate_limit', + message, + nextActions: getRateLimitNextActions(), + }; + } + + if (matchesAny(message, NETWORK_PATTERNS)) { + return { + exitCode: ExitCode.NetworkError, + category: 'network', + message, + nextActions: getNetworkNextActions(), + }; + } + + return { + exitCode: ExitCode.GeneralError, + category: 'general', + message, + nextActions: [], + }; +} diff --git a/src/utils/exit.ts b/src/utils/exit.ts new file mode 100644 index 0000000..28d2910 --- /dev/null +++ b/src/utils/exit.ts @@ -0,0 +1,92 @@ +import { classifyError, type NextAction } from './errors.js'; +import { getCommandSpec } from './specs.js'; +import type { MessageContext, MessageVariables } from './messages.js'; + +function isJsonMode(): boolean { + return globalThis.__TIGRIS_JSON_MODE === true; +} + +function isTTY(): boolean { + return process.stdout.isTTY === true; +} + +/** + * Interpolate {{variable}} placeholders in a string. + */ +function interpolate(template: string, variables?: MessageVariables): string { + if (!variables) return template; + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => { + const value = variables[key]; + return value !== undefined ? String(value) : `{{${key}}}`; + }); +} + +/** + * Exit with a classified error code. + * - JSON mode: outputs structured error JSON to stderr + * - TTY mode: prints "Next steps:" hints + * - Always exits with the classified exit code + */ +export function exitWithError( + error: unknown, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _context?: MessageContext +): never { + const classified = classifyError(error); + + if (isJsonMode()) { + const errorOutput: Record = { + error: { + message: classified.message, + code: classified.exitCode, + category: classified.category, + }, + }; + if (classified.nextActions.length > 0) { + errorOutput.nextActions = classified.nextActions; + } + console.error(JSON.stringify(errorOutput)); + } else if (isTTY() && classified.nextActions.length > 0) { + console.error('\nNext steps:'); + for (const action of classified.nextActions) { + console.error(` → ${action.command} ${action.description}`); + } + } + + process.exit(classified.exitCode); +} + +/** + * Read nextActions from specs.yaml for a command and interpolate variables. + * Returns empty array if no nextActions defined. + */ +export function getSuccessNextActions( + context: MessageContext, + variables?: MessageVariables +): NextAction[] { + const spec = getCommandSpec(context.command, context.operation); + if (!spec?.messages?.nextActions) return []; + + return spec.messages.nextActions.map((action) => ({ + command: interpolate(action.command, variables), + description: interpolate(action.description, variables), + })); +} + +/** + * Print "Next steps:" hints for success cases. + * Only prints in TTY mode and only when nextActions are defined. + */ +export function printNextActions( + context: MessageContext, + variables?: MessageVariables +): void { + if (!isTTY()) return; + const nextActions = getSuccessNextActions(context, variables); + if (nextActions.length === 0) return; + + console.log('\nNext steps:'); + for (const action of nextActions) { + console.log(` → ${action.command} ${action.description}`); + } +} diff --git a/test/specs-completeness.test.ts b/test/specs-completeness.test.ts index 0f5d12f..943e9cb 100644 --- a/test/specs-completeness.test.ts +++ b/test/specs-completeness.test.ts @@ -141,6 +141,38 @@ describe('specs completeness', () => { } }); + describe('nextActions entries have command and description', () => { + const withNextActions = allCommands.filter( + ({ spec }) => + spec.messages && + (spec.messages as Record).nextActions + ); + + if (withNextActions.length === 0) { + it('no commands with nextActions found (skip)', () => { + expect(true).toBe(true); + }); + } + + for (const { spec, path } of withNextActions) { + const label = path.join(' '); + it(`${label}`, () => { + const nextActions = (spec.messages as Record) + .nextActions as Array>; + expect(Array.isArray(nextActions)).toBe(true); + expect(nextActions.length).toBeGreaterThan(0); + for (const action of nextActions) { + expect(action).toHaveProperty('command'); + expect(action).toHaveProperty('description'); + expect(typeof action.command).toBe('string'); + expect(typeof action.description).toBe('string'); + expect((action.command as string).length).toBeGreaterThan(0); + expect((action.description as string).length).toBeGreaterThan(0); + } + }); + } + }); + describe('deprecated commands have onDeprecated message', () => { const deprecated = allCommands.filter(({ spec }) => spec.deprecated); diff --git a/test/utils/errors.test.ts b/test/utils/errors.test.ts new file mode 100644 index 0000000..aec817c --- /dev/null +++ b/test/utils/errors.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from 'vitest'; +import { + classifyError, + ExitCode, + type ClassifiedError, +} from '../../src/utils/errors.js'; + +describe('classifyError', () => { + describe('auth errors (exit code 2)', () => { + const authMessages = [ + 'not authenticated', + 'Not Authenticated - please login', + 'No organization selected', + 'Token refresh failed', + 'Please run "tigris login" to authenticate', + ]; + + for (const msg of authMessages) { + it(`classifies "${msg}" as auth`, () => { + const result = classifyError(new Error(msg)); + expect(result.exitCode).toBe(ExitCode.AuthFailure); + expect(result.category).toBe('auth'); + expect(result.nextActions.length).toBeGreaterThan(0); + expect(result.nextActions.some((a) => a.command.includes('login'))).toBe( + true + ); + }); + } + }); + + describe('permission errors (exit code 2)', () => { + const permissionMessages = [ + 'Access Denied', + 'access denied to resource', + 'Forbidden', + '403 Forbidden', + ]; + + for (const msg of permissionMessages) { + it(`classifies "${msg}" as permission`, () => { + const result = classifyError(new Error(msg)); + expect(result.exitCode).toBe(ExitCode.AuthFailure); + expect(result.category).toBe('permission'); + expect(result.nextActions.length).toBeGreaterThan(0); + expect( + result.nextActions.some((a) => a.command.includes('access-keys')) + ).toBe(true); + }); + } + }); + + describe('not found errors (exit code 3)', () => { + const notFoundMessages = [ + 'Bucket not found', + 'NoSuchBucket', + 'NoSuchKey', + 'Resource does not exist', + 'The specified key does not exist', + 'Object not found in bucket', + ]; + + for (const msg of notFoundMessages) { + it(`classifies "${msg}" as not_found`, () => { + const result = classifyError(new Error(msg)); + expect(result.exitCode).toBe(ExitCode.NotFound); + expect(result.category).toBe('not_found'); + expect(result.nextActions.length).toBeGreaterThan(0); + expect(result.nextActions.some((a) => a.command.includes('ls'))).toBe( + true + ); + }); + } + }); + + describe('rate limit errors (exit code 4)', () => { + const rateLimitMessages = [ + 'Rate limit exceeded', + 'Too many requests', + 'Request throttled', + 'SlowDown', + ]; + + for (const msg of rateLimitMessages) { + it(`classifies "${msg}" as rate_limit`, () => { + const result = classifyError(new Error(msg)); + expect(result.exitCode).toBe(ExitCode.RateLimit); + expect(result.category).toBe('rate_limit'); + expect(result.nextActions.length).toBeGreaterThan(0); + }); + } + }); + + describe('network errors (exit code 5)', () => { + const networkMessages = [ + 'connect ECONNREFUSED 127.0.0.1:443', + 'getaddrinfo ENOTFOUND api.example.com', + 'connect ETIMEDOUT 1.2.3.4:443', + 'read ECONNRESET', + 'socket hang up', + 'fetch failed', + ]; + + for (const msg of networkMessages) { + it(`classifies "${msg}" as network`, () => { + const result = classifyError(new Error(msg)); + expect(result.exitCode).toBe(ExitCode.NetworkError); + expect(result.category).toBe('network'); + expect(result.nextActions.length).toBeGreaterThan(0); + expect( + result.nextActions.some((a) => + a.command.includes('credentials test') + ) + ).toBe(true); + }); + } + }); + + describe('general errors (exit code 1)', () => { + const generalMessages = [ + 'Bucket name is required', + 'Invalid argument', + 'Something unexpected happened', + '', + ]; + + for (const msg of generalMessages) { + it(`classifies "${msg || '(empty)'}" as general`, () => { + const result = classifyError(new Error(msg)); + expect(result.exitCode).toBe(ExitCode.GeneralError); + expect(result.category).toBe('general'); + expect(result.nextActions).toEqual([]); + }); + } + }); + + describe('priority ordering', () => { + it('auth takes priority over permission when both match', () => { + // "not authenticated, access denied" matches both auth and permission + const result = classifyError( + new Error('not authenticated, access denied') + ); + expect(result.exitCode).toBe(ExitCode.AuthFailure); + expect(result.category).toBe('auth'); + }); + + it('permission takes priority over not_found when both match', () => { + // "access denied: resource not found" matches both permission and not_found + const result = classifyError( + new Error('access denied: resource not found') + ); + expect(result.exitCode).toBe(ExitCode.AuthFailure); + expect(result.category).toBe('permission'); + }); + }); + + describe('input types', () => { + it('handles Error objects', () => { + const result = classifyError(new Error('NoSuchBucket')); + expect(result.exitCode).toBe(ExitCode.NotFound); + }); + + it('handles string errors', () => { + const result = classifyError('NoSuchBucket'); + expect(result.exitCode).toBe(ExitCode.NotFound); + }); + + it('handles undefined', () => { + const result = classifyError(undefined); + expect(result.exitCode).toBe(ExitCode.GeneralError); + expect(result.message).toBe('Unknown error'); + }); + + it('handles null', () => { + const result = classifyError(null); + expect(result.exitCode).toBe(ExitCode.GeneralError); + }); + + it('handles number', () => { + const result = classifyError(42); + expect(result.exitCode).toBe(ExitCode.GeneralError); + expect(result.message).toBe('42'); + }); + }); + + describe('ClassifiedError structure', () => { + it('always has all required fields', () => { + const result: ClassifiedError = classifyError(new Error('test')); + expect(result).toHaveProperty('exitCode'); + expect(result).toHaveProperty('category'); + expect(result).toHaveProperty('message'); + expect(result).toHaveProperty('nextActions'); + expect(Array.isArray(result.nextActions)).toBe(true); + }); + + it('nextActions have command and description', () => { + const result = classifyError(new Error('access denied')); + for (const action of result.nextActions) { + expect(action).toHaveProperty('command'); + expect(action).toHaveProperty('description'); + expect(typeof action.command).toBe('string'); + expect(typeof action.description).toBe('string'); + } + }); + }); +}); diff --git a/test/utils/exit.test.ts b/test/utils/exit.test.ts new file mode 100644 index 0000000..14e64f9 --- /dev/null +++ b/test/utils/exit.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import * as YAML from 'yaml'; +import { setSpecs } from '../../src/utils/specs.js'; +import { + exitWithError, + getSuccessNextActions, + printNextActions, +} from '../../src/utils/exit.js'; +import { ExitCode } from '../../src/utils/errors.js'; + +// Pre-populate specs cache +const specsYaml = readFileSync( + join(process.cwd(), 'src', 'specs.yaml'), + 'utf8' +); +setSpecs(YAML.parse(specsYaml, { schema: 'core' })); + +// Save original TTY descriptor +const originalIsTTY = Object.getOwnPropertyDescriptor( + process.stdout, + 'isTTY' +); + +function setTTY(value: boolean) { + Object.defineProperty(process.stdout, 'isTTY', { + value, + writable: true, + configurable: true, + }); +} + +function restoreTTY() { + if (originalIsTTY) { + Object.defineProperty(process.stdout, 'isTTY', originalIsTTY); + } else { + delete (process.stdout as unknown as Record).isTTY; + } +} + +function setJsonMode(value: boolean) { + globalThis.__TIGRIS_JSON_MODE = value; +} + +describe('exitWithError', () => { + let errorSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + setJsonMode(false); + }); + + afterEach(() => { + errorSpy.mockRestore(); + exitSpy.mockRestore(); + restoreTTY(); + setJsonMode(false); + }); + + it('exits with classified code for auth errors', () => { + exitWithError(new Error('access denied')); + expect(exitSpy).toHaveBeenCalledWith(ExitCode.AuthFailure); + }); + + it('exits with classified code for not-found errors', () => { + exitWithError(new Error('NoSuchBucket')); + expect(exitSpy).toHaveBeenCalledWith(ExitCode.NotFound); + }); + + it('exits with classified code for rate limit errors', () => { + exitWithError(new Error('rate limit exceeded')); + expect(exitSpy).toHaveBeenCalledWith(ExitCode.RateLimit); + }); + + it('exits with classified code for network errors', () => { + exitWithError(new Error('ECONNREFUSED')); + expect(exitSpy).toHaveBeenCalledWith(ExitCode.NetworkError); + }); + + it('exits with code 1 for general errors', () => { + exitWithError(new Error('something went wrong')); + expect(exitSpy).toHaveBeenCalledWith(ExitCode.GeneralError); + }); + + it('outputs structured JSON to stderr in JSON mode', () => { + setJsonMode(true); + exitWithError(new Error('access denied')); + + const jsonCalls = errorSpy.mock.calls.filter((call) => { + try { + JSON.parse(call[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonCalls.length).toBe(1); + + const output = JSON.parse(jsonCalls[0][0] as string); + expect(output).toHaveProperty('error'); + expect(output.error).toHaveProperty('message', 'access denied'); + expect(output.error).toHaveProperty('code', ExitCode.AuthFailure); + expect(output.error).toHaveProperty('category', 'permission'); + expect(output).toHaveProperty('nextActions'); + expect(output.nextActions.length).toBeGreaterThan(0); + }); + + it('omits nextActions from JSON when empty', () => { + setJsonMode(true); + exitWithError(new Error('something went wrong')); + + const jsonCalls = errorSpy.mock.calls.filter((call) => { + try { + JSON.parse(call[0] as string); + return true; + } catch { + return false; + } + }); + expect(jsonCalls.length).toBe(1); + + const output = JSON.parse(jsonCalls[0][0] as string); + expect(output).not.toHaveProperty('nextActions'); + }); + + it('prints next steps hints in TTY mode for classified errors', () => { + setTTY(true); + exitWithError(new Error('access denied')); + + const allOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(allOutput).toContain('Next steps:'); + expect(allOutput).toContain('access-keys list'); + }); + + it('does not print next steps in non-TTY non-JSON mode', () => { + setTTY(false); + setJsonMode(false); + exitWithError(new Error('access denied')); + + const allOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(allOutput).not.toContain('Next steps:'); + }); +}); + +describe('getSuccessNextActions', () => { + it('returns nextActions for a command that has them', () => { + const actions = getSuccessNextActions( + { command: 'buckets', operation: 'create' }, + { name: 'my-bucket' } + ); + expect(actions.length).toBeGreaterThan(0); + // Should interpolate {{name}} + expect(actions.some((a) => a.command.includes('my-bucket'))).toBe(true); + expect(actions.every((a) => !a.command.includes('{{name}}'))).toBe(true); + }); + + it('returns empty array for commands without nextActions', () => { + const actions = getSuccessNextActions({ + command: 'buckets', + operation: 'list', + }); + expect(actions).toEqual([]); + }); + + it('interpolates variables in command and description', () => { + const actions = getSuccessNextActions( + { command: 'organizations', operation: 'create' }, + { name: 'test-org' } + ); + expect(actions.length).toBeGreaterThan(0); + expect(actions[0].command).toContain('test-org'); + expect(actions[0].command).not.toContain('{{name}}'); + }); + + it('returns empty array for unknown command', () => { + const actions = getSuccessNextActions({ command: 'nonexistent' }); + expect(actions).toEqual([]); + }); +}); + +describe('printNextActions', () => { + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + restoreTTY(); + }); + + it('prints next steps in TTY mode', () => { + setTTY(true); + printNextActions( + { command: 'buckets', operation: 'create' }, + { name: 'my-bucket' } + ); + + const allOutput = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(allOutput).toContain('Next steps:'); + expect(allOutput).toContain('my-bucket'); + }); + + it('is silent when not TTY', () => { + setTTY(false); + printNextActions( + { command: 'buckets', operation: 'create' }, + { name: 'my-bucket' } + ); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it('is silent when no nextActions defined', () => { + setTTY(true); + printNextActions({ command: 'buckets', operation: 'list' }); + expect(logSpy).not.toHaveBeenCalled(); + }); +}); From 81f623a0bf092b196d4fc6e392c60caef3dbd9f2 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Tue, 17 Mar 2026 11:45:28 +0000 Subject: [PATCH 2/8] chore: pr comments --- src/lib/buckets/delete.ts | 4 ++-- src/lib/objects/delete.ts | 4 ++-- src/utils/errors.ts | 13 ++++++------- src/utils/exit.ts | 3 ++- test/utils/errors.test.ts | 4 +++- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/lib/buckets/delete.ts b/src/lib/buckets/delete.ts index 86825bb..f4ffd27 100644 --- a/src/lib/buckets/delete.ts +++ b/src/lib/buckets/delete.ts @@ -69,9 +69,9 @@ export default async function deleteBucket(options: Record) { console.log(JSON.stringify(output)); } - printNextActions(context); - if (errors.length > 0) { exitWithError(errors[0].error, context); } + + printNextActions(context); } diff --git a/src/lib/objects/delete.ts b/src/lib/objects/delete.ts index c869c2a..53e0b14 100644 --- a/src/lib/objects/delete.ts +++ b/src/lib/objects/delete.ts @@ -83,9 +83,9 @@ export default async function deleteObject(options: Record) { console.log(JSON.stringify(jsonOutput)); } - printNextActions(context, { bucket }); - if (errors.length > 0) { exitWithError(errors[0].error, context); } + + printNextActions(context, { bucket }); } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 81467fb..9312a90 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -1,3 +1,5 @@ +import type { NextAction } from '../types.js'; + export enum ExitCode { Success = 0, GeneralError = 1, @@ -15,11 +17,6 @@ export type ErrorCategory = | 'network' | 'general'; -export interface NextAction { - command: string; - description: string; -} - export interface ClassifiedError { exitCode: ExitCode; category: ErrorCategory; @@ -39,10 +36,12 @@ const AUTH_PATTERNS: RegExp[] = [ const PERMISSION_PATTERNS: RegExp[] = [/access denied/i, /forbidden/i]; const NOT_FOUND_PATTERNS: RegExp[] = [ - /not found/i, /NoSuchBucket/, /NoSuchKey/, - /does not exist/i, + /bucket not found/i, + /object not found/i, + /resource .+ does not exist/i, + /the specified key does not exist/i, ]; const RATE_LIMIT_PATTERNS: RegExp[] = [ diff --git a/src/utils/exit.ts b/src/utils/exit.ts index 28d2910..8212ac7 100644 --- a/src/utils/exit.ts +++ b/src/utils/exit.ts @@ -1,4 +1,5 @@ -import { classifyError, type NextAction } from './errors.js'; +import { classifyError } from './errors.js'; +import type { NextAction } from '../types.js'; import { getCommandSpec } from './specs.js'; import type { MessageContext, MessageVariables } from './messages.js'; diff --git a/test/utils/errors.test.ts b/test/utils/errors.test.ts index aec817c..bdee5f2 100644 --- a/test/utils/errors.test.ts +++ b/test/utils/errors.test.ts @@ -54,7 +54,7 @@ describe('classifyError', () => { 'Bucket not found', 'NoSuchBucket', 'NoSuchKey', - 'Resource does not exist', + 'Resource xyz does not exist', 'The specified key does not exist', 'Object not found in bucket', ]; @@ -120,6 +120,8 @@ describe('classifyError', () => { 'Bucket name is required', 'Invalid argument', 'Something unexpected happened', + 'Source not found: ./myfile', + 'File not found: ./report.pdf', '', ]; From b1b633865768c8b0f9b3247c579e5867205d68d9 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Tue, 17 Mar 2026 14:35:12 +0000 Subject: [PATCH 3/8] chore: pr comments --- src/utils/errors.ts | 20 ++++++++++++++++++-- src/utils/exit.ts | 2 +- test/utils/errors.test.ts | 7 +++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 9312a90..a9f0c95 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -111,9 +111,25 @@ function getNetworkNextActions(): NextAction[] { * Returns a ClassifiedError with the appropriate exit code, category, * and suggested next actions for agents. */ +function hasMessage(error: unknown): error is { message: string } { + return ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' + ); +} + +function extractMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (hasMessage(error)) return error.message; + if (typeof error === 'string') return error; + if (error === null || error === undefined) return 'Unknown error'; + return String(error); +} + export function classifyError(error: unknown): ClassifiedError { - const message = - error instanceof Error ? error.message : String(error || 'Unknown error'); + const message = extractMessage(error); if (matchesAny(message, AUTH_PATTERNS)) { return { diff --git a/src/utils/exit.ts b/src/utils/exit.ts index 8212ac7..f6a4204 100644 --- a/src/utils/exit.ts +++ b/src/utils/exit.ts @@ -82,7 +82,7 @@ export function printNextActions( context: MessageContext, variables?: MessageVariables ): void { - if (!isTTY()) return; + if (!isTTY() || isJsonMode()) return; const nextActions = getSuccessNextActions(context, variables); if (nextActions.length === 0) return; diff --git a/test/utils/errors.test.ts b/test/utils/errors.test.ts index bdee5f2..d73b659 100644 --- a/test/utils/errors.test.ts +++ b/test/utils/errors.test.ts @@ -161,6 +161,13 @@ describe('classifyError', () => { expect(result.exitCode).toBe(ExitCode.NotFound); }); + it('handles plain objects with message property (SDK errors)', () => { + const sdkError = { message: 'NoSuchBucket', code: 'NoSuchBucket' }; + const result = classifyError(sdkError); + expect(result.exitCode).toBe(ExitCode.NotFound); + expect(result.message).toBe('NoSuchBucket'); + }); + it('handles string errors', () => { const result = classifyError('NoSuchBucket'); expect(result.exitCode).toBe(ExitCode.NotFound); From 13dc9568760bdc25648ff9bb51bafd316e4c2db8 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Tue, 17 Mar 2026 14:58:23 +0000 Subject: [PATCH 4/8] chore: pr comments --- src/specs.yaml | 2 +- src/utils/exit.ts | 22 ++++++++-------------- src/utils/messages.ts | 5 ++++- test/utils/exit.test.ts | 37 +++++++++++++++++++++++++++---------- 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/src/specs.yaml b/src/specs.yaml index 8925354..8eee053 100644 --- a/src/specs.yaml +++ b/src/specs.yaml @@ -1471,7 +1471,7 @@ commands: onSuccess: 'Access key created' onFailure: 'Failed to create access key' nextActions: - - command: 'tigris access-keys assign --bucket --role Editor' + - command: 'tigris access-keys assign {{id}} --bucket --role Editor' description: 'Assign bucket roles to the new access key' arguments: - name: name diff --git a/src/utils/exit.ts b/src/utils/exit.ts index f6a4204..e4b780c 100644 --- a/src/utils/exit.ts +++ b/src/utils/exit.ts @@ -1,31 +1,25 @@ import { classifyError } from './errors.js'; import type { NextAction } from '../types.js'; import { getCommandSpec } from './specs.js'; +import { interpolate } from './messages.js'; import type { MessageContext, MessageVariables } from './messages.js'; function isJsonMode(): boolean { return globalThis.__TIGRIS_JSON_MODE === true; } -function isTTY(): boolean { - return process.stdout.isTTY === true; +function isStderrTTY(): boolean { + return process.stderr.isTTY === true; } -/** - * Interpolate {{variable}} placeholders in a string. - */ -function interpolate(template: string, variables?: MessageVariables): string { - if (!variables) return template; - return template.replace(/\{\{(\w+)\}\}/g, (_, key) => { - const value = variables[key]; - return value !== undefined ? String(value) : `{{${key}}}`; - }); +function isStdoutTTY(): boolean { + return process.stdout.isTTY === true; } /** * Exit with a classified error code. * - JSON mode: outputs structured error JSON to stderr - * - TTY mode: prints "Next steps:" hints + * - TTY mode: prints "Next steps:" hints to stderr * - Always exits with the classified exit code */ export function exitWithError( @@ -47,7 +41,7 @@ export function exitWithError( errorOutput.nextActions = classified.nextActions; } console.error(JSON.stringify(errorOutput)); - } else if (isTTY() && classified.nextActions.length > 0) { + } else if (isStderrTTY() && classified.nextActions.length > 0) { console.error('\nNext steps:'); for (const action of classified.nextActions) { console.error(` → ${action.command} ${action.description}`); @@ -82,7 +76,7 @@ export function printNextActions( context: MessageContext, variables?: MessageVariables ): void { - if (!isTTY() || isJsonMode()) return; + if (!isStdoutTTY() || isJsonMode()) return; const nextActions = getSuccessNextActions(context, variables); if (nextActions.length === 0) return; diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 196c7cd..4063632 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -37,7 +37,10 @@ function getMessages(context: MessageContext): Messages | undefined { * Supports {{variableName}} syntax * Also processes \n for multiline support */ -function interpolate(template: string, variables?: MessageVariables): string { +export function interpolate( + template: string, + variables?: MessageVariables +): string { let result = template; // Process escaped newlines for multiline support diff --git a/test/utils/exit.test.ts b/test/utils/exit.test.ts index 14e64f9..0a1db3a 100644 --- a/test/utils/exit.test.ts +++ b/test/utils/exit.test.ts @@ -17,13 +17,17 @@ const specsYaml = readFileSync( ); setSpecs(YAML.parse(specsYaml, { schema: 'core' })); -// Save original TTY descriptor -const originalIsTTY = Object.getOwnPropertyDescriptor( +// Save original TTY descriptors +const originalStdoutIsTTY = Object.getOwnPropertyDescriptor( process.stdout, 'isTTY' ); +const originalStderrIsTTY = Object.getOwnPropertyDescriptor( + process.stderr, + 'isTTY' +); -function setTTY(value: boolean) { +function setStdoutTTY(value: boolean) { Object.defineProperty(process.stdout, 'isTTY', { value, writable: true, @@ -31,12 +35,25 @@ function setTTY(value: boolean) { }); } +function setStderrTTY(value: boolean) { + Object.defineProperty(process.stderr, 'isTTY', { + value, + writable: true, + configurable: true, + }); +} + function restoreTTY() { - if (originalIsTTY) { - Object.defineProperty(process.stdout, 'isTTY', originalIsTTY); + if (originalStdoutIsTTY) { + Object.defineProperty(process.stdout, 'isTTY', originalStdoutIsTTY); } else { delete (process.stdout as unknown as Record).isTTY; } + if (originalStderrIsTTY) { + Object.defineProperty(process.stderr, 'isTTY', originalStderrIsTTY); + } else { + delete (process.stderr as unknown as Record).isTTY; + } } function setJsonMode(value: boolean) { @@ -129,7 +146,7 @@ describe('exitWithError', () => { }); it('prints next steps hints in TTY mode for classified errors', () => { - setTTY(true); + setStderrTTY(true); exitWithError(new Error('access denied')); const allOutput = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n'); @@ -138,7 +155,7 @@ describe('exitWithError', () => { }); it('does not print next steps in non-TTY non-JSON mode', () => { - setTTY(false); + setStderrTTY(false); setJsonMode(false); exitWithError(new Error('access denied')); @@ -196,7 +213,7 @@ describe('printNextActions', () => { }); it('prints next steps in TTY mode', () => { - setTTY(true); + setStdoutTTY(true); printNextActions( { command: 'buckets', operation: 'create' }, { name: 'my-bucket' } @@ -208,7 +225,7 @@ describe('printNextActions', () => { }); it('is silent when not TTY', () => { - setTTY(false); + setStdoutTTY(false); printNextActions( { command: 'buckets', operation: 'create' }, { name: 'my-bucket' } @@ -217,7 +234,7 @@ describe('printNextActions', () => { }); it('is silent when no nextActions defined', () => { - setTTY(true); + setStdoutTTY(true); printNextActions({ command: 'buckets', operation: 'list' }); expect(logSpy).not.toHaveBeenCalled(); }); From c02535e7854cf961494bf18d9070e917a331f3ac Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Tue, 17 Mar 2026 15:07:58 +0000 Subject: [PATCH 5/8] chore: pr comments --- src/utils/errors.ts | 7 +------ test/utils/errors.test.ts | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/utils/errors.ts b/src/utils/errors.ts index a9f0c95..ca072dd 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -89,12 +89,7 @@ function getNotFoundNextActions(): NextAction[] { } function getRateLimitNextActions(): NextAction[] { - return [ - { - command: 'Retry after a short delay', - description: 'Wait a few seconds and retry the command', - }, - ]; + return []; } function getNetworkNextActions(): NextAction[] { diff --git a/test/utils/errors.test.ts b/test/utils/errors.test.ts index d73b659..504c68d 100644 --- a/test/utils/errors.test.ts +++ b/test/utils/errors.test.ts @@ -85,7 +85,7 @@ describe('classifyError', () => { const result = classifyError(new Error(msg)); expect(result.exitCode).toBe(ExitCode.RateLimit); expect(result.category).toBe('rate_limit'); - expect(result.nextActions.length).toBeGreaterThan(0); + expect(result.nextActions).toEqual([]); }); } }); From 59e80c3cbd8637b27afb854acb49c75dba832ae8 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Tue, 17 Mar 2026 15:29:55 +0000 Subject: [PATCH 6/8] chore: pr comments --- src/cli-core.ts | 14 +++++++++----- src/lib/access-keys/assign.ts | 6 +----- src/lib/iam/policies/create.ts | 2 +- src/lib/iam/policies/delete.ts | 2 +- src/lib/iam/policies/edit.ts | 2 +- src/lib/iam/policies/get.ts | 2 +- src/lib/iam/policies/list.ts | 2 +- src/lib/iam/users/invite.ts | 2 +- src/lib/iam/users/list.ts | 2 +- src/lib/iam/users/remove.ts | 2 +- src/lib/iam/users/revoke-invitation.ts | 2 +- src/lib/iam/users/update-role.ts | 2 +- src/utils/errors.ts | 1 + src/utils/messages.ts | 2 ++ test/utils/errors.test.ts | 2 ++ 15 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/cli-core.ts b/src/cli-core.ts index 4dabda7..24ee04e 100644 --- a/src/cli-core.ts +++ b/src/cli-core.ts @@ -34,15 +34,19 @@ export function setupErrorHandlers() { console.error('\nOperation cancelled'); process.exit(1); } - console.error( - '\nError:', - reason instanceof Error ? reason.message : reason - ); + if (globalThis.__TIGRIS_JSON_MODE !== true) { + console.error( + '\nError:', + reason instanceof Error ? reason.message : reason + ); + } exitWithError(reason); }); process.on('uncaughtException', (error) => { - console.error('\nError:', error.message); + if (globalThis.__TIGRIS_JSON_MODE !== true) { + console.error('\nError:', error.message); + } exitWithError(error); }); } diff --git a/src/lib/access-keys/assign.ts b/src/lib/access-keys/assign.ts index dcf55cc..c35bdc1 100644 --- a/src/lib/access-keys/assign.ts +++ b/src/lib/access-keys/assign.ts @@ -100,14 +100,10 @@ export default async function assign(options: Record) { } if (format === 'json') { - const nextActions = getSuccessNextActions(context); - const output: Record = { action: 'revoked', id }; - if (nextActions.length > 0) output.nextActions = nextActions; - console.log(JSON.stringify(output)); + console.log(JSON.stringify({ action: 'revoked', id })); } printSuccess(context); - printNextActions(context); return; } diff --git a/src/lib/iam/policies/create.ts b/src/lib/iam/policies/create.ts index b19641a..606a484 100644 --- a/src/lib/iam/policies/create.ts +++ b/src/lib/iam/policies/create.ts @@ -49,7 +49,7 @@ export default async function create(options: Record) { 'Policies can only be created when logged in via OAuth.\nRun "tigris login oauth" first.' ); exitWithError( - 'Policies can only be created when logged in via OAuth.', + 'Policies can only be created when logged in via OAuth.\nRun "tigris login oauth" first.', context ); } diff --git a/src/lib/iam/policies/delete.ts b/src/lib/iam/policies/delete.ts index 850c3b2..806462d 100644 --- a/src/lib/iam/policies/delete.ts +++ b/src/lib/iam/policies/delete.ts @@ -32,7 +32,7 @@ export default async function del(options: Record) { 'Policies can only be deleted when logged in via OAuth.\nRun "tigris login oauth" first.' ); exitWithError( - 'Policies can only be deleted when logged in via OAuth.', + 'Policies can only be deleted when logged in via OAuth.\nRun "tigris login oauth" first.', context ); } diff --git a/src/lib/iam/policies/edit.ts b/src/lib/iam/policies/edit.ts index 062c2e4..c39a6b8 100644 --- a/src/lib/iam/policies/edit.ts +++ b/src/lib/iam/policies/edit.ts @@ -40,7 +40,7 @@ export default async function edit(options: Record) { 'Policies can only be edited when logged in via OAuth.\nRun "tigris login oauth" first.' ); exitWithError( - 'Policies can only be edited when logged in via OAuth.', + 'Policies can only be edited when logged in via OAuth.\nRun "tigris login oauth" first.', context ); } diff --git a/src/lib/iam/policies/get.ts b/src/lib/iam/policies/get.ts index 56debaf..6f67977 100644 --- a/src/lib/iam/policies/get.ts +++ b/src/lib/iam/policies/get.ts @@ -35,7 +35,7 @@ export default async function get(options: Record) { 'Policies can only be retrieved when logged in via OAuth.\nRun "tigris login oauth" first.' ); exitWithError( - 'Policies can only be retrieved when logged in via OAuth.', + 'Policies can only be retrieved when logged in via OAuth.\nRun "tigris login oauth" first.', context ); } diff --git a/src/lib/iam/policies/list.ts b/src/lib/iam/policies/list.ts index a864875..90cae64 100644 --- a/src/lib/iam/policies/list.ts +++ b/src/lib/iam/policies/list.ts @@ -32,7 +32,7 @@ export default async function list(options: Record) { 'Policies can only be listed when logged in via OAuth.\nRun "tigris login oauth" first.' ); exitWithError( - 'Policies can only be listed when logged in via OAuth.', + 'Policies can only be listed when logged in via OAuth.\nRun "tigris login oauth" first.', context ); } diff --git a/src/lib/iam/users/invite.ts b/src/lib/iam/users/invite.ts index 599a65e..539f7cb 100644 --- a/src/lib/iam/users/invite.ts +++ b/src/lib/iam/users/invite.ts @@ -26,7 +26,7 @@ export default async function invite(options: Record) { 'Users can only be invited when logged in via OAuth.\nRun "tigris login oauth" first.' ); exitWithError( - 'Users can only be invited when logged in via OAuth.', + 'Users can only be invited when logged in via OAuth.\nRun "tigris login oauth" first.', context ); } diff --git a/src/lib/iam/users/list.ts b/src/lib/iam/users/list.ts index ddb9a50..71800fe 100644 --- a/src/lib/iam/users/list.ts +++ b/src/lib/iam/users/list.ts @@ -38,7 +38,7 @@ export default async function list(options: Record) { 'Users can only be listed when logged in via OAuth.\nRun "tigris login oauth" first.' ); exitWithError( - 'Users can only be listed when logged in via OAuth.', + 'Users can only be listed when logged in via OAuth.\nRun "tigris login oauth" first.', context ); } diff --git a/src/lib/iam/users/remove.ts b/src/lib/iam/users/remove.ts index 0e6a73b..a000b62 100644 --- a/src/lib/iam/users/remove.ts +++ b/src/lib/iam/users/remove.ts @@ -33,7 +33,7 @@ export default async function removeUser(options: Record) { 'Users can only be removed when logged in via OAuth.\nRun "tigris login oauth" first.' ); exitWithError( - 'Users can only be removed when logged in via OAuth.', + 'Users can only be removed when logged in via OAuth.\nRun "tigris login oauth" first.', context ); } diff --git a/src/lib/iam/users/revoke-invitation.ts b/src/lib/iam/users/revoke-invitation.ts index 74b4ea8..03def7b 100644 --- a/src/lib/iam/users/revoke-invitation.ts +++ b/src/lib/iam/users/revoke-invitation.ts @@ -35,7 +35,7 @@ export default async function revokeInvitation( 'Invitations can only be revoked when logged in via OAuth.\nRun "tigris login oauth" first.' ); exitWithError( - 'Invitations can only be revoked when logged in via OAuth.', + 'Invitations can only be revoked when logged in via OAuth.\nRun "tigris login oauth" first.', context ); } diff --git a/src/lib/iam/users/update-role.ts b/src/lib/iam/users/update-role.ts index 3627dd6..8c0a54a 100644 --- a/src/lib/iam/users/update-role.ts +++ b/src/lib/iam/users/update-role.ts @@ -30,7 +30,7 @@ export default async function updateRole(options: Record) { 'User roles can only be updated when logged in via OAuth.\nRun "tigris login oauth" first.' ); exitWithError( - 'User roles can only be updated when logged in via OAuth.', + 'User roles can only be updated when logged in via OAuth.\nRun "tigris login oauth" first.', context ); } diff --git a/src/utils/errors.ts b/src/utils/errors.ts index ca072dd..2b37dcc 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -31,6 +31,7 @@ const AUTH_PATTERNS: RegExp[] = [ /no organization selected/i, /token refresh failed/i, /please run "tigris login/i, + /logged in via OAuth/i, ]; const PERMISSION_PATTERNS: RegExp[] = [/access denied/i, /forbidden/i]; diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 4063632..5c49ecb 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -91,12 +91,14 @@ export function printSuccess( /** * Print the onFailure message for a command/operation + * Suppressed in JSON mode to avoid mixing human-readable text with structured JSON on stderr */ export function printFailure( context: MessageContext, error?: string, variables?: MessageVariables ): void { + if (globalThis.__TIGRIS_JSON_MODE === true) return; const messages = getMessages(context); if (messages?.onFailure) { console.error( diff --git a/test/utils/errors.test.ts b/test/utils/errors.test.ts index 504c68d..2fa4eac 100644 --- a/test/utils/errors.test.ts +++ b/test/utils/errors.test.ts @@ -13,6 +13,8 @@ describe('classifyError', () => { 'No organization selected', 'Token refresh failed', 'Please run "tigris login" to authenticate', + 'Policies can only be created when logged in via OAuth.', + 'Users can only be invited when logged in via OAuth.\nRun "tigris login oauth" first.', ]; for (const msg of authMessages) { From 28c242d12da4e866e49b37777ffee7bf9a0ac6fa Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Tue, 17 Mar 2026 16:57:40 +0000 Subject: [PATCH 7/8] chore: pr comments --- src/cli-core.ts | 9 --------- src/lib/cp.ts | 27 --------------------------- src/lib/ls.ts | 3 --- src/lib/mk.ts | 5 ----- src/lib/mv.ts | 14 -------------- src/lib/presign.ts | 20 -------------------- src/lib/rm.ts | 9 --------- src/lib/touch.ts | 4 ---- src/utils/exit.ts | 11 +++++++---- 9 files changed, 7 insertions(+), 95 deletions(-) diff --git a/src/cli-core.ts b/src/cli-core.ts index 24ee04e..bfcd2f8 100644 --- a/src/cli-core.ts +++ b/src/cli-core.ts @@ -34,19 +34,10 @@ export function setupErrorHandlers() { console.error('\nOperation cancelled'); process.exit(1); } - if (globalThis.__TIGRIS_JSON_MODE !== true) { - console.error( - '\nError:', - reason instanceof Error ? reason.message : reason - ); - } exitWithError(reason); }); process.on('uncaughtException', (error) => { - if (globalThis.__TIGRIS_JSON_MODE !== true) { - console.error('\nError:', error.message); - } exitWithError(error); }); } diff --git a/src/lib/cp.ts b/src/lib/cp.ts index e65f630..6b69913 100644 --- a/src/lib/cp.ts +++ b/src/lib/cp.ts @@ -36,9 +36,6 @@ function detectDirection(src: string, dest: string): CopyDirection { const destRemote = isRemotePath(dest); if (!srcRemote && !destRemote) { - console.error( - 'At least one path must be a remote Tigris path (t3:// or tigris://)' - ); exitWithError( 'At least one path must be a remote Tigris path (t3:// or tigris://)' ); @@ -345,15 +342,11 @@ async function copyLocalToRemote( try { stats = statSync(localPath); } catch { - console.error(`Source not found: ${src}`); exitWithError(`Source not found: ${src}`); } if (stats.isDirectory()) { if (!recursive) { - console.error( - `${src} is a directory (not copied). Use -r to copy recursively.` - ); exitWithError( `${src} is a directory (not copied). Use -r to copy recursively.` ); @@ -443,7 +436,6 @@ async function copyLocalToRemote( !_jsonMode ); if (result.error) { - console.error(result.error); exitWithError(result.error); } if (_jsonMode) { @@ -474,7 +466,6 @@ async function copyRemoteToLocal( // t3://bucket/ (no path, trailing slash) = copy all contents from bucket root const rawEndsWithSlash = src.endsWith('/'); if (!srcParsed.path && !rawEndsWithSlash) { - console.error('Cannot copy a bucket. Provide a path within the bucket.'); exitWithError('Cannot copy a bucket. Provide a path within the bucket.'); } @@ -488,9 +479,6 @@ async function copyRemoteToLocal( } if (isFolder && !isWildcard && !recursive) { - console.error( - `Source is a remote folder (not copied). Use -r to copy recursively.` - ); exitWithError( 'Source is a remote folder (not copied). Use -r to copy recursively.' ); @@ -518,7 +506,6 @@ async function copyRemoteToLocal( ); if (error) { - console.error(error.message); exitWithError(error); } @@ -612,7 +599,6 @@ async function copyRemoteToLocal( !_jsonMode ); if (result.error) { - console.error(result.error); exitWithError(result.error); } if (_jsonMode) { @@ -645,7 +631,6 @@ async function copyRemoteToRemote( // t3://bucket/ (no path, trailing slash) = copy all contents from bucket root const rawEndsWithSlash = src.endsWith('/'); if (!srcParsed.path && !rawEndsWithSlash) { - console.error('Cannot copy a bucket. Provide a path within the bucket.'); exitWithError('Cannot copy a bucket. Provide a path within the bucket.'); } @@ -658,9 +643,6 @@ async function copyRemoteToRemote( } if (isFolder && !isWildcard && !recursive) { - console.error( - `Source is a remote folder (not copied). Use -r to copy recursively.` - ); exitWithError( 'Source is a remote folder (not copied). Use -r to copy recursively.' ); @@ -693,7 +675,6 @@ async function copyRemoteToRemote( srcParsed.bucket === destParsed.bucket && prefix === effectiveDestPrefixWithSlash ) { - console.error('Source and destination are the same'); exitWithError('Source and destination are the same'); } @@ -704,7 +685,6 @@ async function copyRemoteToRemote( ); if (error) { - console.error(error.message); exitWithError(error); } @@ -830,7 +810,6 @@ async function copyRemoteToRemote( } if (srcParsed.bucket === destParsed.bucket && srcParsed.path === destKey) { - console.error('Source and destination are the same'); exitWithError('Source and destination are the same'); } @@ -844,7 +823,6 @@ async function copyRemoteToRemote( ); if (result.error) { - console.error(result.error); exitWithError(result.error); } @@ -871,7 +849,6 @@ export default async function cp(options: Record) { const dest = getOption(options, ['dest']); if (!src || !dest) { - console.error('Both src and dest arguments are required'); exitWithError('Both src and dest arguments are required'); } @@ -889,7 +866,6 @@ export default async function cp(options: Record) { case 'local-to-remote': { const destParsed = parseRemotePath(dest); if (!destParsed.bucket) { - console.error('Invalid destination path'); exitWithError('Invalid destination path'); } await copyLocalToRemote(src, destParsed, config, recursive); @@ -898,7 +874,6 @@ export default async function cp(options: Record) { case 'remote-to-local': { const srcParsed = parseRemotePath(src); if (!srcParsed.bucket) { - console.error('Invalid source path'); exitWithError('Invalid source path'); } await copyRemoteToLocal(src, srcParsed, dest, config, recursive); @@ -908,11 +883,9 @@ export default async function cp(options: Record) { const srcParsed = parseRemotePath(src); const destParsed = parseRemotePath(dest); if (!srcParsed.bucket) { - console.error('Invalid source path'); exitWithError('Invalid source path'); } if (!destParsed.bucket) { - console.error('Invalid destination path'); exitWithError('Invalid destination path'); } await copyRemoteToRemote(src, srcParsed, destParsed, config, recursive); diff --git a/src/lib/ls.ts b/src/lib/ls.ts index 19f456d..ae5ddcc 100644 --- a/src/lib/ls.ts +++ b/src/lib/ls.ts @@ -23,7 +23,6 @@ export default async function ls(options: Record) { const { data, error } = await listBuckets({ config }); if (error) { - console.error(error.message); exitWithError(error); } @@ -44,7 +43,6 @@ export default async function ls(options: Record) { const { bucket, path } = parseAnyPath(pathString); if (!bucket) { - console.error('Invalid path'); exitWithError('Invalid path'); } @@ -63,7 +61,6 @@ export default async function ls(options: Record) { }); if (error) { - console.error(error.message); exitWithError(error); } diff --git a/src/lib/mk.ts b/src/lib/mk.ts index 94c53b5..6fe0200 100644 --- a/src/lib/mk.ts +++ b/src/lib/mk.ts @@ -9,14 +9,12 @@ export default async function mk(options: Record) { const pathString = getOption(options, ['path']); if (!pathString) { - console.error('path argument is required'); exitWithError('path argument is required'); } const { bucket, path } = parseAnyPath(pathString); if (!bucket) { - console.error('Invalid path'); exitWithError('Invalid path'); } @@ -74,7 +72,6 @@ export default async function mk(options: Record) { } if (sourceSnapshot && !forkOf) { - console.error('--source-snapshot requires --fork-of'); exitWithError('--source-snapshot requires --fork-of'); } @@ -89,7 +86,6 @@ export default async function mk(options: Record) { }); if (error) { - console.error(error.message); exitWithError(error); } @@ -113,7 +109,6 @@ export default async function mk(options: Record) { }); if (error) { - console.error(error.message); exitWithError(error); } diff --git a/src/lib/mv.ts b/src/lib/mv.ts index c09b08c..e7e5d90 100644 --- a/src/lib/mv.ts +++ b/src/lib/mv.ts @@ -28,14 +28,10 @@ export default async function mv(options: Record) { _jsonMode = format === 'json'; if (!src || !dest) { - console.error('both src and dest arguments are required'); exitWithError('both src and dest arguments are required'); } if (!isRemotePath(src) || !isRemotePath(dest)) { - console.error( - 'Both src and dest must be remote Tigris paths (t3:// or tigris://)' - ); exitWithError( 'Both src and dest must be remote Tigris paths (t3:// or tigris://)' ); @@ -45,12 +41,10 @@ export default async function mv(options: Record) { const destPath = parseRemotePath(dest); if (!srcPath.bucket) { - console.error('Invalid source path'); exitWithError('Invalid source path'); } if (!destPath.bucket) { - console.error('Invalid destination path'); exitWithError('Invalid destination path'); } @@ -59,7 +53,6 @@ export default async function mv(options: Record) { // t3://bucket/ (no path, trailing slash) = move all contents from bucket root const rawEndsWithSlash = src.endsWith('/'); if (!srcPath.path && !rawEndsWithSlash) { - console.error('Cannot move a bucket. Provide a path within the bucket.'); exitWithError('Cannot move a bucket. Provide a path within the bucket.'); } @@ -76,9 +69,6 @@ export default async function mv(options: Record) { } if (isFolder && !isWildcard && !recursive) { - console.error( - `Source is a remote folder (not moved). Use -r to move recursively.` - ); exitWithError( 'Source is a remote folder (not moved). Use -r to move recursively.' ); @@ -112,7 +102,6 @@ export default async function mv(options: Record) { srcPath.bucket === destPath.bucket && prefix === effectiveDestPrefixWithSlash ) { - console.error('Source and destination are the same'); exitWithError('Source and destination are the same'); } @@ -123,7 +112,6 @@ export default async function mv(options: Record) { ); if (error) { - console.error(error.message); exitWithError(error); } @@ -273,7 +261,6 @@ export default async function mv(options: Record) { // Check for same location if (srcPath.bucket === destPath.bucket && srcPath.path === destKey) { - console.error('Source and destination are the same'); exitWithError('Source and destination are the same'); } @@ -298,7 +285,6 @@ export default async function mv(options: Record) { ); if (result.error) { - console.error(result.error); exitWithError(result.error); } diff --git a/src/lib/presign.ts b/src/lib/presign.ts index b49278c..ec33c5c 100644 --- a/src/lib/presign.ts +++ b/src/lib/presign.ts @@ -16,19 +16,16 @@ export default async function presign(options: Record) { const pathString = getOption(options, ['path']); if (!pathString) { - console.error('path argument is required'); exitWithError('path argument is required'); } const { bucket, path } = parseAnyPath(pathString); if (!bucket) { - console.error('Invalid path'); exitWithError('Invalid path'); } if (!path) { - console.error('Object key is required'); exitWithError('Object key is required'); } @@ -59,9 +56,6 @@ export default async function presign(options: Record) { const loginMethod = await getLoginMethod(); if (loginMethod !== 'oauth') { - console.error( - 'Presigning requires an access key. Pass --access-key or configure credentials.' - ); exitWithError( 'Presigning requires an access key. Pass --access-key or configure credentials.' ); @@ -71,9 +65,6 @@ export default async function presign(options: Record) { } if (!accessKeyId) { - console.error( - 'Presigning requires an access key. Pass --access-key or configure credentials.' - ); exitWithError( 'Presigning requires an access key. Pass --access-key or configure credentials.' ); @@ -90,7 +81,6 @@ export default async function presign(options: Record) { }); if (error) { - console.error(error.message); exitWithError(error); } @@ -115,9 +105,6 @@ async function resolveAccessKeyInteractively( targetBucket: string ): Promise { if (!process.stdin.isTTY) { - console.error( - 'Presigning requires an access key. Pass --access-key tid_...' - ); exitWithError( 'Presigning requires an access key. Pass --access-key tid_...' ); @@ -137,14 +124,10 @@ async function resolveAccessKeyInteractively( }); if (error) { - console.error(`Failed to list access keys: ${error.message}`); exitWithError(error); } if (!data.accessKeys || data.accessKeys.length === 0) { - console.error( - 'No access keys found. Create one with "tigris access-keys create "' - ); exitWithError( 'No access keys found. Create one with "tigris access-keys create "' ); @@ -168,9 +151,6 @@ async function resolveAccessKeyInteractively( ); if (activeKeys.length === 0) { - console.error( - 'No active access keys found. Create one with "tigris access-keys create "' - ); exitWithError( 'No active access keys found. Create one with "tigris access-keys create "' ); diff --git a/src/lib/rm.ts b/src/lib/rm.ts index 9ec2186..7a14057 100644 --- a/src/lib/rm.ts +++ b/src/lib/rm.ts @@ -25,19 +25,16 @@ export default async function rm(options: Record) { _jsonMode = format === 'json'; if (!pathString) { - console.error('path argument is required'); exitWithError('path argument is required'); } if (!isRemotePath(pathString)) { - console.error('Path must be a remote Tigris path (t3:// or tigris://)'); exitWithError('Path must be a remote Tigris path (t3:// or tigris://)'); } const { bucket, path } = parseRemotePath(pathString); if (!bucket) { - console.error('Invalid path'); exitWithError('Invalid path'); } @@ -60,7 +57,6 @@ export default async function rm(options: Record) { const { error } = await removeBucket(bucket, { config }); if (error) { - console.error(error.message); exitWithError(error); } @@ -82,9 +78,6 @@ export default async function rm(options: Record) { } if (isFolder && !isWildcard && !recursive) { - console.error( - `Source is a remote folder (not removed). Use -r to remove recursively.` - ); exitWithError( 'Source is a remote folder (not removed). Use -r to remove recursively.' ); @@ -107,7 +100,6 @@ export default async function rm(options: Record) { ); if (error) { - console.error(error.message); exitWithError(error); } @@ -230,7 +222,6 @@ export default async function rm(options: Record) { }); if (error) { - console.error(error.message); exitWithError(error); } diff --git a/src/lib/touch.ts b/src/lib/touch.ts index 45702ba..d3c3353 100644 --- a/src/lib/touch.ts +++ b/src/lib/touch.ts @@ -8,19 +8,16 @@ export default async function touch(options: Record) { const pathString = getOption(options, ['path']); if (!pathString) { - console.error('path argument is required'); exitWithError('path argument is required'); } const { bucket, path } = parseAnyPath(pathString); if (!bucket) { - console.error('Invalid path'); exitWithError('Invalid path'); } if (!path) { - console.error('Object key is required (use mk to create buckets)'); exitWithError('Object key is required (use mk to create buckets)'); } @@ -39,7 +36,6 @@ export default async function touch(options: Record) { }); if (error) { - console.error(error.message); exitWithError(error); } diff --git a/src/utils/exit.ts b/src/utils/exit.ts index e4b780c..ed401bc 100644 --- a/src/utils/exit.ts +++ b/src/utils/exit.ts @@ -41,10 +41,13 @@ export function exitWithError( errorOutput.nextActions = classified.nextActions; } console.error(JSON.stringify(errorOutput)); - } else if (isStderrTTY() && classified.nextActions.length > 0) { - console.error('\nNext steps:'); - for (const action of classified.nextActions) { - console.error(` → ${action.command} ${action.description}`); + } else { + console.error(`\nError: ${classified.message}`); + if (isStderrTTY() && classified.nextActions.length > 0) { + console.error('\nNext steps:'); + for (const action of classified.nextActions) { + console.error(` → ${action.command} ${action.description}`); + } } } From 5cff2b3b8638950cc7763f62b895f5d2d12454c0 Mon Sep 17 00:00:00 2001 From: A Ibrahim Date: Tue, 17 Mar 2026 17:08:54 +0000 Subject: [PATCH 8/8] chore: pr comments --- src/utils/exit.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/utils/exit.ts b/src/utils/exit.ts index ed401bc..b846977 100644 --- a/src/utils/exit.ts +++ b/src/utils/exit.ts @@ -19,14 +19,12 @@ function isStdoutTTY(): boolean { /** * Exit with a classified error code. * - JSON mode: outputs structured error JSON to stderr + * - Non-JSON without context: prints the error message to stderr + * (callers that pass context already printed via printFailure) * - TTY mode: prints "Next steps:" hints to stderr * - Always exits with the classified exit code */ -export function exitWithError( - error: unknown, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _context?: MessageContext -): never { +export function exitWithError(error: unknown, context?: MessageContext): never { const classified = classifyError(error); if (isJsonMode()) { @@ -42,7 +40,9 @@ export function exitWithError( } console.error(JSON.stringify(errorOutput)); } else { - console.error(`\nError: ${classified.message}`); + if (!context) { + console.error(`\nError: ${classified.message}`); + } if (isStderrTTY() && classified.nextActions.length > 0) { console.error('\nNext steps:'); for (const action of classified.nextActions) {