From 3c9bcd07d6731915a8975f0cb7e290ddf7d36c2a Mon Sep 17 00:00:00 2001 From: Dylan Jhaveri Date: Thu, 19 Mar 2026 21:47:28 -0700 Subject: [PATCH 1/8] feat: centralize error handling for consistent permission error messages across all commands --- src/commands/annotations/create.ts | 11 +- src/commands/annotations/delete.ts | 11 +- src/commands/annotations/get.ts | 11 +- src/commands/annotations/list.ts | 11 +- src/commands/annotations/update.ts | 11 +- src/commands/assets/create.ts | 11 +- src/commands/assets/delete.ts | 11 +- src/commands/assets/get.ts | 11 +- src/commands/assets/input-info.ts | 11 +- src/commands/assets/list.ts | 11 +- src/commands/assets/manage/index.ts | 6 +- src/commands/assets/playback-ids/create.ts | 11 +- src/commands/assets/playback-ids/delete.ts | 11 +- src/commands/assets/playback-ids/list.ts | 11 +- .../assets/static-renditions/create.ts | 11 +- .../assets/static-renditions/delete.ts | 11 +- src/commands/assets/static-renditions/list.ts | 11 +- src/commands/assets/tracks/create.ts | 11 +- src/commands/assets/tracks/delete.ts | 11 +- .../assets/tracks/generate-subtitles.ts | 11 +- src/commands/assets/update-master-access.ts | 11 +- src/commands/assets/update.ts | 11 +- src/commands/delivery-usage/list.ts | 11 +- src/commands/dimensions/list.ts | 11 +- src/commands/dimensions/values.ts | 11 +- src/commands/drm-configurations/get.ts | 11 +- src/commands/drm-configurations/list.ts | 11 +- src/commands/errors/list.ts | 11 +- src/commands/exports/list.ts | 11 +- src/commands/incidents/get.ts | 11 +- src/commands/incidents/list.ts | 11 +- src/commands/incidents/related.ts | 11 +- src/commands/live/complete.ts | 11 +- src/commands/live/create.ts | 11 +- .../delete-new-asset-static-renditions.ts | 11 +- src/commands/live/delete.ts | 11 +- src/commands/live/disable.ts | 11 +- src/commands/live/enable.ts | 11 +- src/commands/live/get.ts | 11 +- src/commands/live/list.ts | 11 +- src/commands/live/playback-ids/create.ts | 11 +- src/commands/live/playback-ids/delete.ts | 11 +- src/commands/live/playback-ids/list.ts | 11 +- src/commands/live/reset-stream-key.ts | 11 +- src/commands/live/simulcast-targets/create.ts | 11 +- src/commands/live/simulcast-targets/delete.ts | 11 +- src/commands/live/simulcast-targets/get.ts | 11 +- .../live/update-embedded-subtitles.ts | 11 +- .../live/update-generated-subtitles.ts | 11 +- .../update-new-asset-static-renditions.ts | 11 +- src/commands/live/update.ts | 11 +- src/commands/metrics/breakdown.ts | 11 +- src/commands/metrics/insights.ts | 11 +- src/commands/metrics/list.ts | 11 +- src/commands/metrics/overall.ts | 11 +- src/commands/metrics/timeseries.ts | 11 +- .../monitoring/breakdown-timeseries.ts | 16 +- src/commands/monitoring/breakdown.ts | 11 +- src/commands/monitoring/dimensions.ts | 11 +- .../monitoring/histogram-timeseries.ts | 16 +- src/commands/monitoring/metrics.ts | 11 +- src/commands/monitoring/timeseries.ts | 11 +- src/commands/playback-ids/index.ts | 11 +- src/commands/playback-restrictions/create.ts | 16 +- src/commands/playback-restrictions/delete.ts | 16 +- src/commands/playback-restrictions/get.ts | 11 +- src/commands/playback-restrictions/list.ts | 11 +- .../playback-restrictions/update-referrer.ts | 16 +- .../update-user-agent.ts | 16 +- src/commands/signing-keys/create.ts | 11 +- src/commands/signing-keys/delete.ts | 11 +- src/commands/signing-keys/get.ts | 11 +- src/commands/signing-keys/list.ts | 11 +- .../transcription-vocabularies/create.ts | 16 +- .../transcription-vocabularies/delete.ts | 16 +- .../transcription-vocabularies/get.ts | 16 +- .../transcription-vocabularies/list.ts | 16 +- .../transcription-vocabularies/update.ts | 16 +- src/commands/uploads/cancel.ts | 11 +- src/commands/uploads/create.ts | 11 +- src/commands/uploads/get.ts | 11 +- src/commands/uploads/list.ts | 11 +- src/commands/video-views/get.ts | 11 +- src/commands/video-views/list.ts | 11 +- src/commands/webhooks/listen.ts | 24 +- src/commands/whoami.ts | 10 +- src/lib/errors.test.ts | 117 +++++++ src/lib/errors.ts | 294 ++++++++++++++++++ 88 files changed, 652 insertions(+), 767 deletions(-) create mode 100644 src/lib/errors.test.ts create mode 100644 src/lib/errors.ts diff --git a/src/commands/annotations/create.ts b/src/commands/annotations/create.ts index c173ec2..06f3586 100644 --- a/src/commands/annotations/create.ts +++ b/src/commands/annotations/create.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface CreateOptions { @@ -44,14 +45,6 @@ export const createCommand = new Command() console.log(` Note: ${annotation.note}`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'annotations', 'create', options); } }); diff --git a/src/commands/annotations/delete.ts b/src/commands/annotations/delete.ts index 0a5a6ca..bab2265 100644 --- a/src/commands/annotations/delete.ts +++ b/src/commands/annotations/delete.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -46,14 +47,6 @@ export const deleteCommand = new Command() console.log(`Annotation ${annotationId} deleted successfully.`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'annotations', 'delete', options); } }); diff --git a/src/commands/annotations/get.ts b/src/commands/annotations/get.ts index 110646a..12f44f6 100644 --- a/src/commands/annotations/get.ts +++ b/src/commands/annotations/get.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface GetOptions { @@ -26,14 +27,6 @@ export const getCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'annotations', 'get', options); } }); diff --git a/src/commands/annotations/list.ts b/src/commands/annotations/list.ts index f538b06..4ea2656 100644 --- a/src/commands/annotations/list.ts +++ b/src/commands/annotations/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface ListOptions { @@ -82,14 +83,6 @@ export const listCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'annotations', 'list', options); } }); diff --git a/src/commands/annotations/update.ts b/src/commands/annotations/update.ts index c4dc706..7eccfe5 100644 --- a/src/commands/annotations/update.ts +++ b/src/commands/annotations/update.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface UpdateOptions { @@ -49,14 +50,6 @@ export const updateCommand = new Command() console.log(` Note: ${annotation.note}`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'annotations', 'update', options); } }); diff --git a/src/commands/assets/create.ts b/src/commands/assets/create.ts index 37d5603..9675038 100644 --- a/src/commands/assets/create.ts +++ b/src/commands/assets/create.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import type Mux from '@mux/mux-node'; +import { handleCommandError } from '@/lib/errors.ts'; import { expandGlobPattern, uploadFile } from '@/lib/file-upload.ts'; import { parseAssetConfig } from '@/lib/json-config.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -471,14 +472,6 @@ export const createCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (opts.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'assets', 'create', opts); } }); diff --git a/src/commands/assets/delete.ts b/src/commands/assets/delete.ts index 535e39e..66526a4 100644 --- a/src/commands/assets/delete.ts +++ b/src/commands/assets/delete.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -46,14 +47,6 @@ export const deleteCommand = new Command() console.log(`Asset ${assetId} deleted successfully`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'assets', 'delete', options); } }); diff --git a/src/commands/assets/get.ts b/src/commands/assets/get.ts index 49d9df1..19e32a9 100644 --- a/src/commands/assets/get.ts +++ b/src/commands/assets/get.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { formatAsset } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -24,14 +25,6 @@ export const getCommand = new Command() formatAsset(asset); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'assets', 'get', options); } }); diff --git a/src/commands/assets/input-info.ts b/src/commands/assets/input-info.ts index 2411edb..2ab9d7a 100644 --- a/src/commands/assets/input-info.ts +++ b/src/commands/assets/input-info.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface InputInfoOptions { @@ -69,14 +70,6 @@ export const inputInfoCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'assets', 'input-info', options); } }); diff --git a/src/commands/assets/list.ts b/src/commands/assets/list.ts index 82b83ee..349a62f 100644 --- a/src/commands/assets/list.ts +++ b/src/commands/assets/list.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import type { Asset } from '@mux/mux-node/resources/video/assets'; +import { handleCommandError } from '@/lib/errors.ts'; import { formatAssetStatus, formatCreatedAt, @@ -85,15 +86,7 @@ export const listCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'assets', 'list', options); } }); diff --git a/src/commands/assets/manage/index.ts b/src/commands/assets/manage/index.ts index b85836d..3f31136 100644 --- a/src/commands/assets/manage/index.ts +++ b/src/commands/assets/manage/index.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; export const manageCommand = new Command() @@ -52,9 +53,6 @@ export const manageCommand = new Command() root.render(React.createElement(AssetManageApp, { mux, onPrompt })); } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - console.error(`Error: ${errorMessage}`); - process.exit(1); + await handleCommandError(error, 'assets', 'list', {}); } }); diff --git a/src/commands/assets/playback-ids/create.ts b/src/commands/assets/playback-ids/create.ts index e7e5d35..c014b29 100644 --- a/src/commands/assets/playback-ids/create.ts +++ b/src/commands/assets/playback-ids/create.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { createPlaybackId, type PlaybackIdPolicy } from '@/lib/playback-ids.ts'; import { getPlayerUrl, getStreamUrl } from '@/lib/urls.ts'; @@ -59,14 +60,6 @@ export const createCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'assets', 'create', options); } }); diff --git a/src/commands/assets/playback-ids/delete.ts b/src/commands/assets/playback-ids/delete.ts index 3e4bd3d..38e7e21 100644 --- a/src/commands/assets/playback-ids/delete.ts +++ b/src/commands/assets/playback-ids/delete.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { deletePlaybackId } from '@/lib/playback-ids.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -46,15 +47,7 @@ export const deleteCommand = new Command() console.log(`Playback ID ${playbackId} deleted successfully`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'assets', 'delete', options); } }, ); diff --git a/src/commands/assets/playback-ids/list.ts b/src/commands/assets/playback-ids/list.ts index 9a39912..1b7f1cc 100644 --- a/src/commands/assets/playback-ids/list.ts +++ b/src/commands/assets/playback-ids/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { getPlayerUrl, getStreamUrl } from '@/lib/urls.ts'; @@ -52,14 +53,6 @@ export const listCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'assets', 'list', options); } }); diff --git a/src/commands/assets/static-renditions/create.ts b/src/commands/assets/static-renditions/create.ts index 0e91784..2cbf6b3 100644 --- a/src/commands/assets/static-renditions/create.ts +++ b/src/commands/assets/static-renditions/create.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import type Mux from '@mux/mux-node'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; type Resolution = NonNullable< @@ -85,15 +86,7 @@ export const createCommand = new Command() outputRendition(rendition, options.json, !options.wait); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'assets', 'create', options); } }); diff --git a/src/commands/assets/static-renditions/delete.ts b/src/commands/assets/static-renditions/delete.ts index 2440f11..a41a5b3 100644 --- a/src/commands/assets/static-renditions/delete.ts +++ b/src/commands/assets/static-renditions/delete.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -54,15 +55,7 @@ export const deleteCommand = new Command() ); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'assets', 'delete', options); } }, ); diff --git a/src/commands/assets/static-renditions/list.ts b/src/commands/assets/static-renditions/list.ts index 046e2a2..c7de917 100644 --- a/src/commands/assets/static-renditions/list.ts +++ b/src/commands/assets/static-renditions/list.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import type { Asset } from '@mux/mux-node/resources/video/assets'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; type StaticRenditionFile = NonNullable< @@ -43,15 +44,7 @@ export const listCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'assets', 'list', options); } }); diff --git a/src/commands/assets/tracks/create.ts b/src/commands/assets/tracks/create.ts index fa252ab..f283e39 100644 --- a/src/commands/assets/tracks/create.ts +++ b/src/commands/assets/tracks/create.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface CreateOptions { @@ -105,14 +106,6 @@ export const createCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'assets', 'create', options); } }); diff --git a/src/commands/assets/tracks/delete.ts b/src/commands/assets/tracks/delete.ts index 4f78272..2a691e2 100644 --- a/src/commands/assets/tracks/delete.ts +++ b/src/commands/assets/tracks/delete.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -46,14 +47,6 @@ export const deleteCommand = new Command() console.log(`Track ${trackId} deleted successfully`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'assets', 'delete', options); } }); diff --git a/src/commands/assets/tracks/generate-subtitles.ts b/src/commands/assets/tracks/generate-subtitles.ts index 322107c..f39a471 100644 --- a/src/commands/assets/tracks/generate-subtitles.ts +++ b/src/commands/assets/tracks/generate-subtitles.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface GenerateSubtitlesOptions { @@ -68,15 +69,7 @@ export const generateSubtitlesCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'assets', 'create', options); } }, ); diff --git a/src/commands/assets/update-master-access.ts b/src/commands/assets/update-master-access.ts index d5793ba..b8947e4 100644 --- a/src/commands/assets/update-master-access.ts +++ b/src/commands/assets/update-master-access.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { formatAsset } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -41,14 +42,6 @@ export const updateMasterAccessCommand = new Command() formatAsset(asset); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'assets', 'update', options); } }); diff --git a/src/commands/assets/update.ts b/src/commands/assets/update.ts index 7f9680d..6e18ed9 100644 --- a/src/commands/assets/update.ts +++ b/src/commands/assets/update.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { formatAsset } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -78,14 +79,6 @@ export const updateCommand = new Command() formatAsset(asset); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'assets', 'update', options); } }); diff --git a/src/commands/delivery-usage/list.ts b/src/commands/delivery-usage/list.ts index 0369886..0786803 100644 --- a/src/commands/delivery-usage/list.ts +++ b/src/commands/delivery-usage/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface ListOptions { @@ -82,14 +83,6 @@ export const listCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'delivery-usage', 'list', options); } }); diff --git a/src/commands/dimensions/list.ts b/src/commands/dimensions/list.ts index cc34faa..61c8cac 100644 --- a/src/commands/dimensions/list.ts +++ b/src/commands/dimensions/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface ListOptions { @@ -43,14 +44,6 @@ export const listCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'dimensions', 'list', options); } }); diff --git a/src/commands/dimensions/values.ts b/src/commands/dimensions/values.ts index dabf014..3384c28 100644 --- a/src/commands/dimensions/values.ts +++ b/src/commands/dimensions/values.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import { buildDataFilterParams } from '@/lib/data-filters.ts'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface ValuesOptions { @@ -76,14 +77,6 @@ export const valuesCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'dimensions', 'values', options); } }); diff --git a/src/commands/drm-configurations/get.ts b/src/commands/drm-configurations/get.ts index 3268aea..792e55f 100644 --- a/src/commands/drm-configurations/get.ts +++ b/src/commands/drm-configurations/get.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface GetOptions { @@ -22,14 +23,6 @@ export const getCommand = new Command() console.log(`DRM Configuration ID: ${config.id}`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'drm-configurations', 'get', options); } }); diff --git a/src/commands/drm-configurations/list.ts b/src/commands/drm-configurations/list.ts index 1ee69a3..cb0e846 100644 --- a/src/commands/drm-configurations/list.ts +++ b/src/commands/drm-configurations/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface ListOptions { @@ -48,14 +49,6 @@ export const listCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'drm-configurations', 'list', options); } }); diff --git a/src/commands/errors/list.ts b/src/commands/errors/list.ts index c5e122f..87ed592 100644 --- a/src/commands/errors/list.ts +++ b/src/commands/errors/list.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import { buildDataFilterParams } from '@/lib/data-filters.ts'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface ListOptions { @@ -71,14 +72,6 @@ export const listCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'errors', 'list', options); } }); diff --git a/src/commands/exports/list.ts b/src/commands/exports/list.ts index caa373e..7817563 100644 --- a/src/commands/exports/list.ts +++ b/src/commands/exports/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface ListOptions { @@ -35,14 +36,6 @@ export const listCommand = new Command() console.log(''); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'exports', 'list', options); } }); diff --git a/src/commands/incidents/get.ts b/src/commands/incidents/get.ts index 76339de..c24a4a7 100644 --- a/src/commands/incidents/get.ts +++ b/src/commands/incidents/get.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface GetOptions { @@ -34,14 +35,6 @@ export const getCommand = new Command() console.log(` Affected Views: ${incident.affected_views ?? '-'}`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'incidents', 'get', options); } }); diff --git a/src/commands/incidents/list.ts b/src/commands/incidents/list.ts index c2f4063..1978ce2 100644 --- a/src/commands/incidents/list.ts +++ b/src/commands/incidents/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface ListOptions { @@ -117,14 +118,6 @@ export const listCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'incidents', 'list', options); } }); diff --git a/src/commands/incidents/related.ts b/src/commands/incidents/related.ts index f70c2f0..0385ae3 100644 --- a/src/commands/incidents/related.ts +++ b/src/commands/incidents/related.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface RelatedOptions { @@ -85,14 +86,6 @@ export const relatedCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'incidents', 'related', options); } }); diff --git a/src/commands/live/complete.ts b/src/commands/live/complete.ts index a8824b4..85f9cd7 100644 --- a/src/commands/live/complete.ts +++ b/src/commands/live/complete.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface CompleteOptions { @@ -23,14 +24,6 @@ export const completeCommand = new Command() console.log(`Live stream ${streamId} completed successfully`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'complete', options); } }); diff --git a/src/commands/live/create.ts b/src/commands/live/create.ts index 08d37df..b673aaa 100644 --- a/src/commands/live/create.ts +++ b/src/commands/live/create.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import type Mux from '@mux/mux-node'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; // Extract types from Mux SDK @@ -121,14 +122,6 @@ export const createCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'create', options); } }); diff --git a/src/commands/live/delete-new-asset-static-renditions.ts b/src/commands/live/delete-new-asset-static-renditions.ts index 47b6fbe..e57b426 100644 --- a/src/commands/live/delete-new-asset-static-renditions.ts +++ b/src/commands/live/delete-new-asset-static-renditions.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -52,15 +53,7 @@ export const deleteNewAssetStaticRenditionsCommand = new Command() ); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'delete', options); } }, ); diff --git a/src/commands/live/delete.ts b/src/commands/live/delete.ts index e385a7f..e3ebd08 100644 --- a/src/commands/live/delete.ts +++ b/src/commands/live/delete.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -46,14 +47,6 @@ export const deleteCommand = new Command() console.log(`Live stream ${streamId} deleted successfully`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'delete', options); } }); diff --git a/src/commands/live/disable.ts b/src/commands/live/disable.ts index 04bfe7f..29323d2 100644 --- a/src/commands/live/disable.ts +++ b/src/commands/live/disable.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface DisableOptions { @@ -23,14 +24,6 @@ export const disableCommand = new Command() console.log(`Live stream ${streamId} disabled successfully`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'disable', options); } }); diff --git a/src/commands/live/enable.ts b/src/commands/live/enable.ts index a23a12d..b673cc2 100644 --- a/src/commands/live/enable.ts +++ b/src/commands/live/enable.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface EnableOptions { @@ -23,14 +24,6 @@ export const enableCommand = new Command() console.log(`Live stream ${streamId} enabled successfully`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'enable', options); } }); diff --git a/src/commands/live/get.ts b/src/commands/live/get.ts index 3b2dece..1033d4d 100644 --- a/src/commands/live/get.ts +++ b/src/commands/live/get.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { formatLiveStream } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -24,14 +25,6 @@ export const getCommand = new Command() formatLiveStream(stream); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'get', options); } }); diff --git a/src/commands/live/list.ts b/src/commands/live/list.ts index 6a8bb05..9873f61 100644 --- a/src/commands/live/list.ts +++ b/src/commands/live/list.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import type { LiveStream } from '@mux/mux-node/resources/video/live-streams'; +import { handleCommandError } from '@/lib/errors.ts'; import { formatCreatedAt, formatLiveStreamStatus, @@ -69,15 +70,7 @@ export const listCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'list', options); } }); diff --git a/src/commands/live/playback-ids/create.ts b/src/commands/live/playback-ids/create.ts index 9e78ad7..9652b44 100644 --- a/src/commands/live/playback-ids/create.ts +++ b/src/commands/live/playback-ids/create.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { createLiveStreamPlaybackId, @@ -66,14 +67,6 @@ export const createCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'create', options); } }); diff --git a/src/commands/live/playback-ids/delete.ts b/src/commands/live/playback-ids/delete.ts index 931e71e..ea87790 100644 --- a/src/commands/live/playback-ids/delete.ts +++ b/src/commands/live/playback-ids/delete.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { deleteLiveStreamPlaybackId } from '@/lib/playback-ids.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -54,15 +55,7 @@ export const deleteCommand = new Command() console.log(`Playback ID ${playbackId} deleted successfully`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'delete', options); } }, ); diff --git a/src/commands/live/playback-ids/list.ts b/src/commands/live/playback-ids/list.ts index 4f2235a..58cbd15 100644 --- a/src/commands/live/playback-ids/list.ts +++ b/src/commands/live/playback-ids/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { getPlayerUrl, getStreamUrl } from '@/lib/urls.ts'; @@ -52,14 +53,6 @@ export const listCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'list', options); } }); diff --git a/src/commands/live/reset-stream-key.ts b/src/commands/live/reset-stream-key.ts index 07db147..9ccdd01 100644 --- a/src/commands/live/reset-stream-key.ts +++ b/src/commands/live/reset-stream-key.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { formatLiveStream } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -46,14 +47,6 @@ export const resetStreamKeyCommand = new Command() formatLiveStream(stream); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'reset-stream-key', options); } }); diff --git a/src/commands/live/simulcast-targets/create.ts b/src/commands/live/simulcast-targets/create.ts index 727813b..72a7530 100644 --- a/src/commands/live/simulcast-targets/create.ts +++ b/src/commands/live/simulcast-targets/create.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface CreateOptions { @@ -56,14 +57,6 @@ export const createCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'create', options); } }); diff --git a/src/commands/live/simulcast-targets/delete.ts b/src/commands/live/simulcast-targets/delete.ts index 6a1dc93..0293b04 100644 --- a/src/commands/live/simulcast-targets/delete.ts +++ b/src/commands/live/simulcast-targets/delete.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -47,15 +48,7 @@ export const deleteCommand = new Command() console.log(`Simulcast target ${targetId} deleted successfully`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'delete', options); } }, ); diff --git a/src/commands/live/simulcast-targets/get.ts b/src/commands/live/simulcast-targets/get.ts index 0abb630..befbff5 100644 --- a/src/commands/live/simulcast-targets/get.ts +++ b/src/commands/live/simulcast-targets/get.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface GetOptions { @@ -28,14 +29,6 @@ export const getCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'get', options); } }); diff --git a/src/commands/live/update-embedded-subtitles.ts b/src/commands/live/update-embedded-subtitles.ts index 0feb884..48bc013 100644 --- a/src/commands/live/update-embedded-subtitles.ts +++ b/src/commands/live/update-embedded-subtitles.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { formatLiveStream } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -82,14 +83,6 @@ export const updateEmbeddedSubtitlesCommand = new Command() formatLiveStream(stream); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'update', options); } }); diff --git a/src/commands/live/update-generated-subtitles.ts b/src/commands/live/update-generated-subtitles.ts index d4df442..5477bdd 100644 --- a/src/commands/live/update-generated-subtitles.ts +++ b/src/commands/live/update-generated-subtitles.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { formatLiveStream } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -80,15 +81,7 @@ export const updateGeneratedSubtitlesCommand = new Command() formatLiveStream(stream); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'update', options); } }, ); diff --git a/src/commands/live/update-new-asset-static-renditions.ts b/src/commands/live/update-new-asset-static-renditions.ts index 5f73379..14858c9 100644 --- a/src/commands/live/update-new-asset-static-renditions.ts +++ b/src/commands/live/update-new-asset-static-renditions.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { formatLiveStream } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -74,15 +75,7 @@ export const updateNewAssetStaticRenditionsCommand = new Command() formatLiveStream(stream); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'update', options); } }, ); diff --git a/src/commands/live/update.ts b/src/commands/live/update.ts index 5c69644..4ca8e23 100644 --- a/src/commands/live/update.ts +++ b/src/commands/live/update.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { formatLiveStream } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -105,14 +106,6 @@ export const updateCommand = new Command() formatLiveStream(stream); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'live', 'update', options); } }); diff --git a/src/commands/metrics/breakdown.ts b/src/commands/metrics/breakdown.ts index d5e6b27..9dacfcc 100644 --- a/src/commands/metrics/breakdown.ts +++ b/src/commands/metrics/breakdown.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import { buildDataFilterParams } from '@/lib/data-filters.ts'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface BreakdownOptions { @@ -127,14 +128,6 @@ export const breakdownCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'metrics', 'breakdown', options); } }); diff --git a/src/commands/metrics/insights.ts b/src/commands/metrics/insights.ts index b2b3942..fd1284b 100644 --- a/src/commands/metrics/insights.ts +++ b/src/commands/metrics/insights.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import { buildDataFilterParams } from '@/lib/data-filters.ts'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface InsightsOptions { @@ -85,14 +86,6 @@ export const insightsCommand = new Command() console.log(''); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'metrics', 'insights', options); } }); diff --git a/src/commands/metrics/list.ts b/src/commands/metrics/list.ts index 4d2f614..4dc0af2 100644 --- a/src/commands/metrics/list.ts +++ b/src/commands/metrics/list.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import { buildDataFilterParams } from '@/lib/data-filters.ts'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface ListOptions { @@ -64,14 +65,6 @@ export const listCommand = new Command() console.log(`${metric.name}: ${metric.value}`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'metrics', 'list', options); } }); diff --git a/src/commands/metrics/overall.ts b/src/commands/metrics/overall.ts index 780bdc7..bf998a8 100644 --- a/src/commands/metrics/overall.ts +++ b/src/commands/metrics/overall.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import { buildDataFilterParams } from '@/lib/data-filters.ts'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface OverallOptions { @@ -78,14 +79,6 @@ export const overallCommand = new Command() console.log(`Total Views: ${overall.total_views ?? '-'}`); console.log(`Total Watch Time: ${overall.total_watch_time ?? '-'}`); } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'metrics', 'overall', options); } }); diff --git a/src/commands/metrics/timeseries.ts b/src/commands/metrics/timeseries.ts index b9bdd3b..a1d6cc6 100644 --- a/src/commands/metrics/timeseries.ts +++ b/src/commands/metrics/timeseries.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import { buildDataFilterParams } from '@/lib/data-filters.ts'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface TimeseriesOptions { @@ -96,14 +97,6 @@ export const timeseriesCommand = new Command() console.log(point); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'metrics', 'timeseries', options); } }); diff --git a/src/commands/monitoring/breakdown-timeseries.ts b/src/commands/monitoring/breakdown-timeseries.ts index 2029284..26006fe 100644 --- a/src/commands/monitoring/breakdown-timeseries.ts +++ b/src/commands/monitoring/breakdown-timeseries.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface BreakdownTimeseriesOptions { @@ -92,14 +93,11 @@ export const breakdownTimeseriesCommand = new Command() console.log(''); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError( + error, + 'monitoring', + 'breakdown-timeseries', + options, + ); } }); diff --git a/src/commands/monitoring/breakdown.ts b/src/commands/monitoring/breakdown.ts index 054c355..70bf512 100644 --- a/src/commands/monitoring/breakdown.ts +++ b/src/commands/monitoring/breakdown.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface BreakdownOptions { @@ -82,14 +83,6 @@ export const breakdownCommand = new Command() console.log(''); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'monitoring', 'breakdown', options); } }); diff --git a/src/commands/monitoring/dimensions.ts b/src/commands/monitoring/dimensions.ts index 2c18870..b8ae790 100644 --- a/src/commands/monitoring/dimensions.ts +++ b/src/commands/monitoring/dimensions.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface DimensionsOptions { @@ -30,14 +31,6 @@ export const dimensionsCommand = new Command() console.log(`${dimension.name}: ${dimension.display_name}`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'monitoring', 'dimensions', options); } }); diff --git a/src/commands/monitoring/histogram-timeseries.ts b/src/commands/monitoring/histogram-timeseries.ts index da9a3ef..6fed0d0 100644 --- a/src/commands/monitoring/histogram-timeseries.ts +++ b/src/commands/monitoring/histogram-timeseries.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface HistogramTimeseriesOptions { @@ -49,14 +50,11 @@ export const histogramTimeseriesCommand = new Command() console.log(''); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError( + error, + 'monitoring', + 'histogram-timeseries', + options, + ); } }); diff --git a/src/commands/monitoring/metrics.ts b/src/commands/monitoring/metrics.ts index f7cedac..972a742 100644 --- a/src/commands/monitoring/metrics.ts +++ b/src/commands/monitoring/metrics.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface MetricsListOptions { @@ -30,14 +31,6 @@ export const metricsListCommand = new Command() console.log(`${metric.name}: ${metric.display_name}`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'monitoring', 'metrics', options); } }); diff --git a/src/commands/monitoring/timeseries.ts b/src/commands/monitoring/timeseries.ts index 6e8259f..0f2b85f 100644 --- a/src/commands/monitoring/timeseries.ts +++ b/src/commands/monitoring/timeseries.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface TimeseriesOptions { @@ -53,14 +54,6 @@ export const timeseriesCommand = new Command() ); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'monitoring', 'timeseries', options); } }); diff --git a/src/commands/playback-ids/index.ts b/src/commands/playback-ids/index.ts index 8c947fb..938e9bf 100644 --- a/src/commands/playback-ids/index.ts +++ b/src/commands/playback-ids/index.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { formatAsset, formatLiveStream } from '@/lib/formatters.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; @@ -56,14 +57,6 @@ export const playbackIdsCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'playback-ids', 'retrieve', options); } }); diff --git a/src/commands/playback-restrictions/create.ts b/src/commands/playback-restrictions/create.ts index e958592..23b1181 100644 --- a/src/commands/playback-restrictions/create.ts +++ b/src/commands/playback-restrictions/create.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface CreateOptions { @@ -55,14 +56,11 @@ export const createCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError( + error, + 'playback-restrictions', + 'create', + options, + ); } }); diff --git a/src/commands/playback-restrictions/delete.ts b/src/commands/playback-restrictions/delete.ts index a722267..be82a0e 100644 --- a/src/commands/playback-restrictions/delete.ts +++ b/src/commands/playback-restrictions/delete.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -44,14 +45,11 @@ export const deleteCommand = new Command() ); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError( + error, + 'playback-restrictions', + 'delete', + options, + ); } }); diff --git a/src/commands/playback-restrictions/get.ts b/src/commands/playback-restrictions/get.ts index 2519e4f..6cd0813 100644 --- a/src/commands/playback-restrictions/get.ts +++ b/src/commands/playback-restrictions/get.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface GetOptions { @@ -37,14 +38,6 @@ export const getCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'playback-restrictions', 'get', options); } }); diff --git a/src/commands/playback-restrictions/list.ts b/src/commands/playback-restrictions/list.ts index 1501957..3d3f6dd 100644 --- a/src/commands/playback-restrictions/list.ts +++ b/src/commands/playback-restrictions/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface ListOptions { @@ -64,14 +65,6 @@ export const listCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'playback-restrictions', 'list', options); } }); diff --git a/src/commands/playback-restrictions/update-referrer.ts b/src/commands/playback-restrictions/update-referrer.ts index 6a5f2b5..97730ab 100644 --- a/src/commands/playback-restrictions/update-referrer.ts +++ b/src/commands/playback-restrictions/update-referrer.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface UpdateReferrerOptions { @@ -43,14 +44,11 @@ export const updateReferrerCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError( + error, + 'playback-restrictions', + 'update', + options, + ); } }); diff --git a/src/commands/playback-restrictions/update-user-agent.ts b/src/commands/playback-restrictions/update-user-agent.ts index ce39d61..bcf5d1d 100644 --- a/src/commands/playback-restrictions/update-user-agent.ts +++ b/src/commands/playback-restrictions/update-user-agent.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface UpdateUserAgentOptions { @@ -46,14 +47,11 @@ export const updateUserAgentCommand = new Command() ); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError( + error, + 'playback-restrictions', + 'update', + options, + ); } }); diff --git a/src/commands/signing-keys/create.ts b/src/commands/signing-keys/create.ts index d85c9bc..d60de97 100644 --- a/src/commands/signing-keys/create.ts +++ b/src/commands/signing-keys/create.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import { getCurrentEnvironment, setEnvironment } from '@/lib/config.ts'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -79,14 +80,6 @@ export const createCommand = new Command() console.log(`Key ID: ${keyId}`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'signing-keys', 'create', options); } }); diff --git a/src/commands/signing-keys/delete.ts b/src/commands/signing-keys/delete.ts index cbfc5dd..745edc1 100644 --- a/src/commands/signing-keys/delete.ts +++ b/src/commands/signing-keys/delete.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import { readConfig, setEnvironment } from '@/lib/config.ts'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -102,14 +103,6 @@ export const deleteCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'signing-keys', 'delete', options); } }); diff --git a/src/commands/signing-keys/get.ts b/src/commands/signing-keys/get.ts index 1740921..77ad568 100644 --- a/src/commands/signing-keys/get.ts +++ b/src/commands/signing-keys/get.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import { readConfig } from '@/lib/config.ts'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface GetOptions { @@ -56,14 +57,6 @@ export const getCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'signing-keys', 'get', options); } }); diff --git a/src/commands/signing-keys/list.ts b/src/commands/signing-keys/list.ts index a82c83c..a0a66ab 100644 --- a/src/commands/signing-keys/list.ts +++ b/src/commands/signing-keys/list.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import { readConfig } from '@/lib/config.ts'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface ListOptions { @@ -63,14 +64,6 @@ export const listCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'signing-keys', 'list', options); } }); diff --git a/src/commands/transcription-vocabularies/create.ts b/src/commands/transcription-vocabularies/create.ts index bb9471a..cf8986e 100644 --- a/src/commands/transcription-vocabularies/create.ts +++ b/src/commands/transcription-vocabularies/create.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface CreateOptions { @@ -56,14 +57,11 @@ export const createCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError( + error, + 'transcription-vocabularies', + 'create', + options, + ); } }); diff --git a/src/commands/transcription-vocabularies/delete.ts b/src/commands/transcription-vocabularies/delete.ts index d16716f..132dd6a 100644 --- a/src/commands/transcription-vocabularies/delete.ts +++ b/src/commands/transcription-vocabularies/delete.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -46,14 +47,11 @@ export const deleteCommand = new Command() ); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError( + error, + 'transcription-vocabularies', + 'delete', + options, + ); } }); diff --git a/src/commands/transcription-vocabularies/get.ts b/src/commands/transcription-vocabularies/get.ts index b3ee50b..996cd57 100644 --- a/src/commands/transcription-vocabularies/get.ts +++ b/src/commands/transcription-vocabularies/get.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface GetOptions { @@ -33,14 +34,11 @@ export const getCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError( + error, + 'transcription-vocabularies', + 'get', + options, + ); } }); diff --git a/src/commands/transcription-vocabularies/list.ts b/src/commands/transcription-vocabularies/list.ts index 6c6d98d..101786a 100644 --- a/src/commands/transcription-vocabularies/list.ts +++ b/src/commands/transcription-vocabularies/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface ListOptions { @@ -54,14 +55,11 @@ export const listCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError( + error, + 'transcription-vocabularies', + 'list', + options, + ); } }); diff --git a/src/commands/transcription-vocabularies/update.ts b/src/commands/transcription-vocabularies/update.ts index 4cda145..fd0c035 100644 --- a/src/commands/transcription-vocabularies/update.ts +++ b/src/commands/transcription-vocabularies/update.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface UpdateOptions { @@ -59,14 +60,11 @@ export const updateCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError( + error, + 'transcription-vocabularies', + 'update', + options, + ); } }); diff --git a/src/commands/uploads/cancel.ts b/src/commands/uploads/cancel.ts index 206df52..928802f 100644 --- a/src/commands/uploads/cancel.ts +++ b/src/commands/uploads/cancel.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; import { confirmPrompt } from '@/lib/prompt.ts'; @@ -42,14 +43,6 @@ export const cancelCommand = new Command() console.log(`Upload ${uploadId} cancelled successfully`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'uploads', 'cancel', options); } }); diff --git a/src/commands/uploads/create.ts b/src/commands/uploads/create.ts index 747cc6f..fc6c505 100644 --- a/src/commands/uploads/create.ts +++ b/src/commands/uploads/create.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface CreateOptions { @@ -79,14 +80,6 @@ export const createCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'uploads', 'create', options); } }); diff --git a/src/commands/uploads/get.ts b/src/commands/uploads/get.ts index 5193f92..11d4b2c 100644 --- a/src/commands/uploads/get.ts +++ b/src/commands/uploads/get.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface GetOptions { @@ -36,14 +37,6 @@ export const getCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'uploads', 'get', options); } }); diff --git a/src/commands/uploads/list.ts b/src/commands/uploads/list.ts index b072478..b7ff3f9 100644 --- a/src/commands/uploads/list.ts +++ b/src/commands/uploads/list.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface ListOptions { @@ -60,14 +61,6 @@ export const listCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'uploads', 'list', options); } }); diff --git a/src/commands/video-views/get.ts b/src/commands/video-views/get.ts index 624992d..228d866 100644 --- a/src/commands/video-views/get.ts +++ b/src/commands/video-views/get.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface GetOptions { @@ -34,14 +35,6 @@ export const getCommand = new Command() console.log(`Error Msg: ${errorMessage}`); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'video-views', 'get', options); } }); diff --git a/src/commands/video-views/list.ts b/src/commands/video-views/list.ts index 5c3ef48..52d9b24 100644 --- a/src/commands/video-views/list.ts +++ b/src/commands/video-views/list.ts @@ -1,5 +1,6 @@ import { Command } from '@cliffy/command'; import { buildDataFilterParams } from '@/lib/data-filters.ts'; +import { handleCommandError } from '@/lib/errors.ts'; import { createAuthenticatedMuxClient } from '@/lib/mux.ts'; interface ListOptions { @@ -106,14 +107,6 @@ export const listCommand = new Command() } } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'video-views', 'list', options); } }); diff --git a/src/commands/webhooks/listen.ts b/src/commands/webhooks/listen.ts index bd84859..4b44c31 100644 --- a/src/commands/webhooks/listen.ts +++ b/src/commands/webhooks/listen.ts @@ -1,6 +1,7 @@ import { colors } from '@cliffy/ansi/colors'; import { Command } from '@cliffy/command'; import { getCurrentEnvironment, updateEnvironment } from '@/lib/config.ts'; +import { checkFetchPermissionError } from '@/lib/errors.ts'; import { appendEvent, type StoredEvent } from '@/lib/events-store.ts'; import { getAuthHeaders, getMuxBaseUrl } from '@/lib/mux.ts'; import { parseSSEStream } from '@/lib/sse.ts'; @@ -95,15 +96,22 @@ export const listenCommand = new Command() signal: controller.signal, }); - if (response.status === 401 || response.status === 403) { - console.error( - `Access denied (${response.status}). Your API token may not have "system" permissions.\n` + - 'Manage your access tokens at: https://dashboard.mux.com/settings/access-tokens', - ); - process.exit(1); - } - if (!response.ok) { + // Check for permission issues before entering the reconnection loop. + // Permission errors are not transient and should not be retried. + const permError = await checkFetchPermissionError( + response, + 'webhooks', + 'listen', + ); + if (permError) { + if (options.json) { + console.error(JSON.stringify({ error: permError })); + } else { + console.error(`Error: ${permError}`); + } + return; + } throw new Error( `Server returned ${response.status} ${response.statusText}`, ); diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index d0ee9ae..17e2036 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -1,4 +1,5 @@ import { Command } from '@cliffy/command'; +import { handleCommandError } from '@/lib/errors.ts'; import { getAuthHeaders, getMuxBaseUrl } from '../lib/mux.ts'; interface WhoAmIOptions { @@ -38,13 +39,6 @@ export const whoamiCommand = new Command() `Permissions: ${(data.permissions as string[]).join(', ')}`, ); } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); + await handleCommandError(error, 'whoami', 'get', options); } }); diff --git a/src/lib/errors.test.ts b/src/lib/errors.test.ts new file mode 100644 index 0000000..94a82fe --- /dev/null +++ b/src/lib/errors.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from 'bun:test'; +import { + formatPermissionError, + getRequiredPermission, + isPermissionError, +} from './errors.ts'; + +describe('getRequiredPermission', () => { + test('maps video read commands correctly', () => { + expect(getRequiredPermission('assets', 'get')).toBe('video:read'); + expect(getRequiredPermission('assets', 'list')).toBe('video:read'); + expect(getRequiredPermission('live', 'get')).toBe('video:read'); + expect(getRequiredPermission('live', 'list')).toBe('video:read'); + expect(getRequiredPermission('uploads', 'get')).toBe('video:read'); + expect(getRequiredPermission('delivery-usage', 'list')).toBe('video:read'); + }); + + test('maps video write commands correctly', () => { + expect(getRequiredPermission('assets', 'create')).toBe('video:write'); + expect(getRequiredPermission('assets', 'update')).toBe('video:write'); + expect(getRequiredPermission('assets', 'delete')).toBe('video:write'); + expect(getRequiredPermission('live', 'create')).toBe('video:write'); + expect(getRequiredPermission('live', 'enable')).toBe('video:write'); + expect(getRequiredPermission('uploads', 'create')).toBe('video:write'); + }); + + test('maps data read commands correctly', () => { + expect(getRequiredPermission('metrics', 'list')).toBe('data:read'); + expect(getRequiredPermission('video-views', 'get')).toBe('data:read'); + expect(getRequiredPermission('monitoring', 'breakdown')).toBe('data:read'); + expect(getRequiredPermission('incidents', 'list')).toBe('data:read'); + expect(getRequiredPermission('dimensions', 'list')).toBe('data:read'); + expect(getRequiredPermission('errors', 'list')).toBe('data:read'); + expect(getRequiredPermission('exports', 'list')).toBe('data:read'); + expect(getRequiredPermission('annotations', 'get')).toBe('data:read'); + }); + + test('maps data write commands correctly', () => { + expect(getRequiredPermission('annotations', 'create')).toBe('data:write'); + expect(getRequiredPermission('annotations', 'update')).toBe('data:write'); + expect(getRequiredPermission('annotations', 'delete')).toBe('data:write'); + }); + + test('maps system commands correctly', () => { + expect(getRequiredPermission('webhooks', 'listen')).toBe('system:read'); + expect(getRequiredPermission('signing-keys', 'get')).toBe('system:read'); + expect(getRequiredPermission('signing-keys', 'list')).toBe('system:read'); + expect(getRequiredPermission('signing-keys', 'create')).toBe( + 'system:write', + ); + expect(getRequiredPermission('signing-keys', 'delete')).toBe( + 'system:write', + ); + }); + + test('returns undefined for unknown commands', () => { + expect(getRequiredPermission('unknown', 'get')).toBeUndefined(); + }); +}); + +describe('isPermissionError', () => { + test('detects permission issue when token lacks required scope', () => { + expect(isPermissionError('video:read', ['data:read'])).toBe(true); + expect(isPermissionError('video:read', ['system:read'])).toBe(true); + expect(isPermissionError('data:read', ['video:read'])).toBe(true); + }); + + test('returns false when token has the exact permission', () => { + expect(isPermissionError('video:read', ['video:read'])).toBe(false); + expect(isPermissionError('data:read', ['data:read'])).toBe(false); + }); + + test('returns false when token has write and read is required', () => { + expect(isPermissionError('video:read', ['video:write'])).toBe(false); + }); + + test('detects when token has read but write is required', () => { + expect(isPermissionError('video:write', ['video:read'])).toBe(true); + }); + + test('handles tokens with multiple permissions', () => { + expect(isPermissionError('data:read', ['video:read', 'data:read'])).toBe( + false, + ); + expect(isPermissionError('system:read', ['video:read', 'data:read'])).toBe( + true, + ); + }); +}); + +describe('formatPermissionError', () => { + test('includes required permission and token permissions', () => { + const result = formatPermissionError('video:read', [ + 'data:read', + 'system:read', + ]); + expect(result).toContain('video:read'); + expect(result).toContain('data:read, system:read'); + expect(result).toContain('dashboard.mux.com'); + expect(result).toContain('mux login'); + }); + + test('includes token name when provided', () => { + const result = formatPermissionError( + 'video:read', + ['data:read'], + 'My Token', + ); + expect(result).toContain('My Token'); + }); + + test('formats without token name when not provided', () => { + const result = formatPermissionError('video:read', ['data:read']); + expect(result).toContain('Your token has'); + expect(result).not.toContain('undefined'); + }); +}); diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..474d0f2 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,294 @@ +import { AuthenticationError, NotFoundError } from '@mux/mux-node'; +import { getAuthHeaders, getMuxBaseUrl } from './mux.ts'; + +// Read-only operations +const READ_ACTIONS = new Set([ + 'get', + 'list', + 'retrieve', + 'input-info', + 'related', + 'breakdown', + 'timeseries', + 'overall', + 'insights', + 'values', + 'metrics', + 'dimensions', + 'breakdown-timeseries', + 'histogram-timeseries', + 'listen', +]); + +// Command groups to their permission scope +const VIDEO_COMMANDS = new Set([ + 'assets', + 'live', + 'uploads', + 'playback-ids', + 'playback-restrictions', + 'transcription-vocabularies', + 'drm-configurations', + 'delivery-usage', +]); + +const DATA_COMMANDS = new Set([ + 'metrics', + 'video-views', + 'monitoring', + 'incidents', + 'dimensions', + 'errors', + 'exports', + 'annotations', +]); + +const SYSTEM_COMMANDS = new Set(['signing-keys', 'webhooks']); + +/** + * Get the required permission for a command. + * Returns the permission string (e.g., "video:read") or undefined if unknown. + */ +export function getRequiredPermission( + commandGroup: string, + action: string, +): string | undefined { + let scope: string | undefined; + + if (VIDEO_COMMANDS.has(commandGroup)) { + scope = 'video'; + } else if (DATA_COMMANDS.has(commandGroup)) { + scope = 'data'; + } else if (SYSTEM_COMMANDS.has(commandGroup)) { + scope = 'system'; + } + + if (!scope) return undefined; + + const level = READ_ACTIONS.has(action) ? 'read' : 'write'; + return `${scope}:${level}`; +} + +/** + * Check if a required permission is missing from the token's permissions. + * Write permission implies read permission for the same scope. + */ +export function isPermissionError( + required: string, + tokenPermissions: string[], +): boolean { + const [requiredScope, requiredLevel] = required.split(':'); + + for (const perm of tokenPermissions) { + const [scope, level] = perm.split(':'); + if (scope !== requiredScope) continue; + + // write implies read + if (requiredLevel === 'read' && (level === 'read' || level === 'write')) { + return false; + } + if (requiredLevel === 'write' && level === 'write') { + return false; + } + } + + return true; +} + +/** + * Format a permission error message for display. + */ +export function formatPermissionError( + required: string, + tokenPermissions: string[], + tokenName?: string, +): string { + const lines = [ + `Permission denied. Your API token does not have "${required}" permission.`, + '', + ]; + + if (tokenName) { + lines.push(`Your token "${tokenName}" has: ${tokenPermissions.join(', ')}`); + } else { + lines.push(`Your token has: ${tokenPermissions.join(', ')}`); + } + + lines.push(''); + lines.push('Create a new access token with the required permissions at:'); + lines.push('https://dashboard.mux.com/settings/access-tokens'); + lines.push(''); + lines.push("Then run 'mux login' to authenticate with the new token."); + + return lines.join('\n'); +} + +interface WhoAmIResponse { + data: { + permissions: string[]; + access_token_name?: string; + }; +} + +/** + * Fetch the current token's permissions via /whoami. + * Returns null if the call fails for any reason. + */ +async function fetchTokenInfo(): Promise<{ + permissions: string[]; + tokenName?: string; +} | null> { + try { + const headers = await getAuthHeaders(); + const baseUrl = getMuxBaseUrl(); + const response = await fetch(`${baseUrl}/system/v1/whoami`, { headers }); + + if (!response.ok) return null; + + const body = (await response.json()) as WhoAmIResponse; + return { + permissions: body.data.permissions, + tokenName: body.data.access_token_name, + }; + } catch { + return null; + } +} + +/** + * Handle errors from Mux API commands. + * + * On NotFoundError (404), checks whether the token lacks the required + * permission (since the Mux API returns 404, not 403, for scope issues). + * Falls back to the standard error message if permissions are fine. + */ +export async function handleCommandError( + error: unknown, + commandGroup: string, + action: string, + options: { json?: boolean }, +): Promise { + // 401: invalid/expired credentials + if (error instanceof AuthenticationError) { + const message = + "Authentication failed. Please run 'mux login' to re-authenticate."; + if (options.json) { + console.error(JSON.stringify({ error: message }, null, 2)); + } else { + console.error(`Error: ${message}`); + } + process.exit(1); + } + + // 404: could be a permission issue (Mux API returns 404 for missing scopes) + if (error instanceof NotFoundError) { + const required = getRequiredPermission(commandGroup, action); + + if (required) { + const tokenInfo = await fetchTokenInfo(); + + if (tokenInfo && isPermissionError(required, tokenInfo.permissions)) { + const message = formatPermissionError( + required, + tokenInfo.permissions, + tokenInfo.tokenName, + ); + if (options.json) { + console.error( + JSON.stringify( + { + error: 'permission_denied', + message: `Your API token does not have "${required}" permission.`, + token_permissions: tokenInfo.permissions, + required_permission: required, + docs: 'https://dashboard.mux.com/settings/access-tokens#create', + }, + null, + 2, + ), + ); + } else { + console.error(`Error: ${message}`); + } + process.exit(1); + } + } + } + + // Default: generic error formatting + const errorMessage = error instanceof Error ? error.message : String(error); + + if (options.json) { + console.error(JSON.stringify({ error: errorMessage }, null, 2)); + } else { + console.error(`Error: ${errorMessage}`); + } + process.exit(1); +} + +/** + * Check if a non-ok fetch response is a permission error. + * Returns a formatted error message if it is, or null if not. + */ +export async function checkFetchPermissionError( + response: Response, + commandGroup: string, + action: string, +): Promise { + if (response.status === 401) { + return "Authentication failed. Please run 'mux login' to re-authenticate."; + } + + if (response.status === 404 || response.status === 403) { + const required = getRequiredPermission(commandGroup, action); + + if (required) { + const tokenInfo = await fetchTokenInfo(); + + if (tokenInfo && isPermissionError(required, tokenInfo.permissions)) { + return formatPermissionError( + required, + tokenInfo.permissions, + tokenInfo.tokenName, + ); + } + } + } + + return null; +} + +/** + * Handle a non-ok fetch Response for raw fetch commands (not using the SDK). + * Checks for permission issues on 401, 404, and exits with a formatted error. + */ +export async function handleFetchResponseError( + response: Response, + commandGroup: string, + action: string, + options: { json?: boolean }, +): Promise { + const permError = await checkFetchPermissionError( + response, + commandGroup, + action, + ); + + if (permError) { + if (options.json) { + console.error(JSON.stringify({ error: permError }, null, 2)); + } else { + console.error(`Error: ${permError}`); + } + process.exit(1); + } + + // Generic error + const errorMessage = `${response.status} ${response.statusText}`; + if (options.json) { + console.error(JSON.stringify({ error: errorMessage }, null, 2)); + } else { + console.error(`Error: ${errorMessage}`); + } + process.exit(1); +} From 2068ed46a71f986d7341390f7f7fbcae2ad67eef Mon Sep 17 00:00:00 2001 From: Dylan Jhaveri Date: Thu, 19 Mar 2026 22:13:59 -0700 Subject: [PATCH 2/8] feat: better error messages around auth (404 handling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If you hit a 404 we will show this error now: ``` Error: Permission denied or this route does not exist Your token "{TOKEN_NAME}" has permissions: data:read You can create a new token at: https://dashboard.mux.com/settings/access-tokens Then run 'mux login' to authenticate with the new token. ``` Note this detail: As far as I can tell all 404s are all basically "Insufficient Privileges" - /assets/:wrong_id will return 400 for *any* value of :wrong_id ``` ❯ ./dist/mux assets get "HoGN5KDgq5hEdA98v33cWolrMCvSCLejgLBqI4yiA7k" Error: 401 {"error":{"type":"invalid_parameters","messages":["Invalid external asset ID, mismatching environment"]}} ``` If you somehow truly hit a nonexistant route: - GET /nonexistant-route - GET /assets/:id (but your token doesn't have permissions) Both of these return: ``` Error: 404 {"error":{"type":"not_found","messages":["The requested resource either doesn't exist or you don't have access to it."]}} ``` But with the CLI, it would be fairly hard (impossible?) to hit a "true" 404 So for all intents & purposes, we'll presume the 404 is a permission issue and tell you what permissions the current token has and how you can create a new one. --- src/commands/webhooks/listen.ts | 6 +- src/lib/errors.test.ts | 111 ++-------------- src/lib/errors.ts | 219 +++++--------------------------- 3 files changed, 44 insertions(+), 292 deletions(-) diff --git a/src/commands/webhooks/listen.ts b/src/commands/webhooks/listen.ts index 4b44c31..abeff1c 100644 --- a/src/commands/webhooks/listen.ts +++ b/src/commands/webhooks/listen.ts @@ -99,11 +99,7 @@ export const listenCommand = new Command() if (!response.ok) { // Check for permission issues before entering the reconnection loop. // Permission errors are not transient and should not be retried. - const permError = await checkFetchPermissionError( - response, - 'webhooks', - 'listen', - ); + const permError = await checkFetchPermissionError(response); if (permError) { if (options.json) { console.error(JSON.stringify({ error: permError })); diff --git a/src/lib/errors.test.ts b/src/lib/errors.test.ts index 94a82fe..e3e61b0 100644 --- a/src/lib/errors.test.ts +++ b/src/lib/errors.test.ts @@ -1,117 +1,24 @@ import { describe, expect, test } from 'bun:test'; -import { - formatPermissionError, - getRequiredPermission, - isPermissionError, -} from './errors.ts'; - -describe('getRequiredPermission', () => { - test('maps video read commands correctly', () => { - expect(getRequiredPermission('assets', 'get')).toBe('video:read'); - expect(getRequiredPermission('assets', 'list')).toBe('video:read'); - expect(getRequiredPermission('live', 'get')).toBe('video:read'); - expect(getRequiredPermission('live', 'list')).toBe('video:read'); - expect(getRequiredPermission('uploads', 'get')).toBe('video:read'); - expect(getRequiredPermission('delivery-usage', 'list')).toBe('video:read'); - }); - - test('maps video write commands correctly', () => { - expect(getRequiredPermission('assets', 'create')).toBe('video:write'); - expect(getRequiredPermission('assets', 'update')).toBe('video:write'); - expect(getRequiredPermission('assets', 'delete')).toBe('video:write'); - expect(getRequiredPermission('live', 'create')).toBe('video:write'); - expect(getRequiredPermission('live', 'enable')).toBe('video:write'); - expect(getRequiredPermission('uploads', 'create')).toBe('video:write'); - }); - - test('maps data read commands correctly', () => { - expect(getRequiredPermission('metrics', 'list')).toBe('data:read'); - expect(getRequiredPermission('video-views', 'get')).toBe('data:read'); - expect(getRequiredPermission('monitoring', 'breakdown')).toBe('data:read'); - expect(getRequiredPermission('incidents', 'list')).toBe('data:read'); - expect(getRequiredPermission('dimensions', 'list')).toBe('data:read'); - expect(getRequiredPermission('errors', 'list')).toBe('data:read'); - expect(getRequiredPermission('exports', 'list')).toBe('data:read'); - expect(getRequiredPermission('annotations', 'get')).toBe('data:read'); - }); - - test('maps data write commands correctly', () => { - expect(getRequiredPermission('annotations', 'create')).toBe('data:write'); - expect(getRequiredPermission('annotations', 'update')).toBe('data:write'); - expect(getRequiredPermission('annotations', 'delete')).toBe('data:write'); - }); - - test('maps system commands correctly', () => { - expect(getRequiredPermission('webhooks', 'listen')).toBe('system:read'); - expect(getRequiredPermission('signing-keys', 'get')).toBe('system:read'); - expect(getRequiredPermission('signing-keys', 'list')).toBe('system:read'); - expect(getRequiredPermission('signing-keys', 'create')).toBe( - 'system:write', - ); - expect(getRequiredPermission('signing-keys', 'delete')).toBe( - 'system:write', - ); - }); - - test('returns undefined for unknown commands', () => { - expect(getRequiredPermission('unknown', 'get')).toBeUndefined(); - }); -}); - -describe('isPermissionError', () => { - test('detects permission issue when token lacks required scope', () => { - expect(isPermissionError('video:read', ['data:read'])).toBe(true); - expect(isPermissionError('video:read', ['system:read'])).toBe(true); - expect(isPermissionError('data:read', ['video:read'])).toBe(true); - }); - - test('returns false when token has the exact permission', () => { - expect(isPermissionError('video:read', ['video:read'])).toBe(false); - expect(isPermissionError('data:read', ['data:read'])).toBe(false); - }); - - test('returns false when token has write and read is required', () => { - expect(isPermissionError('video:read', ['video:write'])).toBe(false); - }); - - test('detects when token has read but write is required', () => { - expect(isPermissionError('video:write', ['video:read'])).toBe(true); - }); - - test('handles tokens with multiple permissions', () => { - expect(isPermissionError('data:read', ['video:read', 'data:read'])).toBe( - false, - ); - expect(isPermissionError('system:read', ['video:read', 'data:read'])).toBe( - true, - ); - }); -}); +import { formatPermissionError } from './errors.ts'; describe('formatPermissionError', () => { - test('includes required permission and token permissions', () => { - const result = formatPermissionError('video:read', [ - 'data:read', - 'system:read', - ]); - expect(result).toContain('video:read'); + test('includes token permissions and action items', () => { + const result = formatPermissionError(['data:read', 'system:read']); + expect(result).toContain('Permission denied or this route does not exist'); expect(result).toContain('data:read, system:read'); expect(result).toContain('dashboard.mux.com'); expect(result).toContain('mux login'); }); test('includes token name when provided', () => { - const result = formatPermissionError( - 'video:read', - ['data:read'], - 'My Token', - ); - expect(result).toContain('My Token'); + const result = formatPermissionError(['data:read'], 'My Token'); + expect(result).toContain('"My Token"'); + expect(result).toContain('data:read'); }); test('formats without token name when not provided', () => { - const result = formatPermissionError('video:read', ['data:read']); - expect(result).toContain('Your token has'); + const result = formatPermissionError(['data:read']); + expect(result).toContain('Your token has permissions:'); expect(result).not.toContain('undefined'); }); }); diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 474d0f2..4b144d6 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,123 +1,26 @@ import { AuthenticationError, NotFoundError } from '@mux/mux-node'; import { getAuthHeaders, getMuxBaseUrl } from './mux.ts'; -// Read-only operations -const READ_ACTIONS = new Set([ - 'get', - 'list', - 'retrieve', - 'input-info', - 'related', - 'breakdown', - 'timeseries', - 'overall', - 'insights', - 'values', - 'metrics', - 'dimensions', - 'breakdown-timeseries', - 'histogram-timeseries', - 'listen', -]); - -// Command groups to their permission scope -const VIDEO_COMMANDS = new Set([ - 'assets', - 'live', - 'uploads', - 'playback-ids', - 'playback-restrictions', - 'transcription-vocabularies', - 'drm-configurations', - 'delivery-usage', -]); - -const DATA_COMMANDS = new Set([ - 'metrics', - 'video-views', - 'monitoring', - 'incidents', - 'dimensions', - 'errors', - 'exports', - 'annotations', -]); - -const SYSTEM_COMMANDS = new Set(['signing-keys', 'webhooks']); - -/** - * Get the required permission for a command. - * Returns the permission string (e.g., "video:read") or undefined if unknown. - */ -export function getRequiredPermission( - commandGroup: string, - action: string, -): string | undefined { - let scope: string | undefined; - - if (VIDEO_COMMANDS.has(commandGroup)) { - scope = 'video'; - } else if (DATA_COMMANDS.has(commandGroup)) { - scope = 'data'; - } else if (SYSTEM_COMMANDS.has(commandGroup)) { - scope = 'system'; - } - - if (!scope) return undefined; - - const level = READ_ACTIONS.has(action) ? 'read' : 'write'; - return `${scope}:${level}`; -} - -/** - * Check if a required permission is missing from the token's permissions. - * Write permission implies read permission for the same scope. - */ -export function isPermissionError( - required: string, - tokenPermissions: string[], -): boolean { - const [requiredScope, requiredLevel] = required.split(':'); - - for (const perm of tokenPermissions) { - const [scope, level] = perm.split(':'); - if (scope !== requiredScope) continue; - - // write implies read - if (requiredLevel === 'read' && (level === 'read' || level === 'write')) { - return false; - } - if (requiredLevel === 'write' && level === 'write') { - return false; - } - } - - return true; -} - /** * Format a permission error message for display. */ export function formatPermissionError( - required: string, tokenPermissions: string[], tokenName?: string, ): string { - const lines = [ - `Permission denied. Your API token does not have "${required}" permission.`, - '', - ]; + const lines = ['Permission denied or this route does not exist.', '']; if (tokenName) { - lines.push(`Your token "${tokenName}" has: ${tokenPermissions.join(', ')}`); + lines.push( + `Your token "${tokenName}" has permissions: ${tokenPermissions.join(', ')}`, + ); } else { - lines.push(`Your token has: ${tokenPermissions.join(', ')}`); + lines.push(`Your token has permissions: ${tokenPermissions.join(', ')}`); } lines.push(''); - lines.push('Create a new access token with the required permissions at:'); + lines.push('You can create a new token at:'); lines.push('https://dashboard.mux.com/settings/access-tokens'); - lines.push(''); lines.push("Then run 'mux login' to authenticate with the new token."); return lines.join('\n'); @@ -158,14 +61,13 @@ async function fetchTokenInfo(): Promise<{ /** * Handle errors from Mux API commands. * - * On NotFoundError (404), checks whether the token lacks the required - * permission (since the Mux API returns 404, not 403, for scope issues). - * Falls back to the standard error message if permissions are fine. + * On NotFoundError (404), fetches token permissions via /whoami and displays + * them alongside the error, since the Mux API returns 404 for scope issues. */ export async function handleCommandError( error: unknown, - commandGroup: string, - action: string, + _commandGroup: string, + _action: string, options: { json?: boolean }, ): Promise { // 401: invalid/expired credentials @@ -182,36 +84,29 @@ export async function handleCommandError( // 404: could be a permission issue (Mux API returns 404 for missing scopes) if (error instanceof NotFoundError) { - const required = getRequiredPermission(commandGroup, action); - - if (required) { - const tokenInfo = await fetchTokenInfo(); - - if (tokenInfo && isPermissionError(required, tokenInfo.permissions)) { - const message = formatPermissionError( - required, - tokenInfo.permissions, - tokenInfo.tokenName, + const tokenInfo = await fetchTokenInfo(); + + if (tokenInfo) { + const message = formatPermissionError( + tokenInfo.permissions, + tokenInfo.tokenName, + ); + if (options.json) { + console.error( + JSON.stringify( + { + error: 'not_found_or_permission_denied', + token_permissions: tokenInfo.permissions, + docs: 'https://dashboard.mux.com/settings/access-tokens', + }, + null, + 2, + ), ); - if (options.json) { - console.error( - JSON.stringify( - { - error: 'permission_denied', - message: `Your API token does not have "${required}" permission.`, - token_permissions: tokenInfo.permissions, - required_permission: required, - docs: 'https://dashboard.mux.com/settings/access-tokens#create', - }, - null, - 2, - ), - ); - } else { - console.error(`Error: ${message}`); - } - process.exit(1); + } else { + console.error(`Error: ${message}`); } + process.exit(1); } } @@ -232,63 +127,17 @@ export async function handleCommandError( */ export async function checkFetchPermissionError( response: Response, - commandGroup: string, - action: string, ): Promise { if (response.status === 401) { return "Authentication failed. Please run 'mux login' to re-authenticate."; } if (response.status === 404 || response.status === 403) { - const required = getRequiredPermission(commandGroup, action); - - if (required) { - const tokenInfo = await fetchTokenInfo(); - - if (tokenInfo && isPermissionError(required, tokenInfo.permissions)) { - return formatPermissionError( - required, - tokenInfo.permissions, - tokenInfo.tokenName, - ); - } + const tokenInfo = await fetchTokenInfo(); + if (tokenInfo) { + return formatPermissionError(tokenInfo.permissions, tokenInfo.tokenName); } } return null; } - -/** - * Handle a non-ok fetch Response for raw fetch commands (not using the SDK). - * Checks for permission issues on 401, 404, and exits with a formatted error. - */ -export async function handleFetchResponseError( - response: Response, - commandGroup: string, - action: string, - options: { json?: boolean }, -): Promise { - const permError = await checkFetchPermissionError( - response, - commandGroup, - action, - ); - - if (permError) { - if (options.json) { - console.error(JSON.stringify({ error: permError }, null, 2)); - } else { - console.error(`Error: ${permError}`); - } - process.exit(1); - } - - // Generic error - const errorMessage = `${response.status} ${response.statusText}`; - if (options.json) { - console.error(JSON.stringify({ error: errorMessage }, null, 2)); - } else { - console.error(`Error: ${errorMessage}`); - } - process.exit(1); -} From 9ece5406386cb9647be72d4dc65a887fe0d9d3f0 Mon Sep 17 00:00:00 2001 From: Dylan Jhaveri Date: Thu, 19 Mar 2026 22:34:19 -0700 Subject: [PATCH 3/8] stop showing update notifier in dev --- src/lib/update-notifier.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/lib/update-notifier.ts b/src/lib/update-notifier.ts index 75ed4a2..efeed41 100644 --- a/src/lib/update-notifier.ts +++ b/src/lib/update-notifier.ts @@ -80,11 +80,13 @@ export async function fetchLatestVersion(): Promise { */ export function detectInstallMethod( execPath?: string, -): 'homebrew' | 'npm' | 'shell' | 'unknown' { +): 'homebrew' | 'npm' | 'shell' | 'dev' | 'unknown' { const p = execPath ?? process.argv[0]; if (p.includes('/Cellar/') || p.includes('/homebrew/')) return 'homebrew'; if (p.includes('node_modules')) return 'npm'; if (p.includes('.mux/bin')) return 'shell'; + // Local development builds (e.g., ./dist/mux, bun run src/index.ts) + if (p.includes('/dist/') || p.includes('/src/')) return 'dev'; return 'unknown'; } @@ -92,7 +94,7 @@ export function detectInstallMethod( * Get the appropriate upgrade command for the install method. */ export function getUpgradeCommand( - method: 'homebrew' | 'npm' | 'shell' | 'unknown', + method: 'homebrew' | 'npm' | 'shell' | 'dev' | 'unknown', ): string { switch (method) { case 'homebrew': @@ -171,6 +173,9 @@ export async function checkForUpdate( const method = detectInstallMethod(options?.execPath); + // Skip update notices for local development builds + if (method === 'dev') return null; + // Give Homebrew formulae time to update before notifying if (method === 'homebrew' && Date.now() - firstSeenAt < HOMEBREW_DELAY_MS) { return null; From 1372b147b863d99d175bbb655dd55078c3124543 Mon Sep 17 00:00:00 2001 From: Dylan Jhaveri Date: Fri, 20 Mar 2026 09:30:00 -0700 Subject: [PATCH 4/8] feat: include API response body in permission error messages for better debugging --- src/lib/errors.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 4b144d6..53942b7 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -7,8 +7,13 @@ import { getAuthHeaders, getMuxBaseUrl } from './mux.ts'; export function formatPermissionError( tokenPermissions: string[], tokenName?: string, + apiResponseBody?: unknown, ): string { - const lines = ['Permission denied or this route does not exist.', '']; + const lines = ['Permission denied or this route does not exist.']; + if (apiResponseBody) { + lines.push(JSON.stringify(apiResponseBody)); + } + lines.push(''); if (tokenName) { lines.push( @@ -90,12 +95,14 @@ export async function handleCommandError( const message = formatPermissionError( tokenInfo.permissions, tokenInfo.tokenName, + error.error, ); if (options.json) { console.error( JSON.stringify( { error: 'not_found_or_permission_denied', + api_response: error.error, token_permissions: tokenInfo.permissions, docs: 'https://dashboard.mux.com/settings/access-tokens', }, From 70fa0df18348dcd83f11b05d7bb9eefdd3cb3545 Mon Sep 17 00:00:00 2001 From: Dylan Jhaveri Date: Fri, 20 Mar 2026 09:40:42 -0700 Subject: [PATCH 5/8] a better update avail check - hardcode 0.0.0 in dev package.json - (real version gets published based on GH tag) - surpress the update avail check in dev based on the exact 0.0.0 version --- package.json | 2 +- src/index.ts | 11 +++++++---- src/lib/update-notifier.ts | 11 ++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index c135910..c2475d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mux/cli", - "version": "1.0.0", + "version": "0.0.0", "description": "Official Mux CLI for interacting with Mux APIs", "author": "Mux", "license": "Apache-2.0", diff --git a/src/index.ts b/src/index.ts index bb06c8d..8e29e90 100644 --- a/src/index.ts +++ b/src/index.ts @@ -90,7 +90,13 @@ const cli = new Command() // Run the CLI if (import.meta.main) { - const updateCheck = checkForUpdate(VERSION).catch(() => null); + // Resolve the update check early so the notice is available on exit. + // This reads from a local cache (fast) or fetches with a 3s timeout. + const updateNotice = await checkForUpdate(VERSION).catch(() => null); + + process.on('exit', () => { + if (updateNotice) console.error(updateNotice); + }); try { await cli.parse(preprocessArgs(Bun.argv.slice(2))); @@ -102,9 +108,6 @@ if (import.meta.main) { } process.exit(1); } - - const notice = await updateCheck; - if (notice) console.error(notice); } export { cli }; diff --git a/src/lib/update-notifier.ts b/src/lib/update-notifier.ts index efeed41..e9dc8fe 100644 --- a/src/lib/update-notifier.ts +++ b/src/lib/update-notifier.ts @@ -80,13 +80,11 @@ export async function fetchLatestVersion(): Promise { */ export function detectInstallMethod( execPath?: string, -): 'homebrew' | 'npm' | 'shell' | 'dev' | 'unknown' { +): 'homebrew' | 'npm' | 'shell' | 'unknown' { const p = execPath ?? process.argv[0]; if (p.includes('/Cellar/') || p.includes('/homebrew/')) return 'homebrew'; if (p.includes('node_modules')) return 'npm'; if (p.includes('.mux/bin')) return 'shell'; - // Local development builds (e.g., ./dist/mux, bun run src/index.ts) - if (p.includes('/dist/') || p.includes('/src/')) return 'dev'; return 'unknown'; } @@ -94,7 +92,7 @@ export function detectInstallMethod( * Get the appropriate upgrade command for the install method. */ export function getUpgradeCommand( - method: 'homebrew' | 'npm' | 'shell' | 'dev' | 'unknown', + method: 'homebrew' | 'npm' | 'shell' | 'unknown', ): string { switch (method) { case 'homebrew': @@ -137,6 +135,8 @@ export async function checkForUpdate( currentVersion: string, options?: CheckForUpdateOptions, ): Promise { + // Skip for local development builds (version is 0.0.0 until publish) + if (currentVersion === '0.0.0') return null; // Skip in non-interactive environments if (process.env.CI) return null; if (process.env.MUX_NO_UPDATE_CHECK) return null; @@ -173,9 +173,6 @@ export async function checkForUpdate( const method = detectInstallMethod(options?.execPath); - // Skip update notices for local development builds - if (method === 'dev') return null; - // Give Homebrew formulae time to update before notifying if (method === 'homebrew' && Date.now() - firstSeenAt < HOMEBREW_DELAY_MS) { return null; From cbeb03b0ecde21a3fea94ab25f3ad2f442104094 Mon Sep 17 00:00:00 2001 From: Dylan Jhaveri Date: Fri, 20 Mar 2026 09:54:02 -0700 Subject: [PATCH 6/8] fix: exit on permission errors and handle 403 responses without retry --- src/commands/webhooks/listen.ts | 2 +- src/lib/errors.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/commands/webhooks/listen.ts b/src/commands/webhooks/listen.ts index abeff1c..b1809f4 100644 --- a/src/commands/webhooks/listen.ts +++ b/src/commands/webhooks/listen.ts @@ -106,7 +106,7 @@ export const listenCommand = new Command() } else { console.error(`Error: ${permError}`); } - return; + process.exit(1); } throw new Error( `Server returned ${response.status} ${response.statusText}`, diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 53942b7..731f8a7 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -144,6 +144,11 @@ export async function checkFetchPermissionError( if (tokenInfo) { return formatPermissionError(tokenInfo.permissions, tokenInfo.tokenName); } + // Even if /whoami fails, 403 is never transient — don't return null + // and let it enter a retry loop. + if (response.status === 403) { + return "Permission denied. Please check your token's permissions at:\nhttps://dashboard.mux.com/settings/access-tokens"; + } } return null; From 334a17c677f60ad8ddc962091cc9aa220ba50aee Mon Sep 17 00:00:00 2001 From: Dylan Jhaveri Date: Fri, 20 Mar 2026 09:58:59 -0700 Subject: [PATCH 7/8] better handling of webhook event replays - no longer an option to replay --all (we have no limits on how many events are stored, so that's insane) - you can say `webhooks events replay ` OR - `webhooks events replay --count 10` which will replay the last 10 events in chronological order --- src/commands/webhooks/events/replay.ts | 148 +++++++++++++------------ src/lib/events-store.ts | 14 +++ 2 files changed, 89 insertions(+), 73 deletions(-) diff --git a/src/commands/webhooks/events/replay.ts b/src/commands/webhooks/events/replay.ts index 5ec185a..0dc1f7c 100644 --- a/src/commands/webhooks/events/replay.ts +++ b/src/commands/webhooks/events/replay.ts @@ -1,7 +1,7 @@ import { colors } from '@cliffy/ansi/colors'; import { Command } from '@cliffy/command'; import { getCurrentEnvironment, updateEnvironment } from '@/lib/config.ts'; -import { getAllEvents, getEventById } from '@/lib/events-store.ts'; +import { getEventById, getRecentEvents } from '@/lib/events-store.ts'; import { buildSignedHeaders, getSigningSecretForCurrentEnv, @@ -9,7 +9,7 @@ import { interface ReplayOptions { forwardTo?: string; - all?: boolean; + count?: number; json?: boolean; } @@ -31,19 +31,19 @@ export const replayCommand = new Command() .description('Replay stored webhook events') .arguments('[event-id:string]') .option('--forward-to ', 'POST event(s) to a local URL') - .option('--all', 'Replay all stored events') + .option('--count ', 'Replay the last N events') .option('--json', 'Output JSON instead of pretty format') .action(async (options: ReplayOptions, eventId?: string) => { try { - if (!eventId && !options.all) { + if (!eventId && !options.count) { console.error( - 'Provide an event ID or use --all to replay all stored events.', + 'Provide an event ID or use --count to replay the last N events.', ); process.exit(1); } - if (eventId && options.all) { - console.error('Cannot use both an event ID and --all.'); + if (eventId && options.count) { + console.error('Cannot use both an event ID and --count.'); process.exit(1); } @@ -69,97 +69,99 @@ export const replayCommand = new Command() }); } - if (options.all) { - const events = getAllEvents(environmentId); - if (events.length === 0) { - console.log('No stored events to replay.'); - return; + // Single event replay by ID + if (eventId) { + const event = getEventById(eventId, environmentId); + if (!event) { + console.error(`Event not found: ${eventId}`); + process.exit(1); } if (!options.forwardTo) { - if (options.json) { - console.log(JSON.stringify(events, null, 2)); - } else { - for (const event of events) { - console.log(JSON.stringify(event.payload, null, 2)); - } - } + console.log(JSON.stringify(event.payload, null, 2)); return; } const signingSecret = await getSigningSecretForCurrentEnv(); - - let forwarded = 0; - let failed = 0; - for (const event of events) { - try { - const { status } = await forwardEvent( - options.forwardTo, - event.payload, - signingSecret, - ); - if (status >= 200 && status < 300) { - forwarded++; - if (!options.json) { - console.log( - `${colors.green(`[${status}]`)} ${event.type.padEnd(30)} ${event.id}`, - ); - } - } else { - failed++; - if (!options.json) { - console.log( - `${colors.red(`[${status}]`)} ${event.type.padEnd(30)} ${event.id}`, - ); - } - } - } catch { - failed++; - if (!options.json) { - console.log( - `${colors.red('[ERR]')} ${event.type.padEnd(30)} ${event.id}`, - ); - } - } - } - + const { status } = await forwardEvent( + options.forwardTo, + event.payload, + signingSecret, + ); if (options.json) { - console.log(JSON.stringify({ forwarded, failed }, null, 2)); + console.log(JSON.stringify({ status, eventId: event.id }, null, 2)); + } else if (status >= 200 && status < 300) { + console.log( + `${colors.green(`[${status}]`)} Forwarded ${event.type} (${event.id}) to ${options.forwardTo}`, + ); } else { console.log( - `\nReplayed ${forwarded + failed} events: ${forwarded} forwarded, ${failed} failed.`, + `${colors.red(`[${status}]`)} Failed to forward ${event.type} (${event.id}) to ${options.forwardTo}`, ); } return; } - // Single event replay - const event = getEventById(eventId as string, environmentId); - if (!event) { - console.error(`Event not found: ${eventId}`); - process.exit(1); + // Replay last N events + const count = options.count as number; + const events = getRecentEvents(environmentId, count); + if (events.length === 0) { + console.log('No stored events to replay.'); + return; } if (!options.forwardTo) { - console.log(JSON.stringify(event.payload, null, 2)); + if (options.json) { + console.log(JSON.stringify(events, null, 2)); + } else { + for (const event of events) { + console.log(JSON.stringify(event.payload, null, 2)); + } + } return; } const signingSecret = await getSigningSecretForCurrentEnv(); - const { status } = await forwardEvent( - options.forwardTo, - event.payload, - signingSecret, - ); + + let forwarded = 0; + let failed = 0; + for (const event of events) { + try { + const { status } = await forwardEvent( + options.forwardTo, + event.payload, + signingSecret, + ); + if (status >= 200 && status < 300) { + forwarded++; + if (!options.json) { + console.log( + `${colors.green(`[${status}]`)} ${event.type.padEnd(30)} ${event.id}`, + ); + } + } else { + failed++; + if (!options.json) { + console.log( + `${colors.red(`[${status}]`)} ${event.type.padEnd(30)} ${event.id}`, + ); + } + } + } catch { + failed++; + if (!options.json) { + console.log( + `${colors.red('[ERR]')} ${event.type.padEnd(30)} ${event.id}`, + ); + } + } + } + if (options.json) { - console.log(JSON.stringify({ status, eventId: event.id }, null, 2)); - } else if (status >= 200 && status < 300) { - console.log( - `${colors.green(`[${status}]`)} Forwarded ${event.type} (${event.id}) to ${options.forwardTo}`, - ); + console.log(JSON.stringify({ forwarded, failed }, null, 2)); } else { console.log( - `${colors.red(`[${status}]`)} Failed to forward ${event.type} (${event.id}) to ${options.forwardTo}`, + `\nReplayed ${forwarded + failed} events: ${forwarded} forwarded, ${failed} failed.`, ); } } catch (error) { diff --git a/src/lib/events-store.ts b/src/lib/events-store.ts index 3a0d5c7..0f42cd5 100644 --- a/src/lib/events-store.ts +++ b/src/lib/events-store.ts @@ -108,6 +108,20 @@ export function getAllEvents(environmentId: string): StoredEvent[] { return rows.map(rowToEvent); } +export function getRecentEvents( + environmentId: string, + count: number, +): StoredEvent[] { + const database = getDb(); + const rows = database + .query( + 'SELECT id, type, timestamp, environment_id, payload FROM events WHERE environment_id = ? ORDER BY timestamp DESC LIMIT ?', + ) + .all(environmentId, count) as EventRow[]; + // Reverse to return in chronological order + return rows.reverse().map(rowToEvent); +} + export function getEventTypes(environmentId: string): string[] { const database = getDb(); const rows = database From 5a230d80b7a455faf1a9369f4ed37311ecd8d904 Mon Sep 17 00:00:00 2001 From: Dylan Jhaveri Date: Fri, 20 Mar 2026 15:45:34 -0700 Subject: [PATCH 8/8] refactor: split update checking into cache-only check and background refresh --- src/index.ts | 7 +- src/lib/update-notifier.test.ts | 222 +++++++++++++++++++++----------- src/lib/update-notifier.ts | 57 ++++---- 3 files changed, 179 insertions(+), 107 deletions(-) diff --git a/src/index.ts b/src/index.ts index 8e29e90..7410d46 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,7 +26,7 @@ import { videoViewsCommand } from './commands/video-views/index.ts'; import { webhooksCommand } from './commands/webhooks/index.ts'; import { whoamiCommand } from './commands/whoami.ts'; import { setAgentMode } from './lib/context.ts'; -import { checkForUpdate } from './lib/update-notifier.ts'; +import { checkForUpdate, refreshUpdateCache } from './lib/update-notifier.ts'; const VERSION = pkg.version; @@ -90,9 +90,10 @@ const cli = new Command() // Run the CLI if (import.meta.main) { - // Resolve the update check early so the notice is available on exit. - // This reads from a local cache (fast) or fetches with a 3s timeout. + // Read cached update info (no network, instant) for the exit notice. + // Refresh the cache in the background so the next run has fresh data. const updateNotice = await checkForUpdate(VERSION).catch(() => null); + refreshUpdateCache().catch(() => {}); process.on('exit', () => { if (updateNotice) console.error(updateNotice); diff --git a/src/lib/update-notifier.test.ts b/src/lib/update-notifier.test.ts index e687137..577fd5c 100644 --- a/src/lib/update-notifier.test.ts +++ b/src/lib/update-notifier.test.ts @@ -10,6 +10,7 @@ import { formatUpdateNotice, getUpgradeCommand, readUpdateCache, + refreshUpdateCache, type UpdateCache, writeUpdateCache, } from './update-notifier.ts'; @@ -223,10 +224,9 @@ describe('update-notifier', () => { }); }); - describe('checkForUpdate', () => { + describe('checkForUpdate (cache-only)', () => { let testCacheDir: string; let originalXdgCacheHome: string | undefined; - let originalFetch: typeof globalThis.fetch; let originalCI: string | undefined; let originalNoUpdateCheck: string | undefined; @@ -235,7 +235,6 @@ describe('update-notifier', () => { originalXdgCacheHome = process.env.XDG_CACHE_HOME; process.env.XDG_CACHE_HOME = testCacheDir; - originalFetch = globalThis.fetch; originalCI = process.env.CI; originalNoUpdateCheck = process.env.MUX_NO_UPDATE_CHECK; @@ -250,8 +249,6 @@ describe('update-notifier', () => { process.env.XDG_CACHE_HOME = originalXdgCacheHome; } - globalThis.fetch = originalFetch; - if (originalCI === undefined) { delete process.env.CI; } else { @@ -280,66 +277,142 @@ describe('update-notifier', () => { }); it('should return null when not a TTY', async () => { - globalThis.fetch = mock(() => - Promise.resolve( - new Response(JSON.stringify({ version: '2.0.0' }), { status: 200 }), - ), - ) as unknown as typeof fetch; const result = await checkForUpdate('1.0.0', { isTTY: false }); expect(result).toBeNull(); }); - it('should return notice when newer version is available', async () => { - globalThis.fetch = mock(() => - Promise.resolve( - new Response(JSON.stringify({ version: '2.0.0' }), { status: 200 }), - ), - ) as unknown as typeof fetch; + it('should return null when no cache exists', async () => { + const result = await checkForUpdate('1.0.0', { isTTY: true }); + expect(result).toBeNull(); + }); + + it('should return notice when cache has newer version', async () => { + await writeUpdateCache({ + latestVersion: '2.0.0', + lastChecked: Date.now(), + firstSeenAt: Date.now() - 49 * 60 * 60 * 1000, + }); const result = await checkForUpdate('1.0.0', { isTTY: true }); expect(result).not.toBeNull(); expect(result).toContain('2.0.0'); }); - it('should return null when already on latest version', async () => { + it('should return null when cache version equals current', async () => { + await writeUpdateCache({ + latestVersion: '1.0.0', + lastChecked: Date.now(), + firstSeenAt: Date.now(), + }); + const result = await checkForUpdate('1.0.0', { isTTY: true }); + expect(result).toBeNull(); + }); + + it('should suppress notification for Homebrew when version is recent', async () => { + await writeUpdateCache({ + latestVersion: '2.0.0', + lastChecked: Date.now(), + firstSeenAt: Date.now(), // just discovered + }); + const result = await checkForUpdate('1.0.0', { + isTTY: true, + execPath: '/opt/homebrew/bin/mux', + }); + expect(result).toBeNull(); + }); + + it('should show notification for Homebrew when version is old enough', async () => { + await writeUpdateCache({ + latestVersion: '2.0.0', + lastChecked: Date.now(), + firstSeenAt: Date.now() - 49 * 60 * 60 * 1000, // 49 hours ago + }); + const result = await checkForUpdate('1.0.0', { + isTTY: true, + execPath: '/opt/homebrew/bin/mux', + }); + expect(result).not.toBeNull(); + expect(result).toContain('2.0.0'); + }); + + it('should not apply Homebrew delay for npm installs', async () => { + await writeUpdateCache({ + latestVersion: '2.0.0', + lastChecked: Date.now(), + firstSeenAt: Date.now(), // just discovered, but npm — no delay + }); + const result = await checkForUpdate('1.0.0', { + isTTY: true, + execPath: '/usr/local/lib/node_modules/@mux/cli/bin/mux', + }); + expect(result).not.toBeNull(); + expect(result).toContain('2.0.0'); + }); + }); + + describe('refreshUpdateCache', () => { + let testCacheDir: string; + let originalXdgCacheHome: string | undefined; + let originalFetch: typeof globalThis.fetch; + + beforeEach(async () => { + testCacheDir = join(tmpdir(), `mux-cli-test-cache-${Date.now()}`); + originalXdgCacheHome = process.env.XDG_CACHE_HOME; + process.env.XDG_CACHE_HOME = testCacheDir; + originalFetch = globalThis.fetch; + }); + + afterEach(async () => { + if (originalXdgCacheHome === undefined) { + delete process.env.XDG_CACHE_HOME; + } else { + process.env.XDG_CACHE_HOME = originalXdgCacheHome; + } + globalThis.fetch = originalFetch; + await rm(testCacheDir, { recursive: true, force: true }); + }); + + it('should fetch and write cache when no cache exists', async () => { globalThis.fetch = mock(() => Promise.resolve( - new Response(JSON.stringify({ version: '1.0.0' }), { status: 200 }), + new Response(JSON.stringify({ version: '2.0.0' }), { status: 200 }), ), ) as unknown as typeof fetch; - const result = await checkForUpdate('1.0.0', { isTTY: true }); - expect(result).toBeNull(); + + await refreshUpdateCache(); + + const cache = await readUpdateCache(); + expect(cache).not.toBeNull(); + expect(cache?.latestVersion).toBe('2.0.0'); }); - it('should use cached version when cache is fresh', async () => { - const now = Date.now(); - const cache: UpdateCache = { - latestVersion: '3.0.0', - lastChecked: now, - firstSeenAt: now - 49 * 60 * 60 * 1000, // 49 hours ago (past Homebrew delay) - }; - await writeUpdateCache(cache); + it('should skip fetch when cache is fresh', async () => { + await writeUpdateCache({ + latestVersion: '2.0.0', + lastChecked: Date.now(), + firstSeenAt: Date.now(), + }); - // fetch should NOT be called const fetchMock = mock(() => Promise.resolve( - new Response(JSON.stringify({ version: '4.0.0' }), { status: 200 }), + new Response(JSON.stringify({ version: '3.0.0' }), { status: 200 }), ), ) as unknown as typeof fetch; globalThis.fetch = fetchMock; - const result = await checkForUpdate('1.0.0', { isTTY: true }); - expect(result).toContain('3.0.0'); // cached version, not 4.0.0 + await refreshUpdateCache(); + expect(fetchMock).not.toHaveBeenCalled(); + const cache = await readUpdateCache(); + expect(cache?.latestVersion).toBe('2.0.0'); }); it('should fetch when cache is stale', async () => { - const staleTime = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago - const cache: UpdateCache = { + const staleTime = Date.now() - 25 * 60 * 60 * 1000; + await writeUpdateCache({ latestVersion: '2.0.0', lastChecked: staleTime, firstSeenAt: staleTime, - }; - await writeUpdateCache(cache); + }); globalThis.fetch = mock(() => Promise.resolve( @@ -347,68 +420,63 @@ describe('update-notifier', () => { ), ) as unknown as typeof fetch; - const result = await checkForUpdate('1.0.0', { isTTY: true }); - expect(result).toContain('3.0.0'); // fetched version - }); + await refreshUpdateCache(); - it('should return null when fetch fails and no cache exists', async () => { - globalThis.fetch = mock(() => - Promise.reject(new Error('network error')), - ) as unknown as typeof fetch; - const result = await checkForUpdate('1.0.0', { isTTY: true }); - expect(result).toBeNull(); + const cache = await readUpdateCache(); + expect(cache?.latestVersion).toBe('3.0.0'); }); - it('should suppress notification for Homebrew when version is recent', async () => { + it('should preserve firstSeenAt when version is unchanged', async () => { + const originalFirstSeen = Date.now() - 10 * 60 * 60 * 1000; + const staleTime = Date.now() - 25 * 60 * 60 * 1000; + await writeUpdateCache({ + latestVersion: '2.0.0', + lastChecked: staleTime, + firstSeenAt: originalFirstSeen, + }); + globalThis.fetch = mock(() => Promise.resolve( new Response(JSON.stringify({ version: '2.0.0' }), { status: 200 }), ), ) as unknown as typeof fetch; - const result = await checkForUpdate('1.0.0', { - isTTY: true, - execPath: '/opt/homebrew/bin/mux', - }); - // Version was just discovered (firstSeenAt = now), so Homebrew delay kicks in - expect(result).toBeNull(); + + await refreshUpdateCache(); + + const cache = await readUpdateCache(); + expect(cache?.firstSeenAt).toBe(originalFirstSeen); }); - it('should show notification for Homebrew when version is old enough', async () => { - const oldTime = Date.now() - 49 * 60 * 60 * 1000; // 49 hours ago - const cache: UpdateCache = { + it('should reset firstSeenAt when version changes', async () => { + const staleTime = Date.now() - 25 * 60 * 60 * 1000; + await writeUpdateCache({ latestVersion: '2.0.0', - lastChecked: Date.now(), - firstSeenAt: oldTime, - }; - await writeUpdateCache(cache); + lastChecked: staleTime, + firstSeenAt: staleTime, + }); globalThis.fetch = mock(() => Promise.resolve( - new Response(JSON.stringify({ version: '2.0.0' }), { status: 200 }), + new Response(JSON.stringify({ version: '3.0.0' }), { status: 200 }), ), ) as unknown as typeof fetch; - const result = await checkForUpdate('1.0.0', { - isTTY: true, - execPath: '/opt/homebrew/bin/mux', - }); - expect(result).not.toBeNull(); - expect(result).toContain('2.0.0'); + const before = Date.now(); + await refreshUpdateCache(); + + const cache = await readUpdateCache(); + expect(cache?.firstSeenAt).toBeGreaterThanOrEqual(before); }); - it('should not apply Homebrew delay for npm installs', async () => { + it('should not write cache when fetch fails', async () => { globalThis.fetch = mock(() => - Promise.resolve( - new Response(JSON.stringify({ version: '2.0.0' }), { status: 200 }), - ), + Promise.reject(new Error('network error')), ) as unknown as typeof fetch; - const result = await checkForUpdate('1.0.0', { - isTTY: true, - execPath: '/usr/local/lib/node_modules/@mux/cli/bin/mux', - }); - // npm install — no delay, should show notice immediately - expect(result).not.toBeNull(); - expect(result).toContain('2.0.0'); + + await refreshUpdateCache(); + + const cache = await readUpdateCache(); + expect(cache).toBeNull(); }); }); }); diff --git a/src/lib/update-notifier.ts b/src/lib/update-notifier.ts index e9dc8fe..92034f5 100644 --- a/src/lib/update-notifier.ts +++ b/src/lib/update-notifier.ts @@ -123,10 +123,11 @@ export interface CheckForUpdateOptions { } /** - * Check for an available update. Returns a formatted notice string - * if an update is available, or null otherwise. + * Check for an available update using only the local cache (no network). + * Returns a formatted notice string if an update is available, or null otherwise. * * Skipped when: + * - version is 0.0.0 (local dev) * - stderr is not a TTY (piped output) * - CI env var is set * - MUX_NO_UPDATE_CHECK env var is set @@ -135,40 +136,18 @@ export async function checkForUpdate( currentVersion: string, options?: CheckForUpdateOptions, ): Promise { - // Skip for local development builds (version is 0.0.0 until publish) if (currentVersion === '0.0.0') return null; - // Skip in non-interactive environments if (process.env.CI) return null; if (process.env.MUX_NO_UPDATE_CHECK) return null; const isTTY = options?.isTTY ?? process.stderr.isTTY; if (!isTTY) return null; - // Check cache first const cache = await readUpdateCache(); - let latestVersion: string | null = null; - let firstSeenAt: number = Date.now(); - - if (cache && Date.now() - cache.lastChecked < CACHE_TTL_MS) { - // Cache is fresh - latestVersion = cache.latestVersion; - firstSeenAt = cache.firstSeenAt; - } else { - // Cache is stale or missing — fetch from registry - latestVersion = await fetchLatestVersion(); - if (latestVersion) { - // Preserve firstSeenAt if same version, otherwise record new discovery time - firstSeenAt = - cache?.latestVersion === latestVersion ? cache.firstSeenAt : Date.now(); - await writeUpdateCache({ - latestVersion, - lastChecked: Date.now(), - firstSeenAt, - }).catch(() => {}); // best-effort cache write - } - } + if (!cache) return null; + + const { latestVersion, firstSeenAt } = cache; - if (!latestVersion) return null; if (compareSemver(latestVersion, currentVersion) <= 0) return null; const method = detectInstallMethod(options?.execPath); @@ -181,3 +160,27 @@ export async function checkForUpdate( const command = getUpgradeCommand(method); return formatUpdateNotice(currentVersion, latestVersion, command); } + +/** + * Refresh the update cache in the background. Fetches the latest version + * from npm and writes it to the cache file. Skips the fetch if the cache + * is still fresh. + */ +export async function refreshUpdateCache(): Promise { + const cache = await readUpdateCache(); + + // Cache is still fresh — nothing to do + if (cache && Date.now() - cache.lastChecked < CACHE_TTL_MS) return; + + const latestVersion = await fetchLatestVersion(); + if (!latestVersion) return; + + const firstSeenAt = + cache?.latestVersion === latestVersion ? cache.firstSeenAt : Date.now(); + + await writeUpdateCache({ + latestVersion, + lastChecked: Date.now(), + firstSeenAt, + }).catch(() => {}); // best-effort +}